Compare commits

..

114 Commits

Author SHA1 Message Date
Tak Hoffman
bd842b0574 Refine active memory plugin summary flow 2026-04-08 19:56:33 -05:00
Tak Hoffman
433348672d Let active memory subagent decide relevance 2026-04-08 18:07:17 -05:00
Tak Hoffman
46ab4ffe01 Reuse canonical session keys for active memory 2026-04-08 17:40:20 -05:00
Tak Hoffman
b958bccecf Preserve numeric active memory bullets 2026-04-08 17:35:01 -05:00
Tak Hoffman
c13989d08e Match legacy active memory status prefixes 2026-04-08 17:24:57 -05:00
Tak Hoffman
208c0ef783 Clear stale active memory status lines 2026-04-08 17:17:29 -05:00
Tak Hoffman
64bc1a8e4c Keep usage footer on primary reply 2026-04-08 16:22:13 -05:00
Tak Hoffman
dd7999047b Raise active memory timeout default 2026-04-08 16:00:07 -05:00
Tak Hoffman
7ffb2ceaac Add active memory policy config 2026-04-08 15:52:24 -05:00
Tak Hoffman
5e23a26098 Harden active memory debug and transcript handling 2026-04-08 15:32:26 -05:00
Tak Hoffman
19f3740910 Add active memory changelog entry 2026-04-08 15:12:17 -05:00
Tak Hoffman
b224c12ae1 Sanitize recalled context before retrieval 2026-04-08 15:06:19 -05:00
Tak Hoffman
32bcb93527 Preserve active memory session scope 2026-04-08 14:48:48 -05:00
Tak Hoffman
cbb2697215 Fix active memory cache and recall selection 2026-04-08 14:26:06 -05:00
Tak Hoffman
dae966a9b6 Rename active memory blocking subagent wording 2026-04-08 14:12:27 -05:00
Tak Hoffman
b8540b46b5 Abort active memory sidecar on timeout 2026-04-08 14:07:37 -05:00
Tak Hoffman
8f64587a1e Reduce active memory overhead 2026-04-08 13:46:15 -05:00
Tak Hoffman
8f0698d148 Tighten plugin debug handling 2026-04-08 13:32:40 -05:00
Tak Hoffman
8abf8a1bee Refine plugin debug plumbing 2026-04-08 13:01:25 -05:00
Gustavo Madeira Santana
cfe71e2e44 Docs: document approval adapter subpaths 2026-04-07 16:06:02 -04:00
Gustavo Madeira Santana
ecc9a65f34 Tests: align approval gateway seams 2026-04-07 16:06:02 -04:00
Gustavo Madeira Santana
28fc5d9b5e Plugin SDK: split approval adapter seams 2026-04-07 16:06:02 -04:00
Gustavo Madeira Santana
9bcef781e7 Tests: restore approval runtime coverage 2026-04-07 16:06:02 -04:00
Peter Steinberger
83bdba2bae fix: resolve rebase regressions for ci landing 2026-04-07 21:02:04 +01:00
Peter Steinberger
3e85f9c4ff fix: repair test typing for check gate 2026-04-07 20:58:01 +01:00
Peter Steinberger
a4bb2698dd refactor: dedupe ui provider lowercase helpers 2026-04-07 20:58:01 +01:00
Peter Steinberger
bfff74fb11 refactor: dedupe core lowercase helpers 2026-04-07 20:58:01 +01:00
Peter Steinberger
dffa88f396 refactor: dedupe memory lowercase helpers 2026-04-07 20:58:01 +01:00
Peter Steinberger
ba68537d9d refactor: dedupe line qqbot slack lowercase helpers 2026-04-07 20:58:01 +01:00
Peter Steinberger
5b090561fb refactor: dedupe browser whatsapp qa lowercase helpers 2026-04-07 20:58:01 +01:00
Peter Steinberger
eb9ce9482c refactor: dedupe memory lowercase helpers 2026-04-07 20:57:04 +01:00
Peter Steinberger
f665da8dbc refactor: dedupe ui lowercase helpers 2026-04-07 20:57:04 +01:00
Peter Steinberger
abf81ff1ed refactor: dedupe plugin lowercase helpers 2026-04-07 20:57:04 +01:00
Peter Steinberger
179ccb952c refactor: dedupe telegram matrix lowercase helpers 2026-04-07 20:57:04 +01:00
Peter Steinberger
182d41d678 refactor: dedupe command config lowercase helpers 2026-04-07 20:57:03 +01:00
Peter Steinberger
493e1c246e refactor: dedupe remaining lowercase helpers 2026-04-07 20:57:03 +01:00
Peter Steinberger
e51a00ffc7 refactor: dedupe gateway infra lowercase helpers 2026-04-07 20:57:03 +01:00
Gustavo Madeira Santana
ad6bfc44d5 Tests: align approval runtime helpers 2026-04-07 15:37:28 -04:00
Gustavo Madeira Santana
0995ee0134 Extensions: align approval plugin typing 2026-04-07 15:37:28 -04:00
Gustavo Madeira Santana
b78202d44e fix(exec): harden stale/replay/live requests 2026-04-07 15:37:28 -04:00
Gustavo Madeira Santana
e418a6d0cc docs(changelog): dedupe entry 2026-04-07 15:37:26 -04:00
Gustavo Madeira Santana
6484b41eb9 Approvals: replay pending requests on startup 2026-04-07 15:37:01 -04:00
Peter Steinberger
a00b01f5ed fix: harden complex qa suite scenarios 2026-04-07 20:35:39 +01:00
Peter Steinberger
b5d2bd6f41 fix(qa): tighten frontier scope evals 2026-04-07 20:32:42 +01:00
Peter Steinberger
4e69a9b329 fix(qa): restore safe no-fork gateway runtime 2026-04-07 20:32:42 +01:00
Vincent Koc
cde12e63e7 perf(qa): lazy-load runner catalog for lab ui 2026-04-07 20:32:42 +01:00
Vincent Koc
f312d6c106 fix(qa): preserve gateway cli auth in no-fork rpc path 2026-04-07 20:32:42 +01:00
Vincent Koc
e7538b4499 perf(qa): drop per-rpc gateway cli forks 2026-04-07 20:32:42 +01:00
Vincent Koc
02bd9e8c10 perf(qa): trim frontier direct-agent waits 2026-04-07 20:32:42 +01:00
Vincent Koc
35eb70f1f5 test(qa): retry flaky local fetches in lab server tests 2026-04-07 20:32:42 +01:00
Vincent Koc
986536ff6b fix(qa): keep direct self-check outputs under repo root 2026-04-07 20:32:42 +01:00
Vincent Koc
f6544a0a3b fix(qa): anchor runner artifacts to repo root 2026-04-07 20:32:42 +01:00
Vincent Koc
daeff2fa89 fix(qa): default docker artifacts from repo root 2026-04-07 20:32:42 +01:00
Vincent Koc
76bde3d42b fix(qa): support neutral-cwd docker commands 2026-04-07 20:32:42 +01:00
Vincent Koc
816a3eae8a chore(qa): align qa cli provider input types 2026-04-07 20:32:42 +01:00
Vincent Koc
5aa4fd3216 fix(qa): normalize qa cli lane inputs 2026-04-07 20:32:42 +01:00
Vincent Koc
7d18b145f8 fix(qa): keep manual alternate model aligned 2026-04-07 20:32:42 +01:00
Vincent Koc
cdf18c16b4 fix(qa): default manual lanes by provider mode 2026-04-07 20:32:42 +01:00
Vincent Koc
3182588ad4 fix(qa): allow random qa-lab control-ui origins 2026-04-07 20:32:42 +01:00
Vincent Koc
82535771cd fix(qa): pin gateway child control ui root 2026-04-07 20:32:42 +01:00
Vincent Koc
f9f38a48e6 fix(qa): align mock model-switch continuity 2026-04-07 20:32:42 +01:00
Vincent Koc
9a106f7e3c fix(qa): support neutral-cwd suite runs 2026-04-07 20:32:42 +01:00
Vincent Koc
e8b446b985 docs(qa): expand frontier bakeoff runbook 2026-04-07 20:32:42 +01:00
Vincent Koc
f93b217834 feat(qa): add manual harness lane 2026-04-07 20:32:42 +01:00
Vincent Koc
63e6bb026c fix(qa): isolate gateway child runtime 2026-04-07 20:32:42 +01:00
Vincent Koc
4f421fa0f1 fix(qa): harden frontier claude bakeoffs 2026-04-07 20:32:42 +01:00
Vincent Koc
18fb171179 feat(qa): add frontier harness bakeoff loop 2026-04-07 20:32:41 +01:00
Andrew Demczuk
bffb83acf8 fix(gateway): stop SSRF guard rejecting operator-configured proxy hostnames (#62312)
When allowPrivateProxy is true, the explicit proxy hostname is operator-
configured and trusted. The SSRF guard was checking the proxy hostname
against the target-scoped hostnameAllowlist (e.g. ["api.telegram.org"]),
which rejected localhost and other local proxy hostnames. This broke
Telegram media downloads (and any channel using a local proxy) after
the url-fetch security hardening in 2026.4.x.

Clear the hostnameAllowlist for the proxy hostname check while keeping
private-network IP validation in place via allowPrivateNetwork.

Fixes #61906

Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
2026-04-07 13:22:21 -06:00
Peter Steinberger
cfbe7ac227 fix(test): refresh schema snapshot and stabilize channel registry 2026-04-07 20:04:29 +01:00
Agustin Rivera
e5aae5e056 fix(browser): align browser.proxy profile mutation guards (#60489)
* fix(browser): block proxy profile mutations

* docs(changelog): add browser proxy guard entry

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
2026-04-07 13:00:21 -06:00
Peter Steinberger
744d176744 test: speed up plugin cli tests 2026-04-07 19:59:46 +01:00
Peter Steinberger
4a0b8c6248 test: speed up slack setup entry tests 2026-04-07 19:59:46 +01:00
Peter Steinberger
f02ba9a3ed test: speed up browser plugin entry tests 2026-04-07 19:59:46 +01:00
Nimrod Gutman
6380c872bc feat(ios): improve gateway connection error ux (#62650)
* feat(ios): improve gateway connection error ux

* fix(ios): address gateway problem review feedback

* feat(ios): improve gateway connection error ux (#62650) (thanks @ngutman)
2026-04-07 21:53:22 +03:00
Agustin Rivera
a383878e97 Require re-pairing for node reconnect command upgrades (#62658)
* fix(node): require re-pairing for reconnect command upgrades

Co-authored-by: zsx <git@zsxsoft.com>

* fix(node): tighten reconnect pairing test polling

* docs(changelog): add node reconnect pairing entry

---------

Co-authored-by: zsx <git@zsxsoft.com>
Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-07 12:48:18 -06:00
Peter Steinberger
93ab2ac69d test(gateway): cover isolated cron session key routing 2026-04-07 19:46:16 +01:00
Bruce MacDonald
ceb2311a1b Changelog: restore dropped Approvals/runtime entry from conflict resolution 2026-04-07 11:45:07 -07:00
Bruce MacDonald
86f35a9bc0 chore(ollama): update suggested onboarding models (#62626)
Merged via squash.

Prepared head SHA: 48c083b88a
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-07 11:42:29 -07:00
pgondhi987
23ab290a71 fix: expand host-exec env blocklist for Java, Rust, and Cargo toolchains [AI-assisted] (#62291)
* fix: address issue

* docs(changelog): add host env blocklist entry

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
2026-04-07 12:40:54 -06:00
BitToby
9edf9804b1 feat: add cover image support to Discord event create (#60883)
* feat: add image param to Discord event create for cover art

* fix: pass trusted media roots to event cover image loader

* fix: solve lint error

* fix: add changelog entry for Discord event cover image support (#60883) (thanks @bittoby)

---------

Co-authored-by: Shadow <hi@shadowing.dev>
2026-04-07 13:40:39 -05:00
Gustavo Madeira Santana
d78512b09d Refactor: centralize native approval lifecycle assembly (#62135)
Merged via squash.

Prepared head SHA: b7c20a7398
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-07 14:40:26 -04:00
pgondhi987
4108901932 fix(fetch-guard): drop request body on cross-origin unsafe-method redirects [AI-assisted] (#62357)
* fix: address issue

* fix: address review feedback

* docs(changelog): add fetch guard redirect body entry

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-07 12:11:00 -06:00
Peter Steinberger
d855f5f505 Tests: fix full-suite regressions 2026-04-07 18:59:38 +01:00
DhruvBhatia0
12331f0463 feat: add pluggable compaction provider registry (#56224)
Merged via squash.

Prepared head SHA: 0cc9cf3f30
Co-authored-by: DhruvBhatia0 <69252327+DhruvBhatia0@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-07 10:55:34 -07:00
pgondhi987
14ec1ac50f fix(browser): harden SSRF redirect guard against non-navigation document hops [AI] (#62355)
* fix: address issue

* fix: address PR review feedback

* docs(changelog): add browser redirect SSRF entry

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
2026-04-07 11:37:31 -06:00
i-dentifier
adb7b0d5d6 fix: compaction after tool use abortion cause agent infinite loop calls (#62600)
Merged via squash.

Prepared head SHA: 304ba07207
Co-authored-by: i-dentifier <44976464+i-dentifier@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-07 10:28:00 -07:00
Agustin Rivera
e617aa6d1e fix(browser): add changelog entry for #62023 2026-04-07 17:23:22 +00:00
Peter Steinberger
7c478473fe Tests: tighten cron timeout start handshakes 2026-04-08 01:20:00 +08:00
Peter Steinberger
16cebe5669 Tests: stabilize cron timeout regressions 2026-04-08 01:10:19 +08:00
Agustin Rivera
049acf23cb fix(browser): guard interaction-driven navigations 2026-04-07 10:03:12 -07:00
pgondhi987
df881d5c18 fix(allowlist): gate write commands behind owner check before channel resolution [AI] (#62383)
* fix: address issue

* docs(changelog): add allowlist owner gate entry

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-07 11:01:15 -06:00
EVA
caecd3c1fe fix(agents): heartbeat always targets main session — prevent routing to active subagent sessions (#61803)
Merged via squash.

Prepared head SHA: 5d79db3940
Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-07 09:59:18 -07:00
mappel-nv
c6b5731c5d Plugins: verify ClawHub archive integrity (#60517)
* docs(changelog): add clawhub archive integrity entry

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-07 10:55:22 -06:00
Peter Steinberger
b2dc25cd12 fix: repair ci type narrowing 2026-04-07 17:51:05 +01:00
Peter Steinberger
037340d287 refactor: dedupe gateway lowercase helpers 2026-04-07 17:50:38 +01:00
Peter Steinberger
6058eacaec refactor: dedupe infra lowercase helpers 2026-04-07 17:50:38 +01:00
Peter Steinberger
1a3f141215 refactor: dedupe cli lowercase helpers 2026-04-07 17:50:38 +01:00
Peter Steinberger
cebfa70277 refactor: dedupe auto-reply lowercase helpers 2026-04-07 17:50:37 +01:00
Peter Steinberger
d40dc8f025 refactor: dedupe agent lowercase helpers 2026-04-07 17:50:37 +01:00
Peter Steinberger
d56fe040b4 refactor: dedupe agent lowercase helpers 2026-04-07 17:50:37 +01:00
Peter Steinberger
9e61209780 refactor: dedupe agent lowercase helpers 2026-04-07 17:50:37 +01:00
Peter Steinberger
d4eb3e12c9 test: speed up channel setup entry tests 2026-04-07 17:36:41 +01:00
Peter Steinberger
0828db93e9 test: speed up provider entry tests 2026-04-07 17:36:41 +01:00
Peter Steinberger
c1fc2ed0e8 test: speed up provider auth onboarding test 2026-04-07 17:36:41 +01:00
pgondhi987
f0c9978030 fix(feishu): enforce workspace-only localRoots in docx upload actions [AI-assisted] (#62369)
* fix: address issue

* docs(changelog): add feishu workspace-only docx entry

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-07 10:35:03 -06:00
Peter Steinberger
67a3af7f8d Tests: fix nostr package boundary drift 2026-04-08 00:33:13 +08:00
Josh Lehman
e46e32b98c feat: expose prompt-cache runtime context to context engines (#62179)
* Context engine: plumb prompt cache runtime context

Add a typed prompt-cache payload to the context-engine runtime context and populate it from the embedded runner's resolved retention, last-call usage, cache-break observation, and cache-touch metadata. Also pass the same payload through the retry compaction runtime context when a run attempt already has it.

Regeneration-Prompt: |
  Expose OpenClaw prompt-cache telemetry to context engines in a narrow,
  additive way without changing compaction policy. Keep the public change on
  the OpenClaw side only: add a typed promptCache payload to the context-engine
  runtime context, thread it into afterTurn, and also into compact where the
  existing run loop already has the data cheaply available.

  Use OpenClaw's resolved cache retention, not raw config. Use last-call usage
  for the new payload, not accumulated retry or tool-loop totals. Reuse the
  existing prompt-cache observability result and tracked change causes instead
  of inventing a new heuristic. If cache-touch metadata is already available
  from the cache-TTL bookkeeping, include it; do not invent expiry timestamps
  for providers where OpenClaw cannot know them confidently.

  Keep the interface backward-compatible for engines that ignore the new field.
  Add focused tests around the existing attempt/context-engine helpers and the
  compaction runtime-context propagation path rather than broad new integration
  coverage.

* Agents: fix prompt-cache afterTurn usage

Regeneration-Prompt: |
  Fix PR #62179 so context-engine prompt-cache metadata uses only the current attempt's usage. The review comment pointed out that early exits could reuse a prior turn's assistant usage when no new assistant message was produced. Restrict the prompt-cache lastCallUsage lookup to assistant messages added after prePromptMessageCount, and fall back to current-attempt usage totals instead of stale snapshot history. Also repair the PR's new context-engine test typings and add a regression test for the stale prior-turn case. Two import-only fixes in doctor-state-integrity and config/talk were already broken on origin/main, but they blocked build/check and the gateway-watch regression harness, so include the minimum unblocking imports as well.

* Agents: document prompt-cache context

* Agents: address prompt-cache review feedback

* Doctor: drop unused isRecord import
2026-04-07 09:29:57 -07:00
James Reagan
dac72889e5 fix(bluebubbles): localhost probe respects private-network opt-out (#59373)
* honor localhost private-network policy

* drop flaky monitor private-network test

* align mocks and imports

* preserve account private-network overrides

* keep default account config

* strip stale private-network aliases

* fix(bluebubbles): remove unused channel imports

* fix: add changelog for bluebubbles private-network opt-out landing (#59373) (thanks @jpreagan)

---------

Co-authored-by: Shadow <hi@shadowing.dev>
2026-04-07 11:29:21 -05:00
Peter Steinberger
23edd9921e Tests: isolate channel tool-result session stores 2026-04-08 00:16:22 +08:00
Peter Steinberger
904017814b test: speed up mistral api tests 2026-04-07 17:11:55 +01:00
Peter Steinberger
76bc0ae32f test: speed up irc channel seam tests 2026-04-07 17:11:55 +01:00
Peter Steinberger
2de8b91448 test: speed up telegram and nextcloud talk channel tests 2026-04-07 17:11:55 +01:00
Peter Steinberger
e8c0f25598 test: speed up matrix and nostr channel tests 2026-04-07 17:11:55 +01:00
pgondhi987
5880ec17b1 fix(gateway): invalidate shared-token/password WS sessions on secret rotation [AI] (#62350)
* fix: address issue

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-07 10:10:10 -06:00
797 changed files with 27033 additions and 10183 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Memory/Active Memory: add a new optional Active Memory plugin that gives OpenClaw a dedicated memory subagent right before the main reply, so ongoing chats can automatically pull in relevant preferences, context, and past details without making users remember to manually say "remember this" or "search memory" first. Includes configurable message/recent/full context modes, live `/verbose` inspection, and opt-in transcript persistence for debugging.
- CLI/infer: add a first-class `openclaw infer ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks. Thanks @Takhoffman.
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
- Tools/media generation: preserve intent across auth-backed image, music, and video provider fallback, remap size, aspect ratio, resolution, and duration hints to the closest supported option, and surface explicit provider capabilities plus mode-aware video-to-video support.
@@ -27,6 +28,10 @@ Docs: https://docs.openclaw.ai
- Memory/wiki: add an opt-in `context.includeCompiledDigestPrompt` flag so memory prompt supplements can append a compact compiled wiki snapshot for legacy prompt assembly and context engines that explicitly consume memory prompt sections. Thanks @vincentkoc.
- Plugin SDK/context engines: pass `availableTools` and `citationsMode` into `assemble()`, and expose `buildMemorySystemPromptAddition(...)` so non-legacy context engines can adopt the active memory prompt path without reimplementing it. Thanks @vincentkoc.
- Providers/inferrs: add string-content compatibility for stricter OpenAI-compatible chat backends, document `inferrs` setup with a full config example, and add troubleshooting guidance for local backends that pass direct probes but fail on full agent-runtime prompts.
- Agents/context engine: expose prompt-cache runtime context to context engines and keep current-turn prompt-cache usage aligned with the active attempt instead of stale prior-turn assistant state. (#62179) Thanks @jalehman.
- Tools/media: document per-provider music and video generation capabilities, and add shared live video-to-video sweep coverage for providers that support local reference clips.
- Compaction: add pluggable compaction provider registry so plugins can replace the built-in summarization pipeline. Configure via `agents.defaults.compaction.provider`; falls back to LLM summarization on provider failure. (#56224) Thanks @DhruvBhatia0.
- Discord/events: allow `event-create` to accept a cover image URL or local file path, load and validate PNG/JPG/GIF event cover media, and pass the encoded image payload through Discord admin action/runtime paths. (#60883) Thanks @bittoby.
### Fixes
@@ -35,6 +40,7 @@ Docs: https://docs.openclaw.ai
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
- Agents/history and replies: buffer phaseless OpenAI WS text until a real assistant phase arrives, keep replay and SSE history sequence tracking aligned, hide commentary and leaked tool XML from user-visible history, and keep history-based follow-up replies on `final_answer` text only. (#61729, #61747, #61829, #61855, #61954) Thanks @100yenadmin, @afurm, and @openperf.
- Plugins/channels: keep bundled channel artifact and secret-contract loading stable under lazy loading, preserve plugin-schema defaults during install, and fix Windows `file://` plus native-Jiti plugin loader paths so onboarding, doctor, `openclaw secret`, and bundled plugin installs work again. (#61832, #61836, #61853, #61856) Thanks @Zeesejo and @SuperMarioYL.
- Plugins/ClawHub: verify downloaded plugin archives against version metadata SHA-256, fail closed when archive integrity metadata is missing or malformed, and tighten fallback ZIP verification so plugin installs cannot proceed on mismatched or incomplete ClawHub package metadata. (#60517) Thanks @mappel-nv.
- Auto-reply/media: allow managed generated-media `MEDIA:` paths from normal reply text again while still blocking arbitrary host-local media and document paths, so generated media keep delivering without reopening host-path injection holes.
- Runtime event trust: mark background `notifyOnExit` summaries, ACP parent-stream relays, and wake-hook payloads as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text. (#62003)
- Providers/Anthropic: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so prompt-cache prefixes keep matching, and skip `service_tier` injection on OAuth-authenticated stream wrapper requests so Claude OAuth streaming stops failing with HTTP 401. (#60356, #61793)
@@ -47,8 +53,15 @@ Docs: https://docs.openclaw.ai
- Nodes/exec approvals: keep Windows `cmd.exe /c` wrapper runs approval-gated even when `env` carriers, including env-assignment carriers, wrap the shell invocation. (#62439) Thanks @ngutman.
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one pass and restore a total-context overflow backstop so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, fail loud on invalid elevated cross-host overrides, and keep `strictInlineEval` commands blocked after approval timeouts instead of falling through to automatic execution. (#61739) Thanks @obviyus.
- Host exec/env sanitization: block dangerous `JAVA_OPTS`, `RUSTFLAGS`, and `CARGO_HOME` inputs at the host-exec boundary so attacker-controlled env overrides can no longer inject JVM agents, compiler flags, or Cargo state pivots into host-run processes. (#62291) Thanks @pgondhi987.
- Commands/allowlist: require owner authorization for `/allowlist add` and `/allowlist remove` before channel resolution, so non-owner but command-authorized senders can no longer persistently rewrite allowlist policy state. (#62383) Thanks @pgondhi987.
- Feishu/docx uploads: honor `tools.fs.workspaceOnly` for local `upload_file` and `upload_image` paths by forwarding workspace-constrained `localRoots` into the media loader, so docx uploads can no longer read host-local files outside the workspace when workspace-only mode is active. (#62369) Thanks @pgondhi987.
- Network/fetch guard: drop request bodies and body-describing headers on cross-origin `307` and `308` redirects by default, so attacker-controlled redirect hops cannot receive secret-bearing POST payloads from SSRF-guarded fetch flows unless a caller explicitly opts in. (#62357) Thanks @pgondhi987.
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
- Browser/SSRF: treat main-frame `document` redirect hops as navigations even when Playwright does not flag them as `isNavigationRequest()`, so strict private-network blocking still stops forbidden redirect pivots before the browser reaches the internal target. (#62355) Thanks @pgondhi987.
- Gateway/node pairing: require a fresh pairing request when a previously paired node reconnects with additional declared commands, and keep the live session pinned to the earlier approved command set until the upgrade is approved. (#62658) Thanks @eleqtrizit.
- Gateway/auth: invalidate existing shared-token and password WebSocket sessions when the configured secret rotates, so stale authenticated sockets cannot stay attached after token or password changes. (#62350) Thanks @pgondhi987.
- Gateway/status and containers: auto-bind to `0.0.0.0` inside Docker and Podman environments, and probe local TLS gateways over `wss://` with self-signed fingerprint forwarding so container startup and loopback TLS status checks work again. (#61818, #61935) Thanks @openperf and @ThanhNguyxn07.
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
- Discord: recover forwarded referenced message text and attachments when snapshots are missing, use `ws://` again for gateway monitor sockets, stop forcing a hardcoded temperature for Codex-backed auto-thread titles, and harden voice receive recovery so rapid speaker restarts keep their next utterance. (#41536, #61670) Thanks @artwalker and @wit-oc.
@@ -66,6 +79,7 @@ Docs: https://docs.openclaw.ai
- Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951)
- Exec/runtime events: mark background `notifyOnExit` summaries and ACP parent-stream relays as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text.
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
- Browser/node invoke: block persistent browser profile create, reset, and delete mutations through `browser.proxy` on both gateway-forwarded `node.invoke` and the node-host proxy path, even when no profile allowlist is configured. (#60489)
- Slack/thread mentions: add `channels.slack.thread.requireExplicitMention` so Slack channels that already require mentions can also require explicit `@bot` mentions inside bot-participated threads. (#58276) Thanks @praktika-engineer.
- UI/light mode: target both root and nested WebKit scrollbar thumbs in the light theme so page-level and container scrollbars stay visible on light backgrounds. (#61753) Thanks @chziyue.
- Matrix/onboarding: add an invite auto-join setup step with explicit off warnings and strict stable-target validation so new Matrix accounts stop silently ignoring invited rooms and fresh DM-style invites unless operators opt in. (#62168) Thanks @gumadeiras.
@@ -77,6 +91,12 @@ Docs: https://docs.openclaw.ai
- Agents/model fallback: classify minimal HTTP 404 API errors (for example `404 status code (no body)`) as `model_not_found` so assistant failures throw into the fallback chain instead of stopping at the first fallback candidate. (#62119) Thanks @neeravmakwana.
- Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistrals Chat Completions API. (#62162) Thanks @neeravmakwana.
- OpenAI TTS/Groq: send `wav` to Groq-compatible speech endpoints, honor explicit `responseFormat` overrides on OpenAI-compatible paths, and only mark voice-note output as voice-compatible when the actual format is `opus`. (#62233) Thanks @neeravmakwana.
- BlueBubbles/network: respect explicit private-network opt-out for loopback and private `serverUrl` values across account resolution, status probes, monitor startup, and attachment downloads, while keeping public-host attachment hostname pinning intact. (#59373) Thanks @jpreagan.
- Agents/heartbeat: keep heartbeat runs pinned to the main session so active subagent transcripts are not overwritten by heartbeat status messages. (#61803) Thanks @100yenadmin.
- Agents/compaction: stop compaction-wait aborts from re-entering prompt failover and replaying completed tool turns. (#62600) Thanks @i-dentifier.
- Approvals/runtime: move native approval lifecycle assembly into shared core bootstrap/runtime seams driven by channel capabilities and runtime contexts, and remove the legacy bundled approval fallback wiring. (#62135) Thanks @gumadeiras.
- Security/fetch-guard: stop rejecting operator-configured proxy hostnames against the target-scoped hostname allowlist in SSRF-guarded fetches, restoring proxy-based media downloads for Telegram and other channels. (#62312) Thanks @ademczuk.
- iOS/gateway: replace string-matched connection error UI with structured gateway connection problems, preserve actionable pairing/auth failures over later generic disconnect noise, and surface reusable problem banners and details across onboarding, settings, and root status surfaces. (#62650) Thanks @ngutman.
## 2026.4.5
@@ -215,6 +235,7 @@ Docs: https://docs.openclaw.ai
- Feishu/reasoning: only expose streamed reasoning previews when the session is explicitly `reasoning:stream`, so hidden reasoning traces do not surface on normal streaming sessions. Thanks @vincentkoc.
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345) Thanks @geekhuashan.
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) Thanks @MonkeyLeeT and @mcaxtr.
- Browser/security: re-run SSRF safety checks after interaction-driven navigations and before snapshot reads so click, submit, keyboard, and current-page snapshot flows fail closed on disallowed destinations. (#62023) Thanks @eleqtrizit.
- Memory: keep `memory-core` builtin embedding registration on the already-registered path so selecting `memory-core` no longer recurses through plugin discovery and crashes during startup. (#61402) Thanks @ngutman.
- Agents/tool results: keep large `read` outputs visible longer, preserve the latest `read` output when older tool output can absorb the overflow budget, and fall back to Pi's normal overflow compaction/retry path before replacing a fresh `read` with a compacted stub. Thanks @vincentkoc.
- Memory/QMD: prefer modern `qmd collection add --glob`, accept newer single-line JSON hit metadata while keeping legacy line fields, refresh QMD docs/doctor install guidance and model-override guidance, and keep older QMD releases working. Thanks @vincentkoc.
@@ -812,6 +833,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm.
- Docs: add `pnpm docs:check-links:anchors` for Mintlify anchor validation while keeping `scripts/docs-link-audit.mjs` as the stable link-audit entrypoint. (#55912) Thanks @velvet-shark.
- Tavily: mark outbound API requests with `X-Client-Source: openclaw` so Tavily can attribute OpenClaw-originated traffic. (#55335) Thanks @lakshyaag-tavily.
- Plugins/hooks: add async `requireApproval` to `before_tool_call` hooks, letting plugins pause tool execution and prompt the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the `/approve` command on any channel. The `/approve` command now handles both exec and plugin approvals with automatic fallback. (#55339) Thanks @vaclavbelak and @joshavant.
### Fixes

View File

@@ -1,4 +1,5 @@
import Foundation
import OpenClawKit
enum GatewayConnectionIssue: Equatable {
case none
@@ -29,6 +30,37 @@ enum GatewayConnectionIssue: Equatable {
return false
}
static func detect(problem: GatewayConnectionProblem?) -> Self {
guard let problem else { return .none }
if problem.needsPairingApproval {
return .pairingRequired(requestId: problem.requestId)
}
if problem.needsCredentialUpdate {
return problem.kind == .gatewayAuthTokenMissing ? .tokenMissing : .unauthorized
}
switch problem.kind {
case .deviceIdentityRequired,
.deviceSignatureExpired,
.deviceNonceRequired,
.deviceNonceMismatch,
.deviceSignatureInvalid,
.devicePublicKeyInvalid,
.deviceIdMismatch,
.tailscaleIdentityMissing,
.tailscaleProxyMissing,
.tailscaleWhoisFailed,
.tailscaleIdentityMismatch,
.authRateLimited:
return .unauthorized
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return .network
case .unknown:
return .unknown(problem.message)
default:
return .none
}
}
static func detect(from statusText: String) -> Self {
let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .none }

View File

@@ -0,0 +1,232 @@
import OpenClawKit
import SwiftUI
import UIKit
struct GatewayProblemBanner: View {
let problem: GatewayConnectionProblem
var primaryActionTitle: String?
var onPrimaryAction: (() -> Void)?
var onShowDetails: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: self.iconName)
.font(.headline.weight(.semibold))
.foregroundStyle(self.tint)
.frame(width: 20)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(self.problem.title)
.font(.subheadline.weight(.semibold))
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
Text(self.ownerLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
Text(self.problem.message)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let requestId = self.problem.requestId {
Text("Request ID: \(requestId)")
.font(.system(.caption, design: .monospaced).weight(.medium))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
HStack(spacing: 10) {
if let primaryActionTitle, let onPrimaryAction {
Button(primaryActionTitle, action: onPrimaryAction)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
if let onShowDetails {
Button("Details", action: onShowDetails)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(
.thinMaterial,
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
)
}
private var iconName: String {
switch self.problem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
return "person.crop.circle.badge.clock"
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return "wifi.exclamationmark"
case .deviceIdentityRequired,
.deviceSignatureExpired,
.deviceNonceRequired,
.deviceNonceMismatch,
.deviceSignatureInvalid,
.devicePublicKeyInvalid,
.deviceIdMismatch:
return "lock.shield"
default:
return "exclamationmark.triangle.fill"
}
}
private var tint: Color {
switch self.problem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
return .orange
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return .yellow
default:
return .red
}
}
private var ownerLabel: String {
switch self.problem.owner {
case .gateway:
return "Fix on gateway"
case .iphone:
return "Fix on iPhone"
case .both:
return "Check both"
case .network:
return "Check network"
case .unknown:
return "Needs attention"
}
}
}
struct GatewayProblemDetailsSheet: View {
@Environment(\.dismiss) private var dismiss
let problem: GatewayConnectionProblem
var primaryActionTitle: String?
var onPrimaryAction: (() -> Void)?
@State private var copyFeedback: String?
var body: some View {
NavigationStack {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(self.problem.title)
.font(.title3.weight(.semibold))
Text(self.problem.message)
.font(.body)
.foregroundStyle(.secondary)
Text(self.ownerSummary)
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
if let requestId = self.problem.requestId {
Section("Request") {
Text(verbatim: requestId)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
Button("Copy request ID") {
UIPasteboard.general.string = requestId
self.copyFeedback = "Copied request ID"
}
}
}
if let actionCommand = self.problem.actionCommand {
Section("Gateway command") {
Text(verbatim: actionCommand)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
Button("Copy command") {
UIPasteboard.general.string = actionCommand
self.copyFeedback = "Copied command"
}
}
}
if let docsURL = self.problem.docsURL {
Section("Help") {
Link(destination: docsURL) {
Label("Open docs", systemImage: "book")
}
Text(verbatim: docsURL.absoluteString)
.font(.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
if let technicalDetails = self.problem.technicalDetails {
Section("Technical details") {
Text(verbatim: technicalDetails)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
if let copyFeedback {
Section {
Text(copyFeedback)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Connection problem")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
if let primaryActionTitle, let onPrimaryAction {
Button(primaryActionTitle) {
self.dismiss()
onPrimaryAction()
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
self.dismiss()
}
}
}
}
}
private var ownerSummary: String {
switch self.problem.owner {
case .gateway:
return "Primary fix: gateway"
case .iphone:
return "Primary fix: this iPhone"
case .both:
return "Primary fix: check both this iPhone and the gateway"
case .network:
return "Primary fix: network or remote access"
case .unknown:
return "Primary fix: review details and retry"
}
}
}

View File

@@ -8,6 +8,7 @@ struct GatewayQuickSetupSheet: View {
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var connecting: Bool = false
@State private var connectError: String?
@State private var showGatewayProblemDetails: Bool = false
var body: some View {
NavigationStack {
@@ -15,6 +16,14 @@ struct GatewayQuickSetupSheet: View {
Text("Connect to a Gateway?")
.font(.title2.bold())
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemBanner(
problem: gatewayProblem,
onShowDetails: {
self.showGatewayProblemDetails = true
})
}
if let candidate = self.bestCandidate {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: candidate.name)
@@ -27,7 +36,7 @@ struct GatewayQuickSetupSheet: View {
// Use verbatim strings so Bonjour-provided values can't be interpreted as
// localized format strings (which can crash with Objective-C exceptions).
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
Text(verbatim: "Status: \(self.appModel.gatewayDisplayStatusText)")
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
}
@@ -104,6 +113,11 @@ struct GatewayQuickSetupSheet: View {
}
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(problem: gatewayProblem)
}
}
}
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {

View File

@@ -120,6 +120,10 @@ final class NodeAppModel {
// multiple pending requests and cause the onboarding UI to "flip-flop".
var gatewayPairingPaused: Bool = false
var gatewayPairingRequestId: String?
private(set) var lastGatewayProblem: GatewayConnectionProblem?
var gatewayDisplayStatusText: String {
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
}
var seamColorHex: String?
private var mainSessionBaseKey: String = "main"
var selectedAgentId: String?
@@ -1815,6 +1819,7 @@ extension NodeAppModel {
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
@@ -1848,6 +1853,7 @@ private extension NodeAppModel {
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.operatorGatewayTask?.cancel()
self.gatewayHealthMonitor.stop()
@@ -1866,6 +1872,38 @@ private extension NodeAppModel {
self.apnsLastRegisteredTokenHex = nil
}
func clearGatewayConnectionProblem() {
self.lastGatewayProblem = nil
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
}
func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
self.lastGatewayProblem = problem
self.gatewayStatusText = problem.statusText
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
if problem.pauseReconnect {
self.gatewayAutoReconnectEnabled = false
}
if problem.needsPairingApproval {
self.gatewayPairingPaused = true
self.gatewayPairingRequestId = problem.requestId
} else {
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
}
}
func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
guard let lastGatewayProblem else { return false }
return GatewayConnectionProblemMapper.shouldPreserve(
previousProblem: lastGatewayProblem,
overDisconnectReason: reason)
}
func shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
@@ -2162,6 +2200,7 @@ private extension NodeAppModel {
onConnected: { [weak self] in
guard let self else { return }
await MainActor.run {
self.clearGatewayConnectionProblem()
self.gatewayStatusText = "Connected"
self.gatewayServerName = url.host ?? "gateway"
self.gatewayConnected = true
@@ -2218,7 +2257,13 @@ private extension NodeAppModel {
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
self.gatewayStatusText = "Disconnected: \(reason)"
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
let lastGatewayProblem = self.lastGatewayProblem
{
self.gatewayStatusText = lastGatewayProblem.statusText
} else {
self.gatewayStatusText = "Disconnected: \(reason)"
}
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
@@ -2257,50 +2302,25 @@ private extension NodeAppModel {
}
attempt += 1
await MainActor.run {
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
let problem = await MainActor.run {
let nextProblem = GatewayConnectionProblemMapper.map(
error: error,
preserving: self.lastGatewayProblem)
if let nextProblem {
self.applyGatewayConnectionProblem(nextProblem)
} else {
self.lastGatewayProblem = nil
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
}
return nextProblem
}
GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")
// If auth is missing/rejected, pause reconnect churn until the user intervenes.
// Reconnect loops only spam the same failing handshake and make onboarding noisy.
let lower = error.localizedDescription.lowercased()
if lower.contains("unauthorized") || lower.contains("gateway token missing") {
await MainActor.run {
self.gatewayAutoReconnectEnabled = false
}
}
// If pairing is required, stop reconnect churn. The user must approve the request
// on the gateway before another connect attempt will succeed, and retry loops can
// generate multiple pending requests.
if lower.contains("not_paired") || lower.contains("pairing required") {
let requestId: String? = {
// GatewayResponseError for connect decorates the message with `(requestId: ...)`.
// Keep this resilient since other layers may wrap the text.
let text = error.localizedDescription
guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil }
guard let end = text[start...].firstIndex(of: ")") else { return nil }
let raw = String(text[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
return raw.isEmpty ? nil : raw
}()
await MainActor.run {
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = true
self.gatewayPairingRequestId = requestId
if let requestId, !requestId.isEmpty {
self.gatewayStatusText =
"Pairing required (requestId: \(requestId)). "
+ "Approve on gateway and return to OpenClaw."
} else {
self.gatewayStatusText =
"Pairing required. Approve on gateway and return to OpenClaw."
}
}
if problem?.needsPairingApproval == true {
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
// we don't generate multiple pending requests while waiting for approval.
pausedForPairingApproval = true
@@ -2311,6 +2331,10 @@ private extension NodeAppModel {
break
}
if problem?.pauseReconnect == true {
continue
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
}
@@ -2322,6 +2346,7 @@ private extension NodeAppModel {
}
await MainActor.run {
self.lastGatewayProblem = nil
self.gatewayStatusText = "Offline"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil

View File

@@ -376,7 +376,7 @@ private struct ConnectionStatusBox: View {
gatewayController: GatewayConnectionController
) -> [String] {
var lines: [String] = [
"gateway: \(appModel.gatewayStatusText)",
"gateway: \(appModel.gatewayDisplayStatusText)",
"discovery: \(gatewayController.discoveryStatusText)",
]
lines.append("server: \(appModel.gatewayServerName ?? "")")

View File

@@ -69,6 +69,7 @@ struct OnboardingWizardView: View {
@State private var showQRScanner: Bool = false
@State private var scannerError: String?
@State private var selectedPhoto: PhotosPickerItem?
@State private var showGatewayProblemDetails: Bool = false
@State private var lastPairingAutoResumeAttemptAt: Date?
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
@@ -86,6 +87,10 @@ struct OnboardingWizardView: View {
self.step == .intro || self.step == .welcome || self.step == .success
}
private var currentProblem: GatewayConnectionProblem? {
self.appModel.lastGatewayProblem
}
var body: some View {
NavigationStack {
Group {
@@ -216,6 +221,16 @@ struct OnboardingWizardView: View {
}
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let currentProblem = self.currentProblem {
GatewayProblemDetailsSheet(
problem: currentProblem,
primaryActionTitle: "Retry",
onPrimaryAction: {
Task { await self.retryLastAttempt() }
})
}
}
.onAppear {
self.initializeState()
}
@@ -250,39 +265,11 @@ struct OnboardingWizardView: View {
.onChange(of: self.gatewayPassword) { _, newValue in
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
}
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
}
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
let next = GatewayConnectionIssue.detect(from: newValue)
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
if self.issue.needsPairing, next.needsPairing {
// Keep the requestId sticky even if the status line omits it after we pause.
let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId
self.issue = .pairingRequired(requestId: mergedRequestId)
} else if self.issue.needsPairing, !next.needsPairing {
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
} else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing {
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
// the user retries/scans again or we successfully connect.
} else {
self.issue = next
}
if let requestId = next.requestId, !requestId.isEmpty {
self.pairingRequestId = requestId
}
// If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes.
if next.needsAuthToken {
self.appModel.gatewayAutoReconnectEnabled = false
}
if self.issue.needsAuthToken || self.issue.needsPairing {
self.step = .auth
}
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.connectMessage = newValue
self.statusLine = newValue
}
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
guard newValue != nil else { return }
@@ -509,7 +496,7 @@ struct OnboardingWizardView: View {
Section {
LabeledContent("Mode", value: selectedMode.title)
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText)
LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText)
LabeledContent("Progress", value: self.statusLine)
} header: {
Text("Status")
@@ -612,7 +599,17 @@ struct OnboardingWizardView: View {
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
if self.issue.needsAuthToken {
if let problem = self.currentProblem {
GatewayProblemBanner(
problem: problem,
primaryActionTitle: "Retry connection",
onPrimaryAction: {
Task { await self.retryLastAttempt() }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
} else if self.issue.needsAuthToken {
Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -635,14 +632,15 @@ struct OnboardingWizardView: View {
Text("Pairing Approval")
} footer: {
let requestLine: String = {
if let id = self.issue.requestId, !id.isEmpty {
if let id = self.currentProblem?.requestId ?? self.issue.requestId, !id.isEmpty {
return "Request ID: \(id)"
}
return "Request ID: check `openclaw devices list`."
}()
let commandLine = self.currentProblem?.actionCommand ?? "openclaw devices approve <requestId>"
Text(
"Approve this device on the gateway.\n"
+ "1) `openclaw devices approve` (or `openclaw devices approve <requestId>`)\n"
+ "1) `\(commandLine)`\n"
+ "2) `/pair approve` in your OpenClaw chat\n"
+ "\(requestLine)\n"
+ "OpenClaw will also retry automatically when you return to this app.")
@@ -824,6 +822,45 @@ struct OnboardingWizardView: View {
self.resumeAfterPairingApprovalInBackground()
}
private func updateConnectionIssue(problem: GatewayConnectionProblem?, statusText: String) {
let next = GatewayConnectionIssue.detect(problem: problem)
let fallback = next == .none ? GatewayConnectionIssue.detect(from: statusText) : next
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
if self.issue.needsPairing, fallback.needsPairing {
let mergedRequestId = fallback.requestId ?? self.issue.requestId ?? self.pairingRequestId
self.issue = .pairingRequired(requestId: mergedRequestId)
} else if self.issue.needsPairing, !fallback.needsPairing {
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
} else if self.issue.needsAuthToken, !fallback.needsAuthToken, !fallback.needsPairing {
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
// the user retries/scans again or we successfully connect.
} else {
self.issue = fallback
}
if let requestId = problem?.requestId ?? fallback.requestId, !requestId.isEmpty {
self.pairingRequestId = requestId
}
if self.issue.needsAuthToken || self.issue.needsPairing || problem?.pauseReconnect == true {
self.step = .auth
}
if let problem {
self.connectMessage = problem.message
self.statusLine = problem.message
return
}
let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedStatus.isEmpty {
self.connectMessage = trimmedStatus
self.statusLine = trimmedStatus
}
}
private func detectQRCode(from data: Data) -> String? {
guard let ciImage = CIImage(data: data) else { return nil }
let detector = CIDetector(

View File

@@ -98,6 +98,9 @@ struct RootCanvas: View {
},
openSettings: {
self.presentedSheet = .settings
},
retryGatewayConnection: {
Task { await self.gatewayController.connectLastKnown() }
})
.preferredColorScheme(.dark)
@@ -229,7 +232,7 @@ struct RootCanvas: View {
private func updateCanvasDebugStatus() {
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
guard self.canvasDebugStatusEnabled else { return }
let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let title = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
@@ -454,6 +457,7 @@ private struct CanvasContent: View {
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
@State private var showGatewayActions: Bool = false
@State private var showGatewayProblemDetails: Bool = false
var systemColorScheme: ColorScheme
var gatewayStatus: StatusPill.GatewayState
var voiceWakeEnabled: Bool
@@ -462,6 +466,7 @@ private struct CanvasContent: View {
var cameraHUDKind: NodeAppModel.CameraHUDKind?
var openChat: () -> Void
var openSettings: () -> Void
var retryGatewayConnection: () -> Void
private var brightenButtons: Bool { self.systemColorScheme == .light }
private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled }
@@ -488,6 +493,8 @@ private struct CanvasContent: View {
onStatusTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else if self.appModel.lastGatewayProblem != nil {
self.showGatewayProblemDetails = true
} else {
self.openSettings()
}
@@ -504,13 +511,35 @@ private struct CanvasContent: View {
self.openSettings()
})
}
.overlay(alignment: .top) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
self.gatewayStatus != .connected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: gatewayProblem.retryable ? "Retry" : "Open Settings",
onPrimaryAction: {
if gatewayProblem.retryable {
self.retryGatewayConnection()
} else {
self.openSettings()
}
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
.padding(.horizontal, 12)
.safeAreaPadding(.top, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(
command: voiceWakeToastText,
brighten: self.brightenButtons)
.padding(.leading, 10)
.safeAreaPadding(.top, 58)
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
@@ -518,6 +547,16 @@ private struct CanvasContent: View {
isPresented: self.$showGatewayActions,
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.openSettings() })
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: "Open Settings",
onPrimaryAction: {
self.openSettings()
})
}
}
.onAppear {
// Keep the runtime talk state aligned with persisted toggle state on cold launch.
if self.talkEnabled != self.appModel.talkMode.isEnabled {

View File

@@ -9,6 +9,7 @@ struct RootTabs: View {
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var showGatewayActions: Bool = false
@State private var showGatewayProblemDetails: Bool = false
var body: some View {
TabView(selection: self.$selectedTab) {
@@ -32,6 +33,8 @@ struct RootTabs: View {
onTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else if self.appModel.lastGatewayProblem != nil {
self.showGatewayProblemDetails = true
} else {
self.selectedTab = 2
}
@@ -39,11 +42,29 @@ struct RootTabs: View {
.padding(.leading, 10)
.safeAreaPadding(.top, 10)
}
.overlay(alignment: .top) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
self.gatewayStatus != .connected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: "Open Settings",
onPrimaryAction: {
self.selectedTab = 2
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
.padding(.horizontal, 12)
.safeAreaPadding(.top, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(command: voiceWakeToastText)
.padding(.leading, 10)
.safeAreaPadding(.top, 58)
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
@@ -74,6 +95,16 @@ struct RootTabs: View {
isPresented: self.$showGatewayActions,
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.selectedTab = 2 })
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: "Open Settings",
onPrimaryAction: {
self.selectedTab = 2
})
}
}
}
private var gatewayStatus: StatusPill.GatewayState {

View File

@@ -53,6 +53,7 @@ struct SettingsTab: View {
@State private var selectedAgentPickerId: String = ""
@State private var showResetOnboardingAlert: Bool = false
@State private var showGatewayProblemDetails: Bool = false
@State private var activeFeatureHelp: FeatureHelp?
@State private var suppressCredentialPersist: Bool = false
@@ -63,6 +64,20 @@ struct SettingsTab: View {
Form {
Section {
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
!self.isGatewayConnected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: "Retry connection",
onPrimaryAction: {
Task { await self.retryGatewayConnectionFromProblem() }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
}
if !self.isGatewayConnected {
Text(
"1. Open a chat with your OpenClaw agent and send /pair\n"
@@ -123,7 +138,7 @@ struct SettingsTab: View {
if self.appModel.gatewayServerName == nil {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
}
LabeledContent("Status", value: self.appModel.gatewayStatusText)
LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText)
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
if let serverName = self.appModel.gatewayServerName {
@@ -402,6 +417,16 @@ struct SettingsTab: View {
.accessibilityLabel("Close")
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: "Retry",
onPrimaryAction: {
Task { await self.retryGatewayConnectionFromProblem() }
})
}
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
@@ -593,6 +618,9 @@ struct SettingsTab: View {
if let server = self.appModel.gatewayServerName, self.isGatewayConnected {
return server
}
if let problem = self.appModel.lastGatewayProblem {
return problem.statusText
}
let trimmed = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Not connected" : trimmed
}
@@ -642,7 +670,7 @@ struct SettingsTab: View {
private func gatewayDebugText() -> String {
var lines: [String] = [
"gateway: \(self.appModel.gatewayStatusText)",
"gateway: \(self.appModel.gatewayDisplayStatusText)",
"discovery: \(self.gatewayController.discoveryStatusText)",
]
lines.append("server: \(self.appModel.gatewayServerName ?? "")")
@@ -889,6 +917,9 @@ struct SettingsTab: View {
}
private var setupStatusLine: String? {
if let problem = self.appModel.lastGatewayProblem {
return problem.message
}
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
@@ -987,6 +1018,14 @@ struct SettingsTab: View {
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
}
private func retryGatewayConnectionFromProblem() async {
if self.manualGatewayEnabled || self.connectingGatewayID == "manual" {
await self.connectManual()
return
}
await self.connectLastKnown()
}
private func resetOnboarding() {
// Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
self.appModel.disconnectGateway()

View File

@@ -1,11 +1,24 @@
import Foundation
import OpenClawKit
enum GatewayStatusBuilder {
@MainActor
static func build(appModel: NodeAppModel) -> StatusPill.GatewayState {
if appModel.gatewayServerName != nil { return .connected }
self.build(
gatewayServerName: appModel.gatewayServerName,
lastGatewayProblem: appModel.lastGatewayProblem,
gatewayStatusText: appModel.gatewayStatusText)
}
let text = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
static func build(
gatewayServerName: String?,
lastGatewayProblem: GatewayConnectionProblem?,
gatewayStatusText: String) -> StatusPill.GatewayState
{
if gatewayServerName != nil { return .connected }
if let lastGatewayProblem, lastGatewayProblem.pauseReconnect { return .error }
let text = gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting")
{

View File

@@ -16,6 +16,31 @@ enum StatusActivityBuilder {
tint: .orange)
}
if let gatewayProblem = appModel.lastGatewayProblem {
switch gatewayProblem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
return StatusPill.Activity(
title: "Approval pending",
systemImage: "person.crop.circle.badge.clock",
tint: .orange)
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return StatusPill.Activity(
title: "Check network",
systemImage: "wifi.exclamationmark",
tint: .orange)
default:
if gatewayProblem.pauseReconnect {
return StatusPill.Activity(
title: "Action required",
systemImage: "exclamationmark.triangle.fill",
tint: .orange)
}
}
}
let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased()
if gatewayLower.contains("repair") {

View File

@@ -0,0 +1,36 @@
import OpenClawKit
import Testing
@testable import OpenClaw
@Suite struct GatewayStatusBuilderTests {
@Test func pausedProblemKeepsErrorStatus() {
let state = GatewayStatusBuilder.build(
gatewayServerName: nil,
lastGatewayProblem: GatewayConnectionProblem(
kind: .pairingRequired,
owner: .gateway,
title: "Pairing required",
message: "Approve this device before reconnecting.",
requestId: "req-123",
retryable: false,
pauseReconnect: true),
gatewayStatusText: "Reconnecting…")
#expect(state == .error)
}
@Test func transientProblemAllowsConnectingStatus() {
let state = GatewayStatusBuilder.build(
gatewayServerName: nil,
lastGatewayProblem: GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
retryable: true,
pauseReconnect: false),
gatewayStatusText: "Reconnecting…")
#expect(state == .connecting)
}
}

View File

@@ -38,6 +38,7 @@ enum HostEnvSecurityPolicy {
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE",
"JAVA_OPTS",
"JAVA_TOOL_OPTIONS",
"_JAVA_OPTIONS",
"JDK_JAVA_OPTIONS",
@@ -144,6 +145,8 @@ enum HostEnvSecurityPolicy {
"HGRCPATH",
"PYTHONUSERBASE",
"RUSTC_WRAPPER",
"RUSTFLAGS",
"CARGO_HOME",
"VIRTUAL_ENV",
"LUA_PATH",
"LUA_CPATH",

View File

@@ -624,11 +624,31 @@ public actor GatewayChannelActor {
let detailCode = details?["code"]?.value as? String
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
let recommendedNextStep = details?["recommendedNextStep"]?.value as? String
let requestId = details?["requestId"]?.value as? String
let reason = details?["reason"]?.value as? String
let owner = details?["owner"]?.value as? String
let title = details?["title"]?.value as? String
let userMessage = details?["userMessage"]?.value as? String
let actionLabel = details?["actionLabel"]?.value as? String
let actionCommand = details?["actionCommand"]?.value as? String
let docsURLString = details?["docsUrl"]?.value as? String
let retryableOverride = details?["retryable"]?.value as? Bool
let pauseReconnectOverride = details?["pauseReconnect"]?.value as? Bool
throw GatewayConnectAuthError(
message: msg,
detailCodeRaw: detailCode,
canRetryWithDeviceToken: canRetryWithDeviceToken,
recommendedNextStepRaw: recommendedNextStep)
recommendedNextStepRaw: recommendedNextStep,
requestId: requestId,
detailsReason: reason,
ownerRaw: owner,
titleOverride: title,
userMessageOverride: userMessage,
actionLabel: actionLabel,
actionCommand: actionCommand,
docsURLString: docsURLString,
retryableOverride: retryableOverride,
pauseReconnectOverride: pauseReconnectOverride)
}
guard let payload = res.payload else {
throw NSError(

View File

@@ -0,0 +1,761 @@
import Foundation
public struct GatewayConnectionProblem: Equatable, Sendable {
public enum Kind: String, Equatable, Sendable {
case gatewayAuthTokenMissing
case gatewayAuthTokenMismatch
case gatewayAuthTokenNotConfigured
case gatewayAuthPasswordMissing
case gatewayAuthPasswordMismatch
case gatewayAuthPasswordNotConfigured
case bootstrapTokenInvalid
case deviceTokenMismatch
case pairingRequired
case pairingRoleUpgradeRequired
case pairingScopeUpgradeRequired
case pairingMetadataUpgradeRequired
case deviceIdentityRequired
case deviceSignatureExpired
case deviceNonceRequired
case deviceNonceMismatch
case deviceSignatureInvalid
case devicePublicKeyInvalid
case deviceIdMismatch
case tailscaleIdentityMissing
case tailscaleProxyMissing
case tailscaleWhoisFailed
case tailscaleIdentityMismatch
case authRateLimited
case timeout
case connectionRefused
case reachabilityFailed
case websocketCancelled
case unknown
}
public enum Owner: String, Equatable, Sendable {
case gateway
case iphone
case both
case network
case unknown
}
public let kind: Kind
public let owner: Owner
public let title: String
public let message: String
public let actionLabel: String?
public let actionCommand: String?
public let docsURL: URL?
public let requestId: String?
public let retryable: Bool
public let pauseReconnect: Bool
public let technicalDetails: String?
public init(
kind: Kind,
owner: Owner,
title: String,
message: String,
actionLabel: String? = nil,
actionCommand: String? = nil,
docsURL: URL? = nil,
requestId: String? = nil,
retryable: Bool,
pauseReconnect: Bool,
technicalDetails: String? = nil)
{
self.kind = kind
self.owner = owner
self.title = title
self.message = message
self.actionLabel = Self.trimmedOrNil(actionLabel)
self.actionCommand = Self.trimmedOrNil(actionCommand)
self.docsURL = docsURL
self.requestId = Self.trimmedOrNil(requestId)
self.retryable = retryable
self.pauseReconnect = pauseReconnect
self.technicalDetails = Self.trimmedOrNil(technicalDetails)
}
public var needsPairingApproval: Bool {
switch self.kind {
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
return true
default:
return false
}
}
public var needsCredentialUpdate: Bool {
switch self.kind {
case .gatewayAuthTokenMissing,
.gatewayAuthTokenMismatch,
.gatewayAuthTokenNotConfigured,
.gatewayAuthPasswordMissing,
.gatewayAuthPasswordMismatch,
.gatewayAuthPasswordNotConfigured,
.bootstrapTokenInvalid,
.deviceTokenMismatch:
return true
default:
return false
}
}
public var statusText: String {
switch self.kind {
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
if let requestId {
return "\(self.title) (request ID: \(requestId))"
}
return self.title
default:
return self.title
}
}
private static func trimmedOrNil(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}
public enum GatewayConnectionProblemMapper {
public static func map(error: Error, preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem? {
guard let nextProblem = self.rawMap(error) else {
return nil
}
guard let previousProblem else {
return nextProblem
}
if self.shouldPreserve(previousProblem: previousProblem, over: nextProblem) {
return previousProblem
}
return nextProblem
}
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, over nextProblem: GatewayConnectionProblem) -> Bool {
if nextProblem.kind == .websocketCancelled {
return previousProblem.pauseReconnect || previousProblem.requestId != nil
}
return false
}
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, overDisconnectReason reason: String) -> Bool {
let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty else { return false }
if normalized.contains("cancelled") || normalized.contains("canceled") {
return previousProblem.pauseReconnect || previousProblem.requestId != nil
}
return false
}
private static func rawMap(_ error: Error) -> GatewayConnectionProblem? {
if let authError = error as? GatewayConnectAuthError {
return self.map(authError)
}
if let responseError = error as? GatewayResponseError {
return self.map(responseError)
}
return self.mapTransportError(error)
}
private static func map(_ authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
let pairingCommand = self.approvalCommand(requestId: authError.requestId)
switch authError.detail {
case .authTokenMissing:
return self.problem(
kind: .gatewayAuthTokenMissing,
owner: .both,
title: authError.titleOverride ?? "Gateway token required",
message: authError.userMessageOverride
?? "This gateway requires an auth token, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTokenMismatch:
return self.problem(
kind: .gatewayAuthTokenMismatch,
owner: .both,
title: authError.titleOverride ?? "Gateway token is out of date",
message: authError.userMessageOverride
?? "The token on this iPhone does not match the gateway token.",
actionLabel: authError.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: authError.retryableOverride ?? authError.canRetryWithDeviceToken,
pauseReconnect: authError.pauseReconnectOverride ?? !authError.canRetryWithDeviceToken,
authError: authError)
case .authTokenNotConfigured:
return self.problem(
kind: .gatewayAuthTokenNotConfigured,
owner: .gateway,
title: authError.titleOverride ?? "Gateway token is not configured",
message: authError.userMessageOverride
?? "This gateway is set to token auth, but no gateway token is configured on the gateway.",
actionLabel: authError.actionLabel ?? "Fix on gateway",
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.token <new-token>",
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authPasswordMissing:
return self.problem(
kind: .gatewayAuthPasswordMissing,
owner: .both,
title: authError.titleOverride ?? "Gateway password required",
message: authError.userMessageOverride
?? "This gateway requires a password, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authPasswordMismatch:
return self.problem(
kind: .gatewayAuthPasswordMismatch,
owner: .both,
title: authError.titleOverride ?? "Gateway password is out of date",
message: authError.userMessageOverride
?? "The saved password on this iPhone does not match the gateway password.",
actionLabel: authError.actionLabel ?? "Update password",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authPasswordNotConfigured:
return self.problem(
kind: .gatewayAuthPasswordNotConfigured,
owner: .gateway,
title: authError.titleOverride ?? "Gateway password is not configured",
message: authError.userMessageOverride
?? "This gateway is set to password auth, but no gateway password is configured on the gateway.",
actionLabel: authError.actionLabel ?? "Fix on gateway",
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.password <new-password>",
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authBootstrapTokenInvalid:
return self.problem(
kind: .bootstrapTokenInvalid,
owner: .iphone,
title: authError.titleOverride ?? "Setup code expired",
message: authError.userMessageOverride
?? "The setup QR or bootstrap token is no longer valid.",
actionLabel: authError.actionLabel ?? "Scan QR again",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authDeviceTokenMismatch:
return self.problem(
kind: .deviceTokenMismatch,
owner: .both,
title: authError.titleOverride ?? "This iPhone's saved device token is no longer valid",
message: authError.userMessageOverride
?? "The gateway rejected the stored device token for this role.",
actionLabel: authError.actionLabel ?? "Repair pairing",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .pairingRequired:
return self.pairingProblem(for: authError)
case .controlUiDeviceIdentityRequired, .deviceIdentityRequired:
return self.problem(
kind: .deviceIdentityRequired,
owner: .iphone,
title: authError.titleOverride ?? "Secure device identity is required",
message: authError.userMessageOverride
?? "This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
actionLabel: authError.actionLabel ?? "Retry from the app",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .deviceAuthSignatureExpired:
return self.problem(
kind: .deviceSignatureExpired,
owner: .iphone,
title: authError.titleOverride ?? "Secure handshake expired",
message: authError.userMessageOverride ?? "The device signature is too old to use.",
actionLabel: authError.actionLabel ?? "Check iPhone time",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
authError: authError)
case .deviceAuthNonceRequired:
return self.problem(
kind: .deviceNonceRequired,
owner: .iphone,
title: authError.titleOverride ?? "Secure handshake is incomplete",
message: authError.userMessageOverride
?? "The gateway expected a one-time challenge response, but the nonce was missing.",
actionLabel: authError.actionLabel ?? "Retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
authError: authError)
case .deviceAuthNonceMismatch:
return self.problem(
kind: .deviceNonceMismatch,
owner: .iphone,
title: authError.titleOverride ?? "Secure handshake did not match",
message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.",
actionLabel: authError.actionLabel ?? "Retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
authError: authError)
case .deviceAuthSignatureInvalid, .deviceAuthInvalid:
return self.problem(
kind: .deviceSignatureInvalid,
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway could not verify the identity this iPhone presented.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .deviceAuthPublicKeyInvalid:
return self.problem(
kind: .devicePublicKeyInvalid,
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway could not verify the public key this iPhone presented.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .deviceAuthDeviceIdMismatch:
return self.problem(
kind: .deviceIdMismatch,
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway rejected the device identity because the device ID did not match.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleIdentityMissing:
return self.problem(
kind: .tailscaleIdentityMissing,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "This connection expected Tailscale identity headers, but they were not available.",
actionLabel: authError.actionLabel ?? "Turn on Tailscale",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleProxyMissing:
return self.problem(
kind: .tailscaleProxyMissing,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "The gateway expected a Tailscale auth proxy, but it was not configured.",
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleWhoisFailed:
return self.problem(
kind: .tailscaleWhoisFailed,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "The gateway could not verify this Tailscale client identity.",
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleIdentityMismatch:
return self.problem(
kind: .tailscaleIdentityMismatch,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "The forwarded Tailscale identity did not match the verified identity.",
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authRateLimited:
return self.problem(
kind: .authRateLimited,
owner: .gateway,
title: authError.titleOverride ?? "Too many failed attempts",
message: authError.userMessageOverride
?? "The gateway is temporarily refusing new auth attempts after repeated failures.",
actionLabel: authError.actionLabel ?? "Wait and retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authRequired, .authUnauthorized, .none:
return self.problem(
kind: .unknown,
owner: authError.ownerRaw.flatMap { self.owner(from: $0) } ?? .unknown,
title: authError.titleOverride ?? "Gateway rejected the connection",
message: authError.userMessageOverride ?? authError.message,
actionLabel: authError.actionLabel,
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: nil),
requestId: authError.requestId,
retryable: authError.retryableOverride ?? false,
pauseReconnect: authError.pauseReconnectOverride ?? authError.isNonRecoverable,
authError: authError)
}
}
private static func map(_ responseError: GatewayResponseError) -> GatewayConnectionProblem? {
let code = responseError.code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
if code == "NOT_PAIRED" || responseError.detailsReason == "not-paired" {
let authError = GatewayConnectAuthError(
message: responseError.message,
detailCodeRaw: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
recommendedNextStepRaw: nil,
requestId: self.stringValue(responseError.details["requestId"]?.value),
detailsReason: responseError.detailsReason,
ownerRaw: nil,
titleOverride: nil,
userMessageOverride: nil,
actionLabel: nil,
actionCommand: nil,
docsURLString: nil,
retryableOverride: nil,
pauseReconnectOverride: nil)
return self.map(authError)
}
return nil
}
private static func mapTransportError(_ error: Error) -> GatewayConnectionProblem? {
let nsError = error as NSError
let rawMessage = nsError.userInfo[NSLocalizedDescriptionKey] as? String ?? nsError.localizedDescription
let lower = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if lower.isEmpty {
return nil
}
let urlErrorCode = URLError.Code(rawValue: nsError.code)
if nsError.domain == URLError.errorDomain {
switch urlErrorCode {
case .timedOut:
return GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cannotConnectToHost:
return GatewayConnectionProblem(
kind: .connectionRefused,
owner: .network,
title: "Gateway refused the connection",
message: "The gateway host was reachable, but it refused the connection.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .callIsActive, .dataNotAllowed:
return GatewayConnectionProblem(
kind: .reachabilityFailed,
owner: .network,
title: "Gateway is not reachable",
message: "OpenClaw could not reach the gateway over the current network.",
actionLabel: "Check network",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cancelled:
return GatewayConnectionProblem(
kind: .websocketCancelled,
owner: .network,
title: "Connection interrupted",
message: "The connection to the gateway was interrupted before setup completed.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
default:
break
}
}
if lower.contains("timed out") {
return GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("connection refused") || lower.contains("refused") {
return GatewayConnectionProblem(
kind: .connectionRefused,
owner: .network,
title: "Gateway refused the connection",
message: "The gateway host was reachable, but it refused the connection.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("cannot find host") || lower.contains("could not connect") || lower.contains("network is unreachable") {
return GatewayConnectionProblem(
kind: .reachabilityFailed,
owner: .network,
title: "Gateway is not reachable",
message: "OpenClaw could not reach the gateway over the current network.",
actionLabel: "Check network",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("cancelled") || lower.contains("canceled") {
return GatewayConnectionProblem(
kind: .websocketCancelled,
owner: .network,
title: "Connection interrupted",
message: "The connection to the gateway was interrupted before setup completed.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
return nil
}
private static func pairingProblem(for authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
let requestId = authError.requestId
let pairingCommand = self.approvalCommand(requestId: requestId)
switch authError.detailsReason {
case "role-upgrade":
return self.problem(
kind: .pairingRoleUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Additional approval required",
message: authError.userMessageOverride
?? "This iPhone is already paired, but it is requesting a new role that was not previously approved.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case "scope-upgrade":
return self.problem(
kind: .pairingScopeUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Additional permissions required",
message: authError.userMessageOverride
?? "This iPhone is already paired, but it is requesting new permissions that require approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case "metadata-upgrade":
return self.problem(
kind: .pairingMetadataUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Device approval needs refresh",
message: authError.userMessageOverride
?? "The gateway detected a change in this device's approved identity metadata and requires re-approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
default:
return self.problem(
kind: .pairingRequired,
owner: .gateway,
title: authError.titleOverride ?? "This iPhone is not approved yet",
message: authError.userMessageOverride
?? "The gateway received the connection request, but this device must be approved first.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
}
}
private static func problem(
kind: GatewayConnectionProblem.Kind,
owner: GatewayConnectionProblem.Owner,
title: String,
message: String,
actionLabel: String?,
actionCommand: String?,
docsURL: URL?,
requestId: String?,
retryable: Bool,
pauseReconnect: Bool,
authError: GatewayConnectAuthError)
-> GatewayConnectionProblem
{
GatewayConnectionProblem(
kind: kind,
owner: authError.ownerRaw.flatMap(self.owner(from:)) ?? owner,
title: title,
message: message,
actionLabel: actionLabel,
actionCommand: actionCommand,
docsURL: docsURL,
requestId: requestId,
retryable: authError.retryableOverride ?? retryable,
pauseReconnect: authError.pauseReconnectOverride ?? pauseReconnect,
technicalDetails: self.technicalDetails(for: authError))
}
private static func approvalCommand(requestId: String?) -> String {
if let requestId = self.nonEmpty(requestId) {
return "openclaw devices approve \(requestId)"
}
return "openclaw devices list"
}
private static func technicalDetails(for authError: GatewayConnectAuthError) -> String? {
var parts: [String] = []
if let detail = self.nonEmpty(authError.detailCodeRaw) {
parts.append(detail)
}
if let reason = self.nonEmpty(authError.detailsReason) {
parts.append("reason=\(reason)")
}
if let requestId = self.nonEmpty(authError.requestId) {
parts.append("requestId=\(requestId)")
}
if let nextStep = self.nonEmpty(authError.recommendedNextStepRaw) {
parts.append("next=\(nextStep)")
}
if authError.canRetryWithDeviceToken {
parts.append("deviceTokenRetry=true")
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
private static func docsURL(_ preferred: String?, fallback: String?) -> URL? {
if let preferred = self.nonEmpty(preferred), let url = URL(string: preferred) {
return url
}
if let fallback = self.nonEmpty(fallback), let url = URL(string: fallback) {
return url
}
return nil
}
private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? {
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "gateway":
return .gateway
case "iphone", "ios", "device":
return .iphone
case "both":
return .both
case "network":
return .network
case "unknown", "":
return .unknown
default:
return nil
}
}
private static func stringValue(_ value: Any?) -> String? {
self.nonEmpty(value as? String)
}
private static func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@@ -43,12 +43,32 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
public let detailCodeRaw: String?
public let recommendedNextStepRaw: String?
public let canRetryWithDeviceToken: Bool
public let requestId: String?
public let detailsReason: String?
public let ownerRaw: String?
public let titleOverride: String?
public let userMessageOverride: String?
public let actionLabel: String?
public let actionCommand: String?
public let docsURLString: String?
public let retryableOverride: Bool?
public let pauseReconnectOverride: Bool?
public init(
message: String,
detailCodeRaw: String?,
canRetryWithDeviceToken: Bool,
recommendedNextStepRaw: String? = nil)
recommendedNextStepRaw: String? = nil,
requestId: String? = nil,
detailsReason: String? = nil,
ownerRaw: String? = nil,
titleOverride: String? = nil,
userMessageOverride: String? = nil,
actionLabel: String? = nil,
actionCommand: String? = nil,
docsURLString: String? = nil,
retryableOverride: Bool? = nil,
pauseReconnectOverride: Bool? = nil)
{
let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDetailCode = detailCodeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -59,19 +79,54 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
self.canRetryWithDeviceToken = canRetryWithDeviceToken
self.recommendedNextStepRaw =
trimmedRecommendedNextStep?.isEmpty == false ? trimmedRecommendedNextStep : nil
self.requestId = Self.trimmedOrNil(requestId)
self.detailsReason = Self.trimmedOrNil(detailsReason)
self.ownerRaw = Self.trimmedOrNil(ownerRaw)
self.titleOverride = Self.trimmedOrNil(titleOverride)
self.userMessageOverride = Self.trimmedOrNil(userMessageOverride)
self.actionLabel = Self.trimmedOrNil(actionLabel)
self.actionCommand = Self.trimmedOrNil(actionCommand)
self.docsURLString = Self.trimmedOrNil(docsURLString)
self.retryableOverride = retryableOverride
self.pauseReconnectOverride = pauseReconnectOverride
}
public init(
message: String,
detailCode: String?,
canRetryWithDeviceToken: Bool,
recommendedNextStep: String? = nil)
recommendedNextStep: String? = nil,
requestId: String? = nil,
detailsReason: String? = nil,
ownerRaw: String? = nil,
titleOverride: String? = nil,
userMessageOverride: String? = nil,
actionLabel: String? = nil,
actionCommand: String? = nil,
docsURLString: String? = nil,
retryableOverride: Bool? = nil,
pauseReconnectOverride: Bool? = nil)
{
self.init(
message: message,
detailCodeRaw: detailCode,
canRetryWithDeviceToken: canRetryWithDeviceToken,
recommendedNextStepRaw: recommendedNextStep)
recommendedNextStepRaw: recommendedNextStep,
requestId: requestId,
detailsReason: detailsReason,
ownerRaw: ownerRaw,
titleOverride: titleOverride,
userMessageOverride: userMessageOverride,
actionLabel: actionLabel,
actionCommand: actionCommand,
docsURLString: docsURLString,
retryableOverride: retryableOverride,
pauseReconnectOverride: pauseReconnectOverride)
}
private static func trimmedOrNil(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
public var detailCode: String? { self.detailCodeRaw }

View File

@@ -1,3 +1,4 @@
import Foundation
import OpenClawKit
import Testing
@@ -11,4 +12,81 @@ import Testing
#expect(error.isNonRecoverable)
#expect(error.detail == .authBootstrapTokenInvalid)
}
@Test func connectAuthErrorPreservesStructuredMetadata() {
let error = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
recommendedNextStep: "review_auth_configuration",
requestId: "req-123",
detailsReason: "scope-upgrade",
ownerRaw: "gateway",
titleOverride: "Additional permissions required",
userMessageOverride: "Approve the requested permissions on the gateway, then reconnect.",
actionLabel: "Approve on gateway",
actionCommand: "openclaw devices approve req-123",
docsURLString: "https://docs.openclaw.ai/gateway/pairing",
retryableOverride: false,
pauseReconnectOverride: true)
#expect(error.requestId == "req-123")
#expect(error.detailsReason == "scope-upgrade")
#expect(error.ownerRaw == "gateway")
#expect(error.titleOverride == "Additional permissions required")
#expect(error.actionCommand == "openclaw devices approve req-123")
#expect(error.docsURLString == "https://docs.openclaw.ai/gateway/pairing")
#expect(error.pauseReconnectOverride == true)
}
@Test func pairingProblemUsesStructuredRequestMetadata() {
let error = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
requestId: "req-123",
detailsReason: "scope-upgrade")
let problem = GatewayConnectionProblemMapper.map(error: error)
#expect(problem?.kind == .pairingScopeUpgradeRequired)
#expect(problem?.requestId == "req-123")
#expect(problem?.pauseReconnect == true)
#expect(problem?.actionCommand == "openclaw devices approve req-123")
}
@Test func cancelledTransportDoesNotReplaceStructuredPairingProblem() {
let pairing = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
requestId: "req-123")
let previousProblem = GatewayConnectionProblemMapper.map(error: pairing)
let cancelled = NSError(
domain: URLError.errorDomain,
code: URLError.cancelled.rawValue,
userInfo: [NSLocalizedDescriptionKey: "gateway receive: cancelled"])
let preserved = GatewayConnectionProblemMapper.map(error: cancelled, preserving: previousProblem)
#expect(preserved?.kind == .pairingRequired)
#expect(preserved?.requestId == "req-123")
}
@Test func unmappedTransportErrorClearsStaleStructuredProblem() {
let pairing = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
requestId: "req-123")
let previousProblem = GatewayConnectionProblemMapper.map(error: pairing)
let unknownTransport = NSError(
domain: NSURLErrorDomain,
code: -1202,
userInfo: [NSLocalizedDescriptionKey: "certificate chain validation failed"])
let mapped = GatewayConnectionProblemMapper.map(error: unknownTransport, preserving: previousProblem)
#expect(mapped == nil)
}
}

View File

@@ -1,4 +1,4 @@
af24bd5a2a86e8bb481302211b35c440e82636585c46f57050648c0290b1d4ee config-baseline.json
73bda77ebf7d70609c57f394655332536eb5ff55516a6b7db06243bd4e8e44a5 config-baseline.core.json
f7b342080a730da84d1ac84a888e9506d24ee7ce7ec6ec6c0cc4f1897fabcde3 config-baseline.json
c3dd9fb8a0059dba411c4d88a6b84ca28af1e0b1925c669058ef9f38c6d2718b config-baseline.core.json
d22f4414b79ee03d896e58d875c80523bcc12303cbacb1700261e6ec73945187 config-baseline.channel.json
d42cee3dea4668bdb7daf6ff5e6f87f326fdef56a8c3716d73079b92cab6e7b2 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
5e28885aeddb1c2e73040c88b503d584bbcd871c6941fd1ebf7f22ceac3477a6 plugin-sdk-api-baseline.json
c8bbc54b51588b6b9aecabb3fcf02ecb69867c8ac527b65d5ec3bc5c6288057a plugin-sdk-api-baseline.jsonl
2efa99907731355b31a1b95a6baa9cf5bf8d25c67931837857c9bb9dd39fad95 plugin-sdk-api-baseline.json
6c99467113b5d6a015cbd424f2eb5c7e21a6c665b3e8d0372e0e09a2218ef13e plugin-sdk-api-baseline.jsonl

View File

@@ -354,13 +354,5 @@
{
"source": "Testing",
"target": "测试"
},
{
"source": "Capability CLI Alias",
"target": "Capability CLI 别名"
},
{
"source": "Inference CLI",
"target": "推理 CLI"
}
]

View File

@@ -880,7 +880,8 @@ See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layo
## Exec approvals
Matrix can act as an exec approval client for a Matrix account.
Matrix can act as a native approval client for a Matrix account. The native
DM/channel routing knobs still live under exec approval config:
- `channels.matrix.execApprovals.enabled`
- `channels.matrix.execApprovals.approvers` (optional; falls back to `channels.matrix.dm.allowFrom`)
@@ -888,13 +889,14 @@ Matrix can act as an exec approval client for a Matrix account.
- `channels.matrix.execApprovals.agentFilter`
- `channels.matrix.execApprovals.sessionFilter`
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the exec approval fallback policy.
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved. Exec approvals use `execApprovals.approvers` first and can fall back to `channels.matrix.dm.allowFrom`. Plugin approvals authorize through `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the approval fallback policy.
Native Matrix routing is exec-only today:
Matrix native routing now supports both approval kinds:
- `channels.matrix.execApprovals.*` controls native DM/channel routing for exec approvals only.
- Plugin approvals still use shared same-chat `/approve` plus any configured `approvals.plugin` forwarding.
- Matrix can still reuse `channels.matrix.dm.allowFrom` for plugin-approval authorization when it can infer approvers safely, but it does not expose a separate native plugin-approval DM/channel fanout path.
- `channels.matrix.execApprovals.*` controls the native DM/channel fanout mode for Matrix approval prompts.
- Exec approvals use the exec approver set from `execApprovals.approvers` or `channels.matrix.dm.allowFrom`.
- Plugin approvals use the Matrix DM allowlist from `channels.matrix.dm.allowFrom`.
- Matrix reaction shortcuts and message updates apply to both exec and plugin approvals.
Delivery rules:
@@ -910,9 +912,9 @@ Matrix approval prompts seed reaction shortcuts on the primary approval message:
Approvers can react on that message or use the fallback slash commands: `/approve <id> allow-once`, `/approve <id> allow-always`, or `/approve <id> deny`.
Only resolved approvers can approve or deny. Channel delivery includes the command text, so only enable `channel` or `both` in trusted rooms.
Only resolved approvers can approve or deny. For exec approvals, channel delivery includes the command text, so only enable `channel` or `both` in trusted rooms.
Matrix approval prompts reuse the shared core approval planner. The Matrix-specific native surface is transport only for exec approvals: room/DM routing and message send/update/delete behavior.
Matrix approval prompts reuse the shared core approval planner. The Matrix-specific native surface handles room/DM routing, reactions, and message send/update/delete behavior for both exec and plugin approvals.
Per-account override:

View File

@@ -1,23 +0,0 @@
---
summary: "Compatibility alias page for `openclaw capability`; use the dedicated `openclaw infer` docs"
read_when:
- Adding or modifying `openclaw infer` commands
- Updating compatibility aliases for infer-first automation
title: "Capability CLI Alias"
---
# Capability CLI Alias
`openclaw capability` is a compatibility alias for `openclaw infer`.
Use the dedicated infer page for the current command surface, examples, and JSON output contract:
- [Inference CLI](/cli/infer)
Example:
```bash
openclaw capability model run --prompt "Reply with exactly: smoke-ok" --json
```
`openclaw capability ...` and `openclaw infer ...` accept the same subcommands today, but docs, scripts, and future examples should target `infer`.

View File

@@ -1,5 +1,5 @@
---
summary: "Infer-first CLI for multimodal discuss, generate, convert, transcribe, and edit workflows"
summary: "Infer-first CLI for provider-backed model, image, audio, TTS, video, web, and embedding workflows"
read_when:
- Adding or modifying `openclaw infer` commands
- Designing stable headless capability automation
@@ -8,36 +8,46 @@ title: "Inference CLI"
# Inference CLI
`openclaw infer` is the canonical headless surface for provider-backed multimodal workflows.
`openclaw capability` remains supported as a fallback alias for compatibility.
`openclaw infer` is the canonical headless surface for provider-backed inference workflows.
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
## What infer is for
## Turn infer into a skill
Think about `infer` as the CLI for three broad jobs:
Copy and paste this to an agent:
- Discuss: ask a model, inspect media, transcribe audio, search or fetch web content.
- Generate: create images, video, speech, and embeddings.
- Edit or transform: mutate an existing artifact when the capability supports it.
```text
Read https://docs.openclaw.ai/cli/infer, then create a skill that routes my common workflows to `openclaw infer`.
Focus on model runs, image generation, video generation, audio transcription, TTS, web search, and embeddings.
```
Today that maps to the current infer surface like this:
A good infer-based skill should:
| Modality | Discuss / inspect | Generate / convert | Edit / transform |
| ---------- | --------------------------------------- | ------------------ | ---------------- |
| Text | `model run` | - | - |
| Image | `image describe`, `image describe-many` | `image generate` | `image edit` |
| Audio | `audio transcribe` | `tts convert` | - |
| Video | `video describe` | `video generate` | - |
| Web | `web search`, `web fetch` | - | - |
| Embeddings | - | `embedding create` | - |
- map common user intents to the correct infer subcommand
- include a few canonical infer examples for the workflows it covers
- prefer `openclaw infer ...` in examples and suggestions
- avoid re-documenting the entire infer surface inside the skill body
Current note:
Typical infer-focused skill coverage:
- `infer` already feels like a multimodal discuss and generate surface.
- First-class edit support is currently image-focused on this CLI.
- Audio and video editing are not exposed as dedicated `infer` commands yet, so docs should not imply they exist.
- `openclaw infer model run`
- `openclaw infer image generate`
- `openclaw infer audio transcribe`
- `openclaw infer tts convert`
- `openclaw infer web search`
- `openclaw infer embedding create`
## Why use infer
`openclaw infer` provides one consistent CLI for provider-backed inference tasks inside OpenClaw.
Benefits:
- Use the providers and models already configured in OpenClaw instead of wiring up one-off wrappers for each backend.
- Keep model, image, audio transcription, TTS, video, web, and embedding workflows under one command tree.
- Use a stable `--json` output shape for scripts, automation, and agent-driven workflows.
- Prefer a first-party OpenClaw surface when the task is fundamentally "run inference."
- Use the normal local path without requiring the gateway for most infer commands.
## Command tree
@@ -90,213 +100,139 @@ Current note:
providers
```
## Transport
## Common tasks
Supported transport flags:
This table maps common inference tasks to the corresponding infer command.
- `--local`
- `--gateway`
| Task | Command | Notes |
| ----------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------- |
| Run a text/model prompt | `openclaw infer model run --prompt "..." --json` | Uses the normal local path by default |
| Generate an image | `openclaw infer image generate --prompt "..." --json` | Use `image edit` when starting from an existing file |
| Describe an image file | `openclaw infer image describe --file ./image.png --json` | `--model` must be `<provider/model>` |
| Transcribe audio | `openclaw infer audio transcribe --file ./memo.m4a --json` | `--model` must be `<provider/model>` |
| Synthesize speech | `openclaw infer tts convert --text "..." --output ./speech.mp3 --json` | `tts status` is gateway-oriented |
| Generate a video | `openclaw infer video generate --prompt "..." --json` | |
| Describe a video file | `openclaw infer video describe --file ./clip.mp4 --json` | `--model` must be `<provider/model>` |
| Search the web | `openclaw infer web search --query "..." --json` | |
| Fetch a web page | `openclaw infer web fetch --url https://example.com --json` | |
| Create embeddings | `openclaw infer embedding create --text "..." --json` | |
Default transport is implicit auto at the command-family level:
## Behavior
- `openclaw infer ...` is the primary CLI surface for these workflows.
- Use `--json` when the output will be consumed by another command or script.
- Use `--provider` or `--model provider/model` when a specific backend is required.
- For `image describe`, `audio transcribe`, and `video describe`, `--model` must use the form `<provider/model>`.
- Stateless execution commands default to local.
- Gateway-managed state commands default to gateway.
- The normal local path does not require the gateway to be running.
Examples:
## Model
```bash
openclaw infer model run --prompt "hello" --json
openclaw infer image generate --prompt "friendly lobster" --json
openclaw infer audio transcribe --file ./memo.m4a --language en --prompt "Focus on names and action items" --json
openclaw infer tts convert --text "hello from openclaw" --output ./hello.mp3 --json
openclaw infer video generate --prompt "cinematic sunset over the ocean" --json
openclaw infer web search --query "OpenClaw docs" --limit 5 --json
openclaw infer tts status --json
openclaw infer embedding create --text "hello world" --json
```
## Quick start
These are the primary headless workflows:
Use `model` for provider-backed text inference and model/provider inspection.
```bash
openclaw infer model run --prompt "Reply with exactly: smoke-ok" --json
openclaw infer image generate --prompt "friendly lobster illustration" --output ./lobster.png --json
openclaw infer audio transcribe --file ./memo.m4a --language en --prompt "Focus on names and action items" --json
openclaw infer tts convert --text "hello from openclaw" --output ./hello.mp3 --json
openclaw infer video generate --prompt "cinematic sunset over the ocean" --output ./sunset.mp4 --json
openclaw infer web search --query "OpenClaw docs" --limit 5 --json
openclaw infer embedding create --text "friendly lobster" --json
```
If you want the shortest mental model:
- discuss with `model run`
- inspect media with `image describe`, `video describe`, and `audio transcribe`
- generate media with `image generate`, `video generate`, and `tts convert`
- edit existing images with `image edit`
Use `--model <provider/model>` when you want to pin execution to a specific provider path.
Maintainers can smoke this CLI surface end-to-end with `pnpm test:live:infer`.
For discovery and automation bootstrap:
```bash
openclaw infer list --json
openclaw infer inspect --name image.generate --json
openclaw infer model run --prompt "Summarize this changelog entry" --provider openai --json
openclaw infer model providers --json
openclaw infer image providers --json
openclaw infer audio providers --json
openclaw infer tts providers --json
openclaw infer video providers --json
openclaw infer web providers --json
openclaw infer embedding providers --json
```
## Command families
### `model`
Use `model run` for one-shot text discussion through the agent runtime.
Common commands:
```bash
openclaw infer model run --prompt "Reply with exactly: smoke-ok" --json
openclaw infer model run --prompt "Summarize this file" --model openai/gpt-5.4 --json
openclaw infer model list --json
openclaw infer model inspect --model openai/gpt-5.4 --json
openclaw infer model providers --json
openclaw infer model auth status --json
openclaw infer model inspect --name gpt-5.4 --json
```
Notes:
- `model run` supports `--local` and `--gateway`.
- `--model <provider/model>` follows the same provider/model override shape used elsewhere in OpenClaw.
- Output includes normalized `provider`, `model`, and `outputs`.
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
- `model auth login`, `model auth logout`, and `model auth status` manage saved provider auth state.
### `image`
## Image
Use `image generate` and `image edit` for raster creation and editing. Use `describe` for image discussion and analysis of local files.
Common commands:
Use `image` for generation, edit, and description.
```bash
openclaw infer image generate --prompt "friendly lobster illustration" --output ./lobster.png --json
openclaw infer image generate --prompt "poster art" --model openai/gpt-image-1 --size 1024x1024 --json
openclaw infer image edit --file ./input.png --prompt "remove the background" --output ./edited.png --json
openclaw infer image generate --prompt "friendly lobster illustration" --json
openclaw infer image generate --prompt "cinematic product photo of headphones" --json
openclaw infer image describe --file ./photo.jpg --json
openclaw infer image describe-many --file ./a.jpg --file ./b.jpg --json
openclaw infer image providers --json
openclaw infer image describe --file ./ui-screenshot.png --model openai/gpt-4.1-mini --json
```
Notes:
- `generate` supports `--count`, `--size`, `--aspect-ratio`, `--resolution`, and `--output`.
- Saved output paths follow the returned bytes, not just the requested extension.
- Use `image edit` when starting from existing input files.
- For `image describe`, `--model` must be `<provider/model>`.
### `audio`
## Audio
Use `audio transcribe` for speech-to-text discussion of local audio files.
Common commands:
Use `audio` for file transcription.
```bash
openclaw infer audio transcribe --file ./memo.m4a --json
openclaw infer audio transcribe --file ./memo.m4a --language en --prompt "Focus on names and action items" --json
openclaw infer audio transcribe --file ./memo.m4a --model openai/gpt-4o-transcribe --json
openclaw infer audio providers --json
openclaw infer audio transcribe --file ./team-sync.m4a --language en --prompt "Focus on names and action items" --json
openclaw infer audio transcribe --file ./memo.m4a --model openai/whisper-1 --json
```
Notes:
- `--language` and `--prompt` are request-scoped hints.
- `--model <provider/model>` is the safest way to force a provider-backed transcription path.
- When a local transcription path returns empty output, `infer audio transcribe` retries on provider-backed auto-detect before failing.
- `infer` does not expose first-class audio editing commands today. Audio output generation lives under `tts convert`.
- `audio transcribe` is for file transcription, not realtime session management.
- `--model` must be `<provider/model>`.
### `tts`
## TTS
Use `tts convert` for speech generation from text, and the other commands to inspect or mutate TTS state.
Common commands:
Use `tts` for speech synthesis and TTS provider state.
```bash
openclaw infer tts convert --text "hello from openclaw" --output ./hello.mp3 --json
openclaw infer tts convert --text "hello from openclaw" --model openai/gpt-4o-mini-tts --voice alloy --json
openclaw infer tts voices --provider openai --json
openclaw infer tts convert --text "Your build is complete" --output ./build-complete.mp3 --json
openclaw infer tts providers --json
openclaw infer tts status --json
openclaw infer tts set-provider --provider openai --json
```
Notes:
- `convert`, `providers`, `enable`, `disable`, and `set-provider` support `--local` and `--gateway`.
- `status` is gateway-only because it reflects gateway-managed prefs state.
- `--output` writes the synthesized media to disk and still returns JSON metadata when `--json` is set.
- `tts status` defaults to gateway because it reflects gateway-managed TTS state.
- Use `tts providers`, `tts voices`, and `tts set-provider` to inspect and configure TTS behavior.
### `video`
## Video
Use `video generate` for creation and `video describe` for local discussion and analysis.
Common commands:
Use `video` for generation and description.
```bash
openclaw infer video generate --prompt "cinematic sunset over the ocean" --output ./sunset.mp4 --json
openclaw infer video generate --prompt "city timelapse" --model openai/sora-2 --json
openclaw infer video generate --prompt "cinematic sunset over the ocean" --json
openclaw infer video generate --prompt "slow drone shot over a forest lake" --json
openclaw infer video describe --file ./clip.mp4 --json
openclaw infer video providers --json
openclaw infer video describe --file ./clip.mp4 --model openai/gpt-4.1-mini --json
```
Notes:
- Generated video jobs may take materially longer than text, image, audio, or embedding commands.
- `providers` exposes both generation providers and local description providers.
- `infer` does not expose first-class video editing commands today.
- `--model` must be `<provider/model>` for `video describe`.
### `web`
## Web
Use `web search` for provider-backed search and `web fetch` for direct URL retrieval.
Common commands:
Use `web` for search and fetch workflows.
```bash
openclaw infer web search --query "OpenClaw docs" --limit 5 --json
openclaw infer web search --query "OpenClaw docs" --provider brave --json
openclaw infer web fetch --url https://docs.openclaw.ai/ --json
openclaw infer web search --query "OpenClaw docs" --json
openclaw infer web search --query "OpenClaw infer web providers" --json
openclaw infer web fetch --url https://docs.openclaw.ai/cli/infer --json
openclaw infer web providers --json
```
Notes:
- `search` supports `--provider` and `--limit`.
- `fetch` supports `--provider` and `--format`.
- `providers` returns search and fetch provider lists separately.
- Use `web providers` to inspect available, configured, and selected providers.
### `embedding`
## Embedding
Use `embedding create` for one or more input strings and `providers` for discovery.
Common commands:
Use `embedding` for vector creation and embedding provider inspection.
```bash
openclaw infer embedding create --text "friendly lobster" --json
openclaw infer embedding create --text "friendly lobster" --text "friendly crab" --json
openclaw infer embedding create --text "friendly lobster" --provider openai --model openai/text-embedding-3-small --json
openclaw infer embedding create --text "customer support ticket: delayed shipment" --model openai/text-embedding-3-large --json
openclaw infer embedding providers --json
```
Notes:
- Repeat `--text` to embed multiple strings in one call.
- Output returns one embedding object per input string.
## JSON output
Capability commands normalize JSON output under a shared envelope:
Infer commands normalize JSON output under a shared envelope:
```json
{
@@ -321,8 +257,24 @@ Top-level fields are stable:
- `outputs`
- `error`
## Common pitfalls
```bash
# Bad
openclaw infer media image generate --prompt "friendly lobster"
# Good
openclaw infer image generate --prompt "friendly lobster"
```
```bash
# Bad
openclaw infer audio transcribe --file ./memo.m4a --model whisper-1 --json
# Good
openclaw infer audio transcribe --file ./memo.m4a --model openai/whisper-1 --json
```
## Notes
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
- `tts status` defaults to gateway because it reflects gateway-managed TTS state.
- `openclaw capability ...` is still accepted as a compatibility alias, but `openclaw infer ...` is the canonical surface for docs, scripts, and examples.
- `openclaw capability ...` is an alias for `openclaw infer ...`.

View File

@@ -0,0 +1,504 @@
---
title: "Active Memory"
summary: "A plugin-owned blocking memory subagent that injects relevant memory into interactive chat sessions"
read_when:
- You want to understand what active memory is for
- You want to turn active memory on for a conversational agent
- You want to tune active memory behavior without enabling it everywhere
---
# Active Memory
Active memory is an optional plugin-owned blocking memory subagent that runs
before the main reply for eligible conversational sessions.
It exists because most memory systems are capable but reactive. They rely on
the main agent to decide when to search memory, or on the user to say things
like "remember this" or "search memory." By then, the moment where memory would
have made the reply feel natural has already passed.
Active memory gives the system one bounded chance to surface relevant memory
before the main reply is generated.
## Paste This Into Your Agent
Paste this into your agent if you want it to enable Active Memory with a
self-contained, safe-default setup:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
allowedChatTypes: ["direct"],
modelFallbackPolicy: "default-remote",
queryMode: "recent",
timeoutMs: 15000,
maxSummaryChars: 220,
persistTranscripts: false,
logging: true,
},
},
},
},
}
```
This turns the plugin on for the `main` agent, keeps it limited to direct-message
style sessions by default, lets it inherit the current session model first, and
still allows the built-in remote fallback if no explicit or inherited model is
available.
After that, restart the gateway:
```bash
node scripts/run-node.mjs gateway --profile dev
```
To inspect it live in a conversation:
```text
/verbose on
```
## Turn active memory on
The safest setup is:
1. enable the plugin
2. target one conversational agent
3. keep logging on only while tuning
Start with this in `openclaw.json`:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
allowedChatTypes: ["direct"],
modelFallbackPolicy: "default-remote",
queryMode: "recent",
timeoutMs: 15000,
maxSummaryChars: 220,
persistTranscripts: false,
logging: true,
},
},
},
},
}
```
Then restart the gateway:
```bash
node scripts/run-node.mjs gateway --profile dev
```
What this means:
- `plugins.entries.active-memory.enabled: true` turns the plugin on
- `config.agents: ["main"]` opts only the `main` agent into active memory
- `config.allowedChatTypes: ["direct"]` keeps active memory on for direct-message style sessions only by default
- if `config.model` is unset, active memory inherits the current session model first
- `config.modelFallbackPolicy: "default-remote"` keeps the built-in remote fallback as the default when no explicit or inherited model is available
- active memory still runs only on eligible interactive persistent chat sessions
## How to see it
Active memory injects hidden system context for the model. It does not expose
raw `<active_memory_plugin>...</active_memory_plugin>` tags to the client.
If you want to see what active memory is doing in a live session, turn verbose
mode on for that session:
```text
/verbose on
```
With verbose enabled, OpenClaw can show:
- an active memory status line such as `Active Memory: ok 842ms recent 34 chars`
- a readable debug summary such as `Active Memory Debug: Lemon pepper wings with blue cheese.`
Those lines are derived from the same active memory pass that feeds the hidden
system context, but they are formatted for humans instead of exposing raw prompt
markup.
By default, the blocking memory subagent transcript is temporary and deleted
after the run completes.
Example flow:
```text
/verbose on
what wings should i order?
```
Expected visible reply shape:
```text
...normal assistant reply...
🧩 Active Memory: ok 842ms recent 34 chars
🔎 Active Memory Debug: Lemon pepper wings with blue cheese.
```
## When it runs
Active memory uses two gates:
1. **Config opt-in**
The plugin must be enabled, and the current agent id must appear in
`plugins.entries.active-memory.config.agents`.
2. **Strict runtime eligibility**
Even when enabled and targeted, active memory only runs for eligible
interactive persistent chat sessions.
The actual rule is:
```text
plugin enabled
+
agent id targeted
+
allowed chat type
+
eligible interactive persistent chat session
=
active memory runs
```
If any of those fail, active memory does not run.
## Session types
`config.allowedChatTypes` controls which kinds of conversations may run Active
Memory at all.
The default is:
```json5
allowedChatTypes: ["direct"]
```
That means Active Memory runs by default in direct-message style sessions, but
not in group or channel sessions unless you opt them in explicitly.
Examples:
```json5
allowedChatTypes: ["direct"]
```
```json5
allowedChatTypes: ["direct", "group"]
```
```json5
allowedChatTypes: ["direct", "group", "channel"]
```
## Where it runs
Active memory is a conversational enrichment feature, not a platform-wide
inference feature.
| Surface | Runs active memory? |
| ------------------------------------------------------------------- | ------------------------------------------------------- |
| Control UI / web chat persistent sessions | Yes, if the plugin is enabled and the agent is targeted |
| Other interactive channel sessions on the same persistent chat path | Yes, if the plugin is enabled and the agent is targeted |
| Headless one-shot runs | No |
| Heartbeat/background runs | No |
| Generic internal `agent-command` paths | No |
| Subagent/internal helper execution | No |
## Why use it
Use active memory when:
- the session is persistent and user-facing
- the agent has meaningful long-term memory to search
- continuity and personalization matter more than raw prompt determinism
It works especially well for:
- stable preferences
- recurring habits
- long-term user context that should surface naturally
It is a poor fit for:
- automation
- internal workers
- one-shot API tasks
- places where hidden personalization would be surprising
## How it works
The runtime shape is:
```mermaid
flowchart LR
U["User Message"] --> Q["Build Memory Query"]
Q --> R["Active Memory Blocking Memory Subagent"]
R -->|NONE or empty| M["Main Reply"]
R -->|relevant summary| I["Append Hidden active_memory_plugin System Context"]
I --> M["Main Reply"]
```
The blocking memory subagent can use only:
- `memory_search`
- `memory_get`
If the connection is weak, it should return `NONE`.
## Query modes
`config.queryMode` controls how much conversation the blocking memory subagent sees.
## Model fallback policy
If `config.model` is unset, Active Memory tries to resolve a model in this order:
```text
explicit plugin model
-> current session model
-> agent primary model
-> optional built-in remote fallback
```
`config.modelFallbackPolicy` controls the last step.
Default:
```json5
modelFallbackPolicy: "default-remote"
```
Other option:
```json5
modelFallbackPolicy: "resolved-only"
```
Use `resolved-only` if you want Active Memory to skip recall instead of falling
back to the built-in remote default when no explicit or inherited model is
available.
### `message`
Only the latest user message is sent.
```text
Latest user message only
```
Use this when:
- you want the fastest behavior
- you want the strongest bias toward stable preference recall
- follow-up turns do not need conversational context
Recommended timeout:
- start around `3000` to `5000` ms
### `recent`
The latest user message plus a small recent conversational tail is sent.
```text
Recent conversation tail:
user: ...
assistant: ...
user: ...
Latest user message:
...
```
Use this when:
- you want a better balance of speed and conversational grounding
- follow-up questions often depend on the last few turns
Recommended timeout:
- start around `15000` ms
### `full`
The full conversation is sent to the blocking memory subagent.
```text
Full conversation context:
user: ...
assistant: ...
user: ...
...
```
Use this when:
- the strongest recall quality matters more than latency
- the conversation contains important setup far back in the thread
Recommended timeout:
- increase it substantially compared with `message` or `recent`
- start around `15000` ms or higher depending on thread size
In general, timeout should increase with context size:
```text
message < recent < full
```
## Transcript persistence
Active memory blocking memory subagent runs create a real `session.jsonl`
transcript during the blocking memory subagent call.
By default, that transcript is temporary:
- it is written to a temp directory
- it is used only for the blocking memory subagent run
- it is deleted immediately after the run finishes
If you want to keep those blocking memory subagent transcripts on disk for debugging or
inspection, turn persistence on explicitly:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "active-memory",
},
},
},
},
}
```
When enabled, active memory stores transcripts in a separate directory under the
target agent's sessions folder, not in the main user conversation transcript
path.
The default layout is conceptually:
```text
agents/<agent>/sessions/active-memory/<blocking-memory-subagent-session-id>.jsonl
```
You can change the relative subdirectory with `config.transcriptDir`.
Use this carefully:
- blocking memory subagent transcripts can accumulate quickly on busy sessions
- `full` query mode can duplicate a lot of conversation context
- these transcripts contain hidden prompt context and recalled memories
## Configuration
All active memory configuration lives under:
```text
plugins.entries.active-memory
```
The most important fields are:
| Key | Type | Meaning |
| --------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | Enables the plugin itself |
| `config.agents` | `string[]` | Agent ids that may use active memory |
| `config.model` | `string` | Optional blocking memory subagent model ref; when unset, active memory uses the current session model |
| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory subagent sees |
| `config.timeoutMs` | `number` | Hard timeout for the blocking memory subagent |
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
| `config.logging` | `boolean` | Emits active memory logs while tuning |
| `config.persistTranscripts` | `boolean` | Keeps blocking memory subagent transcripts on disk instead of deleting temp files |
| `config.transcriptDir` | `string` | Relative blocking memory subagent transcript directory under the agent sessions folder |
Useful tuning fields:
| Key | Type | Meaning |
| ----------------------------- | -------- | ------------------------------------------------------------- |
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
| `config.recentUserTurns` | `number` | Prior user turns to include when `queryMode` is `recent` |
| `config.recentAssistantTurns` | `number` | Prior assistant turns to include when `queryMode` is `recent` |
| `config.recentUserChars` | `number` | Max chars per recent user turn |
| `config.recentAssistantChars` | `number` | Max chars per recent assistant turn |
| `config.cacheTtlMs` | `number` | Cache reuse for repeated identical queries |
## Recommended setup
Start with `recent`.
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
queryMode: "recent",
timeoutMs: 15000,
maxSummaryChars: 220,
logging: true,
},
},
},
},
}
```
If you want to inspect live behavior while tuning, use `/verbose on` in the
session instead of looking for a separate active-memory debug command.
Then move to:
- `message` if you want lower latency
- `full` if you decide extra context is worth the slower blocking memory subagent
## Debugging
If active memory is not showing up where you expect:
1. Confirm the plugin is enabled under `plugins.entries.active-memory.enabled`.
2. Confirm the current agent id is listed in `config.agents`.
3. Confirm you are testing through an interactive persistent chat session.
4. Turn on `config.logging: true` and watch the gateway logs.
5. Verify memory search itself works with `openclaw memory status --deep`.
If memory hits are noisy, tighten:
- `maxSummaryChars`
If active memory is too slow:
- lower `queryMode`
- lower `timeoutMs`
- reduce recent turn counts
- reduce per-turn char caps
## Related pages
- [Memory Search](/concepts/memory-search)
- [Memory configuration reference](/reference/memory-config)
- [Plugin SDK setup](/plugins/sdk-setup)

View File

@@ -41,6 +41,71 @@ Before compacting, OpenClaw automatically reminds the agent to save important
notes to [memory](/concepts/memory) files. This prevents context loss.
</Info>
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`.
You can optionally specify a different model for compaction summarization via `agents.defaults.compaction.model`. This is useful when your primary model is a local or small model and you want compaction summaries produced by a more capable model. The override accepts any `provider/model-id` string:
```json
{
"agents": {
"defaults": {
"compaction": {
"model": "openrouter/anthropic/claude-sonnet-4-6"
}
}
}
}
```
This also works with local models, for example a second Ollama model dedicated to summarization or a fine-tuned compaction specialist:
```json
{
"agents": {
"defaults": {
"compaction": {
"model": "ollama/llama3.1:8b"
}
}
}
}
```
When unset, compaction uses the agents primary model.
## Pluggable compaction providers
Plugins can register a custom compaction provider via `registerCompactionProvider()` on the plugin API. When a provider is registered and configured, OpenClaw delegates summarization to it instead of the built-in LLM pipeline.
To use a registered provider, set the provider id in your config:
```json
{
"agents": {
"defaults": {
"compaction": {
"provider": "my-provider"
}
}
}
}
```
Setting a `provider` automatically forces `mode: "safeguard"`. Providers receive the same compaction instructions and identifier-preservation policy as the built-in path, and OpenClaw still preserves recent-turn and split-turn suffix context after provider output. If the provider fails or returns an empty result, OpenClaw falls back to built-in LLM summarization.
## Auto-compaction (default on)
When a session nears or exceeds the models context window, OpenClaw triggers auto-compaction and may retry the original request using the compacted context.
Youll see:
- `🧹 Auto-compaction complete` in verbose mode
- `/status` showing `🧹 Compactions: <count>`
Before compaction, OpenClaw can run a **silent memory flush** turn to store
durable notes to disk. See [Memory](/concepts/memory) for details and config.
## Manual compaction
Type `/compact` in any chat to force a compaction. Add instructions to guide

View File

@@ -138,5 +138,6 @@ earlier conversations. This is opt-in via
## Further reading
- [Active Memory](/concepts/active-memory) -- sidecar memory for interactive chat sessions
- [Memory](/concepts/memory) -- file layout, backends, tools
- [Memory configuration reference](/reference/memory-config) -- all config knobs

View File

@@ -1452,7 +1452,6 @@
"cli/agent",
"cli/agents",
"cli/hooks",
"cli/infer",
"cli/memory",
"cli/message",
"cli/models",

View File

@@ -1160,6 +1160,7 @@ Periodic heartbeat runs.
defaults: {
compaction: {
mode: "safeguard", // default | safeguard
provider: "my-provider", // id of a registered compaction provider plugin (optional)
timeoutSeconds: 900,
reserveTokensFloor: 24000,
identifierPolicy: "strict", // strict | off | custom
@@ -1180,6 +1181,7 @@ Periodic heartbeat runs.
```
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
- `provider`: id of a registered compaction provider plugin. When set, the provider's `summarize()` is called instead of built-in LLM summarization. Falls back to built-in on failure. Setting a provider forces `mode: "safeguard"`. See [Compaction](/concepts/compaction).
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`.
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.

View File

@@ -381,16 +381,18 @@ implemented in `src/gateway/server-methods/*.ts`.
#### Approval families
- `exec.approval.request` and `exec.approval.resolve` cover one-shot exec
approval requests.
- `exec.approval.request`, `exec.approval.get`, `exec.approval.list`, and
`exec.approval.resolve` cover one-shot exec approval requests plus pending
approval lookup/replay.
- `exec.approval.waitDecision` waits on one pending exec approval and returns
the final decision (or `null` on timeout).
- `exec.approvals.get` and `exec.approvals.set` manage gateway exec approval
policy snapshots.
- `exec.approvals.node.get` and `exec.approvals.node.set` manage node-local exec
approval policy via node relay commands.
- `plugin.approval.request`, `plugin.approval.waitDecision`, and
`plugin.approval.resolve` cover plugin-defined approval flows.
- `plugin.approval.request`, `plugin.approval.list`,
`plugin.approval.waitDecision`, and `plugin.approval.resolve` cover
plugin-defined approval flows.
#### Other major families

View File

@@ -2254,7 +2254,7 @@ for usage/billing and raise limits as needed.
Quickest setup:
1. Install Ollama from `https://ollama.com/download`
2. Pull a local model such as `ollama pull glm-4.7-flash`
2. Pull a local model such as `ollama pull gemma4`
3. If you want cloud models too, run `ollama signin`
4. Run `openclaw onboard` and choose `Ollama`
5. Pick `Local` or `Cloud + Local`

View File

@@ -568,32 +568,6 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- `pnpm test:live:media video --video-providers openai,runway --all-providers`
- `pnpm test:live:media music --quiet`
## Infer live harness
- Command: `pnpm test:live:infer`
- Live file: `src/cli/capability-cli.live.test.ts`
- Purpose:
- Runs the `openclaw infer` CLI surface end-to-end instead of only provider runtimes
- Reuses `scripts/test-live.mjs` so quiet mode, heartbeat output, and temp live-home behavior stay consistent
- Auto-loads missing provider env vars from `~/.profile`
- Narrows suites to providers with usable auth by default
- Current suite coverage:
- discovery: `infer list`, `infer inspect`
- discuss: `model run`, `image describe`, `audio transcribe`, `video describe`
- generate or convert: `image generate`, `tts convert`, `video generate`, `embedding create`
- edit: `image edit`
- web: `web providers`, `web search`, `web fetch`
- Media discuss note:
- `image describe` and `video describe` run as best-effort probes by default because some maintainer setups have generation auth but no separate media-discussion provider path.
- Set `OPENCLAW_LIVE_INFER_REQUIRE_MEDIA_DISCUSS=1` to make those probes hard requirements.
- Current non-goals:
- `infer` does not expose dedicated audio-edit or video-edit commands yet, so the harness does not pretend those lanes exist
- Examples:
- `pnpm test:live:infer`
- `pnpm test:live:infer image video --providers openai,google`
- `pnpm test:live:infer audio --audio-providers deepgram,openai --all-providers`
- `pnpm test:live:infer web --quiet`
## Docker runners (optional "works in Linux" checks)
These Docker runners split into two buckets:

View File

@@ -1134,6 +1134,9 @@ authoring plugins:
`openclaw/plugin-sdk/channel-config-schema`,
`openclaw/plugin-sdk/telegram-command-config`,
`openclaw/plugin-sdk/channel-policy`,
`openclaw/plugin-sdk/approval-gateway-runtime`,
`openclaw/plugin-sdk/approval-handler-adapter-runtime`,
`openclaw/plugin-sdk/approval-handler-runtime`,
`openclaw/plugin-sdk/approval-runtime`,
`openclaw/plugin-sdk/config-runtime`,
`openclaw/plugin-sdk/infra-runtime`,
@@ -1152,9 +1155,9 @@ authoring plugins:
assistant-visible-text stripping, markdown render/chunking helpers, redaction
helpers, directive-tag helpers, and safe-text utilities.
- Approval-specific channel seams should prefer one `approvalCapability`
contract on the plugin. Core then reads approval auth, delivery, render, and
native-routing behavior through that one capability instead of mixing
approval behavior into unrelated plugin fields.
contract on the plugin. Core then reads approval auth, delivery, render,
native-routing, and lazy native-handler behavior through that one capability
instead of mixing approval behavior into unrelated plugin fields.
- `openclaw/plugin-sdk/channel-runtime` is deprecated and remains only as a
compatibility shim for older plugins. New code should import the narrower
generic primitives instead, and repo code should not add new imports of the

View File

@@ -60,22 +60,34 @@ Most channel plugins do not need approval-specific code.
- Core owns same-chat `/approve`, shared approval button payloads, and generic fallback delivery.
- Prefer one `approvalCapability` object on the channel plugin when the channel needs approval-specific behavior.
- `ChannelPlugin.approvals` is removed. Put approval delivery/native/render/auth facts on `approvalCapability`.
- `plugin.auth` is login/logout only; core no longer reads approval auth hooks from that object.
- `approvalCapability.authorizeActorAction` and `approvalCapability.getActionAvailabilityState` are the canonical approval-auth seam.
- If your channel exposes native exec approvals, implement `approvalCapability.getActionAvailabilityState` even when the native transport lives entirely under `approvalCapability.native`. Core uses that availability hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native approvals, and include the channel in native-client fallback guidance.
- Use `approvalCapability.getActionAvailabilityState` for same-chat approval auth availability.
- If your channel exposes native exec approvals, use `approvalCapability.getExecInitiatingSurfaceState` for the initiating-surface/native-client state when it differs from same-chat approval auth. Core uses that exec-specific hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native exec approvals, and include the channel in native-client fallback guidance. `createApproverRestrictedNativeApprovalCapability(...)` fills this in for the common case.
- Use `outbound.shouldSuppressLocalPayloadPrompt` or `outbound.beforeDeliverPayload` for channel-specific payload lifecycle behavior such as hiding duplicate local approval prompts or sending typing indicators before delivery.
- Use `approvalCapability.delivery` only for native approval routing or fallback suppression.
- Use `approvalCapability.nativeRuntime` for channel-owned native approval facts. Keep it lazy on hot channel entrypoints with `createLazyChannelApprovalNativeRuntimeAdapter(...)`, which can import your runtime module on demand while still letting core assemble the approval lifecycle.
- Use `approvalCapability.render` only when a channel truly needs custom approval payloads instead of the shared renderer.
- Use `approvalCapability.describeExecApprovalSetup` when the channel wants the disabled-path reply to explain the exact config knobs needed to enable native exec approvals. The hook receives `{ channel, channelLabel, accountId }`; named-account channels should render account-scoped paths such as `channels.<channel>.accounts.<id>.execApprovals.*` instead of top-level defaults.
- If a channel can infer stable owner-like DM identities from existing config, use `createResolvedApproverActionAuthAdapter` from `openclaw/plugin-sdk/approval-runtime` to restrict same-chat `/approve` without adding approval-specific core logic.
- If a channel needs native approval delivery, keep channel code focused on target normalization and transport hooks. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, `createApproverRestrictedNativeApprovalCapability`, and `createChannelNativeApprovalRuntime` from `openclaw/plugin-sdk/approval-runtime` so core owns request filtering, routing, dedupe, expiry, and gateway subscription.
- If a channel needs native approval delivery, keep channel code focused on target normalization plus transport/presentation facts. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, and `createApproverRestrictedNativeApprovalCapability` from `openclaw/plugin-sdk/approval-runtime`. Put the channel-specific facts behind `approvalCapability.nativeRuntime`, ideally via `createChannelApprovalNativeRuntimeAdapter(...)` or `createLazyChannelApprovalNativeRuntimeAdapter(...)`, so core can assemble the handler and own request filtering, routing, dedupe, expiry, gateway subscription, and routed-elsewhere notices. `nativeRuntime` is split into a few smaller seams:
- `availability` — whether the account is configured and whether a request should be handled
- `presentation` — map the shared approval view model into pending/resolved/expired native payloads or final actions
- `transport` — prepare targets plus send/update/delete native approval messages
- `interactions` — optional bind/unbind/clear-action hooks for native buttons or reactions
- `observe` — optional delivery diagnostics hooks
- If the channel needs runtime-owned objects such as a client, token, Bolt app, or webhook receiver, register them through `openclaw/plugin-sdk/channel-runtime-context`. The generic runtime-context registry lets core bootstrap capability-driven handlers from channel startup state without adding approval-specific wrapper glue.
- Reach for the lower-level `createChannelApprovalHandler` or `createChannelNativeApprovalRuntime` only when the capability-driven seam is not expressive enough yet.
- Native approval channels must route both `accountId` and `approvalKind` through those helpers. `accountId` keeps multi-account approval policy scoped to the right bot account, and `approvalKind` keeps exec vs plugin approval behavior available to the channel without hardcoded branches in core.
- Core now owns approval reroute notices too. Channel plugins should not send their own "approval went to DMs / another channel" follow-up messages from `createChannelNativeApprovalRuntime`; instead, expose accurate origin + approver-DM routing through the shared approval capability helpers and let core aggregate actual deliveries before posting any notice back to the initiating chat.
- Preserve the delivered approval id kind end-to-end. Native clients should not
guess or rewrite exec vs plugin approval routing from channel-local state.
- Different approval kinds can intentionally expose different native surfaces.
Current bundled examples:
- Slack keeps native approval routing available for both exec and plugin ids.
- Matrix keeps native DM/channel routing for exec approvals only and leaves
plugin approvals on the shared same-chat `/approve` path.
- Matrix keeps the same native DM/channel routing and reaction UX for exec
and plugin approvals, while still letting auth differ by approval kind.
- `createApproverRestrictedNativeApprovalAdapter` still exists as a compatibility wrapper, but new code should prefer the capability builder and expose `approvalCapability` on the plugin.
For hot channel entrypoints, prefer the narrower runtime subpaths when you only
@@ -84,8 +96,12 @@ need one part of that family:
- `openclaw/plugin-sdk/approval-auth-runtime`
- `openclaw/plugin-sdk/approval-client-runtime`
- `openclaw/plugin-sdk/approval-delivery-runtime`
- `openclaw/plugin-sdk/approval-gateway-runtime`
- `openclaw/plugin-sdk/approval-handler-adapter-runtime`
- `openclaw/plugin-sdk/approval-handler-runtime`
- `openclaw/plugin-sdk/approval-native-runtime`
- `openclaw/plugin-sdk/approval-reply-runtime`
- `openclaw/plugin-sdk/channel-runtime-context`
Likewise, prefer `openclaw/plugin-sdk/setup-runtime`,
`openclaw/plugin-sdk/setup-adapter-runtime`,

View File

@@ -67,6 +67,32 @@ Current bundled provider examples:
## How to migrate
<Steps>
<Step title="Migrate approval-native handlers to capability facts">
Approval-capable channel plugins now expose native approval behavior through
`approvalCapability.nativeRuntime` plus the shared runtime-context registry.
Key changes:
- Replace `approvalCapability.handler.loadRuntime(...)` with
`approvalCapability.nativeRuntime`
- Move approval-specific auth/delivery off legacy `plugin.auth` /
`plugin.approvals` wiring and onto `approvalCapability`
- `ChannelPlugin.approvals` has been removed from the public channel-plugin
contract; move delivery/native/render fields onto `approvalCapability`
- `plugin.auth` remains for channel login/logout flows only; approval auth
hooks there are no longer read by core
- Register channel-owned runtime objects such as clients, tokens, or Bolt
apps through `openclaw/plugin-sdk/channel-runtime-context`
- Do not send plugin-owned reroute notices from native approval handlers;
core now owns routed-elsewhere notices from actual delivery results
- When passing `channelRuntime` into `createChannelManager(...)`, provide a
real `createPluginRuntime().channel` surface. Partial stubs are rejected.
See `/plugins/sdk-channel-plugins` for the current approval capability
layout.
</Step>
<Step title="Audit Windows wrapper fallback behavior">
If your plugin uses `openclaw/plugin-sdk/windows-spawn`, unresolved Windows
`.cmd`/`.bat` wrappers now fail closed unless you explicitly pass
@@ -201,8 +227,12 @@ Current bundled provider examples:
| `plugin-sdk/approval-auth-runtime` | Approval auth helpers | Approver resolution, same-chat action auth |
| `plugin-sdk/approval-client-runtime` | Approval client helpers | Native exec approval profile/filter helpers |
| `plugin-sdk/approval-delivery-runtime` | Approval delivery helpers | Native approval capability/delivery adapters |
| `plugin-sdk/approval-gateway-runtime` | Approval gateway helpers | Shared approval gateway-resolution helper |
| `plugin-sdk/approval-handler-adapter-runtime` | Approval adapter helpers | Lightweight native approval adapter loading helpers for hot channel entrypoints |
| `plugin-sdk/approval-handler-runtime` | Approval handler helpers | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
| `plugin-sdk/approval-native-runtime` | Approval target helpers | Native approval target/account binding helpers |
| `plugin-sdk/approval-reply-runtime` | Approval reply helpers | Exec/plugin approval reply payload helpers |
| `plugin-sdk/channel-runtime-context` | Channel runtime-context helpers | Generic channel runtime-context register/get/watch helpers |
| `plugin-sdk/security-runtime` | Security helpers | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | SSRF policy helpers | Host allowlist and private-network policy helpers |
| `plugin-sdk/ssrf-runtime` | SSRF runtime helpers | Pinned-dispatcher, guarded fetch, SSRF policy helpers |

View File

@@ -151,6 +151,9 @@ explicitly promotes one as public.
| `plugin-sdk/approval-auth-runtime` | Approver resolution and same-chat action-auth helpers |
| `plugin-sdk/approval-client-runtime` | Native exec approval profile/filter helpers |
| `plugin-sdk/approval-delivery-runtime` | Native approval capability/delivery adapters |
| `plugin-sdk/approval-gateway-runtime` | Shared approval gateway-resolution helper |
| `plugin-sdk/approval-handler-adapter-runtime` | Lightweight native approval adapter loading helpers for hot channel entrypoints |
| `plugin-sdk/approval-handler-runtime` | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
| `plugin-sdk/approval-native-runtime` | Native approval target + account-binding helpers |
| `plugin-sdk/approval-reply-runtime` | Exec/plugin approval reply payload helpers |
| `plugin-sdk/command-auth-native` | Native command auth + native session-target helpers |
@@ -172,6 +175,7 @@ explicitly promotes one as public.
| --- | --- |
| `plugin-sdk/runtime` | Broad runtime/logging/backup/plugin-install helpers |
| `plugin-sdk/runtime-env` | Narrow runtime env, logger, timeout, retry, and backoff helpers |
| `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers |
| `plugin-sdk/runtime-store` | `createPluginRuntimeStore` |
| `plugin-sdk/plugin-runtime` | Shared plugin command/hook/http/interactive helpers |
| `plugin-sdk/hook-runtime` | Shared webhook/internal hook pipeline helpers |

View File

@@ -57,7 +57,7 @@ openclaw onboard --non-interactive \
2. Pull a local model if you want local inference:
```bash
ollama pull glm-4.7-flash
ollama pull gemma4
# or
ollama pull gpt-oss:20b
# or
@@ -78,12 +78,12 @@ openclaw onboard
- `Local`: local models only
- `Cloud + Local`: local models plus cloud models
- Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, and `glm-5:cloud` do **not** require a local `ollama pull`
- Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, and `glm-5.1:cloud` do **not** require a local `ollama pull`
OpenClaw currently suggests:
- local default: `glm-4.7-flash`
- cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`
- local default: `gemma4`
- cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, `glm-5.1:cloud`
5. If you prefer manual setup, enable Ollama for OpenClaw directly (any value works; Ollama doesn't require a real key):
@@ -99,7 +99,7 @@ openclaw config set models.providers.ollama.apiKey "ollama-local"
```bash
openclaw models list
openclaw models set ollama/glm-4.7-flash
openclaw models set ollama/gemma4
```
7. Or set the default in config:
@@ -108,7 +108,7 @@ openclaw models set ollama/glm-4.7-flash
{
agents: {
defaults: {
model: { primary: "ollama/glm-4.7-flash" },
model: { primary: "ollama/gemma4" },
},
},
}
@@ -229,7 +229,7 @@ Once configured, all your Ollama models are available:
## Cloud models
Cloud models let you run cloud-hosted models (for example `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`) alongside your local models.
Cloud models let you run cloud-hosted models (for example `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, `glm-5.1:cloud`) alongside your local models.
To use cloud models, select **Cloud + Local** mode during setup. The wizard checks whether you are signed in and opens a browser sign-in flow when needed. If authentication cannot be verified, the wizard falls back to local model defaults.
@@ -355,7 +355,7 @@ To add models:
```bash
ollama list # See what's installed
ollama pull glm-4.7-flash
ollama pull gemma4
ollama pull gpt-oss:20b
ollama pull llama3.3 # Or another model
```

View File

@@ -17,10 +17,22 @@ conceptual overviews, see:
- [Builtin Engine](/concepts/memory-builtin) -- default SQLite backend
- [QMD Engine](/concepts/memory-qmd) -- local-first sidecar
- [Memory Search](/concepts/memory-search) -- search pipeline and tuning
- [Active Memory](/concepts/active-memory) -- enabling the memory sidecar for interactive sessions
All memory search settings live under `agents.defaults.memorySearch` in
`openclaw.json` unless noted otherwise.
If you are looking for the **active memory** feature toggle and sidecar config,
that lives under `plugins.entries.active-memory` instead of `memorySearch`.
Active memory uses a two-gate model:
1. the plugin must be enabled and target the current agent id
2. the request must be an eligible interactive persistent chat session
See [Active Memory](/concepts/active-memory) for the activation model,
plugin-owned config, transcript persistence, and safe rollout pattern.
---
## Provider selection

View File

@@ -275,6 +275,21 @@ Implementation: `ensurePiCompactionReserveTokens()` in `src/agents/pi-settings.t
---
## Pluggable compaction providers
Plugins can register a compaction provider via `registerCompactionProvider()` on the plugin API. When `agents.defaults.compaction.provider` is set to a registered provider id, the safeguard extension delegates summarization to that provider instead of the built-in `summarizeInStages` pipeline.
- `provider`: id of a registered compaction provider plugin. Leave unset for default LLM summarization.
- Setting a `provider` forces `mode: "safeguard"`.
- Providers receive the same compaction instructions and identifier-preservation policy as the built-in path.
- The safeguard still preserves recent-turn and split-turn suffix context after provider output.
- If the provider fails or returns an empty result, OpenClaw falls back to built-in LLM summarization automatically.
- Abort/timeout signals are re-thrown (not swallowed) to respect caller cancellation.
Source: `src/plugins/compaction-provider.ts`, `src/agents/pi-hooks/compaction-safeguard.ts`.
---
## User-visible surfaces
You can observe compaction and session state via:

View File

@@ -557,8 +557,8 @@ Shared behavior:
- Slack approvers can be explicit (`execApprovals.approvers`) or inferred from `commands.ownerAllowFrom`
- Slack native buttons preserve approval id kind, so `plugin:` ids can resolve plugin approvals
without a second Slack-local fallback layer
- Matrix native DM/channel routing is exec-only; Matrix plugin approvals stay on the shared
same-chat `/approve` and optional `approvals.plugin` forwarding paths
- Matrix native DM/channel routing and reaction shortcuts handle both exec and plugin approvals;
plugin authorization still comes from `channels.matrix.dm.allowFrom`
- the requester does not need to be an approver
- the originating chat can approve directly with `/approve` when that chat already supports commands and replies
- native Discord approval buttons route by approval id kind: `plugin:` ids go

View File

@@ -0,0 +1,862 @@
import fs from "node:fs/promises";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import plugin from "./index.js";
const hoisted = vi.hoisted(() => {
const sessionStore: Record<string, Record<string, unknown>> = {
"agent:main:main": {
sessionId: "s-main",
updatedAt: 0,
},
};
return {
sessionStore,
updateSessionStore: vi.fn(
async (_storePath: string, updater: (store: Record<string, unknown>) => void) => {
updater(sessionStore);
},
),
};
});
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
updateSessionStore: hoisted.updateSessionStore,
};
});
describe("active-memory plugin", () => {
const hooks: Record<string, Function> = {};
const runEmbeddedPiAgent = vi.fn();
const api: any = {
pluginConfig: {
agents: ["main"],
logging: true,
},
config: {},
id: "active-memory",
name: "Active Memory",
logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
runtime: {
agent: {
runEmbeddedPiAgent,
session: {
resolveStorePath: vi.fn(() => "/tmp/openclaw-session-store.json"),
loadSessionStore: vi.fn(() => hoisted.sessionStore),
saveSessionStore: vi.fn(async () => {}),
},
},
},
on: vi.fn((hookName: string, handler: Function) => {
hooks[hookName] = handler;
}),
};
beforeEach(() => {
vi.clearAllMocks();
api.pluginConfig = {
agents: ["main"],
logging: true,
};
hoisted.sessionStore["agent:main:main"] = {
sessionId: "s-main",
updatedAt: 0,
};
for (const key of Object.keys(hooks)) {
delete hooks[key];
}
runEmbeddedPiAgent.mockResolvedValue({
payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
});
plugin.register(api as unknown as OpenClawPluginApi);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("registers a before_prompt_build hook", () => {
expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function));
});
it("does not run for agents that are not explicitly targeted", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "support",
trigger: "user",
sessionKey: "agent:support:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("does not rewrite session state for skipped turns with no active-memory entry to clear", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "support",
trigger: "user",
sessionKey: "agent:support:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(hoisted.updateSessionStore).not.toHaveBeenCalled();
});
it("does not run for non-interactive contexts", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "heartbeat",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("defaults to direct-style sessions only", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:group:-100123",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("runs for group sessions when group chat types are explicitly allowed", async () => {
api.pluginConfig = {
agents: ["main"],
allowedChatTypes: ["direct", "group"],
};
plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:group:-100123",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
});
it("injects system context on a successful recall hit", async () => {
const result = await hooks.before_prompt_build(
{
prompt: "what wings should i order?",
messages: [
{ role: "user", content: "i want something greasy tonight" },
{ role: "assistant", content: "let's narrow it down" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"lemon pepper wings",
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
provider: "github-copilot",
model: "gpt-5.4-mini",
sessionKey: expect.stringMatching(/^agent:main:main:active-memory:[a-f0-9]{12}$/),
});
});
it("preserves leading digits in recalled memory bullets", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "- 2024 trip to tokyo\n- 2% milk" }],
});
const result = await hooks.before_prompt_build(
{
prompt: "what should i remember from my 2024 trip and should i buy 2% milk?",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"2024 trip to tokyo",
);
expect((result as { appendSystemContext: string }).appendSystemContext).toContain("2% milk");
});
it("preserves canonical parent session scope in the blocking memory subagent session key", async () => {
await hooks.before_prompt_build(
{ prompt: "what should i grab on the way?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:direct:12345:thread:99",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
/^agent:main:telegram:direct:12345:thread:99:active-memory:[a-f0-9]{12}$/,
);
});
it("falls back to the current session model when no plugin model is configured", async () => {
api.pluginConfig = {
agents: ["main"],
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? temp transcript", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
modelProviderId: "qwen",
modelId: "glm-5",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
provider: "qwen",
model: "glm-5",
});
});
it("can disable default remote model fallback", async () => {
api.pluginConfig = {
agents: ["main"],
modelFallbackPolicy: "resolved-only",
};
plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? no fallback", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:resolved-only",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("persists a readable debug summary alongside the status line", async () => {
const sessionKey = "agent:main:debug";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
};
await hooks.before_prompt_build(
{
prompt: "what wings should i order?",
messages: [],
},
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
expect(hoisted.updateSessionStore).toHaveBeenCalled();
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
{
pluginId: "active-memory",
lines: expect.arrayContaining([
expect.stringContaining("🧩 Active Memory: ok"),
expect.stringContaining("🔎 Active Memory Debug: lemon pepper wings"),
]),
},
]);
});
it("replaces stale legacy active-memory lines on a later empty run", async () => {
const sessionKey = "agent:main:legacy-active-memory-lines";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
pluginStatusLines: [
"Active Memory: ok 13.4s recent 1 mem",
"Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
"Other Plugin: keep me",
],
};
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "NONE" }],
});
await hooks.before_prompt_build(
{ prompt: "what's up with you?", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
pluginStatusLines: [
"Active Memory: ok 13.4s recent 1 mem",
"Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
"Other Plugin: keep me",
],
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
{
pluginId: "active-memory",
lines: [expect.stringContaining("🧩 Active Memory: empty")],
},
]);
expect(store[sessionKey]?.pluginStatusLines).toEqual(["Other Plugin: keep me"]);
});
it("returns nothing when the sidecar says none", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "NONE" }],
});
const result = await hooks.before_prompt_build(
{ prompt: "fair, okay gonna do them by throwing them in the garbage", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
});
it("does not cache timeout results", async () => {
api.pluginConfig = {
agents: ["main"],
timeoutMs: 250,
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
let lastAbortSignal: AbortSignal | undefined;
runEmbeddedPiAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => {
lastAbortSignal = params.abortSignal;
return await new Promise((resolve, reject) => {
const abortHandler = () => reject(new Error("aborted"));
params.abortSignal?.addEventListener("abort", abortHandler, { once: true });
setTimeout(() => {
params.abortSignal?.removeEventListener("abort", abortHandler);
resolve({ payloads: [] });
}, 2_000);
});
});
await hooks.before_prompt_build(
{ prompt: "what wings should i order? timeout test", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:timeout-test",
messageProvider: "webchat",
},
);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? timeout test", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:timeout-test",
messageProvider: "webchat",
},
);
expect(hoisted.updateSessionStore).toHaveBeenCalledTimes(2);
expect(lastAbortSignal?.aborted).toBe(true);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
});
it("does not share cached recall results across session-id-only contexts", async () => {
api.pluginConfig = {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id cache", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-a",
messageProvider: "webchat",
},
);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id cache", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-b",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
});
it("uses a canonical agent session key when only sessionId is available", async () => {
hoisted.sessionStore["agent:main:telegram:direct:12345"] = {
sessionId: "session-a",
updatedAt: 25,
};
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id only", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-a",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
/^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/,
);
});
it("clears stale status on skipped non-interactive turns even when agentId is missing", async () => {
const sessionKey = "agent:main:missing-agent";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
pluginDebugEntries: [
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
],
};
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{ trigger: "heartbeat", sessionKey, messageProvider: "webchat" },
);
expect(result).toBeUndefined();
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
pluginDebugEntries: [
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
],
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toBeUndefined();
});
it("supports message mode by sending only the latest user message", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "message",
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{ role: "assistant", content: "got it" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?");
expect(prompt).not.toContain("Recent conversation tail:");
});
it("supports full mode by sending the whole conversation", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "full",
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{ role: "assistant", content: "got it" },
{ role: "user", content: "packing is annoying" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain("Full conversation context:");
expect(prompt).toContain("user: i have a flight tomorrow");
expect(prompt).toContain("assistant: got it");
expect(prompt).toContain("user: packing is annoying");
});
it("strips prior memory/debug traces from assistant context before retrieval", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{
role: "assistant",
content:
"🧠 Memory Search: favorite food comfort food tacos sushi ramen\n🧩 Active Memory: ok 842ms recent 2 mem\n🔎 Active Memory Debug: spicy ramen; tacos\nSounds like you want something easy before the airport.",
},
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain(
"ignore that text and do not search for those same surfaced memories again",
);
expect(prompt).toContain("assistant: Sounds like you want something easy before the airport.");
expect(prompt).not.toContain("Memory Search:");
expect(prompt).not.toContain("Active Memory:");
expect(prompt).not.toContain("Active Memory Debug:");
expect(prompt).not.toContain("spicy ramen; tacos");
});
it("trusts the subagent's relevance decision for explicit preference recall prompts", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "- aisle seat\n- extra buffer on connections" }],
});
const result = await hooks.before_prompt_build(
{ prompt: "u remember my flight preferences", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("aisle seat"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"extra buffer on connections",
);
});
it("applies total summary truncation after normalizing the sidecar reply", async () => {
api.pluginConfig = {
agents: ["main"],
maxSummaryChars: 40,
};
plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [
{
text: "- lemon pepper wings with extra crisp skin\n- blue cheese dressing on the side",
},
],
});
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("lemon pepper wings"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).not.toContain(
"dressing on the side",
);
});
it("uses the configured maxSummaryChars value in the sidecar prompt", async () => {
api.pluginConfig = {
agents: ["main"],
maxSummaryChars: 90,
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? prompt-count-check", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:prompt-count-check",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt).toContain(
"If something is useful, reply with one compact active-memory summary under 90 characters total.",
);
});
it("keeps sidecar transcripts off disk by default by using a temp session file", async () => {
const mkdtempSpy = vi
.spyOn(fs, "mkdtemp")
.mockResolvedValue("/tmp/openclaw-active-memory-temp");
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? temp transcript path", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(mkdtempSpy).toHaveBeenCalled();
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toBe(
"/tmp/openclaw-active-memory-temp/session.jsonl",
);
expect(rmSpy).toHaveBeenCalledWith("/tmp/openclaw-active-memory-temp", {
recursive: true,
force: true,
});
});
it("persists sidecar transcripts in a separate directory when enabled", async () => {
api.pluginConfig = {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "active-memory-sidecars",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
const mkdtempSpy = vi.spyOn(fs, "mkdtemp");
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
const sessionKey = "agent:main:persist-transcript";
await hooks.before_prompt_build(
{ prompt: "what wings should i order? persist transcript", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
expect(mkdirSpy).toHaveBeenCalledWith("/tmp/active-memory-sidecars", { recursive: true });
expect(mkdtempSpy).not.toHaveBeenCalled();
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
/^\/tmp\/active-memory-sidecars\/active-memory-[a-z0-9]+-[a-f0-9]{8}\.jsonl$/,
);
expect(rmSpy).not.toHaveBeenCalled();
expect(
vi
.mocked(api.logger.info)
.mock.calls.some((call: unknown[]) =>
String(call[0]).includes("transcript=/tmp/active-memory-sidecars/"),
),
).toBe(true);
});
it("falls back to the default transcript directory when transcriptDir is unsafe", async () => {
api.pluginConfig = {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "C:/temp/escape",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? unsafe transcript dir", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:unsafe-transcript",
messageProvider: "webchat",
},
);
expect(mkdirSpy).toHaveBeenCalledWith("/tmp/active-memory", { recursive: true });
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
/^\/tmp\/active-memory\/active-memory-[a-z0-9]+-[a-f0-9]{8}\.jsonl$/,
);
});
it("sanitizes control characters out of debug lines", async () => {
const sessionKey = "agent:main:debug-sanitize";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
};
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "- spicy ramen\u001b[31m\n- fries\r\n- blue cheese\t" }],
});
await hooks.before_prompt_build(
{ prompt: "what should i order?", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
const lines =
(store[sessionKey]?.pluginDebugEntries as Array<{ lines?: string[] }> | undefined)?.[0]
?.lines ?? [];
expect(lines.some((line) => line.includes("\u001b"))).toBe(false);
expect(lines.some((line) => line.includes("\r"))).toBe(false);
});
it("caps the active-memory cache size and evicts the oldest entries", async () => {
api.pluginConfig = {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
for (let index = 0; index <= 1000; index += 1) {
await hooks.before_prompt_build(
{ prompt: `cache pressure prompt ${index}`, messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:cache-cap",
messageProvider: "webchat",
},
);
}
const callsBeforeReplay = runEmbeddedPiAgent.mock.calls.length;
await hooks.before_prompt_build(
{ prompt: "cache pressure prompt 0", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:cache-cap",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.length).toBe(callsBeforeReplay + 1);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(
infoLines.some(
(line: string) => line.includes("cached status=ok") && line.includes("prompt 0"),
),
).toBe(false);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
{
"id": "active-memory",
"name": "Active Memory",
"description": "Runs a bounded blocking memory subagent before eligible conversational replies and injects relevant memory into prompt context.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"agents": {
"type": "array",
"items": { "type": "string" }
},
"model": { "type": "string" },
"modelFallbackPolicy": {
"type": "string",
"enum": ["default-remote", "resolved-only"]
},
"allowedChatTypes": {
"type": "array",
"items": {
"type": "string",
"enum": ["direct", "group", "channel"]
}
},
"timeoutMs": { "type": "integer", "minimum": 250 },
"queryMode": {
"type": "string",
"enum": ["message", "recent", "full"]
},
"maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"recentUserTurns": { "type": "integer", "minimum": 0, "maximum": 4 },
"recentAssistantTurns": { "type": "integer", "minimum": 0, "maximum": 3 },
"recentUserChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"logging": { "type": "boolean" },
"persistTranscripts": { "type": "boolean" },
"transcriptDir": { "type": "string" },
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 }
}
},
"uiHints": {
"agents": {
"label": "Target Agents",
"help": "Explicit agent ids that may use active memory."
},
"model": {
"label": "Memory Model",
"help": "Provider/model used for the blocking memory subagent."
},
"modelFallbackPolicy": {
"label": "Model Fallback Policy",
"help": "Choose whether Active Memory falls back to the built-in remote default model when no explicit or inherited model is available."
},
"allowedChatTypes": {
"label": "Allowed Chat Types",
"help": "Choose which session types may run Active Memory. Defaults to direct-message style sessions only."
},
"timeoutMs": {
"label": "Timeout (ms)"
},
"queryMode": {
"label": "Query Mode",
"help": "Choose whether the blocking memory subagent sees only the latest user message, a small recent tail, or the full conversation."
},
"maxSummaryChars": {
"label": "Max Summary Characters",
"help": "Maximum total characters allowed in the active-memory summary."
},
"logging": {
"label": "Enable Logging",
"help": "Emit active memory timing and result logs."
},
"persistTranscripts": {
"label": "Persist Transcripts",
"help": "Keep blocking memory subagent session transcripts on disk in a separate plugin-owned directory."
},
"transcriptDir": {
"label": "Transcript Directory",
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
}
}
}

View File

@@ -13,7 +13,10 @@ import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/text-runtime";
const log = createSubsystemLogger("bedrock-discovery");
@@ -69,7 +72,7 @@ function buildCacheKey(params: {
}
function includesTextModalities(modalities?: Array<string>): boolean {
return (modalities ?? []).some((entry) => entry.toLowerCase() === "text");
return (modalities ?? []).some((entry) => normalizeOptionalLowercaseString(entry) === "text");
}
function isActive(summary: BedrockModelSummary): boolean {
@@ -81,7 +84,7 @@ function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image
const inputs = summary.inputModalities ?? [];
const mapped = new Set<"text" | "image">();
for (const modality of inputs) {
const lower = modality.toLowerCase();
const lower = normalizeOptionalLowercaseString(modality);
if (lower === "text") {
mapped.add("text");
}
@@ -96,7 +99,9 @@ function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image
}
function inferReasoningSupport(summary: BedrockModelSummary): boolean {
const haystack = `${summary.modelId ?? ""} ${summary.modelName ?? ""}`.toLowerCase();
const haystack = normalizeLowercaseStringOrEmpty(
`${summary.modelId ?? ""} ${summary.modelName ?? ""}`,
);
return haystack.includes("reasoning") || haystack.includes("thinking");
}
@@ -256,7 +261,9 @@ function resolveInferenceProfiles(
const models = profile.models ?? [];
const matchesFilter = models.some((m) => {
const provider = m.modelArn?.split("/")?.[1]?.split(".")?.[0];
return provider ? providerFilter.includes(provider.toLowerCase()) : false;
return provider
? providerFilter.includes(normalizeOptionalLowercaseString(provider) ?? "")
: false;
});
if (!matchesFilter) {
continue;
@@ -265,7 +272,9 @@ function resolveInferenceProfiles(
// Look up the underlying foundation model to inherit its capabilities.
const baseModelId = resolveBaseModelId(profile);
const baseModel = baseModelId ? foundationModels.get(baseModelId.toLowerCase()) : undefined;
const baseModel = baseModelId
? foundationModels.get(normalizeLowercaseStringOrEmpty(baseModelId))
: undefined;
discovered.push({
id: profile.inferenceProfileId,
@@ -356,8 +365,9 @@ export async function discoverBedrockModels(params: {
maxTokens: defaultMaxTokens,
});
discovered.push(def);
seenIds.add(def.id.toLowerCase());
foundationModels.set(def.id.toLowerCase(), def);
const normalizedId = normalizeLowercaseStringOrEmpty(def.id);
seenIds.add(normalizedId);
foundationModels.set(normalizedId, def);
}
// Merge inference profiles — inherit capabilities from foundation models.
@@ -368,9 +378,10 @@ export async function discoverBedrockModels(params: {
foundationModels,
);
for (const profile of inferenceProfiles) {
if (!seenIds.has(profile.id.toLowerCase())) {
const normalizedId = normalizeLowercaseStringOrEmpty(profile.id);
if (!seenIds.has(normalizedId)) {
discovered.push(profile);
seenIds.add(profile.id.toLowerCase());
seenIds.add(normalizedId);
}
}

View File

@@ -193,7 +193,7 @@ function resolveAnthropic46ForwardCompatModel(params: {
fallbackTemplateIds: readonly string[];
}): ProviderRuntimeModel | undefined {
const trimmedModelId = params.ctx.modelId.trim();
const lower = trimmedModelId.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(trimmedModelId);
const is46Model =
lower === params.dashModelId ||
lower === params.dotModelId ||
@@ -247,6 +247,16 @@ function resolveAnthropicForwardCompatModel(
);
}
function shouldUseAnthropicAdaptiveThinkingDefault(modelId: string): boolean {
const lowerModelId = normalizeLowercaseStringOrEmpty(modelId);
return (
lowerModelId.startsWith(ANTHROPIC_OPUS_46_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_SONNET_46_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_SONNET_46_DOT_MODEL_ID)
);
}
function matchesAnthropicModernModel(modelId: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(modelId);
return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix));
@@ -468,11 +478,7 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
resolveReasoningOutputMode: () => "native",
wrapStreamFn: wrapAnthropicProviderStream,
resolveDefaultThinkingLevel: ({ modelId }) =>
matchesAnthropicModernModel(modelId) &&
(modelId.toLowerCase().startsWith(ANTHROPIC_OPUS_46_MODEL_ID) ||
modelId.toLowerCase().startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID) ||
modelId.toLowerCase().startsWith(ANTHROPIC_SONNET_46_MODEL_ID) ||
modelId.toLowerCase().startsWith(ANTHROPIC_SONNET_46_DOT_MODEL_ID))
matchesAnthropicModernModel(modelId) && shouldUseAnthropicAdaptiveThinkingDefault(modelId)
? "adaptive"
: undefined,
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
describe("resolveBlueBubblesServerAccount", () => {
it("respects an explicit private-network opt-out for loopback server URLs", () => {
expect(
resolveBlueBubblesServerAccount({
serverUrl: "http://127.0.0.1:1234",
password: "test-password",
cfg: {
channels: {
bluebubbles: {
network: {
dangerouslyAllowPrivateNetwork: false,
},
},
},
},
}),
).toMatchObject({
baseUrl: "http://127.0.0.1:1234",
password: "test-password",
allowPrivateNetwork: false,
});
});
it("lets a legacy per-account opt-in override a channel-level canonical default", () => {
expect(
resolveBlueBubblesServerAccount({
accountId: "personal",
cfg: {
channels: {
bluebubbles: {
network: {
dangerouslyAllowPrivateNetwork: false,
},
accounts: {
personal: {
serverUrl: "http://127.0.0.1:1234",
password: "test-password",
allowPrivateNetwork: true,
},
},
},
},
},
}),
).toMatchObject({
accountId: "personal",
baseUrl: "http://127.0.0.1:1234",
password: "test-password",
allowPrivateNetwork: true,
allowPrivateNetworkConfig: true,
});
});
it("uses accounts.default config for the default BlueBubbles account", () => {
expect(
resolveBlueBubblesServerAccount({
cfg: {
channels: {
bluebubbles: {
accounts: {
default: {
serverUrl: "http://127.0.0.1:1234",
password: "test-password",
allowPrivateNetwork: true,
},
},
},
},
},
}),
).toMatchObject({
accountId: "default",
baseUrl: "http://127.0.0.1:1234",
password: "test-password",
allowPrivateNetwork: true,
allowPrivateNetworkConfig: true,
});
});
});

View File

@@ -1,11 +1,10 @@
import {
isBlockedHostnameOrIp,
isPrivateNetworkOptInEnabled,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveBlueBubblesAccount } from "./accounts.js";
resolveBlueBubblesAccount,
resolveBlueBubblesEffectiveAllowPrivateNetwork,
resolveBlueBubblesPrivateNetworkConfigValue,
} from "./accounts.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { normalizeResolvedSecretInputString } from "./secret-input.js";
import { normalizeBlueBubblesServerUrl } from "./types.js";
export type BlueBubblesAccountResolveOpts = {
serverUrl?: string;
@@ -19,6 +18,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
password: string;
accountId: string;
allowPrivateNetwork: boolean;
allowPrivateNetworkConfig?: boolean;
} {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
@@ -49,18 +49,14 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
throw new Error("BlueBubbles password is required");
}
let autoAllowPrivateNetwork = false;
try {
const hostname = new URL(normalizeBlueBubblesServerUrl(baseUrl)).hostname.trim();
autoAllowPrivateNetwork = Boolean(hostname) && isBlockedHostnameOrIp(hostname);
} catch {
autoAllowPrivateNetwork = false;
}
return {
baseUrl,
password,
accountId: account.accountId,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config) || autoAllowPrivateNetwork,
allowPrivateNetwork: resolveBlueBubblesEffectiveAllowPrivateNetwork({
baseUrl,
config: account.config,
}),
allowPrivateNetworkConfig: resolveBlueBubblesPrivateNetworkConfigValue(account.config),
};
}

View File

@@ -5,6 +5,7 @@ import {
} from "openclaw/plugin-sdk/account-resolution";
import { resolveChannelStreamingChunkMode } from "openclaw/plugin-sdk/channel-streaming";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
@@ -24,17 +25,88 @@ const {
} = createAccountListHelpers("bluebubbles");
export { listBlueBubblesAccountIds, resolveDefaultBlueBubblesAccountId };
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeBlueBubblesPrivateNetworkAliases(
config: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const record = asRecord(config);
if (!record) {
return config;
}
const network = asRecord(record.network);
const canonicalValue =
typeof network?.dangerouslyAllowPrivateNetwork === "boolean"
? network.dangerouslyAllowPrivateNetwork
: typeof network?.allowPrivateNetwork === "boolean"
? network.allowPrivateNetwork
: typeof record.dangerouslyAllowPrivateNetwork === "boolean"
? record.dangerouslyAllowPrivateNetwork
: typeof record.allowPrivateNetwork === "boolean"
? record.allowPrivateNetwork
: undefined;
if (canonicalValue === undefined) {
return config;
}
const {
allowPrivateNetwork: _legacyFlatAllow,
dangerouslyAllowPrivateNetwork: _legacyFlatDanger,
...rest
} = record;
const {
allowPrivateNetwork: _legacyNetworkAllow,
dangerouslyAllowPrivateNetwork: _legacyNetworkDanger,
...restNetwork
} = network ?? {};
return {
...rest,
network: {
...restNetwork,
dangerouslyAllowPrivateNetwork: canonicalValue,
},
};
}
function normalizeBlueBubblesAccountsMap(
accounts: Record<string, Partial<BlueBubblesAccountConfig>> | undefined,
): Record<string, Partial<BlueBubblesAccountConfig>> | undefined {
if (!accounts) {
return undefined;
}
return Object.fromEntries(
Object.entries(accounts).map(([accountKey, accountConfig]) => [
accountKey,
normalizeBlueBubblesPrivateNetworkAliases(accountConfig) as Partial<BlueBubblesAccountConfig>,
]),
);
}
function mergeBlueBubblesAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): BlueBubblesAccountConfig {
const merged = resolveMergedAccountConfig<BlueBubblesAccountConfig>({
channelConfig: cfg.channels?.bluebubbles as BlueBubblesAccountConfig | undefined,
accounts: cfg.channels?.bluebubbles?.accounts as
const channelConfig = normalizeBlueBubblesPrivateNetworkAliases(
cfg.channels?.bluebubbles as BlueBubblesAccountConfig | undefined,
) as BlueBubblesAccountConfig | undefined;
const accounts = normalizeBlueBubblesAccountsMap(
cfg.channels?.bluebubbles?.accounts as
| Record<string, Partial<BlueBubblesAccountConfig>>
| undefined,
);
const merged = resolveMergedAccountConfig<BlueBubblesAccountConfig>({
channelConfig,
accounts,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
nestedObjectKeys: ["network"],
});
return {
...merged,
@@ -66,6 +138,48 @@ export function resolveBlueBubblesAccount(params: {
};
}
export function resolveBlueBubblesPrivateNetworkConfigValue(
config: BlueBubblesAccountConfig | null | undefined,
): boolean | undefined {
const record = asRecord(config);
if (!record) {
return undefined;
}
const network = asRecord(record.network);
if (typeof network?.dangerouslyAllowPrivateNetwork === "boolean") {
return network.dangerouslyAllowPrivateNetwork;
}
if (typeof network?.allowPrivateNetwork === "boolean") {
return network.allowPrivateNetwork;
}
if (typeof record.dangerouslyAllowPrivateNetwork === "boolean") {
return record.dangerouslyAllowPrivateNetwork;
}
if (typeof record.allowPrivateNetwork === "boolean") {
return record.allowPrivateNetwork;
}
return undefined;
}
export function resolveBlueBubblesEffectiveAllowPrivateNetwork(params: {
baseUrl?: string;
config?: BlueBubblesAccountConfig | null;
}): boolean {
const configuredValue = resolveBlueBubblesPrivateNetworkConfigValue(params.config);
if (configuredValue !== undefined) {
return configuredValue;
}
if (!params.baseUrl) {
return false;
}
try {
const hostname = new URL(normalizeBlueBubblesServerUrl(params.baseUrl)).hostname.trim();
return Boolean(hostname) && isBlockedHostnameOrIp(hostname);
} catch {
return false;
}
}
export function listEnabledBlueBubblesAccounts(cfg: OpenClawConfig): ResolvedBlueBubblesAccount[] {
return listBlueBubblesAccountIds(cfg)
.map((accountId) => resolveBlueBubblesAccount({ cfg, accountId }))

View File

@@ -318,6 +318,28 @@ describe("downloadBlueBubblesAttachment", () => {
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
});
it("respects an explicit private-network opt-out for loopback serverUrl", async () => {
mockSuccessfulAttachmentDownload();
const attachment: BlueBubblesAttachment = { guid: "att-opt-out" };
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
cfg: {
channels: {
bluebubbles: {
network: {
dangerouslyAllowPrivateNetwork: false,
},
},
},
},
});
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
});
it("allowlists public serverUrl hostname when allowPrivateNetwork is not set", async () => {
mockSuccessfulAttachmentDownload();
@@ -330,6 +352,28 @@ describe("downloadBlueBubblesAttachment", () => {
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["bluebubbles.example.com"] });
});
it("keeps public serverUrl hostname pinning when private-network access is explicitly disabled", async () => {
mockSuccessfulAttachmentDownload();
const attachment: BlueBubblesAttachment = { guid: "att-public-host-opt-out" };
await downloadBlueBubblesAttachment(attachment, {
serverUrl: "https://bluebubbles.example.com:1234",
password: "test",
cfg: {
channels: {
bluebubbles: {
network: {
dangerouslyAllowPrivateNetwork: false,
},
},
},
},
});
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowedHostnames: ["bluebubbles.example.com"] });
});
});
describe("sendBlueBubblesAttachment", () => {

View File

@@ -1,6 +1,7 @@
import crypto from "node:crypto";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
@@ -101,7 +102,8 @@ export async function downloadBlueBubblesAttachment(
if (!guid) {
throw new Error("BlueBubbles attachment guid is required");
}
const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts);
const { baseUrl, password, allowPrivateNetwork, allowPrivateNetworkConfig } =
resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
@@ -109,6 +111,7 @@ export async function downloadBlueBubblesAttachment(
});
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
const trustedHostname = safeExtractHostname(baseUrl);
const trustedHostnameIsPrivate = trustedHostname ? isBlockedHostnameOrIp(trustedHostname) : false;
try {
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
url,
@@ -116,7 +119,7 @@ export async function downloadBlueBubblesAttachment(
maxBytes,
ssrfPolicy: allowPrivateNetwork
? { allowPrivateNetwork: true }
: trustedHostname
: trustedHostname && (allowPrivateNetworkConfig !== false || !trustedHostnameIsPrivate)
? { allowedHostnames: [trustedHostname] }
: undefined,
fetchImpl: async (input, init) =>

View File

@@ -0,0 +1,80 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "./runtime-api.js";
const probeBlueBubblesMock = vi.hoisted(() => vi.fn());
const cfg: OpenClawConfig = {};
vi.mock("./channel.runtime.js", () => ({
blueBubblesChannelRuntime: {
probeBlueBubbles: probeBlueBubblesMock,
},
}));
vi.mock("../../../src/channels/plugins/bundled.js", () => ({
bundledChannelPlugins: [],
bundledChannelSetupPlugins: [],
}));
let bluebubblesPlugin: typeof import("./channel.js").bluebubblesPlugin;
describe("bluebubblesPlugin.status.probeAccount", () => {
beforeAll(async () => {
({ bluebubblesPlugin } = await import("./channel.js"));
});
beforeEach(() => {
probeBlueBubblesMock.mockReset();
probeBlueBubblesMock.mockResolvedValue({ ok: true, status: 200 });
});
it("auto-enables private-network probes for loopback server URLs", async () => {
await bluebubblesPlugin.status?.probeAccount?.({
cfg,
account: {
accountId: "default",
enabled: true,
configured: true,
config: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
baseUrl: "http://localhost:1234",
},
timeoutMs: 5000,
});
expect(probeBlueBubblesMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234",
password: "test-password",
timeoutMs: 5000,
allowPrivateNetwork: true,
});
});
it("respects an explicit private-network opt-out for loopback server URLs", async () => {
await bluebubblesPlugin.status?.probeAccount?.({
cfg,
account: {
accountId: "default",
enabled: true,
configured: true,
config: {
serverUrl: "http://localhost:1234",
password: "test-password",
network: {
dangerouslyAllowPrivateNetwork: false,
},
},
baseUrl: "http://localhost:1234",
},
timeoutMs: 5000,
});
expect(probeBlueBubblesMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234",
password: "test-password",
timeoutMs: 5000,
allowPrivateNetwork: false,
});
});
});

View File

@@ -8,13 +8,15 @@ import {
} from "openclaw/plugin-sdk/channel-policy";
import { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { type ResolvedBlueBubblesAccount } from "./accounts.js";
import {
type ResolvedBlueBubblesAccount,
resolveBlueBubblesEffectiveAllowPrivateNetwork,
} from "./accounts.js";
import { bluebubblesMessageActions } from "./actions.js";
import {
bluebubblesCapabilities,
@@ -226,7 +228,10 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
baseUrl: account.baseUrl,
password: account.config.password ?? null,
timeoutMs,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
allowPrivateNetwork: resolveBlueBubblesEffectiveAllowPrivateNetwork({
baseUrl: account.baseUrl,
config: account.config,
}),
}),
resolveAccountSnapshot: ({ account, runtime, probe }) => {
const running = runtime?.running ?? false;

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
import type { OpenClawConfig } from "./runtime-api.js";
@@ -70,7 +71,7 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized
continue;
}
// Skip duplicate text (URL might be in both text message and balloon)
const normalizedText = text.toLowerCase();
const normalizedText = normalizeLowercaseStringOrEmpty(text);
if (seenTexts.has(normalizedText)) {
continue;
}

View File

@@ -273,7 +273,7 @@ function rememberPendingOutboundMessageId(entry: {
chatId: typeof entry.chatId === "number" ? entry.chatId : undefined,
snippetRaw,
snippetNorm,
isMediaSnippet: snippetRaw.toLowerCase().startsWith("<media:"),
isMediaSnippet: normalizeLowercaseStringOrEmpty(snippetRaw).startsWith("<media:"),
createdAt: Date.now(),
});
return pendingOutboundMessageIdCounter;
@@ -396,7 +396,7 @@ function resolveBlueBubblesAckReaction(params: {
normalizeBlueBubblesReactionInput(raw);
return raw;
} catch {
const key = raw.toLowerCase();
const key = normalizeLowercaseStringOrEmpty(raw);
if (!invalidAckReactions.has(key)) {
invalidAckReactions.add(key);
logVerbose(

View File

@@ -1,8 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesEffectiveAllowPrivateNetwork } from "./accounts.js";
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
import {
asRecord,
@@ -321,6 +321,10 @@ export async function monitorBlueBubblesProvider(
const { account, config, runtime, abortSignal, statusSink } = options;
const core = getBlueBubblesRuntime();
const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
const allowPrivateNetwork = resolveBlueBubblesEffectiveAllowPrivateNetwork({
baseUrl: account.baseUrl,
config: account.config,
});
// Fetch and cache server info (for macOS version detection in action gating)
const serverInfo = await fetchBlueBubblesServerInfo({
@@ -328,7 +332,7 @@ export async function monitorBlueBubblesProvider(
password: account.config.password,
accountId: account.accountId,
timeoutMs: 5000,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
allowPrivateNetwork,
}).catch(() => null);
if (serverInfo?.os_version) {
runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);

View File

@@ -1,6 +1,7 @@
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { describe, expect, it, vi } from "vitest";
import {
createSetupWizardAdapter,
@@ -322,6 +323,36 @@ describe("resolveBlueBubblesAccount", () => {
expect(resolved.configured).toBe(true);
expect(resolved.baseUrl).toBe("http://localhost:1234");
});
it("strips stale legacy private-network aliases after canonical normalization", () => {
const resolved = resolveBlueBubblesAccount({
cfg: {
channels: {
bluebubbles: {
network: {
allowPrivateNetwork: true,
},
accounts: {
work: {
serverUrl: "http://localhost:1234",
password: "secret", // pragma: allowlist secret
network: {
dangerouslyAllowPrivateNetwork: false,
},
},
},
},
},
},
accountId: "work",
});
expect(resolved.config.network).toEqual({
dangerouslyAllowPrivateNetwork: false,
});
expect("allowPrivateNetwork" in resolved.config).toBe(false);
expect(isPrivateNetworkOptInEnabled(resolved.config)).toBe(false);
});
});
describe("BlueBubblesConfigSchema", () => {

View File

@@ -1,6 +1,7 @@
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
import type { Mock } from "vitest";
import { afterEach, beforeEach, vi } from "vitest";
import { _setFetchGuardForTesting } from "./types.js";
import { _setFetchGuardForTesting, normalizeBlueBubblesServerUrl } from "./types.js";
export const BLUE_BUBBLES_PRIVATE_API_STATUS = {
enabled: true,
@@ -27,21 +28,96 @@ export function mockBlueBubblesPrivateApiStatusOnce(
mock.mockReturnValueOnce(value);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeBlueBubblesPrivateNetworkAliases(
config: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const record = asRecord(config);
if (!record) {
return config;
}
const network = asRecord(record.network);
const canonicalValue =
typeof network?.dangerouslyAllowPrivateNetwork === "boolean"
? network.dangerouslyAllowPrivateNetwork
: typeof network?.allowPrivateNetwork === "boolean"
? network.allowPrivateNetwork
: typeof record.dangerouslyAllowPrivateNetwork === "boolean"
? record.dangerouslyAllowPrivateNetwork
: typeof record.allowPrivateNetwork === "boolean"
? record.allowPrivateNetwork
: undefined;
if (canonicalValue === undefined) {
return config;
}
const {
allowPrivateNetwork: _legacyFlatAllow,
dangerouslyAllowPrivateNetwork: _legacyFlatDanger,
...rest
} = record;
const {
allowPrivateNetwork: _legacyNetworkAllow,
dangerouslyAllowPrivateNetwork: _legacyNetworkDanger,
...restNetwork
} = network ?? {};
return {
...rest,
network: {
...restNetwork,
dangerouslyAllowPrivateNetwork: canonicalValue,
},
};
}
function normalizeBlueBubblesAccountsMap(
accounts: Record<string, Record<string, unknown> | undefined> | undefined,
): Record<string, Record<string, unknown> | undefined> | undefined {
if (!accounts) {
return undefined;
}
return Object.fromEntries(
Object.entries(accounts).map(([accountKey, accountConfig]) => [
accountKey,
normalizeBlueBubblesPrivateNetworkAliases(accountConfig),
]),
);
}
export function resolveBlueBubblesAccountFromConfig(params: {
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
accountId?: string;
}) {
const baseConfig = params.cfg?.channels?.bluebubbles ?? {};
const baseConfig =
normalizeBlueBubblesPrivateNetworkAliases(params.cfg?.channels?.bluebubbles ?? {}) ?? {};
const accounts = normalizeBlueBubblesAccountsMap(
baseConfig.accounts as Record<string, Record<string, unknown> | undefined> | undefined,
);
const accountId = params.accountId ?? "default";
const accountConfig =
accountId === "default"
? {}
: ((baseConfig.accounts as Record<string, Record<string, unknown> | undefined> | undefined)?.[
accountId
] ?? {});
const config = {
normalizeBlueBubblesPrivateNetworkAliases(accounts?.[accountId] ?? {}) ?? {};
const config: Record<string, unknown> = {
...baseConfig,
...accountConfig,
network:
typeof baseConfig.network === "object" &&
baseConfig.network &&
!Array.isArray(baseConfig.network) &&
typeof accountConfig.network === "object" &&
accountConfig.network &&
!Array.isArray(accountConfig.network)
? {
...(baseConfig.network as Record<string, unknown>),
...(accountConfig.network as Record<string, unknown>),
}
: (accountConfig.network ?? baseConfig.network),
};
return {
accountId,
@@ -51,9 +127,57 @@ export function resolveBlueBubblesAccountFromConfig(params: {
};
}
function resolveBlueBubblesPrivateNetworkConfigValueFromConfig(
config: Record<string, unknown> | undefined,
): boolean | undefined {
const record = asRecord(config);
if (!record) {
return undefined;
}
const network = asRecord(record.network);
if (typeof network?.dangerouslyAllowPrivateNetwork === "boolean") {
return network.dangerouslyAllowPrivateNetwork;
}
if (typeof network?.allowPrivateNetwork === "boolean") {
return network.allowPrivateNetwork;
}
if (typeof record.dangerouslyAllowPrivateNetwork === "boolean") {
return record.dangerouslyAllowPrivateNetwork;
}
if (typeof record.allowPrivateNetwork === "boolean") {
return record.allowPrivateNetwork;
}
return undefined;
}
function resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig(params: {
baseUrl?: string;
config?: Record<string, unknown>;
}) {
const configuredValue = resolveBlueBubblesPrivateNetworkConfigValueFromConfig(params.config);
if (configuredValue !== undefined) {
return configuredValue;
}
if (!params.baseUrl) {
return false;
}
try {
const hostname = new URL(normalizeBlueBubblesServerUrl(params.baseUrl)).hostname.trim();
return Boolean(hostname) && isBlockedHostnameOrIp(hostname);
} catch {
return false;
}
}
export function createBlueBubblesAccountsMockModule() {
return {
resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig),
resolveBlueBubblesEffectiveAllowPrivateNetwork: vi.fn(
resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig,
),
resolveBlueBubblesPrivateNetworkConfigValue: vi.fn(
resolveBlueBubblesPrivateNetworkConfigValueFromConfig,
),
};
}

View File

@@ -1,5 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import {
browserPluginNodeHostCommands,
browserPluginReload,
browserSecurityAuditCollectors,
registerBrowserPlugin,
} from "./plugin-registration.js";
import type { OpenClawPluginApi } from "./runtime-api.js";
const runtimeApiMocks = vi.hoisted(() => ({
@@ -26,8 +32,6 @@ vi.mock("./register.runtime.js", async () => {
};
});
import browserPlugin from "./index.js";
function createApi() {
const registerCli = vi.fn();
const registerGatewayMethod = vi.fn();
@@ -49,19 +53,19 @@ function createApi() {
describe("browser plugin", () => {
it("exposes static browser metadata on the plugin definition", () => {
expect(browserPlugin.reload).toEqual({ restartPrefixes: ["browser"] });
expect(browserPlugin.nodeHostCommands).toEqual([
expect(browserPluginReload).toEqual({ restartPrefixes: ["browser"] });
expect(browserPluginNodeHostCommands).toEqual([
expect.objectContaining({
command: "browser.proxy",
cap: "browser",
}),
]);
expect(browserPlugin.securityAuditCollectors).toHaveLength(1);
expect(browserSecurityAuditCollectors).toHaveLength(1);
});
it("forwards per-session browser options into the tool factory", async () => {
const { api, registerTool } = createApi();
await browserPlugin.register(api);
await registerBrowserPlugin(api);
const tool = registerTool.mock.calls[0]?.[0];
if (typeof tool !== "function") {

View File

@@ -1,41 +1,17 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import {
definePluginEntry,
type OpenClawPluginToolContext,
type OpenClawPluginToolFactory,
} from "openclaw/plugin-sdk/plugin-entry";
import {
collectBrowserSecurityAuditFindings,
createBrowserPluginService,
createBrowserTool,
handleBrowserGatewayRequest,
registerBrowserCli,
runBrowserProxyCommand,
} from "./register.runtime.js";
browserPluginNodeHostCommands,
browserPluginReload,
browserSecurityAuditCollectors,
registerBrowserPlugin,
} from "./plugin-registration.js";
export default definePluginEntry({
id: "browser",
name: "Browser",
description: "Default browser tool plugin",
reload: { restartPrefixes: ["browser"] },
nodeHostCommands: [
{
command: "browser.proxy",
cap: "browser",
handle: runBrowserProxyCommand,
},
],
securityAuditCollectors: [collectBrowserSecurityAuditFindings],
register(api) {
api.registerTool(((ctx: OpenClawPluginToolContext) =>
createBrowserTool({
sandboxBridgeUrl: ctx.browser?.sandboxBridgeUrl,
allowHostControl: ctx.browser?.allowHostControl,
agentSessionKey: ctx.sessionKey,
})) as OpenClawPluginToolFactory);
api.registerCli(({ program }) => registerBrowserCli(program), { commands: ["browser"] });
api.registerGatewayMethod("browser.request", handleBrowserGatewayRequest, {
scope: "operator.write",
});
api.registerService(createBrowserPluginService());
},
reload: browserPluginReload,
nodeHostCommands: browserPluginNodeHostCommands,
securityAuditCollectors: [...browserSecurityAuditCollectors],
register: registerBrowserPlugin,
});

View File

@@ -0,0 +1,39 @@
import type {
OpenClawPluginApi,
OpenClawPluginToolContext,
OpenClawPluginToolFactory,
} from "openclaw/plugin-sdk/plugin-entry";
import {
collectBrowserSecurityAuditFindings,
createBrowserPluginService,
createBrowserTool,
handleBrowserGatewayRequest,
registerBrowserCli,
runBrowserProxyCommand,
} from "./register.runtime.js";
export const browserPluginReload = { restartPrefixes: ["browser"] };
export const browserPluginNodeHostCommands = [
{
command: "browser.proxy",
cap: "browser",
handle: runBrowserProxyCommand,
},
];
export const browserSecurityAuditCollectors = [collectBrowserSecurityAuditFindings] as const;
export function registerBrowserPlugin(api: OpenClawPluginApi) {
api.registerTool(((ctx: OpenClawPluginToolContext) =>
createBrowserTool({
sandboxBridgeUrl: ctx.browser?.sandboxBridgeUrl,
allowHostControl: ctx.browser?.allowHostControl,
agentSessionKey: ctx.sessionKey,
})) as OpenClawPluginToolFactory);
api.registerCli(({ program }) => registerBrowserCli(program), { commands: ["browser"] });
api.registerGatewayMethod("browser.request", handleBrowserGatewayRequest, {
scope: "operator.write",
});
api.registerService(createBrowserPluginService());
}

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import WebSocket from "ws";
import { isLoopbackHost } from "../gateway/net.js";
import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js";
@@ -106,7 +107,7 @@ export function getHeadersWithAuth(url: string, headers: Record<string, string>
try {
const parsed = new URL(url);
const hasAuthHeader = Object.keys(mergedHeaders).some(
(key) => key.toLowerCase() === "authorization",
(key) => normalizeLowercaseStringOrEmpty(key) === "authorization",
);
if (hasAuthHeader) {
return mergedHeaders;

View File

@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { ResolvedBrowserConfig } from "./config.js";
export type BrowserExecutable = {
@@ -121,7 +122,7 @@ function execText(
}
function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"] {
const id = identifier.toLowerCase();
const id = normalizeLowercaseStringOrEmpty(identifier);
if (id.includes("brave")) {
return "brave";
}
@@ -146,7 +147,7 @@ function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"]
}
function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] {
const lower = name.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(name);
if (lower.includes("brave")) {
return "brave";
}
@@ -285,7 +286,7 @@ function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
if (!resolved) {
return null;
}
const exeName = path.posix.basename(resolved).toLowerCase();
const exeName = normalizeLowercaseStringOrEmpty(path.posix.basename(resolved));
if (!CHROMIUM_EXE_NAMES.has(exeName)) {
return null;
}
@@ -307,7 +308,7 @@ function detectDefaultChromiumExecutableWindows(): BrowserExecutable | null {
if (!exists(exePath)) {
return null;
}
const exeName = path.win32.basename(exePath).toLowerCase();
const exeName = normalizeLowercaseStringOrEmpty(path.win32.basename(exePath));
if (!CHROMIUM_EXE_NAMES.has(exeName)) {
return null;
}
@@ -464,7 +465,7 @@ function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecu
function findFirstChromeExecutable(candidates: string[]): BrowserExecutable | null {
for (const candidate of candidates) {
if (exists(candidate)) {
const normalizedPath = candidate.toLowerCase();
const normalizedPath = normalizeLowercaseStringOrEmpty(candidate);
return {
kind:
normalizedPath.includes("beta") ||

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
export type RoleRef = {
@@ -56,7 +57,7 @@ function matchInteractiveSnapshotLine(
if (roleRaw.startsWith("/")) {
return null;
}
const role = roleRaw.toLowerCase();
const role = normalizeLowercaseStringOrEmpty(roleRaw);
return {
roleRaw,
role,
@@ -174,7 +175,7 @@ function processLine(
return options.interactive ? null : line;
}
const role = roleRaw.toLowerCase();
const role = normalizeLowercaseStringOrEmpty(roleRaw);
const isInteractive = INTERACTIVE_ROLES.has(role);
const isContent = CONTENT_ROLES.has(role);
const isStructural = STRUCTURAL_ROLES.has(role);
@@ -379,7 +380,7 @@ export function buildRoleSnapshotFromAiSnapshot(
continue;
}
const role = roleRaw.toLowerCase();
const role = normalizeLowercaseStringOrEmpty(roleRaw);
const isStructural = STRUCTURAL_ROLES.has(role);
if (options.compact && isStructural && !name) {

View File

@@ -179,6 +179,44 @@ describe("pw-session createPageViaPlaywright navigation guard", () => {
expect(pageClose).toHaveBeenCalledTimes(1);
});
it("blocks private redirect hops even when Playwright marks hop as non-navigation", async () => {
const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks();
pageGoto.mockImplementationOnce(async () => {
const handler = getRouteHandler();
if (!handler) {
throw new Error("missing route handler");
}
await handler(
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
{
isNavigationRequest: () => true,
frame: () => mainFrame,
url: () => "https://93.184.216.34/start",
},
);
await handler(
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
{
isNavigationRequest: () => false,
frame: () => mainFrame,
resourceType: () => "document",
url: () => "http://127.0.0.1:18080/internal-hop",
},
);
throw new Error("Navigation aborted");
});
await expect(
createPageViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
url: "https://93.184.216.34/start",
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
expect(pageGoto).toHaveBeenCalledTimes(1);
expect(pageClose).toHaveBeenCalledTimes(1);
});
it("preserves the created tab on ordinary navigation failure", async () => {
const { pageGoto, pageClose } = installBrowserMocks();
pageGoto.mockRejectedValueOnce(new Error("page.goto: net::ERR_NAME_NOT_RESOLVED"));

View File

@@ -665,13 +665,29 @@ export async function getPageForTargetId(opts: {
}
function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
if (!request.isNavigationRequest()) {
let sameMainFrame = false;
try {
sameMainFrame = request.frame() === page.mainFrame();
} catch {
// Frame resolution can fail during redirect/renderer churn; fail closed.
sameMainFrame = true;
}
if (!sameMainFrame) {
return false;
}
try {
return request.frame() === page.mainFrame();
if (request.isNavigationRequest()) {
return true;
}
} catch {
return true;
// Ignore and fall back to resource-type check below.
}
try {
return request.resourceType() === "document";
} catch {
return false;
}
}

View File

@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const pageState = vi.hoisted(() => ({
page: null as Record<string, unknown> | null,
locator: null as Record<string, unknown> | null,
}));
const sessionMocks = vi.hoisted(() => ({
assertPageNavigationCompletedSafely: vi.fn(async () => {}),
ensurePageState: vi.fn(() => ({})),
forceDisconnectPlaywrightForTarget: vi.fn(async () => {}),
getPageForTargetId: vi.fn(async () => {
if (!pageState.page) {
throw new Error("missing page");
}
return pageState.page;
}),
gotoPageWithNavigationGuard: vi.fn(async () => null),
refLocator: vi.fn(() => {
if (!pageState.locator) {
throw new Error("missing locator");
}
return pageState.locator;
}),
restoreRoleRefsForTarget: vi.fn(() => {}),
storeRoleRefsForTarget: vi.fn(() => {}),
}));
const pageCdpMocks = vi.hoisted(() => ({
withPageScopedCdpClient: vi.fn(
async ({ fn }: { fn: (send: () => Promise<unknown>) => unknown }) =>
await fn(async () => ({ nodes: [] })),
),
}));
vi.mock("./pw-session.js", () => sessionMocks);
vi.mock("./pw-session.page-cdp.js", () => pageCdpMocks);
const interactions = await import("./pw-tools-core.interactions.js");
const snapshots = await import("./pw-tools-core.snapshot.js");
describe("pw-tools-core browser SSRF guards", () => {
beforeEach(() => {
pageState.page = null;
pageState.locator = null;
for (const fn of Object.values(sessionMocks)) {
fn.mockClear();
}
for (const fn of Object.values(pageCdpMocks)) {
fn.mockClear();
}
});
it("re-checks click-triggered navigations with the session safety helper", async () => {
pageState.page = { url: vi.fn(() => "https://example.com") };
pageState.locator = { click: vi.fn(async () => {}) };
await interactions.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
ref: "1",
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18792",
page: pageState.page,
response: null,
ssrfPolicy: { allowPrivateNetwork: false },
targetId: "tab-1",
});
});
it("preserves helper compatibility when no ssrfPolicy is provided", async () => {
pageState.page = { url: vi.fn(() => "https://example.com") };
pageState.locator = { click: vi.fn(async () => {}) };
await interactions.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
ref: "1",
// no ssrfPolicy: direct helper callers keep previous compatibility semantics
});
expect(sessionMocks.assertPageNavigationCompletedSafely).not.toHaveBeenCalled();
});
it("re-checks batched click-triggered navigations with the session safety helper", async () => {
pageState.page = { url: vi.fn(() => "https://example.com") };
pageState.locator = { click: vi.fn(async () => {}) };
await interactions.batchViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
actions: [{ kind: "click", ref: "1" }],
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18792",
page: pageState.page,
response: null,
ssrfPolicy: { allowPrivateNetwork: false },
targetId: "tab-1",
});
});
it("re-checks current page URL before snapshotting AI content", async () => {
const snapshotForAI = vi.fn(async () => ({ full: 'button "Save"' }));
pageState.page = {
_snapshotForAI: snapshotForAI,
url: vi.fn(() => "https://example.com"),
};
await snapshots.snapshotAiViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18792",
page: pageState.page,
response: null,
ssrfPolicy: { allowPrivateNetwork: false },
targetId: "tab-1",
});
expect(
sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
).toBeLessThan(snapshotForAI.mock.invocationCallOrder[0]);
});
it("re-checks current page URL before role snapshots", async () => {
const ariaSnapshot = vi.fn(async () => "");
pageState.page = {
locator: vi.fn(() => ({ ariaSnapshot })),
url: vi.fn(() => "https://example.com"),
};
await snapshots.snapshotRoleViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18792",
page: pageState.page,
response: null,
ssrfPolicy: { allowPrivateNetwork: false },
targetId: "tab-1",
});
expect(
sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
).toBeLessThan(ariaSnapshot.mock.invocationCallOrder[0]);
});
it("re-checks current page URL before aria snapshots", async () => {
pageState.page = {
url: vi.fn(() => "https://example.com"),
};
await snapshots.snapshotAriaViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18792",
page: pageState.page,
response: null,
ssrfPolicy: { allowPrivateNetwork: false },
targetId: "tab-1",
});
expect(
sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
).toBeLessThan(pageCdpMocks.withPageScopedCdpClient.mock.invocationCallOrder[0]);
});
});

View File

@@ -1,8 +1,10 @@
import { formatErrorMessage } from "../infra/errors.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js";
import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js";
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import {
assertPageNavigationCompletedSafely,
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
@@ -64,6 +66,24 @@ async function awaitEvalWithAbort<T>(
}
}
async function assertPostInteractionNavigationSafe(opts: {
cdpUrl: string;
page: Awaited<ReturnType<typeof getPageForTargetId>>;
ssrfPolicy?: SsrFPolicy;
targetId?: string;
}): Promise<void> {
if (!opts.ssrfPolicy) {
return;
}
await assertPageNavigationCompletedSafely({
cdpUrl: opts.cdpUrl,
page: opts.page,
response: null,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
}
export async function highlightViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
@@ -88,6 +108,7 @@ export async function clickViaPlaywright(opts: {
modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">;
delayMs?: number;
timeoutMs?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts);
@@ -115,6 +136,12 @@ export async function clickViaPlaywright(opts: {
modifiers: opts.modifiers,
});
}
await assertPostInteractionNavigationSafe({
cdpUrl: opts.cdpUrl,
page,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
} catch (err) {
throw toAIFriendlyError(err, label);
}
@@ -202,6 +229,7 @@ export async function pressKeyViaPlaywright(opts: {
targetId?: string;
key: string;
delayMs?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<void> {
const key = String(opts.key ?? "").trim();
if (!key) {
@@ -212,6 +240,12 @@ export async function pressKeyViaPlaywright(opts: {
await page.keyboard.press(key, {
delay: Math.max(0, Math.floor(opts.delayMs ?? 0)),
});
await assertPostInteractionNavigationSafe({
cdpUrl: opts.cdpUrl,
page,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
}
export async function typeViaPlaywright(opts: {
@@ -223,6 +257,7 @@ export async function typeViaPlaywright(opts: {
submit?: boolean;
slowly?: boolean;
timeoutMs?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const text = String(opts.text ?? "");
@@ -241,6 +276,12 @@ export async function typeViaPlaywright(opts: {
}
if (opts.submit) {
await locator.press("Enter", { timeout });
await assertPostInteractionNavigationSafe({
cdpUrl: opts.cdpUrl,
page,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
}
} catch (err) {
throw toAIFriendlyError(err, label);
@@ -713,6 +754,7 @@ async function executeSingleAction(
cdpUrl: string,
targetId?: string,
evaluateEnabled?: boolean,
ssrfPolicy?: SsrFPolicy,
depth = 0,
): Promise<void> {
if (depth > MAX_BATCH_DEPTH) {
@@ -733,6 +775,7 @@ async function executeSingleAction(
>,
delayMs: action.delayMs,
timeoutMs: action.timeoutMs,
ssrfPolicy,
});
break;
case "type":
@@ -745,6 +788,7 @@ async function executeSingleAction(
submit: action.submit,
slowly: action.slowly,
timeoutMs: action.timeoutMs,
ssrfPolicy,
});
break;
case "press":
@@ -753,6 +797,7 @@ async function executeSingleAction(
targetId: effectiveTargetId,
key: action.key,
delayMs: action.delayMs,
ssrfPolicy,
});
break;
case "hover":
@@ -852,6 +897,7 @@ async function executeSingleAction(
actions: action.actions,
stopOnError: action.stopOnError,
evaluateEnabled,
ssrfPolicy,
depth: depth + 1,
});
break;
@@ -866,6 +912,7 @@ export async function batchViaPlaywright(opts: {
actions: BrowserActRequest[];
stopOnError?: boolean;
evaluateEnabled?: boolean;
ssrfPolicy?: SsrFPolicy;
depth?: number;
}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> {
const depth = opts.depth ?? 0;
@@ -878,7 +925,14 @@ export async function batchViaPlaywright(opts: {
const results: Array<{ ok: boolean; error?: string }> = [];
for (const action of opts.actions) {
try {
await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth);
await executeSingleAction(
action,
opts.cdpUrl,
opts.targetId,
opts.evaluateEnabled,
opts.ssrfPolicy,
depth,
);
results.push({ ok: true });
} catch (err) {
const message = formatErrorMessage(err);

View File

@@ -23,6 +23,7 @@ export async function snapshotAriaViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
limit?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ nodes: AriaSnapshotNode[] }> {
const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500)));
const page = await getPageForTargetId({
@@ -30,6 +31,15 @@ export async function snapshotAriaViaPlaywright(opts: {
targetId: opts.targetId,
});
ensurePageState(page);
if (opts.ssrfPolicy) {
await assertPageNavigationCompletedSafely({
cdpUrl: opts.cdpUrl,
page,
response: null,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
}
const res = (await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
@@ -52,12 +62,22 @@ export async function snapshotAiViaPlaywright(opts: {
targetId?: string;
timeoutMs?: number;
maxChars?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ snapshot: string; truncated?: boolean; refs: RoleRefMap }> {
const page = await getPageForTargetId({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
});
ensurePageState(page);
if (opts.ssrfPolicy) {
await assertPageNavigationCompletedSafely({
cdpUrl: opts.cdpUrl,
page,
response: null,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
}
const maybe = page as unknown as WithSnapshotForAI;
if (!maybe._snapshotForAI) {
@@ -98,6 +118,7 @@ export async function snapshotRoleViaPlaywright(opts: {
frameSelector?: string;
refsMode?: "role" | "aria";
options?: RoleSnapshotOptions;
ssrfPolicy?: SsrFPolicy;
}): Promise<{
snapshot: string;
refs: Record<string, { role: string; name?: string; nth?: number }>;
@@ -108,6 +129,15 @@ export async function snapshotRoleViaPlaywright(opts: {
targetId: opts.targetId,
});
ensurePageState(page);
if (opts.ssrfPolicy) {
await assertPageNavigationCompletedSafely({
cdpUrl: opts.cdpUrl,
page,
response: null,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
}
if (opts.refsMode === "aria") {
if (opts.selector?.trim() || opts.frameSelector?.trim()) {

View File

@@ -101,6 +101,7 @@ export function registerBrowserAgentActHookRoutes(
cdpUrl,
targetId: tab.targetId,
ref,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
}
}

View File

@@ -482,6 +482,7 @@ export function registerBrowserAgentActRoutes(
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const profileName = profileCtx.profile.name;
@@ -539,6 +540,7 @@ export function registerBrowserAgentActRoutes(
cdpUrl,
targetId: tab.targetId,
doubleClick,
ssrfPolicy,
};
if (ref) {
clickRequest.ref = ref;
@@ -616,6 +618,7 @@ export function registerBrowserAgentActRoutes(
text,
submit,
slowly,
ssrfPolicy,
};
if (ref) {
typeRequest.ref = ref;
@@ -656,6 +659,7 @@ export function registerBrowserAgentActRoutes(
targetId: tab.targetId,
key,
delayMs: delayMs ?? undefined,
ssrfPolicy,
});
return res.json({ ok: true, targetId: tab.targetId });
}
@@ -1105,6 +1109,7 @@ export function registerBrowserAgentActRoutes(
actions,
stopOnError,
evaluateEnabled,
ssrfPolicy,
});
return res.json({ ok: true, targetId: tab.targetId, results: result.results });
}

View File

@@ -498,6 +498,7 @@ export function registerBrowserAgentSnapshotRoutes(
selector: plan.selectorValue,
frameSelector: plan.frameSelectorValue,
refsMode: plan.refsMode,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
@@ -511,6 +512,7 @@ export function registerBrowserAgentSnapshotRoutes(
.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
...(typeof plan.resolvedMaxChars === "number"
? { maxChars: plan.resolvedMaxChars }
: {}),
@@ -579,6 +581,7 @@ export function registerBrowserAgentSnapshotRoutes(
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
});
})()

View File

@@ -43,6 +43,9 @@ describe("browser control server", () => {
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) =>
@@ -54,6 +57,9 @@ describe("browser control server", () => {
expect(lastCall).toEqual({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
});
@@ -91,6 +97,9 @@ describe("browser control server", () => {
doubleClick: false,
button: "left",
modifiers: ["Shift"],
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const clickSelector = await realFetch(`${base}/act`, {
@@ -105,6 +114,9 @@ describe("browser control server", () => {
targetId: "abcd1234",
selector: "button.save",
doubleClick: false,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
@@ -120,6 +132,9 @@ describe("browser control server", () => {
text: "",
submit: false,
slowly: false,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const press = await postJson<{ ok: boolean }>(`${base}/act`, {
@@ -131,6 +146,9 @@ describe("browser control server", () => {
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const hover = await postJson<{ ok: boolean }>(`${base}/act`, {

View File

@@ -1,4 +1,7 @@
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/text-runtime";
import { browserCloseTab } from "./client.js";
export type TrackedSessionBrowserTab = {
@@ -36,7 +39,7 @@ function toTrackedTabId(params: { targetId: string; baseUrl?: string; profile?:
}
function isIgnorableCloseError(err: unknown): boolean {
const message = String(err).toLowerCase();
const message = normalizeLowercaseStringOrEmpty(String(err));
return (
message.includes("tab not found") ||
message.includes("target closed") ||

View File

@@ -316,9 +316,7 @@ describe("runBrowserProxyCommand", () => {
timeoutMs: 50,
}),
),
).rejects.toThrow(
"INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured",
);
).rejects.toThrow("INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles");
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
@@ -336,9 +334,7 @@ describe("runBrowserProxyCommand", () => {
timeoutMs: 50,
}),
),
).rejects.toThrow(
"INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured",
);
).rejects.toThrow("INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles");
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
@@ -357,9 +353,7 @@ describe("runBrowserProxyCommand", () => {
timeoutMs: 50,
}),
),
).rejects.toThrow(
"INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured",
);
).rejects.toThrow("INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles");
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
@@ -390,27 +384,17 @@ describe("runBrowserProxyCommand", () => {
);
});
it("preserves legacy proxy behavior when allowProfiles is empty", async () => {
dispatcherMocks.dispatch.mockResolvedValue({
status: 200,
body: { ok: true },
});
await runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/profiles/create",
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
timeoutMs: 50,
}),
);
expect(dispatcherMocks.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
path: "/profiles/create",
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
}),
);
it("rejects persistent profile creation when allowProfiles is empty", async () => {
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/profiles/create",
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
timeoutMs: 50,
}),
),
).rejects.toThrow("INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles");
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
});

View File

@@ -240,12 +240,10 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
profile: params.profile,
}) ?? "";
const allowedProfiles = proxyConfig.allowProfiles;
if (isPersistentBrowserProfileMutation(method, path)) {
throw new Error("INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles");
}
if (allowedProfiles.length > 0) {
if (isPersistentBrowserProfileMutation(method, path)) {
throw new Error(
"INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured",
);
}
if (path !== "/profiles") {
const profileToCheck = requestedProfile || resolved.defaultProfile;
if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: profileToCheck })) {

View File

@@ -1,5 +1,6 @@
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
const log = createSubsystemLogger("chutes-models");
@@ -564,12 +565,13 @@ export async function discoverChutesModels(accessToken?: string): Promise<ModelD
}
seen.add(id);
const lowerId = normalizeLowercaseStringOrEmpty(id);
const isReasoning =
entry.supported_features?.includes("reasoning") ||
id.toLowerCase().includes("r1") ||
id.toLowerCase().includes("thinking") ||
id.toLowerCase().includes("reason") ||
id.toLowerCase().includes("tee");
lowerId.includes("r1") ||
lowerId.includes("thinking") ||
lowerId.includes("reason") ||
lowerId.includes("tee");
const input: Array<"text" | "image"> = (entry.input_modalities || ["text"]).filter(
(i): i is "text" | "image" => i === "text" || i === "image",

View File

@@ -1,4 +1,3 @@
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
// not drag the broad Discord API barrel into lightweight plugin loads.
// not drag setup-only surfaces into lightweight channel plugin loads.
export { discordPlugin } from "./src/channel.js";
export { discordSetupPlugin } from "./src/channel.setup.js";

View File

@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
specifier: "./setup-plugin-api.js",
exportName: "discordSetupPlugin",
},
});

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Discord channel plugin surface.
export { discordSetupPlugin } from "./src/channel.setup.js";

View File

@@ -15,7 +15,7 @@ import {
type Ctx = Pick<
ChannelMessageActionContext,
"action" | "params" | "cfg" | "accountId" | "requesterSenderId"
"action" | "params" | "cfg" | "accountId" | "requesterSenderId" | "mediaLocalRoots"
>;
export async function tryHandleDiscordMessageActionGuildAdmin(params: {
@@ -336,6 +336,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
const channelId = readStringParam(actionParams, "channelId");
const location = readStringParam(actionParams, "location");
const entityType = readStringParam(actionParams, "eventType");
const image = readStringParam(actionParams, "image", { trim: false });
return await handleDiscordAction(
{
action: "eventCreate",
@@ -348,8 +349,10 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
channelId,
location,
entityType,
image,
},
cfg,
{ mediaLocalRoots: ctx.mediaLocalRoots },
);
}

View File

@@ -30,6 +30,7 @@ import {
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
resolveEventCoverImage,
} from "../send.js";
import { readDiscordParentIdParam } from "./runtime.shared.js";
@@ -37,6 +38,7 @@ export const discordGuildActionRuntime = {
addRoleDiscord,
createChannelDiscord,
createScheduledEventDiscord,
resolveEventCoverImage,
deleteChannelDiscord,
editChannelDiscord,
fetchChannelInfoDiscord,
@@ -95,6 +97,7 @@ export async function handleDiscordGuildAction(
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
cfg?: OpenClawConfig,
options?: { mediaLocalRoots?: readonly string[] },
): Promise<AgentToolResult<unknown>> {
const accountId = readStringParam(params, "accountId");
switch (action) {
@@ -299,8 +302,14 @@ export async function handleDiscordGuildAction(
const description = readStringParam(params, "description");
const channelId = readStringParam(params, "channelId");
const location = readStringParam(params, "location");
const imageUrl = readStringParam(params, "image", { trim: false });
const entityTypeRaw = readStringParam(params, "entityType");
const entityType = entityTypeRaw === "stage" ? 1 : entityTypeRaw === "external" ? 3 : 2;
const image = imageUrl
? await discordGuildActionRuntime.resolveEventCoverImage(imageUrl, {
localRoots: options?.mediaLocalRoots,
})
: undefined;
const payload = {
name,
description,
@@ -309,6 +318,7 @@ export async function handleDiscordGuildAction(
entity_type: entityType,
channel_id: channelId,
entity_metadata: entityType === 3 && location ? { location } : undefined,
image,
privacy_level: 2,
};
const event = accountId

View File

@@ -69,7 +69,7 @@ export async function handleDiscordAction(
return await handleDiscordMessagingAction(action, params, isActionEnabled, options, cfg);
}
if (guildActions.has(action)) {
return await handleDiscordGuildAction(action, params, isActionEnabled, cfg);
return await handleDiscordGuildAction(action, params, isActionEnabled, cfg, options);
}
if (moderationActions.has(action)) {
return await handleDiscordModerationAction(action, params, isActionEnabled);

View File

@@ -0,0 +1,45 @@
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import { describe, expect, it } from "vitest";
import { discordApprovalNativeRuntime } from "./approval-handler.runtime.js";
describe("discordApprovalNativeRuntime", () => {
it("routes origin approval updates to the Discord thread channel when threadId is present", async () => {
const prepared = await discordApprovalNativeRuntime.transport.prepareTarget({
cfg: {} as never,
accountId: "main",
context: {
token: "discord-token",
config: {} as never,
},
plannedTarget: {
surface: "origin",
reason: "preferred",
target: {
to: "123456789",
threadId: "777888999",
},
},
request: {
id: "req-1",
request: {
command: "hostname",
},
createdAtMs: 0,
expiresAtMs: 1_000,
},
approvalKind: "exec",
view: {} as never,
pendingPayload: {} as never,
});
expect(prepared).toEqual({
dedupeKey: buildChannelApprovalNativeTargetKey({
to: "123456789",
threadId: "777888999",
}),
target: {
discordChannelId: "777888999",
},
});
});
});

View File

@@ -0,0 +1,626 @@
import {
Button,
Row,
Separator,
TextDisplay,
serializePayload,
type MessagePayloadObject,
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
import type {
ChannelApprovalCapabilityHandlerContext,
ExecApprovalExpiredView,
ExecApprovalPendingView,
ExecApprovalResolvedView,
PendingApprovalView,
PluginApprovalExpiredView,
PluginApprovalPendingView,
PluginApprovalResolvedView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
ExecApprovalActionDescriptor,
ExecApprovalDecision,
} from "openclaw/plugin-sdk/infra-runtime";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
import { shouldHandleDiscordApprovalRequest } from "./approval-native.js";
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
import { createDiscordClient, stripUndefinedFields } from "./send.shared.js";
import { DiscordUiContainer } from "./ui.js";
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
};
type DiscordPendingDelivery = {
body: ReturnType<typeof stripUndefinedFields>;
};
type PreparedDeliveryTarget = {
discordChannelId: string;
recipientUserId?: string;
};
export type DiscordApprovalHandlerContext = {
token: string;
config: DiscordExecApprovalConfig;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: DiscordApprovalHandlerContext;
} | null {
const context = params.context as DiscordApprovalHandlerContext | undefined;
const accountId = params.accountId?.trim() || "";
if (!context?.token || !accountId) {
return null;
}
return { accountId, context };
}
class ExecApprovalContainer extends DiscordUiContainer {
constructor(params: {
cfg: OpenClawConfig;
accountId: string;
title: string;
description?: string;
commandPreview: string;
commandSecondaryPreview?: string | null;
metadataLines?: string[];
actionRow?: Row<Button>;
footer?: string;
accentColor?: string;
}) {
const components: Array<TextDisplay | Separator | Row<Button>> = [
new TextDisplay(`## ${params.title}`),
];
if (params.description) {
components.push(new TextDisplay(params.description));
}
components.push(new Separator({ divider: true, spacing: "small" }));
components.push(new TextDisplay(`### Command\n\`\`\`\n${params.commandPreview}\n\`\`\``));
if (params.commandSecondaryPreview) {
components.push(
new TextDisplay(`### Shell Preview\n\`\`\`\n${params.commandSecondaryPreview}\n\`\`\``),
);
}
if (params.metadataLines?.length) {
components.push(new TextDisplay(params.metadataLines.join("\n")));
}
if (params.actionRow) {
components.push(params.actionRow);
}
if (params.footer) {
components.push(new Separator({ divider: false, spacing: "small" }));
components.push(new TextDisplay(`-# ${params.footer}`));
}
super({
cfg: params.cfg,
accountId: params.accountId,
components,
accentColor: params.accentColor,
});
}
}
class ExecApprovalActionButton extends Button {
customId: string;
label: string;
style: ButtonStyle;
constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) {
super();
this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision);
this.label = params.descriptor.label;
this.style =
params.descriptor.style === "success"
? ButtonStyle.Success
: params.descriptor.style === "primary"
? ButtonStyle.Primary
: params.descriptor.style === "danger"
? ButtonStyle.Danger
: ButtonStyle.Secondary;
}
}
class ExecApprovalActionRow extends Row<Button> {
constructor(params: { approvalId: string; actions: readonly ExecApprovalActionDescriptor[] }) {
super(
params.actions.map(
(descriptor) => new ExecApprovalActionButton({ approvalId: params.approvalId, descriptor }),
),
);
}
}
function createApprovalActionRow(view: PendingApprovalView): Row<Button> {
return new ExecApprovalActionRow({
approvalId: view.approvalId,
actions: view.actions,
});
}
function buildApprovalMetadataLines(
metadata: readonly { label: string; value: string }[],
): string[] {
return metadata.map((item) => `- ${item.label}: ${item.value}`);
}
function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayloadObject {
const components: TopLevelComponents[] = [container];
return { components };
}
function formatCommandPreview(commandText: string, maxChars: number): string {
const commandRaw =
commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText;
return commandRaw.replace(/`/g, "\u200b`");
}
function formatOptionalCommandPreview(
commandText: string | null | undefined,
maxChars: number,
): string | null {
if (!commandText) {
return null;
}
return formatCommandPreview(commandText, maxChars);
}
function resolveCommandPreviews(
commandText: string,
commandPreview: string | null | undefined,
maxChars: number,
secondaryMaxChars: number,
): { commandPreview: string; commandSecondaryPreview: string | null } {
return {
commandPreview: formatCommandPreview(commandText, maxChars),
commandSecondaryPreview: formatOptionalCommandPreview(commandPreview, secondaryMaxChars),
};
}
function createExecApprovalRequestContainer(params: {
view: ExecApprovalPendingView;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
1000,
500,
);
const expiresAtSeconds = Math.max(0, Math.floor(params.view.expiresAtMs / 1000));
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval Required",
description: "A command needs your approval.",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
accentColor: "#FFA500",
});
}
function createPluginApprovalRequestContainer(params: {
view: PluginApprovalPendingView;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const expiresAtSeconds = Math.max(0, Math.floor(params.view.expiresAtMs / 1000));
const severity = params.view.severity;
const accentColor =
severity === "critical" ? "#ED4245" : severity === "info" ? "#5865F2" : "#FAA61A";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Plugin Approval Required",
description: "A plugin action needs your approval.",
commandPreview: formatCommandPreview(params.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
accentColor,
});
}
function createExecResolvedContainer(params: {
view: ExecApprovalResolvedView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
500,
300,
);
const decisionLabel =
params.view.decision === "allow-once"
? "Allowed (once)"
: params.view.decision === "allow-always"
? "Allowed (always)"
: "Denied";
const accentColor =
params.view.decision === "deny"
? "#ED4245"
: params.view.decision === "allow-always"
? "#5865F2"
: "#57F287";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: `Exec Approval: ${decisionLabel}`,
description: params.view.resolvedBy ? `Resolved by ${params.view.resolvedBy}` : "Resolved",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor,
});
}
function createPluginResolvedContainer(params: {
view: PluginApprovalResolvedView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const decisionLabel =
params.view.decision === "allow-once"
? "Allowed (once)"
: params.view.decision === "allow-always"
? "Allowed (always)"
: "Denied";
const accentColor =
params.view.decision === "deny"
? "#ED4245"
: params.view.decision === "allow-always"
? "#5865F2"
: "#57F287";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: `Plugin Approval: ${decisionLabel}`,
description: params.view.resolvedBy ? `Resolved by ${params.view.resolvedBy}` : "Resolved",
commandPreview: formatCommandPreview(params.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor,
});
}
function createExecExpiredContainer(params: {
view: ExecApprovalExpiredView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
500,
300,
);
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval: Expired",
description: "This approval request has expired.",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor: "#99AAB5",
});
}
function createPluginExpiredContainer(params: {
view: PluginApprovalExpiredView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Plugin Approval: Expired",
description: "This approval request has expired.",
commandPreview: formatCommandPreview(params.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor: "#99AAB5",
});
}
export function buildExecApprovalCustomId(
approvalId: string,
action: ExecApprovalDecision,
): string {
return [`execapproval:id=${encodeURIComponent(approvalId)}`, `action=${action}`].join(";");
}
async function updateMessage(params: {
cfg: OpenClawConfig;
accountId: string;
token: string;
channelId: string;
messageId: string;
container: DiscordUiContainer;
}): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: params.token, accountId: params.accountId },
params.cfg,
);
const payload = buildExecApprovalPayload(params.container);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(params.channelId, params.messageId), {
body: stripUndefinedFields(serializePayload(payload)),
}),
"update-approval",
);
} catch (err) {
logError(`discord approvals: failed to update message: ${String(err)}`);
}
}
async function finalizeMessage(params: {
cfg: OpenClawConfig;
accountId: string;
token: string;
cleanupAfterResolve?: boolean;
channelId: string;
messageId: string;
container: DiscordUiContainer;
}): Promise<void> {
if (!params.cleanupAfterResolve) {
await updateMessage(params);
return;
}
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: params.token, accountId: params.accountId },
params.cfg,
);
await discordRequest(
() => rest.delete(Routes.channelMessage(params.channelId, params.messageId)) as Promise<void>,
"delete-approval",
);
} catch (err) {
logError(`discord approvals: failed to delete message: ${String(err)}`);
await updateMessage(params);
}
}
export const discordApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
DiscordPendingDelivery,
PreparedDeliveryTarget,
PendingApproval,
never
>({
eventKinds: ["exec", "plugin"],
resolveApprovalKind: (request) => (request.id.startsWith("plugin:") ? "plugin" : "exec"),
availability: {
isConfigured: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? isDiscordExecApprovalClientEnabled({
cfg: params.cfg,
accountId: resolved.accountId,
configOverride: resolved.context.config,
})
: false;
},
shouldHandle: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? shouldHandleDiscordApprovalRequest({
cfg: params.cfg,
accountId: resolved.accountId,
request: params.request,
configOverride: resolved.context.config,
})
: false;
},
},
presentation: {
buildPendingPayload: ({ cfg, accountId, context, view }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return { body: {} };
}
const actionRow = createApprovalActionRow(view);
const container =
view.approvalKind === "plugin"
? createPluginApprovalRequestContainer({
view: view,
cfg,
accountId: resolved.accountId,
actionRow,
})
: createExecApprovalRequestContainer({
view: view,
cfg,
accountId: resolved.accountId,
actionRow,
});
return {
body: stripUndefinedFields(serializePayload(buildExecApprovalPayload(container))),
};
},
buildResolvedResult: ({ cfg, accountId, context, view }) => {
const resolvedContext = resolveHandlerContext({ cfg, accountId, context });
if (!resolvedContext) {
return { kind: "delete" } as const;
}
const container =
view.approvalKind === "plugin"
? createPluginResolvedContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
})
: createExecResolvedContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
});
return { kind: "update", payload: container } as const;
},
buildExpiredResult: ({ cfg, accountId, context, view }) => {
const resolvedContext = resolveHandlerContext({ cfg, accountId, context });
if (!resolvedContext) {
return { kind: "delete" } as const;
}
const container =
view.approvalKind === "plugin"
? createPluginExpiredContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
})
: createExecExpiredContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
});
return { kind: "update", payload: container } as const;
},
},
transport: {
prepareTarget: async ({ cfg, accountId, context, plannedTarget }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
if (plannedTarget.surface === "origin") {
const destinationId =
typeof plannedTarget.target.threadId === "string" &&
plannedTarget.target.threadId.trim().length > 0
? plannedTarget.target.threadId.trim()
: plannedTarget.target.to;
return {
dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target),
target: {
discordChannelId: destinationId,
},
};
}
const { rest, request: discordRequest } = createDiscordClient(
{ token: resolved.context.token, accountId: resolved.accountId },
cfg,
);
const userId = plannedTarget.target.to;
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord approvals: failed to create DM for user ${userId}`);
return null;
}
return {
dedupeKey: dmChannel.id,
target: {
discordChannelId: dmChannel.id,
recipientUserId: userId,
},
};
},
deliverPending: async ({
cfg,
accountId,
context,
plannedTarget,
preparedTarget,
pendingPayload,
}) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const { rest, request: discordRequest } = createDiscordClient(
{ token: resolved.context.token, accountId: resolved.accountId },
cfg,
);
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(preparedTarget.discordChannelId), {
body: pendingPayload.body,
}) as Promise<{ id: string; channel_id: string }>,
plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
if (plannedTarget.surface === "origin") {
logError("discord approvals: failed to send to channel");
} else if (preparedTarget.recipientUserId) {
logError(
`discord approvals: failed to send message to user ${preparedTarget.recipientUserId}`,
);
}
return null;
}
return {
discordMessageId: message.id,
discordChannelId: preparedTarget.discordChannelId,
};
},
updateEntry: async ({ cfg, accountId, context, entry, payload, phase }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const container = payload as DiscordUiContainer;
await finalizeMessage({
cfg,
accountId: resolved.accountId,
token: resolved.context.token,
cleanupAfterResolve:
phase === "resolved" ? resolved.context.config.cleanupAfterResolve : false,
channelId: entry.discordChannelId,
messageId: entry.discordMessageId,
container,
});
},
},
observe: {
onDuplicateSkipped: ({ preparedTarget, request }) => {
logDebug(
`discord approvals: skipping duplicate approval ${request.id} for channel ${preparedTarget.dedupeKey}`,
);
},
onDelivered: ({ plannedTarget, preparedTarget, request }) => {
if (plannedTarget.surface === "origin") {
logDebug(
`discord approvals: sent approval ${request.id} to channel ${preparedTarget.target.discordChannelId}`,
);
return;
}
logDebug(`discord approvals: sent approval ${request.id} to user ${plannedTarget.target.to}`);
},
onDeliveryError: ({ error, plannedTarget }) => {
if (plannedTarget.surface === "origin") {
logError(`discord approvals: failed to send to channel: ${String(error)}`);
return;
}
logError(
`discord approvals: failed to notify user ${plannedTarget.target.to}: ${String(error)}`,
);
},
},
});

View File

@@ -220,7 +220,7 @@ describe("createDiscordNativeApprovalAdapter", () => {
},
});
expect(target).toEqual({ to: "123456789" });
expect(target).toEqual({ to: "123456789", threadId: undefined });
});
it("falls back to extracting the channel id from the session key", async () => {
@@ -242,7 +242,55 @@ describe("createDiscordNativeApprovalAdapter", () => {
},
});
expect(target).toEqual({ to: "987654321" });
expect(target).toEqual({ to: "987654321", threadId: undefined });
});
it("preserves explicit turn-source thread ids on origin targets", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_APPROVAL_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:channel:123456789:thread:777888999",
turnSourceChannel: "discord",
turnSourceTo: "channel:123456789",
turnSourceThreadId: "777888999",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toEqual({ to: "123456789", threadId: "777888999" });
});
it("falls back to extracting thread ids from the session key", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_APPROVAL_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:channel:987654321:thread:444555666",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toEqual({ to: "987654321", threadId: "444555666" });
});
it("rejects origin delivery for requests bound to another Discord account", async () => {

View File

@@ -1,3 +1,5 @@
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime";
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
@@ -19,6 +21,8 @@ import {
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
// Legacy export kept for monitor test/support surfaces; native routing now uses
// the shared session-conversation fallback helper instead.
export function extractDiscordChannelId(sessionKey?: string | null): string | null {
if (!sessionKey) {
return null;
@@ -27,6 +31,14 @@ export function extractDiscordChannelId(sessionKey?: string | null): string | nu
return match ? match[1] : null;
}
export function extractDiscordThreadId(sessionKey?: string | null): string | null {
if (!sessionKey) {
return null;
}
const match = sessionKey.match(/discord:(?:channel|group):\d+:thread:(\d+)/);
return match ? match[1] : null;
}
function extractDiscordSessionKind(sessionKey?: string | null): "channel" | "group" | "dm" | null {
if (!sessionKey) {
return null;
@@ -53,6 +65,17 @@ function normalizeDiscordOriginChannelId(value?: string | null): string | null {
return /^\d+$/.test(trimmed) ? trimmed : null;
}
function normalizeDiscordThreadId(value?: string | number | null): string | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : undefined;
}
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim();
return /^\d+$/.test(normalized) ? normalized : undefined;
}
export function shouldHandleDiscordApprovalRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@@ -103,34 +126,63 @@ function createDiscordOriginTargetResolver(configOverride?: DiscordExecApprovalC
configOverride,
}),
resolveTurnSourceTarget: (request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
channel: "discord",
});
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const rawTurnSourceTo = request.request.turnSourceTo?.trim() || "";
const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo);
const threadId =
normalizeDiscordThreadId(request.request.turnSourceThreadId) ??
normalizeDiscordThreadId(sessionConversation?.threadId) ??
undefined;
const hasExplicitOriginTarget = /^(?:channel|group):/i.test(rawTurnSourceTo);
if (turnSourceChannel !== "discord" || !turnSourceTo || sessionKind === "dm") {
return null;
}
return hasExplicitOriginTarget || sessionKind === "channel" || sessionKind === "group"
? { to: turnSourceTo }
? { to: turnSourceTo, threadId }
: null;
},
resolveSessionTarget: (sessionTarget, request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
channel: "discord",
});
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
if (sessionKind === "dm") {
return null;
}
const targetTo = normalizeDiscordOriginChannelId(sessionTarget.to);
return targetTo ? { to: targetTo } : null;
return targetTo
? {
to: targetTo,
threadId:
normalizeDiscordThreadId(sessionTarget.threadId) ??
normalizeDiscordThreadId(sessionConversation?.threadId) ??
undefined,
}
: null;
},
targetsMatch: (a, b) => a.to === b.to,
targetsMatch: (a, b) => a.to === b.to && a.threadId === b.threadId,
resolveFallbackTarget: (request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
channel: "discord",
});
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
if (sessionKind === "dm") {
return null;
}
const legacyChannelId = extractDiscordChannelId(request.request.sessionKey?.trim() || null);
return legacyChannelId ? { to: legacyChannelId } : null;
const fallbackChannelId = normalizeDiscordOriginChannelId(sessionConversation?.id);
return fallbackChannelId
? {
to: fallbackChannelId,
threadId: normalizeDiscordThreadId(sessionConversation?.threadId) ?? undefined,
}
: null;
},
});
}
@@ -175,6 +227,20 @@ export function createDiscordApprovalCapability(configOverride?: DiscordExecAppr
resolveOriginTarget: createDiscordOriginTargetResolver(configOverride),
resolveApproverDmTargets: createDiscordApproverDmTargetResolver(configOverride),
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId }) =>
isDiscordExecApprovalClientEnabled({ cfg, accountId, configOverride }),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleDiscordApprovalRequest({
cfg,
accountId,
request,
configOverride,
}),
load: async () =>
(await import("./approval-handler.runtime.js")).discordApprovalNativeRuntime,
}),
});
}

View File

@@ -797,6 +797,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
channelRuntime: ctx.channelRuntime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
historyLimit: account.config.historyLimit,

View File

@@ -1,4 +1,7 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { parseDiscordTarget } from "./target-parsing.js";
function normalizeDiscordTarget(
@@ -37,7 +40,7 @@ export function resolveDiscordCurrentConversationIdentity(params: {
commandTo?: string | null;
fallbackTo?: string | null;
}): string | undefined {
if (normalizeOptionalString(params.chatType)?.toLowerCase() === "direct") {
if (normalizeOptionalLowercaseString(params.chatType) === "direct") {
const senderTarget = normalizeDiscordTarget(params.from, "user");
if (senderTarget?.startsWith("user:")) {
return senderTarget;

View File

@@ -1,4 +1,5 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
const DISCORD_DIRECTORY_CACHE_MAX_ENTRIES = 4000;
const DISCORD_DISCRIMINATOR_SUFFIX = /#\d{4}$/;
@@ -29,7 +30,7 @@ function normalizeHandleKey(raw: string): string | null {
if (!handle || /\s/.test(handle)) {
return null;
}
return handle.toLowerCase();
return normalizeLowercaseStringOrEmpty(handle);
}
function ensureAccountCache(accountId?: string | null): Map<string, string> {

View File

@@ -26,7 +26,7 @@ function createOpenGuildConfig(
channels: Record<string, { allow: boolean; includeThreadStarter?: boolean }>,
extra: Partial<Config> = {},
): Config {
return {
const cfg: Config = {
...createMentionRequiredGuildConfig(),
...extra,
channels: {
@@ -41,7 +41,8 @@ function createOpenGuildConfig(
},
},
},
} as Config;
};
return cfg;
}
describe("discord tool result dispatch", () => {

View File

@@ -70,12 +70,12 @@ describe("discord tool result dispatch", () => {
});
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
const cfg = {
const cfg: Config = {
...BASE_CFG,
channels: {
discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
},
} as Config;
};
const handler = await createDmHandler({ cfg });
const client = createDmClient();

View File

@@ -9,26 +9,10 @@ export const readAllowFromStoreMock: MockFn = vi.fn();
export const upsertPairingRequestMock: MockFn = vi.fn();
export const loadConfigMock: MockFn = vi.fn();
export const TOOL_RESULT_SESSION_STORE_PATH = `/tmp/openclaw-sessions-${process.pid}.json`;
const sendModule = await import("./send.js");
vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation(
(...args) => sendMock(...args) as never,
);
vi.spyOn(sendModule, "reactMessageDiscord").mockImplementation(async (...args) => {
reactMock(...args);
return { ok: true };
});
const replyRuntimeModule = await import("openclaw/plugin-sdk/reply-runtime");
vi.spyOn(replyRuntimeModule, "dispatchInboundMessage").mockImplementation(
(...args) => dispatchMock(...args) as never,
);
vi.spyOn(replyRuntimeModule, "dispatchInboundMessageWithDispatcher").mockImplementation(
(...args) => dispatchMock(...args) as never,
);
vi.spyOn(replyRuntimeModule, "dispatchInboundMessageWithBufferedDispatcher").mockImplementation(
(...args) => dispatchMock(...args) as never,
);
const conversationRuntimeModule = await import("openclaw/plugin-sdk/conversation-runtime");
type ReadChannelAllowFromStore = typeof conversationRuntimeModule.readChannelAllowFromStore;
type UpsertChannelPairingRequest = typeof conversationRuntimeModule.upsertChannelPairingRequest;
@@ -49,22 +33,42 @@ function createPairingStoreMocks() {
}
const pairingStoreMocks = createPairingStoreMocks();
vi.spyOn(conversationRuntimeModule, "readChannelAllowFromStore").mockImplementation((...args) =>
pairingStoreMocks.readChannelAllowFromStore(...args),
);
vi.spyOn(conversationRuntimeModule, "upsertChannelPairingRequest").mockImplementation((...args) =>
pairingStoreMocks.upsertChannelPairingRequest(...args),
);
const configRuntimeModule = await import("openclaw/plugin-sdk/config-runtime");
vi.spyOn(configRuntimeModule, "loadConfig").mockImplementation(
(...args) => loadConfigMock(...args) as never,
);
vi.spyOn(configRuntimeModule, "readSessionUpdatedAt").mockImplementation(() => undefined);
vi.spyOn(configRuntimeModule, "resolveStorePath").mockImplementation(
() => "/tmp/openclaw-sessions.json",
);
vi.spyOn(configRuntimeModule, "updateLastRoute").mockImplementation(
(...args) => updateLastRouteMock(...args) as never,
);
vi.spyOn(configRuntimeModule, "resolveSessionKey").mockImplementation(vi.fn() as never);
export function installDiscordToolResultHarnessSpies() {
vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation(
(...args) => sendMock(...args) as never,
);
vi.spyOn(sendModule, "reactMessageDiscord").mockImplementation(async (...args) => {
reactMock(...args);
return { ok: true };
});
vi.spyOn(replyRuntimeModule, "dispatchInboundMessage").mockImplementation(
(...args) => dispatchMock(...args) as never,
);
vi.spyOn(replyRuntimeModule, "dispatchInboundMessageWithDispatcher").mockImplementation(
(...args) => dispatchMock(...args) as never,
);
vi.spyOn(replyRuntimeModule, "dispatchInboundMessageWithBufferedDispatcher").mockImplementation(
(...args) => dispatchMock(...args) as never,
);
vi.spyOn(conversationRuntimeModule, "readChannelAllowFromStore").mockImplementation((...args) =>
pairingStoreMocks.readChannelAllowFromStore(...args),
);
vi.spyOn(conversationRuntimeModule, "upsertChannelPairingRequest").mockImplementation((...args) =>
pairingStoreMocks.upsertChannelPairingRequest(...args),
);
vi.spyOn(configRuntimeModule, "loadConfig").mockImplementation(
(...args) => loadConfigMock(...args) as never,
);
vi.spyOn(configRuntimeModule, "readSessionUpdatedAt").mockImplementation(() => undefined);
vi.spyOn(configRuntimeModule, "resolveStorePath").mockImplementation(
() => TOOL_RESULT_SESSION_STORE_PATH,
);
vi.spyOn(configRuntimeModule, "updateLastRoute").mockImplementation(
(...args) => updateLastRouteMock(...args) as never,
);
vi.spyOn(configRuntimeModule, "resolveSessionKey").mockImplementation(vi.fn() as never);
}
installDiscordToolResultHarnessSpies();

View File

@@ -4,9 +4,11 @@ import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { vi } from "vitest";
import {
dispatchMock,
installDiscordToolResultHarnessSpies,
loadConfigMock,
readAllowFromStoreMock,
sendMock,
TOOL_RESULT_SESSION_STORE_PATH,
updateLastRouteMock,
upsertPairingRequestMock,
} from "./monitor.tool-result.test-harness.js";
@@ -26,7 +28,7 @@ export const BASE_CFG: Config = {
messages: {
inbound: { debounceMs: 0 },
},
session: { store: "/tmp/openclaw-sessions.json" },
session: { store: TOOL_RESULT_SESSION_STORE_PATH },
};
export const CATEGORY_GUILD_CFG = {
@@ -45,6 +47,7 @@ export const CATEGORY_GUILD_CFG = {
} satisfies Config;
export function resetDiscordToolResultHarness() {
installDiscordToolResultHarnessSpies();
__resetDiscordChannelInfoCacheForTest();
sendMock.mockClear().mockResolvedValue(undefined);
updateLastRouteMock.mockClear();
@@ -304,7 +307,7 @@ export function createMentionRequiredGuildConfig(overrides?: Partial<Config>): C
},
},
...overrides,
} as Config;
};
}
export function captureNextDispatchCtx<

File diff suppressed because it is too large Load Diff

View File

@@ -1,87 +1,24 @@
import {
Button,
Row,
Separator,
TextDisplay,
serializePayload,
type ButtonInteraction,
type ComponentData,
type MessagePayloadObject,
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon";
import { ButtonStyle } from "discord-api-types/v10";
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
ExecApprovalActionDescriptor,
ExecApprovalDecision,
ExecApprovalRequest,
ExecApprovalResolved,
PluginApprovalRequest,
PluginApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import {
buildExecApprovalActionDescriptors,
createChannelNativeApprovalRuntime,
getExecApprovalApproverDmNoticeText,
resolveExecApprovalCommandDisplay,
type ExecApprovalChannelRuntime,
} from "openclaw/plugin-sdk/infra-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
import {
createDiscordApprovalCapability,
shouldHandleDiscordApprovalRequest,
} from "../approval-native.js";
import {
getDiscordExecApprovalApprovers,
isDiscordExecApprovalClientEnabled,
} from "../exec-approvals.js";
import { createDiscordClient, stripUndefinedFields } from "../send.shared.js";
import { DiscordUiContainer } from "../ui.js";
export { buildExecApprovalCustomId } from "../approval-handler.runtime.js";
import { getDiscordExecApprovalApprovers } from "../exec-approvals.js";
const EXEC_APPROVAL_KEY = "execapproval";
export { extractDiscordChannelId } from "../approval-native.js";
export type {
ExecApprovalRequest,
ExecApprovalResolved,
PluginApprovalRequest,
PluginApprovalResolved,
};
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
type ApprovalKind = "exec" | "plugin";
function buildDiscordApprovalDmRedirectNotice(): { content: string } {
return {
content: getExecApprovalApproverDmNoticeText(),
};
}
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
timeoutId?: NodeJS.Timeout;
};
type DiscordPendingDelivery = {
body: ReturnType<typeof stripUndefinedFields>;
};
type PreparedDeliveryTarget = {
discordChannelId: string;
recipientUserId?: string;
};
function resolveApprovalKindFromId(approvalId: string): ApprovalKind {
return approvalId.startsWith("plugin:") ? "plugin" : "exec";
}
function isPluginApprovalRequest(request: ApprovalRequest): request is PluginApprovalRequest {
return resolveApprovalKindFromId(request.id) === "plugin";
}
function encodeCustomIdValue(value: string): string {
return encodeURIComponent(value);
}
} from "openclaw/plugin-sdk/infra-runtime";
function decodeCustomIdValue(value: string): string {
try {
@@ -91,15 +28,6 @@ function decodeCustomIdValue(value: string): string {
}
}
export function buildExecApprovalCustomId(
approvalId: string,
action: ExecApprovalDecision,
): string {
return [`${EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`, `action=${action}`].join(
";",
);
}
export function parseExecApprovalData(
data: ComponentData,
): { approvalId: string; action: ExecApprovalDecision } | null {
@@ -123,681 +51,18 @@ export function parseExecApprovalData(
};
}
type ExecApprovalContainerParams = {
cfg: OpenClawConfig;
accountId: string;
title: string;
description?: string;
commandPreview: string;
commandSecondaryPreview?: string | null;
metadataLines?: string[];
actionRow?: Row<Button>;
footer?: string;
accentColor?: string;
};
class ExecApprovalContainer extends DiscordUiContainer {
constructor(params: ExecApprovalContainerParams) {
const components: Array<TextDisplay | Separator | Row<Button>> = [
new TextDisplay(`## ${params.title}`),
];
if (params.description) {
components.push(new TextDisplay(params.description));
}
components.push(new Separator({ divider: true, spacing: "small" }));
components.push(new TextDisplay(`### Command\n\`\`\`\n${params.commandPreview}\n\`\`\``));
if (params.commandSecondaryPreview) {
components.push(
new TextDisplay(`### Shell Preview\n\`\`\`\n${params.commandSecondaryPreview}\n\`\`\``),
);
}
if (params.metadataLines?.length) {
components.push(new TextDisplay(params.metadataLines.join("\n")));
}
if (params.actionRow) {
components.push(params.actionRow);
}
if (params.footer) {
components.push(new Separator({ divider: false, spacing: "small" }));
components.push(new TextDisplay(`-# ${params.footer}`));
}
super({
cfg: params.cfg,
accountId: params.accountId,
components,
accentColor: params.accentColor,
});
}
}
class ExecApprovalActionButton extends Button {
customId: string;
label: string;
style: ButtonStyle;
constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) {
super();
this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision);
this.label = params.descriptor.label;
this.style =
params.descriptor.style === "success"
? ButtonStyle.Success
: params.descriptor.style === "primary"
? ButtonStyle.Primary
: params.descriptor.style === "danger"
? ButtonStyle.Danger
: ButtonStyle.Secondary;
}
}
class ExecApprovalActionRow extends Row<Button> {
constructor(params: {
approvalId: string;
ask?: string | null;
allowedDecisions?: readonly ExecApprovalDecision[];
}) {
super(
buildExecApprovalActionDescriptors({
approvalCommandId: params.approvalId,
ask: params.ask,
allowedDecisions: params.allowedDecisions,
}).map(
(descriptor) => new ExecApprovalActionButton({ approvalId: params.approvalId, descriptor }),
),
);
}
}
function createApprovalActionRow(request: ApprovalRequest): Row<Button> {
if (isPluginApprovalRequest(request)) {
return new ExecApprovalActionRow({
approvalId: request.id,
});
}
return new ExecApprovalActionRow({
approvalId: request.id,
ask: request.request.ask,
allowedDecisions: request.request.allowedDecisions,
});
}
function buildExecApprovalMetadataLines(request: ExecApprovalRequest): string[] {
const lines: string[] = [];
if (request.request.cwd) {
lines.push(`- Working Directory: ${request.request.cwd}`);
}
if (request.request.host) {
lines.push(`- Host: ${request.request.host}`);
}
if (Array.isArray(request.request.envKeys) && request.request.envKeys.length > 0) {
lines.push(`- Env Overrides: ${request.request.envKeys.join(", ")}`);
}
if (request.request.agentId) {
lines.push(`- Agent: ${request.request.agentId}`);
}
return lines;
}
function buildPluginApprovalMetadataLines(request: PluginApprovalRequest): string[] {
const lines: string[] = [];
const severity = request.request.severity ?? "warning";
lines.push(
`- Severity: ${severity === "critical" ? "Critical" : severity === "info" ? "Info" : "Warning"}`,
);
if (request.request.toolName) {
lines.push(`- Tool: ${request.request.toolName}`);
}
if (request.request.pluginId) {
lines.push(`- Plugin: ${request.request.pluginId}`);
}
if (request.request.agentId) {
lines.push(`- Agent: ${request.request.agentId}`);
}
return lines;
}
function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayloadObject {
const components: TopLevelComponents[] = [container];
return { components };
}
function formatCommandPreview(commandText: string, maxChars: number): string {
const commandRaw =
commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText;
return commandRaw.replace(/`/g, "\u200b`");
}
function formatOptionalCommandPreview(
commandText: string | null | undefined,
maxChars: number,
): string | null {
if (!commandText) {
return null;
}
return formatCommandPreview(commandText, maxChars);
}
function resolveExecApprovalPreviews(
request: ExecApprovalRequest["request"],
maxChars: number,
secondaryMaxChars: number,
): { commandPreview: string; commandSecondaryPreview: string | null } {
const { commandText, commandPreview: secondaryPreview } =
resolveExecApprovalCommandDisplay(request);
return {
commandPreview: formatCommandPreview(commandText, maxChars),
commandSecondaryPreview: formatOptionalCommandPreview(secondaryPreview, secondaryMaxChars),
};
}
function createExecApprovalRequestContainer(params: {
request: ExecApprovalRequest;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
params.request.request,
1000,
500,
);
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval Required",
description: "A command needs your approval.",
commandPreview,
commandSecondaryPreview,
metadataLines: buildExecApprovalMetadataLines(params.request),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.request.id}`,
accentColor: "#FFA500",
});
}
function createPluginApprovalRequestContainer(params: {
request: PluginApprovalRequest;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
const severity = params.request.request.severity ?? "warning";
const accentColor =
severity === "critical" ? "#ED4245" : severity === "info" ? "#5865F2" : "#FAA61A";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Plugin Approval Required",
description: "A plugin action needs your approval.",
commandPreview: formatCommandPreview(params.request.request.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.request.request.description, 1000),
metadataLines: buildPluginApprovalMetadataLines(params.request),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.request.id}`,
accentColor,
});
}
function createExecResolvedContainer(params: {
request: ExecApprovalRequest;
decision: ExecApprovalDecision;
resolvedBy?: string | null;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
params.request.request,
500,
300,
);
const decisionLabel =
params.decision === "allow-once"
? "Allowed (once)"
: params.decision === "allow-always"
? "Allowed (always)"
: "Denied";
const accentColor =
params.decision === "deny"
? "#ED4245"
: params.decision === "allow-always"
? "#5865F2"
: "#57F287";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: `Exec Approval: ${decisionLabel}`,
description: params.resolvedBy ? `Resolved by ${params.resolvedBy}` : "Resolved",
commandPreview,
commandSecondaryPreview,
footer: `ID: ${params.request.id}`,
accentColor,
});
}
function createPluginResolvedContainer(params: {
request: PluginApprovalRequest;
decision: ExecApprovalDecision;
resolvedBy?: string | null;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const decisionLabel =
params.decision === "allow-once"
? "Allowed (once)"
: params.decision === "allow-always"
? "Allowed (always)"
: "Denied";
const accentColor =
params.decision === "deny"
? "#ED4245"
: params.decision === "allow-always"
? "#5865F2"
: "#57F287";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: `Plugin Approval: ${decisionLabel}`,
description: params.resolvedBy ? `Resolved by ${params.resolvedBy}` : "Resolved",
commandPreview: formatCommandPreview(params.request.request.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.request.request.description, 1000),
metadataLines: buildPluginApprovalMetadataLines(params.request),
footer: `ID: ${params.request.id}`,
accentColor,
});
}
function createExecExpiredContainer(params: {
request: ExecApprovalRequest;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
params.request.request,
500,
300,
);
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval: Expired",
description: "This approval request has expired.",
commandPreview,
commandSecondaryPreview,
footer: `ID: ${params.request.id}`,
accentColor: "#99AAB5",
});
}
function createPluginExpiredContainer(params: {
request: PluginApprovalRequest;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Plugin Approval: Expired",
description: "This approval request has expired.",
commandPreview: formatCommandPreview(params.request.request.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.request.request.description, 1000),
metadataLines: buildPluginApprovalMetadataLines(params.request),
footer: `ID: ${params.request.id}`,
accentColor: "#99AAB5",
});
}
export type DiscordExecApprovalHandlerOpts = {
token: string;
accountId: string;
config: DiscordExecApprovalConfig;
gatewayUrl?: string;
cfg: OpenClawConfig;
runtime?: RuntimeEnv;
onResolve?: (id: string, decision: ExecApprovalDecision) => Promise<void>;
};
export class DiscordExecApprovalHandler {
private readonly runtime: ExecApprovalChannelRuntime<ApprovalRequest, ApprovalResolved>;
private opts: DiscordExecApprovalHandlerOpts;
constructor(opts: DiscordExecApprovalHandlerOpts) {
this.opts = opts;
this.runtime = createChannelNativeApprovalRuntime<
PendingApproval,
PreparedDeliveryTarget,
DiscordPendingDelivery
>({
label: "discord/exec-approvals",
clientDisplayName: "Discord Exec Approvals",
cfg: this.opts.cfg,
accountId: this.opts.accountId,
gatewayUrl: this.opts.gatewayUrl,
eventKinds: ["exec", "plugin"],
nativeAdapter: createDiscordApprovalCapability(this.opts.config).native,
isConfigured: () =>
isDiscordExecApprovalClientEnabled({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
configOverride: this.opts.config,
}),
shouldHandle: (request) => this.shouldHandle(request),
buildPendingContent: ({ request }) => {
const actionRow = createApprovalActionRow(request);
const container = isPluginApprovalRequest(request)
? createPluginApprovalRequestContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow,
})
: createExecApprovalRequestContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow,
});
const payload = buildExecApprovalPayload(container);
return {
body: stripUndefinedFields(serializePayload(payload)),
};
},
sendOriginNotice: async ({ originTarget }) => {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
await discordRequest(
() =>
rest.post(Routes.channelMessages(originTarget.to), {
body: buildDiscordApprovalDmRedirectNotice(),
}) as Promise<{ id: string; channel_id: string }>,
"send-approval-dm-redirect-notice",
);
},
prepareTarget: async ({ plannedTarget }) => {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
if (plannedTarget.surface === "origin") {
return {
dedupeKey: plannedTarget.target.to,
target: {
discordChannelId: plannedTarget.target.to,
},
};
}
const userId = plannedTarget.target.to;
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord exec approvals: failed to create DM for user ${userId}`);
return null;
}
return {
dedupeKey: dmChannel.id,
target: {
discordChannelId: dmChannel.id,
recipientUserId: userId,
},
};
},
deliverTarget: async ({
plannedTarget,
preparedTarget,
pendingContent,
request: _request,
}) => {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(preparedTarget.discordChannelId), {
body: pendingContent.body,
}) as Promise<{ id: string; channel_id: string }>,
plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
if (plannedTarget.surface === "origin") {
logError("discord exec approvals: failed to send to channel");
} else if (preparedTarget.recipientUserId) {
logError(
`discord exec approvals: failed to send message to user ${preparedTarget.recipientUserId}`,
);
}
return null;
}
return {
discordMessageId: message.id,
discordChannelId: preparedTarget.discordChannelId,
};
},
onOriginNoticeError: ({ error }) => {
logError(`discord exec approvals: failed to send DM redirect notice: ${String(error)}`);
},
onDuplicateSkipped: ({ preparedTarget, request }) => {
logDebug(
`discord exec approvals: skipping duplicate approval ${request.id} for channel ${preparedTarget.dedupeKey}`,
);
},
onDelivered: ({ plannedTarget, preparedTarget, request }) => {
if (plannedTarget.surface === "origin") {
logDebug(
`discord exec approvals: sent approval ${request.id} to channel ${preparedTarget.target.discordChannelId}`,
);
return;
}
logDebug(
`discord exec approvals: sent approval ${request.id} to user ${plannedTarget.target.to}`,
);
},
onDeliveryError: ({ error, plannedTarget }) => {
if (plannedTarget.surface === "origin") {
logError(`discord exec approvals: failed to send to channel: ${String(error)}`);
return;
}
logError(
`discord exec approvals: failed to notify user ${plannedTarget.target.to}: ${String(error)}`,
);
},
finalizeResolved: async ({ request, resolved, entries }) => {
await this.finalizeResolved(request, resolved, entries);
},
finalizeExpired: async ({ request, entries }) => {
await this.finalizeExpired(request, entries);
},
});
}
shouldHandle(request: ApprovalRequest): boolean {
return shouldHandleDiscordApprovalRequest({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
configOverride: this.opts.config,
});
}
async start(): Promise<void> {
await this.runtime.start();
}
async stop(): Promise<void> {
await this.runtime.stop();
}
async handleApprovalRequested(request: ApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}
async handleApprovalResolved(resolved: ApprovalResolved): Promise<void> {
await this.runtime.handleResolved(resolved);
}
async handleApprovalTimeout(approvalId: string, _source?: "channel" | "dm"): Promise<void> {
await this.runtime.handleExpired(approvalId);
}
private async finalizeResolved(
request: ApprovalRequest,
resolved: ApprovalResolved,
entries: PendingApproval[],
): Promise<void> {
const container = isPluginApprovalRequest(request)
? createPluginResolvedContainer({
request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecResolvedContainer({
request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
for (const pending of entries) {
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
}
private async finalizeExpired(
request: ApprovalRequest,
entries: PendingApproval[],
): Promise<void> {
const container = isPluginApprovalRequest(request)
? createPluginExpiredContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecExpiredContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
for (const pending of entries) {
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
}
private async finalizeMessage(
channelId: string,
messageId: string,
container: DiscordUiContainer,
): Promise<void> {
if (!this.opts.config.cleanupAfterResolve) {
await this.updateMessage(channelId, messageId, container);
return;
}
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
await discordRequest(
() => rest.delete(Routes.channelMessage(channelId, messageId)) as Promise<void>,
"delete-approval",
);
} catch (err) {
logError(`discord exec approvals: failed to delete message: ${String(err)}`);
await this.updateMessage(channelId, messageId, container);
}
}
private async updateMessage(
channelId: string,
messageId: string,
container: DiscordUiContainer,
): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
const payload = buildExecApprovalPayload(container);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(channelId, messageId), {
body: stripUndefinedFields(serializePayload(payload)),
}),
"update-approval",
);
} catch (err) {
logError(`discord exec approvals: failed to update message: ${String(err)}`);
}
}
async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise<boolean> {
const method =
resolveApprovalKindFromId(approvalId) === "plugin"
? "plugin.approval.resolve"
: "exec.approval.resolve";
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision} via ${method}`);
try {
await this.runtime.request(method, {
id: approvalId,
decision,
});
logDebug(`discord exec approvals: resolved ${approvalId} successfully`);
return true;
} catch (err) {
logError(`discord exec approvals: resolve failed: ${String(err)}`);
return false;
}
}
/** Return the list of configured approver IDs. */
getApprovers(): string[] {
return getDiscordExecApprovalApprovers({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
configOverride: this.opts.config,
});
}
}
export type ExecApprovalButtonContext = {
handler: DiscordExecApprovalHandler;
getApprovers: () => string[];
resolveApproval: (approvalId: string, decision: ExecApprovalDecision) => Promise<boolean>;
};
export class ExecApprovalButton extends Button {
label = "execapproval";
customId = `${EXEC_APPROVAL_KEY}:seed=1`;
customId = "execapproval:seed=1";
style = ButtonStyle.Primary;
private ctx: ExecApprovalButtonContext;
constructor(ctx: ExecApprovalButtonContext) {
constructor(private readonly ctx: ExecApprovalButtonContext) {
super();
this.ctx = ctx;
}
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
@@ -808,14 +73,11 @@ export class ExecApprovalButton extends Button {
content: "This approval is no longer valid.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
} catch {}
return;
}
// Verify the user is an authorized approver
const approvers = this.ctx.handler.getApprovers();
const approvers = this.ctx.getApprovers();
const userId = interaction.userId;
if (!approvers.some((id) => String(id) === userId)) {
try {
@@ -823,9 +85,7 @@ export class ExecApprovalButton extends Button {
content: "⛔ You are not authorized to approve exec requests.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
} catch {}
return;
}
@@ -836,31 +96,52 @@ export class ExecApprovalButton extends Button {
? "Allowed (always)"
: "Denied";
// Acknowledge immediately so Discord does not fail the interaction while
// the gateway resolve roundtrip completes. The resolved event will update
// the approval card in-place with the final state.
try {
await interaction.acknowledge();
} catch {
// Interaction may have expired, try to continue anyway
}
const ok = await this.ctx.handler.resolveApproval(parsed.approvalId, parsed.action);
} catch {}
const ok = await this.ctx.resolveApproval(parsed.approvalId, parsed.action);
if (!ok) {
try {
await interaction.followUp({
content: `Failed to submit approval decision for **${decisionLabel}**. The request may have expired or already been resolved.`,
ephemeral: true,
});
} catch {
// Interaction may have expired
}
} catch {}
}
// On success, the handleApprovalResolved event will update the message with the final result
}
}
export function createExecApprovalButton(ctx: ExecApprovalButtonContext): Button {
return new ExecApprovalButton(ctx);
}
export function createDiscordExecApprovalButtonContext(params: {
cfg: OpenClawConfig;
accountId: string;
config: DiscordExecApprovalConfig;
gatewayUrl?: string;
}): ExecApprovalButtonContext {
return {
getApprovers: () =>
getDiscordExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
configOverride: params.config,
}),
resolveApproval: async (approvalId, decision) => {
try {
await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId,
decision,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Discord approval (${params.accountId})`,
});
return true;
} catch {
return false;
}
},
};
}

View File

@@ -5,6 +5,7 @@ import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import * as undici from "undici";
import * as ws from "ws";
import { validateDiscordProxyUrl } from "../proxy-fetch.js";
@@ -57,7 +58,7 @@ function isTransientDiscordGatewayResponse(status: number, body: string): boolea
if (status >= 500) {
return true;
}
const normalized = body.toLowerCase();
const normalized = normalizeLowercaseStringOrEmpty(body);
return (
normalized.includes("upstream connect error") ||
normalized.includes("disconnect/reset before headers") ||

View File

@@ -5,6 +5,7 @@ import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { mergeAbortSignals } from "./timeouts.js";
const DISCORD_CDN_HOSTNAMES = [
@@ -564,7 +565,7 @@ function isImageAttachment(attachment: APIAttachment): boolean {
if (mime.startsWith("image/")) {
return true;
}
const name = attachment.filename?.toLowerCase() ?? "";
const name = normalizeLowercaseStringOrEmpty(attachment.filename);
if (!name) {
return false;
}

View File

@@ -274,10 +274,9 @@ describe("Discord native slash commands with commands.allowFrom", () => {
},
});
const dispatchCall = vi.mocked(dispatcherModule.dispatchReplyWithDispatcher).mock
.calls[0]?.[0] as
const dispatchCall:
| Parameters<typeof dispatcherModule.dispatchReplyWithDispatcher>[0]
| undefined;
| undefined = vi.mocked(dispatcherModule.dispatchReplyWithDispatcher).mock.calls[0]?.[0];
await dispatchCall?.dispatcherOptions.deliver({ text: longReply }, { kind: "final" });
expect(interaction.followUp).toHaveBeenCalledWith(

View File

@@ -48,19 +48,18 @@ function createNativeCommand(
throw new Error(`missing native command: ${name}`);
}
const baseCfg: ReturnType<typeof loadConfig> = opts?.cfg ?? {};
const discordConfig = (opts?.discordConfig ?? baseCfg.channels?.discord ?? {}) as NonNullable<
OpenClawConfig["channels"]
>["discord"];
const discordConfig: NonNullable<OpenClawConfig["channels"]>["discord"] =
opts?.discordConfig ?? baseCfg.channels?.discord ?? {};
const cfg =
opts?.discordConfig === undefined
? baseCfg
: ({
: {
...baseCfg,
channels: {
...baseCfg.channels,
discord: discordConfig,
},
} as ReturnType<typeof loadConfig>);
};
return createDiscordNativeCommand({
command,
cfg,

View File

@@ -312,7 +312,9 @@ function buildDiscordCommandOptions(params: {
model: context?.model,
});
const filtered = focusValue
? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue))
? choices.filter((choice) =>
normalizeLowercaseStringOrEmpty(choice.label).includes(focusValue),
)
: choices;
await interaction.respond(
filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),

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