Compare commits

..

749 Commits

Author SHA1 Message Date
Josh Lehman
92d0e7dbe6 fix(session): address parent fork review feedback
Handle transcript-read failures when estimating parent fork token counts,
short-circuit the fallback path when the parent fork guard is disabled, and
remove the dead skip-fork log fallback now that the guarded branch only runs
with numeric parent token counts.

Regeneration-Prompt: |
  The rebased PR for the parent fork overflow guard picked up review feedback.
  Keep the original fix intact, but tighten the implementation in three small
  ways: do not pay transcript-read/token-estimation cost when
  session.parentForkMaxTokens is disabled, do not let synchronous transcript
  read failures bubble out of session initialization, and remove any dead
  parentTokens fallback text in the skip-fork log branch once that branch is
  already guarded by typeof parentTokens === "number".
2026-04-03 12:20:53 -07:00
Josh Lehman
706efe3628 docs(changelog): note parent fork overflow guard fix
Add the unreleased changelog entry for PR #60463 so the branch satisfies
this repo's changelog gate for user-facing fixes.

Regeneration-Prompt: |
  After opening PR #60463 for the parent fork overflow guard fix, this repo's
  PR workflow required a matching CHANGELOG.md line under ## Unreleased with
  the PR number and author credit. Append a concise Fixes entry describing
  that thread/session forking now falls back to transcript-estimated parent
  token counts when cached totals are stale or missing, and include
  (#60463) Thanks @jalehman on the same line.
2026-04-03 12:20:03 -07:00
Josh Lehman
4570c91651 fix(session): harden parent fork overflow guard
Prefer fresh persisted token counts when deciding whether a child session
should fork its parent transcript, and fall back to estimating token usage
from the parent transcript when the cached total is stale or missing. Add a
regression test covering a large parent transcript with stale token metadata
so forked thread sessions start fresh instead of cloning an oversized parent.

Regeneration-Prompt: |
  User asked to investigate and fix the broader OpenClaw-side fork/session
  overflow issue related to lossless-claw issue 206, working in
  ~/Projects/openclaw instead of the lossless-claw repo. Current OpenClaw
  already had a parent-fork size guard on main, so the task was not to
  reintroduce that feature, but to verify whether a gap remained.

  Investigation showed initSessionState only looked at the parent session
  entry's cached totalTokens when deciding whether to fork the parent
  transcript into a child thread session. That meant older or sparsely
  accounted sessions with totalTokens missing or marked stale could still
  clone a huge raw parent transcript and recreate the original overflow
  behavior.

  Preserve the existing configurable session.parentForkMaxTokens behavior,
  but make the guard trustworthy by preferring fresh persisted totals and
  otherwise estimating token count from the parent transcript before forking.
  Add a focused regression test that constructs a large parent transcript with
  totalTokensFresh=false and proves the child session starts fresh rather than
  forking the parent's transcript file.
2026-04-03 12:20:02 -07:00
Tak Hoffman
a18167a2cb fix: honor feishu tool account context 2026-04-03 14:19:15 -05:00
Peter Steinberger
f5ec0e429f test: trim more agent tool partial mocks 2026-04-03 20:18:56 +01:00
Peter Steinberger
1fbf863f53 test: trim more agent tool mocks 2026-04-03 20:16:50 +01:00
Peter Steinberger
e286ba2bab test: trim more agent partial mocks 2026-04-03 20:15:55 +01:00
Peter Steinberger
ee5113b1ae test: trim sandbox and transcript partial mocks 2026-04-03 20:14:39 +01:00
Peter Steinberger
6a465611d8 test: trim openclaw tools partial mocks 2026-04-03 20:14:39 +01:00
Tak Hoffman
6286ef55da fix: honor discord default guild action account 2026-04-03 14:13:00 -05:00
Vincent Koc
9224afca3d refactor(providers): share xai and replay helpers 2026-04-04 04:11:57 +09:00
Vincent Koc
cc1881a838 refactor(providers): share payload patch helpers 2026-04-04 04:11:56 +09:00
Vincent Koc
0273062dfd refactor(signal): lazy-load send runtime 2026-04-04 04:11:45 +09:00
Vincent Koc
b361667f98 test(contracts): split config write lanes 2026-04-04 04:11:00 +09:00
Vincent Koc
24afd52fcd test(contracts): remove old group policy runner 2026-04-04 04:10:15 +09:00
Vincent Koc
1d4fcb6a01 test(contracts): split group policy lanes 2026-04-04 04:10:15 +09:00
Vincent Koc
724dd5ca3d refactor(slack): lazy-load action and send runtimes 2026-04-04 04:08:41 +09:00
Tak Hoffman
c7554d3072 fix: honor discord default action runtime account 2026-04-03 14:08:32 -05:00
Vincent Koc
0bbacca828 test(contracts): split channel catalog lanes 2026-04-04 04:08:24 +09:00
lurebat
37de88181b fix(whatsapp): ignore self-chat quoted replies in groups (#60148)
Merged via squash.

Prepared head SHA: c51b55e0ba
Co-authored-by: lurebat <154669821+lurebat@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-04-03 16:07:46 -03:00
Peter Steinberger
2d2fe2bf47 test: trim more agent partial mocks 2026-04-03 20:06:42 +01:00
Tak Hoffman
5f17362667 fix: honor slack default channel-type account 2026-04-03 14:05:35 -05:00
Peter Steinberger
4578351488 test: trim agent and discord harness partial mocks 2026-04-03 20:04:52 +01:00
Peter Steinberger
811efa2db0 fix(ci): honor bluebubbles account action gates 2026-04-03 20:03:27 +01:00
Peter Steinberger
35a9eeb857 test: trim sandbox and pi runner partial mocks 2026-04-03 20:02:57 +01:00
Peter Steinberger
4e27e22663 test: trim agents importActual mocks 2026-04-03 20:00:35 +01:00
Peter Steinberger
ffba320a2c fix(ci): align test mock typings 2026-04-03 19:59:55 +01:00
Vincent Koc
0464435777 fix(ci): align windows builtin mock types 2026-04-04 03:57:48 +09:00
Vincent Koc
fa5ea4529a fix(types): align rebased channel setup flows 2026-04-04 03:57:47 +09:00
Vincent Koc
e57b6be85f fix(types): align setup helper contracts 2026-04-04 03:57:47 +09:00
Vincent Koc
516e9054de fix(types): align portable runtime helpers 2026-04-04 03:57:47 +09:00
Vincent Koc
5e204df0bf fix(types): align rebased main helper contracts 2026-04-04 03:57:47 +09:00
Vincent Koc
88d3b73c6d fix(types): annotate portable exported helper types 2026-04-04 03:57:47 +09:00
Peter Steinberger
4b71a94450 fix(ci): repair contract and interaction drift 2026-04-03 19:57:35 +01:00
Peter Steinberger
c9dfc35dfd test: fix discord runtime mock typing and lock UX 2026-04-03 19:56:37 +01:00
Peter Steinberger
9215ff0615 test: route pure infra tests through boundary lane 2026-04-03 19:56:12 +01:00
Tak Hoffman
7c25af83e4 fix: honor line monitor default account 2026-04-03 13:55:44 -05:00
Peter Steinberger
03aea06321 test: trim gateway importActual mocks 2026-04-03 19:54:37 +01:00
Peter Steinberger
a301e2ef87 test: trim cli and infra importActual mocks 2026-04-03 19:54:37 +01:00
Tak Hoffman
961d8eb095 fix: honor line default runtime account 2026-04-03 13:54:01 -05:00
Peter Steinberger
5e9ae0bfd4 test(node): fix execFile mock typing 2026-04-03 19:53:38 +01:00
Peter Steinberger
4948760c65 test(plugins): genericize core helper contracts 2026-04-03 19:53:38 +01:00
Peter Steinberger
1c66a050c2 refactor(plugins): move outbound dep aliases into extensions 2026-04-03 19:53:38 +01:00
Tak Hoffman
f007082e06 fix: honor signal setup default account 2026-04-03 13:52:28 -05:00
Vincent Koc
eecb36eff4 fix(ci): stabilize zero-delay retry and slack interaction tests 2026-04-04 03:52:07 +09:00
Gustavo Madeira Santana
1420b3bad7 docs: tighten skills and Matrix wording 2026-04-03 14:51:37 -04:00
Vincent Koc
1c470c2736 test(contracts): split tts lanes 2026-04-04 03:51:10 +09:00
Tak Hoffman
d305a80acd fix: honor imessage setup default account 2026-04-03 13:50:52 -05:00
Peter Steinberger
bc23db501b test: trim more core importOriginal usage 2026-04-03 19:49:43 +01:00
Peter Steinberger
6115a9498c test: trim config importOriginal usage 2026-04-03 19:49:43 +01:00
Peter Steinberger
8e8f8d0745 test: trim more extension importOriginal usage 2026-04-03 19:49:43 +01:00
Vincent Koc
d8458a1481 refactor(providers): share transport stream helpers 2026-04-04 03:49:09 +09:00
Vincent Koc
fcec417d7d fix(ci): preserve conversation runtime mock signatures 2026-04-04 03:48:58 +09:00
Tak Hoffman
d2ca915a7f fix: honor telegram default action account 2026-04-03 13:48:45 -05:00
Peter Steinberger
88ab29f492 fix(ci): relax discord runtime mock module constraint 2026-04-03 19:46:59 +01:00
Tak Hoffman
534b0c663e fix: honor zalouser default runtime account 2026-04-03 13:46:36 -05:00
Tak Hoffman
f66c9b829e fix: honor slack default runtime account 2026-04-03 13:45:28 -05:00
Tak Hoffman
4f5f1fa724 fix: honor imessage default runtime account 2026-04-03 13:44:15 -05:00
Peter Steinberger
b8af2c65e5 fix(ci): bind full discord conversation runtime mock type 2026-04-03 19:44:05 +01:00
Tak Hoffman
4ca1ae8046 fix: honor signal default runtime account 2026-04-03 13:43:09 -05:00
Tak Hoffman
c7875f193b fix: honor discord default runtime account 2026-04-03 13:41:55 -05:00
Peter Steinberger
e3f410efb5 fix(ci): widen discord binding runtime mock type 2026-04-03 19:40:47 +01:00
Peter Steinberger
3fb6e3e91f test: trim more extension importOriginal usage 2026-04-03 19:40:20 +01:00
Tak Hoffman
17c0026c04 fix: honor bluebubbles default runtime account 2026-04-03 13:39:22 -05:00
Tak Hoffman
9289f967df fix: honor mattermost default runtime account 2026-04-03 13:38:03 -05:00
Peter Steinberger
e76a16dfa5 fix(ci): preserve omitted feishu finalize account context 2026-04-03 19:38:00 +01:00
Vincent Koc
e697fa5e75 feat(providers): add google transport runtime 2026-04-04 03:35:58 +09:00
Peter Steinberger
2156bf0210 test: fix setup wizard and execFile test drift 2026-04-03 19:35:38 +01:00
Peter Steinberger
0ad2da060e test: route openclaw root through boundary config 2026-04-03 19:35:27 +01:00
Peter Steinberger
cc62fd38f6 test: trim more extension mock imports 2026-04-03 19:34:55 +01:00
Tak Hoffman
a8302e8eab fix: honor mattermost default reply account 2026-04-03 13:34:53 -05:00
Peter Steinberger
323ad51eb8 fix(ci): align execFile mock typings 2026-04-03 19:31:41 +01:00
Peter Steinberger
8be2dea382 test: trim more extension partial mocks 2026-04-03 19:31:32 +01:00
Peter Steinberger
b27fd7cc49 fix(setup): narrow default account id typing 2026-04-03 19:30:35 +01:00
Peter Steinberger
0c95e3f073 refactor(plugins): move command ui policy into extensions 2026-04-03 19:30:35 +01:00
Peter Steinberger
e5d2181403 fix(ci): repair discord interactive test seams 2026-04-03 19:29:14 +01:00
Peter Steinberger
45a6f769bb test: trim core partial mocks 2026-04-03 19:28:19 +01:00
Peter Steinberger
6eca4e0136 test: trim extension partial mocks 2026-04-03 19:28:19 +01:00
Tak Hoffman
24a4ed1013 fix: honor matrix default runtime account 2026-04-03 13:26:34 -05:00
Peter Steinberger
eea069bdc3 fix(ci): repair bundled and extension test drift 2026-04-03 19:25:23 +01:00
Tak Hoffman
e063f67ac0 fix: honor nextcloud default runtime account 2026-04-03 13:24:58 -05:00
Vincent Koc
26b7260bf4 refactor(signal): narrow channel runtime imports 2026-04-04 03:24:21 +09:00
Vincent Koc
e9cbdc7439 fix(plugins): narrow top-level allowFrom account resolver 2026-04-04 03:24:21 +09:00
Tak Hoffman
d5c6e7af0f fix: honor whatsapp default heartbeat account 2026-04-03 13:23:29 -05:00
Gustavo Madeira Santana
1f660bf930 Docs: document agent skill allowlists 2026-04-03 14:23:05 -04:00
Tak Hoffman
5c3dc40794 fix: honor googlechat default runtime account 2026-04-03 13:22:11 -05:00
Peter Steinberger
28b8e019f7 test(setup): fix latest type regressions 2026-04-03 19:21:46 +01:00
Peter Steinberger
df18f4c517 refactor(matrix): move legacy migrations behind doctor 2026-04-03 19:21:24 +01:00
Tak Hoffman
5eb3341db1 fix: honor zalo default runtime account 2026-04-03 13:19:50 -05:00
Gustavo Madeira Santana
5e365a8ec4 agents: preserve remote skill sync eligibility 2026-04-03 14:19:43 -04:00
Tak Hoffman
045010a2a5 fix: honor zalouser default runtime account 2026-04-03 13:18:11 -05:00
Peter Steinberger
72b8025107 fix: align feishu and matrix type guards 2026-04-03 19:17:14 +01:00
Peter Steinberger
4c5c361db7 test: stub gateway speech providers 2026-04-03 19:16:56 +01:00
Vincent Koc
956e746da1 fix(plugins): narrow nested allowFrom account resolver 2026-04-04 03:16:14 +09:00
Vincent Koc
7d691a3ce3 refactor(whatsapp): narrow channel runtime imports 2026-04-04 03:16:14 +09:00
Peter Steinberger
5c6dca78d9 fix(discord): avoid bundled sibling requires 2026-04-03 19:15:21 +01:00
Peter Steinberger
53f8c2047a fix(ci): restore channel approval and lifecycle harnesses 2026-04-03 19:14:42 +01:00
Tak Hoffman
d20e3d5691 fix: honor feishu setup adapter default 2026-04-03 13:14:10 -05:00
Tak Hoffman
a89cb679a2 fix: honor nostr setup default account 2026-04-03 13:12:49 -05:00
Peter Steinberger
13bc70397a test: trim test partial mocks 2026-04-03 19:10:56 +01:00
Tak Hoffman
5c4551458f fix: honor qqbot setup default account 2026-04-03 13:10:49 -05:00
Peter Steinberger
181bd6327f test(plugins): fix rebase fallout 2026-04-03 19:10:00 +01:00
Peter Steinberger
42ffe86fc7 test(cron): stabilize regression harness 2026-04-03 19:09:21 +01:00
Peter Steinberger
03a43fe231 refactor(plugins): genericize core channel seams 2026-04-03 19:09:21 +01:00
Peter Steinberger
856592cf00 fix(outbound): restore generic delivery and security seams 2026-04-03 19:09:20 +01:00
Peter Steinberger
ab96520bba refactor(plugins): move channel behavior into plugins 2026-04-03 19:09:20 +01:00
Josh Lehman
c52df32878 refactor: move bundled replay policy ownership into plugins (#60452)
* refactor: move bundled replay policy ownership into plugins

* test: preserve replay fallback until providers adopt hooks

* test: cover response replay branches for ollama and zai

---------

Co-authored-by: Shakker <shakkerdroid@gmail.com>
2026-04-03 19:08:10 +01:00
Tak Hoffman
7fb58afb41 fix: honor googlechat default allowFrom account 2026-04-03 13:07:07 -05:00
Tak Hoffman
7be2d361de fix: honor feishu finalize default account 2026-04-03 13:04:12 -05:00
Vincent Koc
abc3f27ba9 refactor(zalo): narrow action runtime imports 2026-04-04 03:03:15 +09:00
Vincent Koc
0ba93afda9 fix(feishu): guard scoped setup config access 2026-04-04 03:03:15 +09:00
Tak Hoffman
b7b53b29e8 fix: honor discord setup default account 2026-04-03 13:01:28 -05:00
Peter Steinberger
d9e59f7329 fix(ci): align loader and channel test expectations 2026-04-03 19:00:23 +01:00
Vincent Koc
54479220f5 refactor(xai): share model hint helper 2026-04-04 02:58:58 +09:00
Vincent Koc
ea4265a820 feat(providers): add anthropic transport runtime 2026-04-04 02:58:58 +09:00
Tak Hoffman
8fc684cb55 fix: honor feishu default account setup policy 2026-04-03 12:58:50 -05:00
Peter Steinberger
5d20c73e05 fix: route Copilot Claude through Anthropic 2026-04-04 02:57:59 +09:00
Gustavo Madeira Santana
e588a363f9 fix: respect approval request filters in ambiguity checks 2026-04-03 13:57:18 -04:00
Tak Hoffman
4bbd67c21e fix: honor imessage default account setup policy 2026-04-03 12:56:42 -05:00
Peter Steinberger
de49b26bb1 test: trim acp spawn parent stream resets 2026-04-03 18:56:17 +01:00
Peter Steinberger
91a3554cd7 test: trim session status module resets 2026-04-03 18:55:23 +01:00
Peter Steinberger
3fd27211b1 fix(ci): stabilize channel approval and monitor tests 2026-04-03 18:54:48 +01:00
Peter Steinberger
7eed2e2911 fix: align cron and bluebubbles test drift 2026-04-03 18:53:58 +01:00
Peter Steinberger
7439479047 test: tighten runtime setup and boundary guards 2026-04-03 18:53:34 +01:00
Peter Steinberger
3edfc494df test: expand builtin mock helper usage 2026-04-03 18:53:34 +01:00
Peter Steinberger
613393621c test: reduce discord monitor partial mocks 2026-04-03 18:53:03 +01:00
Tak Hoffman
5888e44745 fix: honor signal default account setup policy 2026-04-03 12:52:14 -05:00
Peter Steinberger
6739c28718 refactor: clarify auth failover policy 2026-04-04 02:49:18 +09:00
Tak Hoffman
1d1a8264ec fix: honor zalo default account setup policy 2026-04-03 12:49:09 -05:00
Vincent Koc
50e1eb56d7 fix(security): harden discord proxy and bundled channel activation (#60455)
* fix(security): tighten discord proxy and mobile tls guards

* fix(plugins): enforce allowlists for bundled channels

* fix(types): align callers with removed legacy config aliases

* fix(security): preserve bundled channel opt-in and ipv6 proxies
2026-04-04 02:48:52 +09:00
Peter Steinberger
3ddf745f97 fix(ci): restore account setup typings 2026-04-03 18:48:44 +01:00
Gustavo Madeira Santana
dc306013e1 Approvals: scope foreign-channel account routing (#60417)
Merged via squash.

Prepared head SHA: 3ad6cae91f
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-03 13:48:00 -04:00
Tak Hoffman
0769590f03 fix: honor zalouser default account setup policy 2026-04-03 12:47:22 -05:00
Tak Hoffman
f115aba582 fix: honor bluebubbles default account setup policy 2026-04-03 12:45:42 -05:00
Tak Hoffman
638e831bca fix: honor telegram default account setup policy 2026-04-03 12:43:51 -05:00
Peter Steinberger
379c329f81 test: trim dispatch and command partial mocks 2026-04-03 18:42:52 +01:00
Tak Hoffman
4edf6a2c7d fix: honor line default account setup policy 2026-04-03 12:42:18 -05:00
Tak Hoffman
4afa720a0c fix: honor nextcloud default account setup policy 2026-04-03 12:42:18 -05:00
Tak Hoffman
d0a43cf8c0 fix: honor googlechat default account setup policy 2026-04-03 12:42:18 -05:00
Tak Hoffman
acfa09679c fix: honor qqbot default account config 2026-04-03 12:42:18 -05:00
Tak Hoffman
b929a4c27d fix: honor imessage setup account status 2026-04-03 12:42:18 -05:00
Tak Hoffman
9f049cb1d8 fix: honor nostr default account routing 2026-04-03 12:42:18 -05:00
Tak Hoffman
f77054eaee fix: honor feishu setup account id 2026-04-03 12:42:18 -05:00
Tak Hoffman
35256d6f1d fix: honor nostr setup account label 2026-04-03 12:42:18 -05:00
Tak Hoffman
17060ca124 fix: honor feishu setup account writes 2026-04-03 12:42:17 -05:00
Tak Hoffman
53612fa128 fix: label whatsapp setup account status 2026-04-03 12:42:17 -05:00
Tak Hoffman
fc61e8d280 fix: honor feishu setup account status 2026-04-03 12:42:17 -05:00
Tak Hoffman
e37c0da23a fix: honor synology setup account status 2026-04-03 12:42:17 -05:00
Tak Hoffman
42ebc8c170 fix: honor irc setup account status 2026-04-03 12:42:17 -05:00
Tak Hoffman
e6a9408c3b fix: honor qqbot setup account status 2026-04-03 12:42:17 -05:00
Gustavo Madeira Santana
ddd250d130 feat(skills): add inherited agent skill allowlists (#59992)
Merged via squash.

Prepared head SHA: 6f60779a57
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-03 13:41:28 -04:00
Peter Steinberger
04f59a7227 fix: remove duplicate bluebubbles actions field 2026-04-03 18:40:28 +01:00
Peter Steinberger
636a23b73e test: extract node builtin mock helpers 2026-04-03 18:40:28 +01:00
Vincent Koc
47b8be7116 refactor(discord): lazy-load message actions 2026-04-04 02:38:43 +09:00
Vincent Koc
7e3b48c254 test(ci): align memory fallback reindex assertion 2026-04-04 02:37:38 +09:00
Peter Steinberger
1bee69f79b refactor: route direct extension test targets 2026-04-04 02:36:48 +09:00
Peter Steinberger
d0d5b34b44 fix(ci): repair extension test regressions 2026-04-03 18:36:35 +01:00
Peter Steinberger
2c5f244554 chore: update changelog for #60404 (thanks @extrasmall0) 2026-04-04 02:35:27 +09:00
Peter Steinberger
865fa2ba72 fix: narrow auth permanent lockouts 2026-04-04 02:35:27 +09:00
Extra Small
42e1d489fd fix(auth): use shorter backoff for auth_permanent failures
auth_permanent errors (e.g. API_KEY_INVALID) can be caused by transient
provider outages rather than genuinely revoked credentials. Previously
these used the same 5h-24h billing backoff, which left providers disabled
long after the upstream issue resolved.

Introduce separate authPermanentBackoffMinutes (default: 10) and
authPermanentMaxMinutes (default: 60) config options so auth_permanent
failures recover in minutes rather than hours.

Fixes #56838
2026-04-04 02:35:27 +09:00
Vincent Koc
022a24ec48 refactor(signal): split uuid helper from monitor 2026-04-04 02:34:14 +09:00
Vincent Koc
c4eaa30ee7 fix(bluebubbles): remove duplicate action config field 2026-04-04 02:34:14 +09:00
Vincent Koc
e88f4fe5f4 fix(ci): narrow matrix action test discovery 2026-04-04 02:33:59 +09:00
Peter Steinberger
a7dd6036a0 style: format doctor and gateway harness mocks 2026-04-03 18:33:47 +01:00
Peter Steinberger
68edc53090 test: trim doctor and gateway partial mocks 2026-04-03 18:33:47 +01:00
Peter Steinberger
f36ed7105f test: reduce extension runtime partial mocks 2026-04-03 18:33:47 +01:00
Peter Steinberger
14c863dc4a test: reduce telegram media harness imports 2026-04-03 18:33:47 +01:00
Peter Steinberger
fb9be1fcb6 test: trim models and cron partial mocks 2026-04-03 18:33:47 +01:00
Peter Steinberger
1c16c6a94a test: split inbound contract helpers 2026-04-03 18:33:46 +01:00
Peter Steinberger
d6e89f96d6 refactor: share gateway config auth helpers 2026-04-04 02:29:29 +09:00
Vincent Koc
646e271c72 test(contracts): split provider wizard lanes 2026-04-04 02:29:00 +09:00
Vincent Koc
71de4adcce test(contracts): split bundled web search lanes 2026-04-04 02:28:15 +09:00
Vincent Koc
f93f76dcc4 fix(ci): dedupe bluebubbles actions config type 2026-04-04 02:26:42 +09:00
Vincent Koc
22fd61e483 test(contracts): split plugin registration lanes 2026-04-04 02:26:39 +09:00
Peter Steinberger
ebdade0efc ci: shard extension fast checks 2026-04-03 18:26:26 +01:00
Vincent Koc
230c61885d refactor(slack): lazy-load webhook handler 2026-04-04 02:26:19 +09:00
Vincent Koc
7ad72281f7 refactor(providers): share pi openai reasoning compat gate 2026-04-04 02:25:10 +09:00
Vincent Koc
bee60a479b test(contracts): fix tts provider fixtures 2026-04-04 02:24:28 +09:00
Peter Steinberger
5fbef0f914 fix(ci): resolve tracked merge markers 2026-04-03 18:22:03 +01:00
Peter Steinberger
be9db66533 fix: split discord voice timeouts and restore gate on main (#60345) (thanks @geekhuashan) 2026-04-04 02:21:43 +09:00
geekhuashan
0c575f37fd fix(discord): add DiscordVoiceReadyListener fire-and-forget error-path test
Add test covering the DiscordVoiceReadyListener.handle() path where
autoJoin() rejects, confirming the error is caught and does not propagate.
2026-04-04 02:21:43 +09:00
geekhuashan
db593440c4 fix(discord voice): fire-and-forget autoJoin and increase playback timeout to 60s 2026-04-04 02:21:43 +09:00
Vincent Koc
136f177cb3 test(contracts): split provider contract lanes 2026-04-04 02:20:35 +09:00
Peter Steinberger
7fd9e40960 fix: tighten gateway shared-auth disconnects (#60387) (thanks @mappel-nv) 2026-04-04 02:20:22 +09:00
Michael Appel
c742963fd9 Gateway: avoid secret-ref auth disconnect churn 2026-04-04 02:20:22 +09:00
Michael Appel
97558f2325 Gateway: expand shared-auth rotation coverage 2026-04-04 02:20:22 +09:00
Michael Appel
54b269b2cb Gateway: disconnect shared-auth sessions on auth change 2026-04-04 02:20:22 +09:00
Peter Steinberger
e0580e6863 test: harden shared-worker runtime setup 2026-04-03 18:18:56 +01:00
Peter Steinberger
2981cce130 fix: align config and plugin test types 2026-04-03 18:18:56 +01:00
Vincent Koc
ff68fd3060 refactor(providers): share completions format defaults 2026-04-04 02:18:12 +09:00
Vincent Koc
39a16c600f test(contracts): localize provider contract suites 2026-04-04 02:17:15 +09:00
Vincent Koc
f881eb066c test(contracts): remove dead session binding helper 2026-04-04 02:16:04 +09:00
Vincent Koc
514b37e185 fix(providers): keep native modelstudio streaming usage compat 2026-04-04 02:15:46 +09:00
Vincent Koc
feed4007fe test(contracts): localize surface registry helpers 2026-04-04 02:15:01 +09:00
Vincent Koc
dd42154e45 fix(providers): stop forcing reasoning effort on proxy completions 2026-04-04 02:14:10 +09:00
Vincent Koc
dd35b97398 test(contracts): narrow session binding registry seeding 2026-04-04 02:13:31 +09:00
Vincent Koc
7836c9a6c2 fix(providers): stop forcing store on proxy completions 2026-04-04 02:12:59 +09:00
Peter Steinberger
ae359c0c8b fix: remove changelog conflict marker 2026-04-04 02:12:10 +09:00
Peter Steinberger
5e69d7e75b fix: land discord everyone mention gating 2026-04-04 02:12:10 +09:00
geekhuashan
6ba490ec7b fix(discord): guard @everyone shortcut against bot-authored messages
Preserve the !author.bot || sender.isPluralKit guard when short-circuiting
wasMentioned on mentionedEveryone, so bot relay messages don't spuriously
trigger mention-gate logic. Add test coverage for the wasMentioned path.
2026-04-04 02:12:10 +09:00
geekhuashan
0b69119f1b fix(discord): detect @everyone mentions in message preflight 2026-04-04 02:12:10 +09:00
Vincent Koc
f575bc2bfe test(ci): harden proxy-sensitive and timeout unit tests 2026-04-04 02:12:00 +09:00
Vincent Koc
b871707628 test(contracts): localize remaining suite helpers 2026-04-04 02:11:44 +09:00
Vincent Koc
50d85dcd59 refactor(providers): share openai compat defaults 2026-04-04 02:10:24 +09:00
Vincent Koc
c5a45eb274 test(contracts): localize registry-backed contract helpers 2026-04-04 02:09:25 +09:00
Peter Steinberger
3c07b126ed fix(ci): restore discord action loader 2026-04-03 18:07:31 +01:00
Vincent Koc
f1911274aa test(contracts): localize surface and session binding helpers 2026-04-04 02:06:38 +09:00
chziyue
d5c42a07ef fix(ui): Stop button shows Send during tool execution (#54528)
Merged via squash.

Prepared head SHA: f2d65a5c3d
Co-authored-by: chziyue <62380760+chziyue@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-04-03 18:59:47 +02:00
Peter Steinberger
54cd0859d3 test(ci): align discord ack removal expectation 2026-04-03 17:58:33 +01:00
Peter Steinberger
911e3974f7 refactor: clarify Discord classic fallback 2026-04-04 01:58:06 +09:00
Tak Hoffman
9e6de5ece4 fix: honor tlon setup account status 2026-04-03 11:57:50 -05:00
Peter Steinberger
f076e97a3c fix(ci): restore discord ack cleanup 2026-04-03 17:54:44 +01:00
Tak Hoffman
b2a9e0d7a7 fix: honor setup binary path account overrides 2026-04-03 11:54:21 -05:00
Shakker
2c0967150d test: preserve whatsapp account helpers in setup mock 2026-04-03 17:51:01 +01:00
Vincent Koc
eec6f59a77 fix(providers): disable z.ai strict tool shaping 2026-04-04 01:50:55 +09:00
Vincent Koc
745f1c9812 fix(types): align callers with removed legacy config aliases 2026-04-04 01:50:44 +09:00
Vincent Koc
ddaef48421 fix(ci): restore discord reaction account context 2026-04-04 01:50:34 +09:00
Tak Hoffman
51f6bc4940 fix: honor selected account in setup status 2026-04-03 11:50:09 -05:00
Peter Steinberger
5edefc4d5b fix: remove changelog conflict marker (#60066) (thanks @huntharo) 2026-04-04 01:49:35 +09:00
Peter Steinberger
ec01cd0ceb fix: tidy plugin update override docs (#60066) (thanks @huntharo) 2026-04-04 01:49:35 +09:00
huntharo
c4f40c3f7d Plugins: allow unsafe-force override on update 2026-04-04 01:49:35 +09:00
Vincent Koc
824ff335c6 fix(providers): align custom transport compat defaults 2026-04-04 01:48:00 +09:00
Peter Steinberger
958cebcc87 fix: preserve Discord component-only media behavior (#60361) (thanks @geekhuashan) 2026-04-04 01:46:32 +09:00
geekhuashan
0e07c8973e fix(discord): forward mediaReadFile and mediaAccess in component classic message path
Forward mediaReadFile and mediaAccess through the sendMessageDiscord shortcut
in sendDiscordComponentMessage, so local-file media works correctly when
falling back to classic Discord messages. Add test coverage.
2026-04-04 01:46:32 +09:00
geekhuashan
efbb9a1296 fix(discord): downgrade text-only component+media to classic message and auto-append file block 2026-04-04 01:46:32 +09:00
Vincent Koc
36ed66cce2 fix(providers): honor moonshot transport compat (#60411) 2026-04-04 01:45:45 +09:00
Vincent Koc
54e8790ad7 fix(providers): honor moonshot transport compat 2026-04-04 01:45:19 +09:00
Shakker
33d54e93b8 test: keep legacy tts contract fixtures typed 2026-04-03 17:43:55 +01:00
Shakker
21002a60be test: align legacy streaming config coverage 2026-04-03 17:43:55 +01:00
Shakker
21a87eaf2b fix: use safeExtend for bluebubbles config 2026-04-03 17:43:17 +01:00
Shakker
06b2e8b79a test: satisfy xai transport model typing 2026-04-03 17:43:17 +01:00
Shakker
91572df932 fix: restore bluebubbles action config typing 2026-04-03 17:43:17 +01:00
Peter Steinberger
a5e725a3b8 test: align vitest defaults with migrated config 2026-04-03 17:42:48 +01:00
Tak Hoffman
b63a175d3d fix: honor imessage probe account config 2026-04-03 11:40:50 -05:00
Marcus Castro
b473e056a0 fix(whatsapp): restore source login tool import 2026-04-03 13:40:20 -03:00
Shakker
408dac8d21 docs(changelog): note secrets runtime isolation cleanup 2026-04-03 17:38:05 +01:00
Shakker
bc46c1dacc test: restore secrets suite isolation cleanup 2026-04-03 17:38:05 +01:00
Tak Hoffman
7f4dd21227 fix: honor zalo action discovery account config 2026-04-03 11:37:22 -05:00
Tak Hoffman
eb497a89cd fix: honor zalouser action discovery account config 2026-04-03 11:35:55 -05:00
Peter Steinberger
b118efea80 test: split reply flow coverage by owner surface 2026-04-03 17:35:01 +01:00
Peter Steinberger
d74d47443e test: trim extension setup startup 2026-04-03 17:33:45 +01:00
Peter Steinberger
2e041c8b66 test: split slack monitor coverage 2026-04-03 17:33:45 +01:00
Peter Steinberger
1512fab782 test: remove discord channel partial mocks 2026-04-03 17:33:45 +01:00
Peter Steinberger
e263b5d7b6 test: split telegram channel coverage 2026-04-03 17:33:45 +01:00
Peter Steinberger
6686ef0b3a test: split whatsapp channel coverage 2026-04-03 17:33:45 +01:00
Tak Hoffman
d21ae7173f fix: honor slack action discovery account config 2026-04-03 11:33:23 -05:00
Vincent Koc
2a19771c3c test(contracts): split plugins-core lanes 2026-04-04 01:30:48 +09:00
Peter Steinberger
a1cad12139 fix(ci): restore compat config types 2026-04-03 17:29:43 +01:00
Tak Hoffman
c22f2a0cab fix: honor feishu action discovery account config 2026-04-03 11:28:51 -05:00
Peter Steinberger
570ed4285e refactor: extract Discord ack reaction helpers 2026-04-04 01:28:04 +09:00
Tak Hoffman
226e2389f6 fix: honor mattermost action discovery account config 2026-04-03 11:27:07 -05:00
Vincent Koc
09f66b0073 fix(build): align bluebubbles action gating 2026-04-04 01:25:02 +09:00
Vincent Koc
c7a947dc0a fix(config): remove legacy config aliases from public schema 2026-04-04 01:24:14 +09:00
Tak Hoffman
d59d236a90 fix: honor matrix action discovery account config 2026-04-03 11:23:13 -05:00
Vincent Koc
6ac5806a39 fix(providers): honor mistral transport compat (#60405) 2026-04-04 01:21:41 +09:00
Tak Hoffman
fb8048a188 fix: honor telegram action discovery account config 2026-04-03 11:20:49 -05:00
Peter Steinberger
2b900b576c refactor: modernize vitest projects config 2026-04-03 17:20:30 +01:00
Vincent Koc
9dba944c42 fix(build): restore current main type gates 2026-04-04 01:20:25 +09:00
Peter Steinberger
cf4d3c4daf refactor: share Discord ack reaction runtime context 2026-04-04 01:19:57 +09:00
Vincent Koc
4c969391fe test(contracts): split plugins-core extension lanes 2026-04-04 01:18:54 +09:00
Tak Hoffman
832810a5bb fix: honor discord action discovery account config 2026-04-03 11:18:26 -05:00
Vincent Koc
8ffd1c0973 test(contracts): split outbound payload contract lanes 2026-04-04 01:17:58 +09:00
Vincent Koc
1f509e0e04 test(contracts): split setup and status contract lanes 2026-04-04 01:16:38 +09:00
Vincent Koc
56f8de0cf9 test(contracts): split plugin and action contract lanes 2026-04-04 01:15:39 +09:00
Vincent Koc
6389498a7c test(contracts): split surface contract lanes 2026-04-04 01:13:53 +09:00
Tak Hoffman
632a10cddc fix: honor googlechat action discovery account config 2026-04-03 11:13:33 -05:00
Vincent Koc
a2836e6db6 fix(ci): narrow openai responses input literals 2026-04-04 01:13:09 +09:00
Tak Hoffman
da5c6ac2b6 fix: honor bluebubbles action discovery account config 2026-04-03 11:11:58 -05:00
Vincent Koc
0b4cdfc53e docs: fix changelog attribution gaps and remove duplicates 2026-04-04 01:11:03 +09:00
Vincent Koc
7ed789d67d fix(providers): centralize compat endpoint detection (#60399) 2026-04-04 01:10:50 +09:00
Tak Hoffman
fb4127082a fix: honor signal action discovery account config 2026-04-03 11:09:39 -05:00
Vincent Koc
9fbf501d5a fix(ci): align whatsapp and responses typing 2026-04-04 01:09:28 +09:00
Peter Steinberger
2766a3409c fix: resolve rebase type drift (#60249) (thanks @shakkernerd) 2026-04-04 01:07:28 +09:00
Peter Steinberger
664265fc66 test(heartbeat): pass reply spy into seeded override case 2026-04-04 01:07:28 +09:00
Peter Steinberger
2a1a7ea6f9 fix(browser): route test support through sdk testing 2026-04-04 01:07:28 +09:00
Peter Steinberger
7e2b26f77b fix(plugins): restore activation state wrapper 2026-04-04 01:07:28 +09:00
Peter Steinberger
0b74755894 test(utils): drop moved provider setup 2026-04-04 01:07:28 +09:00
Peter Steinberger
8541a5c3fa docs(changelog): note test-runtime perf work 2026-04-04 01:07:28 +09:00
Peter Steinberger
88dac32623 fix(reply-threading): align fallback test coverage 2026-04-04 01:07:28 +09:00
Peter Steinberger
0324055d09 test: align latest main runtime harnesses 2026-04-04 01:07:28 +09:00
Peter Steinberger
5bafa6edcf fix(auto-reply): align fallback model runtime state 2026-04-04 01:07:28 +09:00
Peter Steinberger
c563cdc901 fix(telegram): allow target approvals fallback 2026-04-04 01:07:28 +09:00
Peter Steinberger
6f5e71fdbc test: fix talk voice runtime type import 2026-04-04 01:07:28 +09:00
Shakker
2fa3a09137 test: harden command queue timer cleanup 2026-04-04 01:07:28 +09:00
Shakker
1877a2ea26 fix: stabilize rebased test surfaces 2026-04-04 01:07:28 +09:00
Shakker
d75fa152b9 test: trim secrets runtime snapshot setup 2026-04-04 01:07:28 +09:00
Shakker
38f76a1f8f perf: trim media secret setup and isolate heavy tests 2026-04-04 01:07:28 +09:00
Shakker
20250653ce fix: resolve rebased planner and loader regressions 2026-04-04 01:07:28 +09:00
Shakker
192f880a0b refactor: trim cron session cleanup imports 2026-04-04 01:07:28 +09:00
Shakker
998810a6a3 test: localize imessage alias channel coverage 2026-04-04 01:07:28 +09:00
Shakker
ce784b62f5 refactor: trim outbound direct-send runtimes 2026-04-04 01:07:28 +09:00
Shakker
5dd6189a2a refactor: split plugin interactive registration 2026-04-04 01:07:28 +09:00
Shakker
5b176c8cc5 test: split plugin install source coverage 2026-04-04 01:07:28 +09:00
Shakker
4919a8871b refactor: lazy load compaction store updates 2026-04-04 01:07:28 +09:00
Shakker
a3227e58d2 test: split secrets runtime integration coverage 2026-04-04 01:07:28 +09:00
Shakker
db76dbc546 test: split plugin loader coverage by concern 2026-04-04 01:07:28 +09:00
Shakker
b53dcb9380 test: fix bluebubbles monitor route typing 2026-04-04 01:07:28 +09:00
Shakker
da120962b9 test: fix image generation runtime test types 2026-04-04 01:07:28 +09:00
Shakker
383eea86dc test: move image generation runtime coverage to owner 2026-04-04 01:07:28 +09:00
Shakker
3c50139285 refactor: narrow heartbeat session imports 2026-04-04 01:07:28 +09:00
Shakker
590655472b perf: fast-path built-in reasoning provider checks 2026-04-04 01:07:28 +09:00
Shakker
9c4ea016d9 fix: use pnpm exec for scripted vitest runs 2026-04-04 01:07:28 +09:00
Shakker
e1143fb95f test: tighten bluebubbles monitor runtime typing 2026-04-04 01:07:28 +09:00
Shakker
24da2c39f3 refactor: isolate session transcript coverage 2026-04-04 01:07:28 +09:00
Shakker
27a8ef1284 refactor: narrow telegram message context runtime imports 2026-04-04 01:07:28 +09:00
Shakker
71b5a7c35b test: slim shared plugin runtime mock 2026-04-04 01:07:28 +09:00
Shakker
e9e7033ea1 test: trim embeddings provider import cost 2026-04-04 01:07:28 +09:00
Shakker
0af1d0ddb2 test: split security audit code safety coverage 2026-04-04 01:07:28 +09:00
Shakker
eb2cd09b72 test: trim facade runtime circular harness cost 2026-04-04 01:07:28 +09:00
Shakker
3cce39cae2 test: shrink media runtime facade coverage 2026-04-04 01:07:28 +09:00
Shakker
a65ff4de9f test: drain cron regression queue work before cleanup 2026-04-04 01:07:28 +09:00
Shakker
6299a5fbfe test: merge cron delivery-target thread coverage 2026-04-04 01:07:28 +09:00
Shakker
5ff72867bf test: type heartbeat reply mocks 2026-04-04 01:07:28 +09:00
Shakker
dcc3467a2b test: reset cron regression command queue state 2026-04-04 01:07:28 +09:00
Shakker
8bd3067e69 refactor: move built-in channel normalization to ids 2026-04-04 01:07:28 +09:00
Shakker
57ee3d9673 test: route config artifact checks through contracts 2026-04-04 01:07:28 +09:00
Shakker
33248980d9 test: split cron service regression ownership 2026-04-04 01:07:28 +09:00
Shakker
deb70e7e25 test: split cron isolated-agent turn coverage 2026-04-04 01:07:28 +09:00
Shakker
ebb4e9f0a6 test: route repo contract tests through contracts surface 2026-04-04 01:07:28 +09:00
Shakker
75b66403be test: route script suites through contracts surface 2026-04-04 01:07:28 +09:00
Shakker
335b472c37 test: merge subagent context-engine coverage into registry suite 2026-04-04 01:07:28 +09:00
Shakker
a78dba4396 refactor: lazy load heartbeat reply runtime 2026-04-04 01:07:28 +09:00
Shakker
bf3d1f85b8 test: avoid resetting cron issue regression modules 2026-04-04 01:07:28 +09:00
Shakker
5ae346427f test: fix stale typing in active suites 2026-04-04 01:07:28 +09:00
Shakker
ccdd33545b test: narrow qr dashboard integration surfaces 2026-04-04 01:07:28 +09:00
Shakker
652f273a0f refactor: lazy load approval forwarder defaults 2026-04-04 01:07:28 +09:00
Shakker
48fe2fd8be test: trim subagent context-engine harness cost 2026-04-04 01:07:28 +09:00
Shakker
9cf7b92e0d refactor: trim bundled capability metadata imports 2026-04-04 01:07:28 +09:00
Shakker
ac7e1f7c6c test: fix branch regression coverage 2026-04-04 01:07:28 +09:00
Shakker
6be5d34f2f test: avoid rebuilding openclaw tools in camera tests 2026-04-04 01:07:28 +09:00
Shakker
18891b1806 refactor: lazy load subagent registry runtime hooks 2026-04-04 01:07:28 +09:00
Shakker
08560c1f48 refactor: lazy load task cancellation control runtime 2026-04-04 01:07:28 +09:00
Shakker
192c02cd92 test: reuse subagent registry loop guard harness 2026-04-04 01:07:28 +09:00
Shakker
2d4428bcbb test: isolate outbound target registry boundaries 2026-04-04 01:07:28 +09:00
Shakker
2afa169250 test: isolate plugin dispatch runner boundaries 2026-04-04 01:07:28 +09:00
Shakker
0126653783 test: isolate message action runner test boundaries 2026-04-04 01:07:28 +09:00
Shakker
36c8282795 refactor: lazy load cli gateway helper runtimes 2026-04-04 01:07:28 +09:00
Shakker
58f1044ec0 test: drop duplicate outbound target coverage 2026-04-04 01:07:28 +09:00
Shakker
23422ccb68 refactor: lazy load cli gateway rpc runtime 2026-04-04 01:07:28 +09:00
Shakker
94340fdbae test: split message action runner boundaries 2026-04-04 01:07:28 +09:00
Shakker
42786afc64 refactor: trim image generation runtime imports 2026-04-04 01:07:28 +09:00
Shakker
768ec2a712 test: trim message action poll runner setup 2026-04-04 01:07:28 +09:00
Shakker
0875c2e370 test: trim message action media runner setup 2026-04-04 01:07:28 +09:00
Shakker
50069bcb59 fix: guard media image auto model resolution 2026-04-04 01:07:28 +09:00
Shakker
4b79ae7ad8 test: trim provider usage auth normalization setup 2026-04-04 01:07:28 +09:00
Shakker
14ff2c30d1 perf: prefer configured media auth providers 2026-04-04 01:07:28 +09:00
Shakker
25e9ff01cf refactor: trim media understanding runner test imports 2026-04-04 01:07:28 +09:00
Shakker
19493b681d test: harden channel metadata validation harness 2026-04-04 01:07:28 +09:00
Shakker
8d5c11d31b refactor: trim thinking helper import graph 2026-04-04 01:07:28 +09:00
Shakker
54af005f59 refactor: lazy load cron delivery outbound runtime 2026-04-04 01:07:28 +09:00
Shakker
9951f22766 refactor: split lightweight provider model id helpers 2026-04-04 01:07:28 +09:00
Shakker
9a6dda1b66 refactor: localize workspace skill prompt contract 2026-04-04 01:07:28 +09:00
Shakker
cc57bcfe2f refactor: lazy load cron subagent followup runtime 2026-04-04 01:07:28 +09:00
Shakker
9919e978ca refactor: lazy load cron auth and model runtime 2026-04-04 01:07:28 +09:00
Shakker
49563843dc refactor: trim remote skills startup imports 2026-04-04 01:07:28 +09:00
Shakker
c593ed0055 refactor: split lightweight plugin config policy 2026-04-04 01:07:28 +09:00
Shakker
4499d572fa refactor: split skill command specs from workspace snapshot 2026-04-04 01:07:28 +09:00
Shakker
24edb82ece refactor: split delivery target runtime seams 2026-04-04 01:07:28 +09:00
Shakker
d6ad92c1a0 fix: trim non-live test setup work 2026-04-04 01:07:28 +09:00
chi
33e6a6724d fix(telegram): enable HTML formatting for model switch messages (#60042)
* fix(telegram): enable HTML formatting for model switch messages

The model switch confirmation message was displaying raw Markdown
(**text**) instead of bold formatting because parse_mode was not set.

Changes:
- Add optional extra parameter to editMessageWithButtons for parse_mode
- Change format from Markdown ** to HTML <b> tags
- Pass parse_mode: 'HTML' when editing model switch message

Fixes the issue where model names appeared as **provider/model**
instead of bold text in Telegram.

* fix(telegram): escape HTML entities in model switch confirmation

Add defensive `escapeHtml` helper to sanitize `selection.provider`
and `selection.model` before interpolating them into the HTML
callback message. This prevents potential API rejection (HTTP 400)
if future provider or model names contain `<`, `>`, or `&`.

Addresses review feedback on unescaped HTML interpolation.

* test(telegram): cover HTML model switch confirmation

---------

Co-authored-by: Frank Yang <frank.ekn@gmail.com>
2026-04-04 00:05:09 +08:00
Tak Hoffman
7c738ad036 fix: honor whatsapp heartbeat account allowFrom 2026-04-03 11:04:00 -05:00
Vincent Koc
3d799ba004 fix(ci): tighten whatsapp and openai transport types 2026-04-04 01:02:41 +09:00
Vincent Koc
f49d8f665c test(providers): use preferred gpt-5.4 constant 2026-04-04 00:59:50 +09:00
Tak Hoffman
8805c7b55b fix: honor imessage setup dm policy accounts 2026-04-03 10:56:55 -05:00
Tak Hoffman
d114b4e033 fix: honor signal setup dm policy accounts 2026-04-03 10:54:40 -05:00
Peter Steinberger
b7f524abaa fix: resolve post-rebase gate follow-ups for #60081 2026-04-04 00:53:45 +09:00
Peter Steinberger
bf6bd7432a fix: harden discord ack auth and gate fallout (#60081) (thanks @FunJim) 2026-04-04 00:53:45 +09:00
FunJim
c1741abc3c test(discord): update ack reaction assertions to expect propagated cfg
The implementation fix propagates the hydrated cfg to reactMessageDiscord
and removeReactionDiscord. Update test assertions to expect the cfg
property in the options argument using expect.objectContaining to handle
the dynamic session store path.
2026-04-04 00:53:45 +09:00
FunJim
b51214ec3e fix(discord): pass hydrated config to ack reactions to fix SecretRef resolution
When extracting `reactMessageDiscord`, it defaulted to reading the raw config (which contains `SecretRef`s) if a hydrated `cfg` was omitted. We now pass the pre-resolved `cfg` context into the reaction options so the plugin SDK resolves the token via memory rather than the raw file.
2026-04-04 00:53:45 +09:00
Tak Hoffman
27ced5c1d3 fix: honor line setup dm policy accounts 2026-04-03 10:52:03 -05:00
Vincent Koc
f02f3b925d refactor(zalouser): lazy-load async runtime surfaces 2026-04-04 00:51:22 +09:00
Tak Hoffman
d1883470e7 fix: honor whatsapp setup dm policy accounts 2026-04-03 10:49:39 -05:00
Vincent Koc
bd4f745833 fix(providers): respect responses developer-role compat (#60385) 2026-04-04 00:49:16 +09:00
Vincent Koc
62b736a8c2 refactor(whatsapp): lazy-load login tool 2026-04-04 00:47:33 +09:00
Shakker
d9dbce4093 fix: restore missing contract registry config import 2026-04-03 16:45:32 +01:00
Tak Hoffman
69d018ce4f fix: honor zalouser setup dm policy accounts 2026-04-03 10:44:46 -05:00
Tak Hoffman
4107a5a4f0 fix: honor zalo setup dm policy accounts 2026-04-03 10:42:23 -05:00
Peter Steinberger
41ce3269f4 refactor(plugins): split activation snapshot and compat flow 2026-04-04 00:42:11 +09:00
Vincent Koc
eb3481fca9 refactor(discord): lazy-load actions and audit 2026-04-04 00:40:30 +09:00
Shakker
a6a200ebc2 docs(changelog): note browser and whatsapp seam split 2026-04-03 16:39:47 +01:00
Shakker
b1747d8b1c fix: remove unused sandbox browser type import 2026-04-03 16:39:47 +01:00
Shakker
846bfaa045 fix: align plugin sdk subpath expectations 2026-04-03 16:39:47 +01:00
Peter Steinberger
3aac90fc85 fix: restore browser-config sdk compatibility 2026-04-03 16:39:47 +01:00
Shakker
9a88a933cf refactor: narrow audit browser enablement check 2026-04-03 16:39:47 +01:00
Shakker
35541377d1 test: split whatsapp setup surface coverage 2026-04-03 16:39:47 +01:00
Shakker
9b8c892ff4 perf: direct export whatsapp target helpers 2026-04-03 16:39:47 +01:00
Shakker
5e7ebd098e fix: remove duplicate sandbox browser import 2026-04-03 16:39:47 +01:00
Shakker
e7cb9dec43 refactor: add approval auth runtime subpath 2026-04-03 16:39:47 +01:00
Shakker
6e3203a728 refactor: narrow whatsapp chunking imports 2026-04-03 16:39:47 +01:00
Shakker
4615ddf89b test: trim whatsapp channel test barrels 2026-04-03 16:39:47 +01:00
Shakker
6d6060d3ec perf: split whatsapp targets facade 2026-04-03 16:39:47 +01:00
Shakker
4528f8779e test: localize browser config env helper 2026-04-03 16:39:47 +01:00
Shakker
a5b23f17fb perf: split browser config sdk support 2026-04-03 16:39:47 +01:00
Shakker
557a07bd5b perf: skip browser runtime lookup for empty tab cleanup 2026-04-03 16:39:47 +01:00
Shakker
f41a67b118 fix: restore browser and whatsapp boundary contracts 2026-04-03 16:39:47 +01:00
Shakker
2e520d112d refactor: split browser sdk imports for sandbox and audit 2026-04-03 16:39:47 +01:00
Tak Hoffman
625201bddc fix: honor bluebubbles setup dm policy accounts 2026-04-03 10:39:32 -05:00
Vincent Koc
4b93000d11 test(contracts): isolate plugin registry 2026-04-04 00:38:27 +09:00
Tak Hoffman
6a28756e98 fix: honor nextcloud setup dm policy accounts 2026-04-03 10:37:07 -05:00
Vincent Koc
e67be773d6 test(contracts): isolate action registry 2026-04-04 00:35:35 +09:00
Tak Hoffman
3d8a039149 fix: honor legacy setup dm policy accounts 2026-04-03 10:34:18 -05:00
Peter Steinberger
904d9db132 fix(ci): repair whatsapp harness mocking 2026-04-03 16:32:47 +01:00
Vincent Koc
f2204cb35a test(contracts): isolate setup and status registries 2026-04-04 00:32:20 +09:00
Vincent Koc
d755709ddd refactor(discord): lazy-load cross-context ui 2026-04-04 00:31:29 +09:00
Tak Hoffman
b1026a0b28 fix: honor account-scoped setup dm policy 2026-04-03 10:31:00 -05:00
Peter Steinberger
c6b8109bd8 fix(ci): use sdk seams in whatsapp test harnesses 2026-04-03 16:29:53 +01:00
Peter Steinberger
8b6c224554 refactor(plugins): share activation context for provider runtimes 2026-04-04 00:29:09 +09:00
Vincent Koc
c8318754b5 test(contracts): lazily resolve session binding registry 2026-04-04 00:28:22 +09:00
Vincent Koc
7c6eba4634 test(config): cover thread binding legacy doctor paths 2026-04-04 00:26:41 +09:00
Vincent Koc
f1f6b98639 test(contracts): isolate slack outbound harness 2026-04-04 00:26:16 +09:00
Vincent Koc
79aa212789 refactor(whatsapp): lazy-load send and action runtimes 2026-04-04 00:25:02 +09:00
Tak Hoffman
dae0400a8f fix: honor discord account guild policy config 2026-04-03 10:24:42 -05:00
Vincent Koc
745aa26420 fix(ci): remove duplicate migrated test imports 2026-04-04 00:24:20 +09:00
Vincent Koc
ed297eb8b9 fix(providers): align cache-ttl anthropic semantics (#60375) 2026-04-04 00:22:32 +09:00
Vincent Koc
af835acd00 test(config): cover legacy tts doctor paths 2026-04-04 00:21:45 +09:00
Vincent Koc
ade6b61358 test(contracts): split registry-backed channel contract lanes 2026-04-04 00:21:30 +09:00
Vincent Koc
f71ef47288 fix(ci): disable automatic clawhub release workflow 2026-04-04 00:20:28 +09:00
Vincent Koc
a592cd67cb fix(ci): bump clawhub plugin versions for release gate 2026-04-04 00:20:27 +09:00
Peter Steinberger
1dfcdbdf91 fix(testing): repair bundled plugin helper imports 2026-04-03 16:19:39 +01:00
Tak Hoffman
e3fea41b59 fix: honor telegram account topic mention config 2026-04-03 10:19:11 -05:00
Vincent Koc
c71df2f4b0 test(commands): allow scoped channel test registries 2026-04-04 00:18:34 +09:00
Peter Steinberger
2e779a1b20 refactor(discord): share thread starter snapshot parsing 2026-04-04 00:17:57 +09:00
Vincent Koc
0f129c87ba test(config): cover telegram and x_search legacy doctor paths 2026-04-04 00:16:57 +09:00
Tak Hoffman
a3541a1cce fix: honor telegram account replyToMode 2026-04-03 10:16:05 -05:00
Tak Hoffman
30c0dc3d47 fix: honor discord account replyToMode 2026-04-03 10:14:42 -05:00
Vincent Koc
4e3b2781fb test(contracts): split session binding registry seams 2026-04-04 00:13:40 +09:00
Peter Steinberger
93f136cbed test: split inbound contract suites by channel 2026-04-03 16:13:09 +01:00
Peter Steinberger
2da3b45ce7 test: reduce discord component partial mocks 2026-04-03 16:13:09 +01:00
Tak Hoffman
8e9607c064 fix: honor googlechat account replyToMode 2026-04-03 10:12:47 -05:00
Vincent Koc
f08a1c34dd fix(providers): scope anthropic-family cache semantics (#60370) 2026-04-04 00:11:57 +09:00
Vincent Koc
b50b85a5db refactor(zalo): lazy-load setup wizard surface 2026-04-04 00:11:07 +09:00
Vincent Koc
702a200844 fix(ci): guard optional discord reaction cleanup 2026-04-04 00:09:32 +09:00
Tak Hoffman
78c390ea86 docs: align messages config support notes 2026-04-03 10:08:34 -05:00
Vincent Koc
5ad2c61c9a test(config): cover gateway bind legacy doctor flow 2026-04-04 00:07:19 +09:00
Peter Steinberger
cee5f960b5 fix(gateway): preserve raw activation source for startup plugin loads 2026-04-04 00:06:57 +09:00
Vincent Koc
93d514f816 fix(ci): correct zalo status helper imports 2026-04-04 00:06:47 +09:00
Vincent Koc
0eb9416d9c refactor(telegram): lazy-load send and action runtimes 2026-04-04 00:06:38 +09:00
Tak Hoffman
30fc29c9b0 fix: honor discord status reactions toggle 2026-04-03 10:05:17 -05:00
Vincent Koc
ca68d57dc2 test(config): cover legacy heartbeat and memorySearch doctor paths 2026-04-04 00:04:25 +09:00
Vincent Koc
9e6da1e70a fix(providers): pass anthropic cache retention through custom apis (#60359) 2026-04-04 00:04:09 +09:00
Vincent Koc
6366010884 fix(ci): route extension test helpers through public sdk seams 2026-04-04 00:03:48 +09:00
Peter Steinberger
ad8870ae28 test: narrow gateway and model status runtime seams 2026-04-03 16:03:32 +01:00
Peter Steinberger
a6816cb59c test: reduce subagent announce import overhead 2026-04-03 16:03:32 +01:00
Peter Steinberger
25a187568f test: trim whatsapp monitor import overhead 2026-04-03 16:03:32 +01:00
Shakker
b98ee01814 fix: restore cron context window priming 2026-04-03 16:03:10 +01:00
Shakker
f5276ed38b test: preserve cron model-selection helper exports 2026-04-03 16:03:10 +01:00
Shakker
de952c036a refactor: split cron delivery planning from sending 2026-04-03 16:03:10 +01:00
Shakker
bd8d29c2b1 fix: align cron test delivery result types 2026-04-03 16:03:10 +01:00
Shakker
6363094e93 refactor: trim cron session store startup imports 2026-04-03 16:03:10 +01:00
Shakker
1f0c4a624b refactor: route cron subagent reads through registry seam 2026-04-03 16:03:10 +01:00
Shakker
11dbcdc46d refactor: narrow model fallback auth imports 2026-04-03 16:03:10 +01:00
Shakker
b721f5e48a refactor: lazy load cron gateway cleanup 2026-04-03 16:03:10 +01:00
Shakker
a4efe7c028 refactor: narrow cron delivery session imports 2026-04-03 16:03:10 +01:00
Shakker
12fa700579 refactor: lazy load cron usage formatting 2026-04-03 16:03:10 +01:00
Shakker
fc8ab82aab refactor: trim cron session startup imports 2026-04-03 16:03:10 +01:00
Shakker
88b1c00b39 refactor: lazy load cron cli runtime 2026-04-03 16:03:10 +01:00
Shakker
7a9ad3820e refactor: localize cron channel test outbounds 2026-04-03 16:03:10 +01:00
Hiroshi Tanaka
e9a1f7818c fix(discord): extract forwarded message text in thread starter resolution (#60139)
resolveDiscordThreadStarter only checked content and embeds, returning
null for forwarded messages where the text lives in message_snapshots.

Add a local resolveStarterForwardedText helper that extracts text
directly from the message_snapshots array on the REST response object.
This avoids fragile type casts and keeps the change self-contained
within threading.ts.

Fixes #60129
2026-04-04 00:02:42 +09:00
Vincent Koc
fbd361d338 fix(config): surface legacy channel streaming aliases (#60358) 2026-04-04 00:00:38 +09:00
Vincent Koc
3257136160 refactor(zalo): narrow channel sdk imports 2026-04-04 00:00:34 +09:00
Vincent Koc
5ccf6d229b test(channels): remove unused group policy helper 2026-04-03 23:57:43 +09:00
Tak Hoffman
759c81ceb8 test: audit heartbeat config honor 2026-04-03 09:55:51 -05:00
Vincent Koc
35cf7d0340 fix(config): migrate legacy sandbox perSession alias (#60346)
* fix(config): migrate legacy sandbox perSession alias

* fix(config): preserve invalid sandbox persession values
2026-04-03 23:55:47 +09:00
Vincent Koc
b6dd7ac232 test(channels): remove unused dm policy helper 2026-04-03 23:54:07 +09:00
Vincent Koc
f9f0a593e4 test(helpers): remove unused plugin helper wrappers 2026-04-03 23:52:58 +09:00
Vincent Koc
a028e16eaa test(helpers): remove unused bundled plugin surface wrapper 2026-04-03 23:50:55 +09:00
Vincent Koc
35aa6c6126 test(channel): remove unused outbound helper 2026-04-03 23:50:06 +09:00
Vincent Koc
756cf847e0 refactor(telegram): lazy-load audit and monitor surfaces 2026-04-03 23:49:53 +09:00
Vincent Koc
279ee5e842 test(commands): lazy-load default channel registry plugins 2026-04-03 23:48:38 +09:00
Frank Yang
e9f82ac752 fix: clear stale ClawHub query results on input change (#60267)
* fix: clear stale ClawHub query results on input change

* docs: move ClawHub follow-up changelog entry to section tail
2026-04-03 22:48:14 +08:00
Vincent Koc
76ff144037 test(outbound): remove unused runner helper 2026-04-03 23:46:24 +09:00
Vincent Koc
66825c0969 refactor(providers): centralize native provider detection (#60341)
* refactor(providers): centralize native provider detection

* fix(providers): preserve openrouter thinking format

* fix(providers): preserve openrouter host thinking format
2026-04-03 23:46:21 +09:00
Vincent Koc
24e10e6e45 test(contracts): lazy-load slack outbound contract surface 2026-04-03 23:45:01 +09:00
Vincent Koc
38a4b2b14c refactor(signal): route target normalization through channel-targets 2026-04-03 23:44:50 +09:00
Vincent Koc
5ce7aee33b test(cron): localize core channel outbound test loads 2026-04-03 23:41:54 +09:00
Vincent Koc
e9acbdb111 fix(ci): share heavy check lock across test and lint 2026-04-03 23:41:34 +09:00
Vincent Koc
8ffeadd8f9 test(contracts): lazy-load outbound contract plugin helpers 2026-04-03 23:40:19 +09:00
Peter Steinberger
cd38eba316 refactor: unify plugin activation source plumbing 2026-04-03 23:39:36 +09:00
Vincent Koc
975d2ddce2 test(contracts): lazy-load inbound contract plugin helpers 2026-04-03 23:39:26 +09:00
Vincent Koc
3b69b8e3c4 fix(ci): route extension test helpers through sdk testing 2026-04-03 23:39:06 +09:00
Vincent Koc
c013b9cdf3 test(contracts): lazy-load session binding test facades 2026-04-03 23:37:59 +09:00
Vincent Koc
316978700e test(gateway): lazy-load speech provider test surfaces 2026-04-03 23:33:16 +09:00
Vincent Koc
316da43dd7 fix(config): migrate legacy talk config via doctor (#60333)
* fix(config): migrate legacy talk config via doctor

* fix(config): harden legacy talk provider migration
2026-04-03 23:32:36 +09:00
Vincent Koc
23d8a979b3 test(contracts): lazy-load discord thread binding test surface 2026-04-03 23:31:47 +09:00
Vincent Koc
1556490ee7 fix(ci): collapse duplicate provider request union 2026-04-03 23:30:25 +09:00
Vincent Koc
cddb34ba6a refactor(line): lazy-load card command 2026-04-03 23:29:34 +09:00
Vincent Koc
0d7d573cd6 test(commands): split default channel test registry helper 2026-04-03 23:29:24 +09:00
Vincent Koc
06ed0eaad5 fix(ci): narrow discord subagent hook types 2026-04-03 23:28:03 +09:00
Peter Steinberger
b40d4b63f6 refactor: centralize update targets and extension guardrails 2026-04-03 23:26:31 +09:00
Vincent Koc
8f5f78bbe8 feat(providers): reopen model request transport config (#60327)
* feat(providers): reopen model request transport config

* chore(config): refresh request override baselines
2026-04-03 23:25:11 +09:00
Vincent Koc
cedc8bdebb refactor(signal): lazy-load monitor surfaces 2026-04-03 23:24:42 +09:00
Vincent Koc
4e9629d60c fix(ci): route bluebubbles helper through local barrel 2026-04-03 23:24:17 +09:00
Peter Steinberger
d375cd727e fix: migrate legacy web search config on startup 2026-04-03 23:24:02 +09:00
Shakker
549e0bb268 test: keep imessage test plugin facade-free by default 2026-04-03 15:23:50 +01:00
Vincent Koc
690c58baa2 refactor(discord): lazy-load subagent hooks 2026-04-03 23:22:34 +09:00
Vincent Koc
0ecf84524d fix(ci): restore line runtime seams 2026-04-03 23:19:39 +09:00
Vincent Koc
78fb352506 refactor(feishu): lazy-load subagent hooks 2026-04-03 23:19:11 +09:00
Vincent Koc
52008e2e60 fix(doctor): clarify legacy config migration guidance (#60326) 2026-04-03 23:16:11 +09:00
Vincent Koc
a9a057d1eb fix(ci): normalize extension harness imports 2026-04-03 23:15:57 +09:00
Vincent Koc
ac20eed335 fix(ci): route extension tests through sdk seams 2026-04-03 23:15:57 +09:00
Vincent Koc
ed166ba338 test(contracts): extract narrow channel contract helpers 2026-04-03 23:14:45 +09:00
Josh Lehman
799c6f40aa refactor: move provider replay runtime ownership into plugins (#60126)
* refactor: move provider replay runtime ownership into plugins

* fix(provider-runtime): address review followups

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-03 23:14:37 +09:00
Shakker
f328e7f4a6 docs: add changelog for outbound seam refactor 2026-04-03 15:10:48 +01:00
Shakker
6395336454 fix: resolve outbound seam follow-ups 2026-04-03 15:10:48 +01:00
Shakker
1a23627e32 refactor: split delivery target runtime seams 2026-04-03 15:10:48 +01:00
Shakker
c2e93c76bd refactor: split session store loader from maintenance 2026-04-03 15:10:48 +01:00
Shakker
883a35a38c refactor: narrow cron delivery target session imports 2026-04-03 15:10:48 +01:00
Shakker
cef0f36931 refactor: split chat history text helpers 2026-04-03 15:10:48 +01:00
Shakker
4a0905b94b refactor: lazy load outbound channel bootstrap 2026-04-03 15:10:48 +01:00
Shakker
4a81771290 refactor: lazy load outbound transcript mirroring 2026-04-03 15:10:48 +01:00
Shakker
e26a590f7a refactor: drop heavy channel outbound test imports 2026-04-03 15:10:48 +01:00
Shakker
a61408737f refactor: localize deliver test outbounds 2026-04-03 15:10:48 +01:00
Shakker
d338299dc7 refactor: keep deliver tests channel-generic 2026-04-03 15:10:48 +01:00
Shakker
909895c471 refactor: narrow deliver test channel boundaries 2026-04-03 15:10:48 +01:00
Shakker
d7e4fad872 refactor: trim outbound delivery test imports 2026-04-03 15:10:48 +01:00
Shakker
52717ee399 refactor: trim outbound target test imports 2026-04-03 15:10:48 +01:00
Agustin Rivera
3cd9aac6bb Require owner access for /allowlist writes (#59836)
* fix(allowlist): require owner access for writes

* docs(changelog): note allowlist owner gate fix

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
2026-04-03 07:07:36 -07:00
Vincent Koc
62b1fe0b85 fix(ci): correct browser live test export 2026-04-03 23:07:20 +09:00
Peter Steinberger
8d0bed458e refactor: simplify reply-threading and test helpers 2026-04-03 23:06:22 +09:00
Vincent Koc
1125cd3b97 refactor(xai): lazy-load heavy tool modules 2026-04-03 23:03:33 +09:00
Vincent Koc
efeee6f921 fix(ci): use plugin registry test bridges 2026-04-03 23:03:15 +09:00
Vincent Koc
4b2c7404e5 test(types): remove remaining testing barrel references 2026-04-03 23:03:02 +09:00
Vincent Koc
5341d483d1 test(acpx): use direct ACP adapter contract seam 2026-04-03 23:01:51 +09:00
Peter Steinberger
0dad4072b4 fix: keep extension helper imports behind local runtime barrels (#60153) 2026-04-03 23:01:43 +09:00
Peter Steinberger
96e8352bda fix: align extension boundary guardrails for landing (#60153) 2026-04-03 23:01:43 +09:00
Peter Steinberger
0c0d84fbd9 fix: route pnpm test wrappers through the active runner (#60153) 2026-04-03 23:01:43 +09:00
Peter Steinberger
ab57d24f79 fix: stabilize npm owner update test on Windows (#60153) (thanks @jayeshp19) 2026-04-03 23:01:43 +09:00
jayeshp19
b9ede82cc2 greptile fix 2026-04-03 23:01:43 +09:00
jayeshp19
eb4b6f7024 Use owning npm prefix for global updates 2026-04-03 23:01:43 +09:00
Vincent Koc
c1d68f213d test(helpers): use direct internal seams 2026-04-03 23:00:28 +09:00
Vincent Koc
d2427c19e0 fix(ci): restore extension runtime seams 2026-04-03 22:57:28 +09:00
Vincent Koc
9b83e462cf test(channels): use narrow active registries in sticky tests 2026-04-03 22:57:16 +09:00
Vincent Koc
9aab672a69 refactor(bluebubbles): narrow monitor processing exports 2026-04-03 22:56:26 +09:00
Peter Steinberger
bc137951e9 fix: preserve allowlist guard for auto-enabled bundled channels (#60233) (thanks @dorukardahan) 2026-04-03 22:55:31 +09:00
Doruk Ardahan
cd08facd7a fix(plugins): keep auto-enabled channels behind allowlists 2026-04-03 22:55:30 +09:00
Doruk Ardahan
f7d24c1ed5 fix(plugins): allow configured bundled channels past allowlists 2026-04-03 22:55:30 +09:00
Vincent Koc
2ad69083d2 refactor(feishu): narrow reply dispatcher exports 2026-04-03 22:54:29 +09:00
Vincent Koc
1ad9fe0b52 test(discord): use direct channel test helper 2026-04-03 22:51:37 +09:00
Vincent Koc
f6e99bd514 refactor(msteams): narrow messenger sdk imports 2026-04-03 22:50:54 +09:00
pgondhi987
b48b528b02 fix(skills): block UV_PYTHON in workspace dotenv and harden uv installer env [AI] (#59178)
* fix: address issue

* fix: finalize issue changes

* fix: address PR review feedback

* Changelog: note uv installer env hardening

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
2026-04-03 06:50:43 -07:00
Vincent Koc
8b5e80fcaa refactor(msteams): narrow store sdk imports 2026-04-03 22:49:27 +09:00
Vincent Koc
6f9b4b52f8 refactor(msteams): narrow send sdk imports 2026-04-03 22:47:07 +09:00
Vincent Koc
3b358414d3 test(channels): use direct contract helper imports 2026-04-03 22:46:58 +09:00
Vincent Koc
4798e125f4 feat(providers): add llm transport adapter seam (#60264)
* feat(providers): add llm transport adapter seam

* fix(providers): harden openai transport adapter

* fix(providers): correct transport usage accounting
2026-04-03 22:45:47 +09:00
Vincent Koc
875c3813aa refactor(msteams): narrow outbound sdk imports 2026-04-03 22:45:09 +09:00
Vincent Koc
f904a49568 refactor(feishu): narrow channel runtime exports 2026-04-03 22:43:09 +09:00
Vincent Koc
020093bf4b test(slack): use direct outbound payload harness 2026-04-03 22:43:06 +09:00
Vincent Koc
597416fdd9 refactor(feishu): narrow outbound runtime exports 2026-04-03 22:41:46 +09:00
Peter Steinberger
173bb0aea0 test: remove update-cli shared partial mock 2026-04-03 14:40:11 +01:00
Onur
fa9e1e3d8e CI: add ClawHub plugin release workflow (#59179)
* CI: add ClawHub plugin release workflow

* CI: harden ClawHub plugin release workflow

* CI: finish ClawHub plugin release hardening

* CI: watch shared ClawHub release inputs

* CI: harden ClawHub publish workflow

* CI: watch more ClawHub release deps

* CI: match shared release inputs by prefix

* CI: pin ClawHub publish source commit

* CI: refresh pinned ClawHub release commit

* CI: rename ClawHub plugin release environment

---------

Co-authored-by: Onur Solmaz <onur@solmaz.io>
2026-04-03 15:40:07 +02:00
Shakker
e0e5df25e6 test: unstick context lookup fake-timer warmup 2026-04-03 14:38:30 +01:00
Peter Steinberger
8962a8fb73 test: reduce status and update-cli partial mocks 2026-04-03 14:37:47 +01:00
Peter Steinberger
bab14fb8f1 test: lazy-load config doc baseline runtime 2026-04-03 14:29:17 +01:00
Peter Steinberger
bb25a8050c test: baseline bundled runtime sidecar paths 2026-04-03 14:26:01 +01:00
Shakker
8fabfa5d1c fix: rehydrate context token cache after module reload 2026-04-03 14:23:44 +01:00
Peter Steinberger
ba7297ff21 test: narrow media fs-safe test seams 2026-04-03 14:22:12 +01:00
Shakker
3b727d2433 test: harden package-root mocks after setup slimming 2026-04-03 14:17:16 +01:00
Shakker
a898cd464a fix: use runtime plugin state in test setup 2026-04-03 14:17:16 +01:00
Shakker
a764b8f8cd refactor: trim test setup agent imports 2026-04-03 14:17:16 +01:00
Shakker
73073a91bb refactor: isolate session store test cleanup state 2026-04-03 14:17:16 +01:00
Shakker
7bd6bb91c4 fix: trim non-live test setup work 2026-04-03 14:17:16 +01:00
Peter Steinberger
6510ecafb0 test: narrow bluebubbles reply-cache imports 2026-04-03 14:16:29 +01:00
Peter Steinberger
85bd5b3ce7 fix(ci): refresh protocol models and align channel tests 2026-04-03 14:13:32 +01:00
Peter Steinberger
e43a362ad9 test: trim update-cli metadata fixture import 2026-04-03 14:09:28 +01:00
Peter Steinberger
3a2667b5fc test: fix status and update-cli mock drift 2026-04-03 14:06:32 +01:00
Peter Steinberger
bbbfbd2db4 test: import slack monitor helpers directly 2026-04-03 14:03:05 +01:00
Peter Steinberger
91618438bc test: narrow googlechat channel test deps 2026-04-03 14:03:05 +01:00
Peter Steinberger
6d05607cd9 test: trim nextcloud send config import cost 2026-04-03 14:03:05 +01:00
Peter Steinberger
a884ad3cf2 fix(ci): route extension test helpers through sdk seams 2026-04-03 13:58:21 +01:00
Peter Steinberger
35d890b5ef test: narrow subagent spawn test seams 2026-04-03 13:53:11 +01:00
Peter Steinberger
88bc6d852f fix(ci): route remaining feishu runtime seams locally 2026-04-03 13:52:40 +01:00
Peter Steinberger
1a75fc9e05 fix: align latest-main gate drift on #60221 2026-04-03 21:52:35 +09:00
Ayaan Zaidi
39361d13be fix: restore bootstrap tokens after send failure (#60221) 2026-04-03 21:52:35 +09:00
Ayaan Zaidi
5e3a3c42ca fix(gateway): revoke bootstrap tokens after handshake commit 2026-04-03 21:52:35 +09:00
Ayaan Zaidi
b08d58c917 fix(gateway): track bootstrap profile redemption 2026-04-03 21:52:35 +09:00
Ayaan Zaidi
0891253012 fix(pairing): preserve mixed-role node scopes 2026-04-03 21:52:35 +09:00
Ayaan Zaidi
a42f000b53 fix(gateway): defer bootstrap token revocation 2026-04-03 21:52:35 +09:00
Peter Steinberger
df115822b9 test: reduce non-telegram import overhead 2026-04-03 13:49:51 +01:00
Peter Steinberger
4f4aa46d00 test: split telegram bot command menu coverage 2026-04-03 13:49:51 +01:00
Peter Steinberger
da1980b923 fix(ci): route final feishu helper barrels locally 2026-04-03 13:46:06 +01:00
Vincent Koc
a3b9ae8fab refactor(feishu): narrow bot runtime exports 2026-04-03 21:44:51 +09:00
Vincent Koc
8735dd7d19 fix(ci): restore channel helper seams 2026-04-03 21:43:32 +09:00
Vincent Koc
7ede711cae fix(test): use shell spawn for windows test runner 2026-04-03 21:43:32 +09:00
Vincent Koc
01a163c7f3 fix(discord): restore runtime action seam 2026-04-03 21:43:32 +09:00
Vincent Koc
851de3554e refactor(feishu): split comment dispatcher seam 2026-04-03 21:43:29 +09:00
Vincent Koc
349d3e8289 test(plugin-sdk): extract direct helper seams 2026-04-03 21:42:04 +09:00
Vincent Koc
2fff6be4c3 refactor(feishu): split monitor state seam 2026-04-03 21:41:47 +09:00
Peter Steinberger
2a54a7f7cd fix(ci): route remaining feishu helper barrels locally 2026-04-03 13:41:32 +01:00
Vincent Koc
77c04ec29c test(whatsapp): use direct outbound helper fixtures 2026-04-03 21:40:12 +09:00
Vincent Koc
56ead96f48 test(discord): use direct system event helpers 2026-04-03 21:39:30 +09:00
Vincent Koc
027a544d8f refactor(feishu): split dedup runtime seam 2026-04-03 21:38:51 +09:00
Vincent Koc
710c63edad test(extensions): use direct runtime capture helpers 2026-04-03 21:37:41 +09:00
Vincent Koc
319aa2f1fe refactor(feishu): split runtime helper seams 2026-04-03 21:37:32 +09:00
Peter Steinberger
899fe6ffa5 fix(ci): route feishu bot helpers through local barrel 2026-04-03 13:36:55 +01:00
Vincent Koc
0ca2a91213 test(extensions): use direct helper seams in provider tests 2026-04-03 21:36:13 +09:00
Vincent Koc
a05daaa832 refactor(feishu): split channel runtime seam 2026-04-03 21:34:08 +09:00
Vincent Koc
0a61138e77 test(deepgram): use direct audio test helpers 2026-04-03 21:32:47 +09:00
Vincent Koc
e4cc8cd975 refactor(feishu): split outbound runtime seam 2026-04-03 21:32:25 +09:00
Peter Steinberger
ee39ec29d1 fix(ci): restore talk-voice plugin runtime export 2026-04-03 13:32:16 +01:00
Vincent Koc
344717a2d5 refactor(feishu): split comment handler seam 2026-04-03 21:30:16 +09:00
Peter Steinberger
65cddd79eb fix(ci): align discord actions contract with config discovery 2026-04-03 13:29:17 +01:00
Vincent Koc
beb108cfaa refactor(feishu): split bot runtime seam 2026-04-03 21:28:15 +09:00
Peter Steinberger
49936f6066 refactor: move ollama synthetic auth precedence into extension 2026-04-03 21:25:02 +09:00
Vincent Koc
a0dbba1626 test(extensions): narrow provider registration test helpers 2026-04-03 21:24:43 +09:00
Vincent Koc
568859e1fb test(extensions): avoid barrel testing helpers in media tests 2026-04-03 21:23:47 +09:00
Vincent Koc
2a04d5c16f test(extensions): narrow utility test helper imports 2026-04-03 21:23:47 +09:00
Vincent Koc
a3cadfd51d test(talk-voice): slim command runtime fixture 2026-04-03 21:22:00 +09:00
Peter Steinberger
7c41b9fca9 fix(ci): route telegram test harness through reply runtime 2026-04-03 13:21:38 +01:00
Vincent Koc
7fa0c76ffc test(mattermost): slim leaf runtime fixtures 2026-04-03 21:21:24 +09:00
Vincent Koc
f0a4423271 fix(tui): tolerate clock skew in pending-history reconciliation 2026-04-03 21:21:09 +09:00
Peter Steinberger
a3f34a8f77 test: reduce telegram context partial mocks 2026-04-03 13:19:50 +01:00
Vincent Koc
c186644662 test(googlechat): use narrow registry helpers in webhook routing tests 2026-04-03 21:18:51 +09:00
Vincent Koc
e4abd34466 test(feishu): drop unused client runtime imports 2026-04-03 21:18:17 +09:00
Vincent Koc
4e60653959 fix(test): default local Vitest to one worker (#60281) 2026-04-03 21:18:12 +09:00
Vincent Koc
69da71c0ce test(feishu): slim tool runtime fixtures 2026-04-03 21:17:11 +09:00
Vincent Koc
cd81e0a07b test(zalo): replace heavy testing helpers in monitor tests 2026-04-03 21:17:02 +09:00
Peter Steinberger
5184522f2f refactor: trim extension test runner surface 2026-04-03 13:15:43 +01:00
Vincent Koc
3a68414569 test(feishu): slim bot runtime fixtures 2026-04-03 21:14:08 +09:00
Peter Steinberger
99397254a1 fix(ci): relax feishu runtime test casts 2026-04-03 13:12:23 +01:00
Peter Steinberger
d2dae50a75 test: trim telegram bot import graph 2026-04-03 13:10:43 +01:00
Vincent Koc
9245b9e2f4 test(zalo): use narrow registry helpers in lifecycle tests 2026-04-03 21:10:33 +09:00
Peter Steinberger
f59d0eac68 refactor(plugin-runtime): remove plugin-specific core seams 2026-04-03 13:08:39 +01:00
Vincent Koc
4846ebce12 fix(test): serialize local heavy checks (#60273) 2026-04-03 21:07:56 +09:00
Vincent Koc
feca4aa49e test(feishu): replace heavy runtime mock helper in bot tests 2026-04-03 21:07:43 +09:00
Peter Steinberger
fbb2537774 refactor: clarify tool schema normalization 2026-04-03 21:07:19 +09:00
Peter Steinberger
1118d032ca refactor: split extension test helpers 2026-04-03 13:06:11 +01:00
Peter Steinberger
87abcfd6a6 fix: harden ollama tool-call replay (#52253) (thanks @Adam-Researchh) 2026-04-03 21:06:06 +09:00
Vincent Koc
e116e7d584 test(feishu): slim comment monitor runtime fixtures 2026-04-03 21:05:54 +09:00
Vincent Koc
dc312a4c76 test(feishu): slim reaction monitor runtime fixtures 2026-04-03 21:02:26 +09:00
Peter Steinberger
685ef52284 refactor: simplify test workflow helpers 2026-04-03 13:00:00 +01:00
Peter Steinberger
71a54d0c95 fix(ci): forward bluebubbles barrel and node env fixes 2026-04-03 12:58:10 +01:00
Vincent Koc
688eb8435b test(bluebubbles): split webhook ingress seam 2026-04-03 20:58:03 +09:00
Vincent Koc
2734d06ade test(matrix): avoid loading send module in thread binding tests 2026-04-03 20:56:58 +09:00
Vincent Koc
a8040ab9d9 test(matrix): avoid loading action modules in tool tests 2026-04-03 20:55:47 +09:00
Vincent Koc
b925f6d46c test(zalo): avoid loading monitor and probe modules in startup test 2026-04-03 20:55:09 +09:00
Vincent Koc
fbc4fa6ac3 test(feishu): avoid loading streaming card module in dispatcher tests 2026-04-03 20:54:24 +09:00
Vincent Koc
d888ce242b test(bluebubbles): split monitor processing seam 2026-04-03 20:54:08 +09:00
Peter Steinberger
0d938748a5 refactor(sessions): clarify duplicate session resolution 2026-04-03 20:53:17 +09:00
Vincent Koc
a0c6ea5aba test(feishu): avoid loading bot and send modules in menu tests 2026-04-03 20:52:26 +09:00
Peter Steinberger
a2077b28ef refactor: trim vitest wrapper layers 2026-04-03 12:52:14 +01:00
Vincent Koc
bd1e78ea34 test(msteams): avoid loading graph upload module in messenger tests 2026-04-03 20:50:00 +09:00
Vincent Koc
82fca281b6 test(msteams): avoid loading graph module in message tests 2026-04-03 20:50:00 +09:00
Vincent Koc
b410c5434c test(msteams): avoid loading graph module in member tests 2026-04-03 20:50:00 +09:00
Vincent Koc
d9aa88dd6c test(bluebubbles): split channel status seam 2026-04-03 20:46:42 +09:00
Vincent Koc
9bd05d3841 test(browser): stop reloading auth server module 2026-04-03 20:45:45 +09:00
Peter Steinberger
57999f9965 fix: narrow empty MCP tool schema normalization (#60176) (thanks @Bartok9) 2026-04-03 20:45:33 +09:00
Bartok Moltbot
19dbe00763 fix(tools): normalize truly empty MCP tool schemas for OpenAI
Fixes #60158

MCP tools with parameter-free schemas may return truly empty objects
`{}` without a `type` field. The existing normalization handled
`{ type: "object" }` → `{ type: "object", properties: {} }` but
missed the truly empty case.

OpenAI gpt-5.4 rejects tool schemas without `type: "object"` and
`properties`, causing HTTP 400 errors:

```
Invalid schema for function 'flux-mcp__get_flux_instance':
In context=(), object schema missing properties.
```

This change catches empty schemas (no type, no properties, no unions)
before the final pass-through and converts them to the required format.

Added test case for parameter-free MCP tool schemas.
2026-04-03 20:45:33 +09:00
Peter Steinberger
6845b8061c docs: simplify vitest workflow guidance 2026-04-03 12:45:13 +01:00
Peter Steinberger
9ef5d85e40 refactor: remove custom test planner runtime 2026-04-03 12:45:13 +01:00
Peter Steinberger
c80c1cf56f test: drop planner fixtures and coverage 2026-04-03 12:45:13 +01:00
Vincent Koc
d21d859ded test(browser): stop reloading cdp screenshot module 2026-04-03 20:44:53 +09:00
Vincent Koc
11c6202ec0 test(bluebubbles): split action metadata seam 2026-04-03 20:44:23 +09:00
Vincent Koc
9a53c3d772 test(browser): drop redundant module resets 2026-04-03 20:43:49 +09:00
Vincent Koc
6e3eb34a90 test(bluebubbles): narrow action helper imports 2026-04-03 20:42:29 +09:00
Peter Steinberger
36a233ff98 fix(config): honor isolated state-dir config writes 2026-04-03 12:42:08 +01:00
Vincent Koc
51d6d7013f fix(tui): preserve pending sends and busy-state visibility (#59800)
* fix(tui): preserve pending messages across refreshes

* fix(tui): keep fallback runs visibly active

* fix(tui): expose full verbose mode and reclaim width

* refactor(tui): drop stale optimistic-send state

* test(tui): drop unused state binding

* docs(changelog): add tui beta note

* fix(tui): bound fallback wait and dedupe pending restore

* fix(tui): preserve queued sends and busy-state visibility

* chore(changelog): align tui pending-send note

* chore(changelog): refine tui release note
2026-04-03 20:39:55 +09:00
Vincent Koc
79da4a46b4 fix(feishu): annotate send target return 2026-04-03 20:36:24 +09:00
Vincent Koc
dc6e041cfe test(bluebubbles): narrow monitor normalize number parsing 2026-04-03 20:36:24 +09:00
Vincent Koc
2ec9d3d58b test(bluebubbles): narrow request-url export 2026-04-03 20:36:24 +09:00
Vincent Koc
69e0fbd95e test(bluebubbles): narrow media-send limit import 2026-04-03 20:36:24 +09:00
Vincent Koc
13412c0c50 test(bluebubbles): narrow send markdown import 2026-04-03 20:36:24 +09:00
Peter Steinberger
afa78a5b13 test: trim telegram testing barrel imports 2026-04-03 12:36:07 +01:00
Peter Steinberger
de1d0f4fae fix(ci): restore telegram real registry test support 2026-04-03 12:31:28 +01:00
samzong
37ab4b7fdc [Feat] Add ClawHub skill search and detail in Control UI (#60134)
* feat(gateway): add skills.search and skills.detail RPC methods

Expose ClawHub search and detail capabilities through the Gateway protocol,
enabling desktop/web clients to browse and inspect skills from the registry.

New RPCs:
- skills.search: search ClawHub skills by query with optional limit
- skills.detail: fetch full detail for a single skill by slug

Both methods delegate to existing agent-layer functions
(searchSkillsFromClawHub, fetchSkillDetailFromClawHub) which wrap
the ClawHub HTTP client. No new external dependencies.

Signed-off-by: samzong <samzong.lu@gmail.com>

* feat(skills): add ClawHub skill search and detail in Control UI

Add skills.search and skills.detail Gateway RPC methods with typed
protocol schemas, AJV validators, and handler implementations. Wire
the new RPCs into the Control UI Skills panel with a debounced search
input, results list, detail dialog, and one-click install from ClawHub.

Gateway:
- SkillsSearchParams/ResultSchema and SkillsDetailParams/ResultSchema
- Handler calls searchClawHubSkills and fetchClawHubSkillDetail directly
- Remove zero-logic fetchSkillDetailFromClawHub wrapper
- 9 handler tests including boundary validation

Control UI:
- searchClawHub, loadClawHubDetail, installFromClawHub controllers
- 300ms debounced search input to avoid 429 rate limits
- Dedicated install busy state (clawhubInstallSlug) with success/error feedback
- Install buttons disabled during install with progress text
- Detail dialog with owner, version, changelog, platform metadata

Part of #43301

Signed-off-by: samzong <samzong.lu@gmail.com>

* fix(skills): guard search and detail responses against stale writes

Signed-off-by: samzong <samzong.lu@gmail.com>

* fix(skills): reset loading flags on query clear and detail close

Signed-off-by: samzong <samzong.lu@gmail.com>

* fix(gateway): register skills.search/detail in read scope and method list

Add skills.search and skills.detail to the operator READ scope group
and the server methods list. Without this, unclassified methods default
to operator.admin, blocking read-only operator sessions.

Also guard the detail loading reset in the finally block by the active
slug to prevent a transient flash when rapidly switching skills.

Signed-off-by: samzong <samzong.lu@gmail.com>

* fix(skills): guard search loading reset by active query

Signed-off-by: samzong <samzong.lu@gmail.com>

* test: cover ClawHub skills UI flow

* fix: clear stale ClawHub search results

---------

Signed-off-by: samzong <samzong.lu@gmail.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
2026-04-03 19:30:44 +08:00
Peter Steinberger
d39e4dff6a test: make planner lanes explicit 2026-04-03 12:29:29 +01:00
Peter Steinberger
f4393791eb test: split vitest setup for projects 2026-04-03 12:29:29 +01:00
Peter Steinberger
1dd88c6288 fix(sessions): harden session id resolution 2026-04-03 20:29:20 +09:00
Peter Steinberger
1337be3063 refactor: narrow telegram native command test seams 2026-04-03 12:25:47 +01:00
Peter Steinberger
db6d149f75 test: route telegram plugin tests through extensions 2026-04-03 12:25:47 +01:00
Peter Steinberger
0a2a1ff778 fix(ci): make gateway audit path test platform-safe 2026-04-03 12:22:29 +01:00
Vincent Koc
5021b12ac1 perf(browser): trim invoke-browser test imports 2026-04-03 20:12:40 +09:00
Vincent Koc
045d590542 perf(matrix): isolate probe runtime deps 2026-04-03 20:05:50 +09:00
Vincent Koc
fac89d403b perf(browser): split remote profile tab op tests 2026-04-03 20:03:48 +09:00
Peter Steinberger
d3310f4837 fix(ci): resolve changelog conflict markers 2026-04-03 12:03:22 +01:00
Peter Steinberger
e2e1197fa9 refactor(gateway): clarify local mode guardrails 2026-04-03 20:02:32 +09:00
Peter Steinberger
4e22e75697 test: reduce telegram broad partial mocks 2026-04-03 12:01:10 +01:00
Peter Steinberger
225431665a test: trim telegram media retry import cost 2026-04-03 12:01:10 +01:00
Peter Steinberger
05df7f802b docs: add test cost guardrails 2026-04-03 12:01:10 +01:00
Peter Steinberger
566fc72106 refactor(discord): share proxy resolution helpers 2026-04-03 19:58:23 +09:00
Vincent Koc
53504b3662 fix(agents): suppress profile allowlist warnings 2026-04-03 19:55:05 +09:00
Peter Steinberger
2c7eea8f10 fix(gateway): fail closed on missing mode 2026-04-03 19:50:45 +09:00
Peter Steinberger
a6649201b7 docs: clarify default subagent allowlists 2026-04-03 19:45:05 +09:00
Peter Steinberger
d921784718 fix: support default subagent allowlists (#59944) (thanks @hclsys) 2026-04-03 19:43:17 +09:00
HCL
a57766bad0 fix(agents): fall back to defaults for subagents.allowAgents
resolveAgentConfig().subagents.allowAgents reads only the per-agent
entry, never falling back to agents.defaults.subagents.allowAgents.
Other subagent defaults like runTimeoutSeconds correctly read from
cfg.agents.defaults.subagents — allowAgents was missed.

Root cause: subagent-spawn.ts:463 and agents-list-tool.ts:49 both
use resolveAgentConfig() which returns only per-agent config without
defaults merging. The same pattern is already established at
subagent-spawn.ts:403 for runTimeoutSeconds.

Fix: add cfg.agents.defaults.subagents.allowAgents as fallback when
per-agent entry doesn't specify allowAgents. Both call sites fixed.

Closes #59938

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: HCL <chenglunhu@gmail.com>
2026-04-03 19:42:24 +09:00
Peter Steinberger
50f4bffbb6 fix: unblock Discord land by breaking import cycles (#57465) 2026-04-03 19:38:59 +09:00
Peter Steinberger
32ebaa3757 refactor: share session model resolution helpers 2026-04-03 19:37:56 +09:00
Peter Steinberger
67d87abf7c style: normalize telegram fetch test formatting 2026-04-03 11:37:41 +01:00
Peter Steinberger
bb3ea2137b test: move telegram fetch coverage into extensions 2026-04-03 11:37:41 +01:00
Peter Steinberger
e0c4458a2f test: add lighter extensions vitest setup 2026-04-03 11:37:41 +01:00
Peter Steinberger
52225db134 fix(ci): reuse sdk tool auth error for whatsapp 2026-04-03 11:35:45 +01:00
Peter Steinberger
5400980305 test(plugin-sdk): tighten boundary guardrails 2026-04-03 11:35:37 +01:00
Peter Steinberger
1c26e806ff refactor: simplify gateway startup logs 2026-04-03 11:31:34 +01:00
Peter Steinberger
16ca1f4d74 test: route extension boundary inventory off unit 2026-04-03 11:30:45 +01:00
Peter Steinberger
b406b7d2e4 refactor: extract embedded runner failover helpers 2026-04-03 19:28:39 +09:00
Peter Steinberger
71f8c0344a fix(ci): refresh schema and boundary expectations 2026-04-03 11:26:06 +01:00
Vincent Koc
1a5787137f style(msteams): format split graph message import 2026-04-03 19:23:26 +09:00
Vincent Koc
b53ab34d04 perf(msteams): split graph message tests 2026-04-03 19:23:26 +09:00
Peter Steinberger
a5eb8e08ad test: reroute telegram fetch network policy suite 2026-04-03 11:19:29 +01:00
Vincent Koc
c0a8d07fce test(browser): collapse wrapper suite files 2026-04-03 19:18:49 +09:00
Peter Steinberger
fb0d82ba9f fix(ci): refresh guardrails and config baselines 2026-04-03 11:18:40 +01:00
Peter Steinberger
2766c27b2a refactor(plugin-sdk): genericize web channel runtime seams 2026-04-03 11:17:28 +01:00
Peter Steinberger
182bec5091 test: run boundary inventory suites without global setup 2026-04-03 11:17:00 +01:00
Vincent Koc
d55e580307 perf(acpx): clean up runtime fixtures per test 2026-04-03 19:15:26 +09:00
Vincent Koc
e18611188d test(discord): defer provider runtime mocks 2026-04-03 19:13:10 +09:00
Peter Steinberger
d68840ef40 fix(ci): handle bundled fast-check task 2026-04-03 11:10:50 +01:00
Vincent Koc
e414e51761 perf(nextcloud-talk): split setup test hotspots 2026-04-03 19:09:49 +09:00
Peter Steinberger
80c5764482 refactor(telegram): streamline media runtime options 2026-04-03 19:09:13 +09:00
Peter Steinberger
122e6f0f79 fix(plugins): route runtime imports through sdk facades 2026-04-03 11:07:31 +01:00
Vincent Koc
ddd1c77b49 perf(feishu): narrow hotspot runtime seams 2026-04-03 19:06:49 +09:00
Ayaan Zaidi
1fabc96acc fix: keep device approval scopes aligned (#60208) 2026-04-03 15:34:05 +05:30
Ayaan Zaidi
403e0e6521 fix(pairing): mint tokens for merged device roles 2026-04-03 15:34:05 +05:30
Vincent Koc
61f13173c2 feat(providers): add model request transport overrides (#60200)
* feat(providers): add model request transport overrides

* chore(providers): finalize request override follow-ups

* fix(providers): narrow model request overrides
2026-04-03 19:00:06 +09:00
Peter Steinberger
55e43cbc7f test: isolate bundled plugin coverage from unit 2026-04-03 10:58:44 +01:00
Peter Steinberger
64755c52f2 test: move extension-owned coverage out of core 2026-04-03 10:58:44 +01:00
Vincent Koc
2bfbddb81f perf(browser): remove duplicate heavy test wrappers 2026-04-03 18:57:05 +09:00
Peter Steinberger
355dc7f3a8 fix(msteams): avoid discord approval auth import cycle 2026-04-03 10:55:47 +01:00
Vincent Koc
0657cfbb34 test(feishu): slim bot menu runtime fixtures 2026-04-03 18:54:42 +09:00
Peter Steinberger
6e2b46d666 docs: clarify DM pairing vs group auth 2026-04-03 18:51:51 +09:00
Peter Steinberger
0710cfaa71 chore: ignore local vitest timing artifact 2026-04-03 10:51:26 +01:00
Vincent Koc
294d425ae4 test(feishu): slim comment handler runtime fixtures 2026-04-03 18:49:43 +09:00
Peter Steinberger
86ff57518f fix: keep Discord proxy fallback local (#57465) (thanks @geekhuashan) 2026-04-03 18:49:14 +09:00
geekhuashan
3a4fd62135 test(discord): proxy fetch regression coverage for REST, webhook, and stagger 2026-04-03 18:49:14 +09:00
geekhuashan
c8223606ca fix(discord): proxy Carbon REST, webhook and monitor fetch paths; stagger multi-bot startup 2026-04-03 18:49:14 +09:00
Peter Steinberger
dfb423532b docs(telegram): clarify RFC2544 vs fake-IP SSRF guidance 2026-04-03 18:48:14 +09:00
Vincent Koc
726bfd3434 fix(plugin-sdk): export ssrf policy type 2026-04-03 18:47:31 +09:00
Vincent Koc
1bba19decb perf(msteams): narrow secret and ssrf runtime seams 2026-04-03 18:47:31 +09:00
Vincent Koc
8a58a18a0a test(feishu): slim broadcast runtime fixtures 2026-04-03 18:47:08 +09:00
Peter Steinberger
2ca97a7d48 docs(plugin-sdk): refresh seam cleanup docs 2026-04-03 10:45:11 +01:00
Peter Steinberger
f2d7a825b1 refactor(plugin-sdk): remove channel-specific sdk seams 2026-04-03 10:45:10 +01:00
Peter Steinberger
ad6fdf1e3c test: suppress expected nodes run stderr noise 2026-04-03 10:44:20 +01:00
Peter Steinberger
1a68e55f47 test: stabilize Windows startup fallback daemon tests 2026-04-03 10:43:42 +01:00
Vincent Koc
5e0decd9b5 test(msteams): slim messenger runtime fixtures 2026-04-03 18:42:59 +09:00
Vincent Koc
b55ac9e64d test(msteams): trim attachment test runtime footprint 2026-04-03 18:39:50 +09:00
Vincent Koc
e1093a3177 test(diffs): split render coverage from config tests 2026-04-03 18:39:50 +09:00
Peter Steinberger
4bfa9260ce fix(telegram): add dangerous private-network media opt-in 2026-04-03 18:39:17 +09:00
Peter Steinberger
f29c139a7a test: deduplicate provider discovery contract suite 2026-04-03 18:32:15 +09:00
Peter Steinberger
7bf0496dd8 feat: add qwen3.6-plus to modelstudio catalog 2026-04-03 18:32:14 +09:00
Vincent Koc
ddb7e4cc34 perf(test): add gh run ingestion for memory hotspots (#60187)
* perf(test): add gh run ingestion for memory hotspots

* perf(test): harden gh run hotspot ingestion
2026-04-03 18:30:51 +09:00
Peter Steinberger
87b7bb1d14 fix(agents): harden rate-limit fallback handoff
Co-authored-by: TechFath3r <thetechfath3r@gmail.com>
2026-04-03 18:28:56 +09:00
Vincent Koc
f5c3b409ea Config: separate core/plugin baseline entries (#60162)
* Config: separate core/plugin baseline entries

* Config: split config baseline by kind

* Config: split generated baselines by kind

* chore(build): skip generated baseline shards in local tooling

* chore(build): forbid generated docs in npm pack
2026-04-03 18:26:23 +09:00
Ayaan Zaidi
9e58a0892b fix: align mobile pairing secure endpoint UX (#60128) 2026-04-03 14:51:24 +05:30
Ayaan Zaidi
b9897eec7c fix: align mobile pairing secure endpoint UX (#60128) 2026-04-03 14:51:24 +05:30
Ayaan Zaidi
fadd627467 fix: align mobile pairing secure endpoint UX (#60128) 2026-04-03 14:51:24 +05:30
Ayaan Zaidi
a1d07796fc fix(pairing): honor operator scopes for mixed bootstrap approvals 2026-04-03 14:51:24 +05:30
Ayaan Zaidi
a2b53522eb fix(pairing): allow private lan mobile ws 2026-04-03 14:51:24 +05:30
Ayaan Zaidi
84add47525 fix(pairing): allow emulator ws setup urls 2026-04-03 14:51:24 +05:30
Ayaan Zaidi
acd5734aa9 fix(pairing): align mobile setup with secure endpoints 2026-04-03 14:51:24 +05:30
Vincent Koc
c6f95a0c37 test: summarize diagnostic report memory growth 2026-04-03 18:19:47 +09:00
@zimeg
f9785c63e7 docs(slack): add groups:history scope to app manifest 2026-04-03 02:15:53 -07:00
2091 changed files with 196502 additions and 99518 deletions

View File

@@ -33,6 +33,8 @@ node_modules
**/.next
coverage
**/coverage
docs/.generated
**/.generated
*.log
tmp
**/tmp

View File

@@ -17,8 +17,8 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# Preflight: establish routing truth and planner-owned matrices once, then let
# real work fan out from a single source of truth.
# Preflight: establish routing truth and job matrices once, then let real
# work fan out from a single source of truth.
preflight:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
@@ -36,7 +36,8 @@ jobs:
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
checks_fast_matrix: ${{ steps.manifest.outputs.checks_fast_matrix }}
checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
checks_fast_extensions_matrix: ${{ steps.manifest.outputs.checks_fast_extensions_matrix }}
run_checks: ${{ steps.manifest.outputs.run_checks }}
checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
run_extension_fast: ${{ steps.manifest.outputs.run_extension_fast }}
@@ -103,7 +104,7 @@ jobs:
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import { listChangedExtensionIds } from "./scripts/test-extension.mjs";
import { listChangedExtensionIds } from "./scripts/lib/changed-extensions.mjs";
const extensionIds = listChangedExtensionIds({
base: process.env.BASE_SHA,
@@ -129,7 +130,150 @@ jobs:
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
run: node scripts/ci-write-manifest-outputs.mjs --workflow ci
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import {
createExtensionTestShards,
DEFAULT_EXTENSION_TEST_SHARD_COUNT,
} from "./scripts/lib/extension-test-plan.mjs";
const parseBoolean = (value, fallback = false) => {
if (value === undefined) return fallback;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") return true;
if (normalized === "false" || normalized === "0" || normalized === "") return false;
return fallback;
};
const parseJson = (value, fallback) => {
try {
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
};
const createMatrix = (include) => ({ include });
const outputPath = process.env.GITHUB_OUTPUT;
const eventName = process.env.GITHUB_EVENT_NAME ?? "pull_request";
const isPush = eventName === "push";
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
const runMacos = parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly;
const runAndroid = parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly;
const runWindows = parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && !docsOnly;
const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly;
const hasChangedExtensions =
parseBoolean(process.env.OPENCLAW_CI_HAS_CHANGED_EXTENSIONS) && !docsOnly;
const changedExtensionsMatrix = hasChangedExtensions
? parseJson(process.env.OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX, { include: [] })
: { include: [] };
const extensionShardMatrix = createMatrix(
runNode
? createExtensionTestShards({
shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT,
}).map((shard) => ({
check_name: shard.checkName,
extensions_csv: shard.extensionIds.join(","),
shard_index: shard.index + 1,
task: "extensions-batch",
}))
: [],
);
const manifest = {
docs_only: docsOnly,
docs_changed: docsChanged,
run_node: runNode,
run_macos: runMacos,
run_android: runAndroid,
run_skills_python: runSkillsPython,
run_windows: runWindows,
has_changed_extensions: hasChangedExtensions,
changed_extensions_matrix: changedExtensionsMatrix,
run_build_artifacts: runNode,
run_checks_fast: runNode,
checks_fast_core_matrix: createMatrix(
runNode
? [
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
{
check_name: "checks-fast-contracts-protocol",
runtime: "node",
task: "contracts-protocol",
},
]
: [],
),
checks_fast_extensions_matrix: extensionShardMatrix,
run_checks: runNode,
checks_matrix: createMatrix(
runNode
? [
{ check_name: "checks-node-test", runtime: "node", task: "test" },
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
...(isPush
? [
{
check_name: "checks-node-compat-node22",
runtime: "node",
task: "compat-node22",
node_version: "22.x",
cache_key_suffix: "node22",
},
]
: []),
]
: [],
),
run_extension_fast: hasChangedExtensions,
extension_fast_matrix: createMatrix(
hasChangedExtensions
? (changedExtensionsMatrix.include ?? []).map((entry) => ({
check_name: `extension-fast-${entry.extension}`,
extension: entry.extension,
}))
: [],
),
run_check: runNode,
run_check_additional: runNode,
run_build_smoke: runNode,
run_check_docs: docsChanged,
run_skills_python_job: runSkillsPython,
run_checks_windows: runWindows,
checks_windows_matrix: createMatrix(
runWindows
? [{ check_name: "checks-windows-node-test", runtime: "node", task: "test" }]
: [],
),
run_macos_node: runMacos,
macos_node_matrix: createMatrix(
runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
),
run_macos_swift: runMacos,
run_android_job: runAndroid,
android_matrix: createMatrix(
runAndroid
? [
{ check_name: "android-test-play", task: "test-play" },
{ check_name: "android-test-third-party", task: "test-third-party" },
{ check_name: "android-build-play", task: "build-play" },
{ check_name: "android-build-third-party", task: "build-third-party" },
]
: [],
),
};
for (const [key, value] of Object.entries(manifest)) {
appendFileSync(
outputPath,
`${key}=${typeof value === "string" ? value : JSON.stringify(value)}\n`,
"utf8",
);
}
EOF
# Run the fast security/SCM checks in parallel with scope detection so the
# main Node jobs do not have to wait for Python/pre-commit setup.
@@ -277,7 +421,7 @@ jobs:
include-hidden-files: true
retention-days: 1
checks-fast:
checks-fast-core:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
@@ -285,7 +429,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_matrix) }}
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -302,18 +446,12 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
env:
TASK: ${{ matrix.task }}
SHARD_COUNT: ${{ matrix.shard_count || '' }}
SHARD_INDEX: ${{ matrix.shard_index || '' }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
extensions)
if [ -n "$SHARD_COUNT" ] && [ -n "$SHARD_INDEX" ]; then
export OPENCLAW_TEST_SHARDS="$SHARD_COUNT"
export OPENCLAW_TEST_SHARD_INDEX="$SHARD_INDEX"
fi
pnpm test:extensions
bundled)
pnpm test:bundled
;;
contracts|contracts-protocol)
pnpm build
@@ -326,6 +464,49 @@ jobs:
;;
esac
checks-fast-extensions-shard:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_extensions_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run extension shard
env:
OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }}
run: pnpm test:extensions:batch -- "$OPENCLAW_EXTENSION_BATCH"
checks-fast-extensions:
name: checks-fast-extensions
needs: [preflight, checks-fast-extensions-shard]
if: always() && needs.preflight.outputs.run_checks_fast == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 5
steps:
- name: Verify extension shards
env:
SHARD_RESULT: ${{ needs.checks-fast-extensions-shard.result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Extension shard checks failed: $SHARD_RESULT" >&2
exit 1
fi
checks:
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
@@ -360,21 +541,12 @@ jobs:
if: (github.event_name != 'pull_request' || matrix.task != 'compat-node22') && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'channels' || matrix.task == 'compat-node22')
env:
TASK: ${{ matrix.task }}
SHARD_COUNT: ${{ matrix.shard_count || '' }}
SHARD_INDEX: ${{ matrix.shard_index || '' }}
run: |
# `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
if [ "$TASK" = "channels" ]; then
echo "OPENCLAW_TEST_WORKERS=1" >> "$GITHUB_ENV"
echo "OPENCLAW_VITEST_MAX_WORKERS=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_ISOLATE=1" >> "$GITHUB_ENV"
fi
if [ -n "$SHARD_COUNT" ] && [ -n "$SHARD_INDEX" ]; then
echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
fi
- name: Download dist artifact
if: matrix.task == 'test'
@@ -394,6 +566,7 @@ jobs:
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
env:
TASK: ${{ matrix.task }}
NODE_OPTIONS: --max-old-space-size=6144
shell: bash
run: |
set -euo pipefail
@@ -735,8 +908,7 @@ jobs:
env:
NODE_OPTIONS: --max-old-space-size=6144
# Keep total concurrency predictable on the 32 vCPU runner.
# Windows shard 2 has shown intermittent instability at 2 workers.
OPENCLAW_TEST_WORKERS: 1
OPENCLAW_VITEST_MAX_WORKERS: 1
defaults:
run:
shell: bash
@@ -809,15 +981,6 @@ jobs:
# caches can skip repeated rebuild/download work on later shards/runs.
pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true
- name: Configure test shard (Windows)
if: matrix.task == 'test'
env:
SHARD_COUNT: ${{ matrix.shard_count }}
SHARD_INDEX: ${{ matrix.shard_index }}
run: |
echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
- name: Download dist artifact
if: matrix.task == 'test'
uses: actions/download-artifact@v8
@@ -881,17 +1044,10 @@ jobs:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
- name: Configure test shard (macOS)
env:
SHARD_COUNT: ${{ matrix.shard_count }}
SHARD_INDEX: ${{ matrix.shard_index }}
run: |
echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
- name: TS tests (macOS)
env:
NODE_OPTIONS: --max-old-space-size=4096
OPENCLAW_VITEST_MAX_WORKERS: 2
TASK: ${{ matrix.task }}
shell: bash
run: |

View File

@@ -67,16 +67,18 @@ jobs:
id: manifest
env:
OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
OPENCLAW_CI_DOCS_CHANGED: "false"
OPENCLAW_CI_RUN_NODE: "false"
OPENCLAW_CI_RUN_MACOS: "false"
OPENCLAW_CI_RUN_ANDROID: "false"
OPENCLAW_CI_RUN_WINDOWS: "false"
OPENCLAW_CI_RUN_SKILLS_PYTHON: "false"
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: "false"
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: '{"include":[]}'
OPENCLAW_CI_RUN_CHANGED_SMOKE: ${{ steps.changed_scope.outputs.run_changed_smoke || 'false' }}
run: node scripts/ci-write-manifest-outputs.mjs --workflow install-smoke
run: |
docs_only="${OPENCLAW_CI_DOCS_ONLY:-false}"
run_changed_smoke="${OPENCLAW_CI_RUN_CHANGED_SMOKE:-false}"
run_install_smoke=false
if [ "$docs_only" != "true" ] && [ "$run_changed_smoke" = "true" ]; then
run_install_smoke=true
fi
{
echo "docs_only=$docs_only"
echo "run_install_smoke=$run_install_smoke"
} >> "$GITHUB_OUTPUT"
install-smoke:
needs: [preflight]

View File

@@ -0,0 +1,276 @@
name: Plugin ClawHub Release
on:
workflow_dispatch:
inputs:
publish_scope:
description: Publish the selected plugins or all ClawHub-publishable plugins from the workflow ref
required: true
default: selected
type: choice
options:
- selected
- all-publishable
plugins:
description: Comma-separated plugin package names to publish when publish_scope=selected
required: false
type: string
concurrency:
group: plugin-clawhub-release-${{ github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.23.0"
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_REPOSITORY: "openclaw/clawhub"
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
CLAWHUB_REF: "4af2bd50a71465683dbf8aa269af764b9d39bdf5"
jobs:
preview_plugins_clawhub:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
ref_sha: ${{ steps.ref.outputs.sha }}
has_candidates: ${{ steps.plan.outputs.has_candidates }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
use-sticky-disk: "false"
- name: Resolve checked-out ref
id: ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on main
run: |
set -euo pipefail
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor HEAD origin/main
- name: Validate publishable plugin metadata
env:
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
HEAD_REF: ${{ steps.ref.outputs.sha }}
run: |
set -euo pipefail
if [[ -n "${PUBLISH_SCOPE}" ]]; then
release_args=(--selection-mode "${PUBLISH_SCOPE}")
if [[ -n "${RELEASE_PLUGINS}" ]]; then
release_args+=(--plugins "${RELEASE_PLUGINS}")
fi
pnpm release:plugins:clawhub:check -- "${release_args[@]}"
elif [[ -n "${BASE_REF}" ]]; then
pnpm release:plugins:clawhub:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}"
else
pnpm release:plugins:clawhub:check
fi
- name: Resolve plugin release plan
id: plan
env:
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
HEAD_REF: ${{ steps.ref.outputs.sha }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
mkdir -p .local
if [[ -n "${PUBLISH_SCOPE}" ]]; then
plan_args=(--selection-mode "${PUBLISH_SCOPE}")
if [[ -n "${RELEASE_PLUGINS}" ]]; then
plan_args+=(--plugins "${RELEASE_PLUGINS}")
fi
node --import tsx scripts/plugin-clawhub-release-plan.ts "${plan_args[@]}" > .local/plugin-clawhub-release-plan.json
elif [[ -n "${BASE_REF}" ]]; then
node --import tsx scripts/plugin-clawhub-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-clawhub-release-plan.json
else
node --import tsx scripts/plugin-clawhub-release-plan.ts > .local/plugin-clawhub-release-plan.json
fi
cat .local/plugin-clawhub-release-plan.json
candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)"
skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)"
has_candidates="false"
if [[ "${candidate_count}" != "0" ]]; then
has_candidates="true"
fi
matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)"
{
echo "candidate_count=${candidate_count}"
echo "skipped_published_count=${skipped_published_count}"
echo "has_candidates=${has_candidates}"
echo "matrix=${matrix_json}"
} >> "$GITHUB_OUTPUT"
echo "Plugin release candidates:"
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Already published / skipped:"
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json
- name: Fail manual publish when target versions already exist
if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
run: |
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
exit 1
preview_plugin_pack:
needs: preview_plugins_clawhub
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
use-sticky-disk: "false"
install-deps: "false"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: ${{ env.CLAWHUB_REF }}
path: clawhub-source
fetch-depth: 1
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: bun install --frozen-lockfile
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
EOF
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Preview publish command
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
publish_plugins_clawhub:
needs: [preview_plugins_clawhub, preview_plugin_pack]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
use-sticky-disk: "false"
install-deps: "false"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: ${{ env.CLAWHUB_REF }}
path: clawhub-source
fetch-depth: 1
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: bun install --frozen-lockfile
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
EOF
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Ensure version is not already published
env:
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')"
encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')"
url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}"
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")"
if [[ "${status}" =~ ^2 ]]; then
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
exit 1
fi
if [[ "${status}" != "404" ]]; then
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
exit 1
fi
- name: Publish
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"

2
.gitignore vendored
View File

@@ -141,3 +141,5 @@ changelog/fragments/
# Local scratch workspace
.tmp/
test/fixtures/openclaw-vitest-unit-report.json
analysis/

View File

@@ -185,6 +185,11 @@
- Test performance guardrail: inside an extension package, prefer a thin local seam (`./api.ts`, `./runtime-api.ts`, or a narrower local `*.runtime-api.ts`) over direct `openclaw/plugin-sdk/*` imports for internal production code. Keep local seams curated and lightweight; only reach for direct `plugin-sdk/*` imports when you are crossing a real package boundary or when no suitable local seam exists yet.
- Test performance guardrail: keep expensive runtime fallback work such as snapshotting, migration, installs, or bootstrap behind dedicated `*.runtime.ts` boundaries so tests can mock the seam instead of accidentally invoking real work.
- Test performance guardrail: for import-only/runtime-wrapper tests, keep the wrapper lazy. Do not eagerly load heavy verification/bootstrap/runtime modules at module top level if the exported function can import them on demand.
- Test performance guardrail: prefer explicit mock factories over `importOriginal()` for broad modules. Reserve `importOriginal()` for narrow modules where partial-real behavior is genuinely needed.
- Test performance guardrail: do not partial-mock broad `openclaw/plugin-sdk/*` barrels in hot tests. Add a plugin-local `*.runtime.ts` seam and mock that seam instead.
- Test performance guardrail: when production code already accepts `deps`, callbacks, or runtime injection, use that seam in tests before adding module-level mocks.
- Test performance guardrail: prefer narrow public SDK subpaths such as `models-provider-runtime`, `skill-commands-runtime`, and `reply-dispatch-runtime` over older broad helper barrels when both expose the needed helper.
- Test performance guardrail: treat import-dominated test time as a boundary bug. Refactor the import surface before adding more cases to the slow file.
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
- Do not set test workers above 16; tried already.

View File

@@ -9,9 +9,16 @@ Docs: https://docs.openclaw.ai
- Channels/context visibility: add configurable `contextVisibility` per channel (`all`, `allowlist`, `allowlist_quote`) so supplemental quote, thread, and fetched history context can be filtered by sender allowlists instead of always passing through as received.
- Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras.
- Providers/StepFun: add the bundled StepFun provider plugin with standard and Step Plan endpoints, China/global onboarding choices, `step-3.5-flash` on both catalogs, and `step-3.5-flash-2603` currently exposed on Step Plan. (#60032) Thanks @hengm3467.
- Providers/config: add full `models.providers.*.request` transport overrides for model-provider paths, including headers, auth, proxy, and TLS, and keep media provider HTTP request transport overrides aligned with the same request-policy surface. (#60200) Thanks @vincentkoc.
- Control UI/skills: add ClawHub search, detail, and install flows directly in the Skills panel. (#60134) Thanks @samzong.
- Outbound/runtime seams: split delivery, target-resolution, and session/transcript helper loading into narrower runtime seams so outbound hot paths and their owner tests avoid broader setup fan-out. (#60311) Thanks @shakkernerd.
- Plugins/browser seams: split browser and WhatsApp plugin-sdk seams into narrower browser, approval-auth, and target-helper facades so hot paths and owner tests avoid broader runtime fan-out. (#60376) Thanks @shakkernerd.
- Tests/runtime: trim local unit-test import/runtime fan-out across browser, WhatsApp, cron, task, and reply flows so owner suites start faster with lower shared-worker overhead while preserving the same focused behavior coverage. (#60249) Thanks @shakkernerd.
- Tests/secrets runtime: restore split secrets suite cache and env isolation cleanup so broader runs do not leak stale plugin or provider snapshot state. (#60395) Thanks @shakkernerd.
### Fixes
- Skills/uv install: block workspace `.env` from overriding `UV_PYTHON` and strip related interpreter override keys from uv skill-install subprocesses so repository-controlled env files cannot steer the selected Python runtime. (#59178) Thanks @pgondhi987.
- Telegram/reactions: preserve `reactionNotifications: "own"` across gateway restarts by persisting sent-message ownership state instead of treating cold cache as a permissive fallback. (#59207) Thanks @samzong.
- Gateway/startup: detect PID recycling in gateway lock files on Windows and macOS, and add startup progress so stale lock conflicts no longer block healthy restarts. (#59843) Thanks @TonyDerek-dot.
- MS Teams/DM media: download inline images in 1:1 chats via Graph API so Teams DM image attachments stop failing to load. (#52212) Thanks @Ted-developer.
@@ -22,6 +29,8 @@ Docs: https://docs.openclaw.ai
- Telegram/replies: preserve explicit topic targets when `replyTo` is present while still inheriting the current topic for same-chat replies without an explicit topic. (#59634) Thanks @dashhuang.
- Telegram/native commands: clean up metadata-driven progress placeholders when replies fall back, edits fail, or local exec approval prompts are suppressed. (#59300) Thanks @jalehman.
- Media/request overrides: resolve shared and capability-filtered media request SecretRefs correctly and expose media transport override fields to schema-driven config consumers. (#59848) Thanks @vincentkoc.
- Providers/request overrides: stop advertising unsupported proxy and TLS transport settings on `models.providers.*.request`, and fail closed if unvalidated config tries to route LLM model-provider traffic through dead transport fields. (#59682) Thanks @vincentkoc.
- Discord/mentions: treat `@everyone` and `@here` as valid mention-gate triggers in guild preflight so mention-required bots still respond to those broadcasts. (#60343) Thanks @geekhuashan.
- Matrix: allow secret-storage recreation during automatic repair bootstrap so clients that lose their recovery key can recover and persist new cross-signing keys. (#59846) Thanks @al3mart.
- Matrix/crypto persistence: capture and write the IndexedDB snapshot while holding the snapshot file lock so concurrent gateway and CLI persists cannot overwrite newer crypto state. (#59851) Thanks @al3mart.
- Ollama/auth: prefer real cloud auth over local marker during model auth resolution so cloud-backed Ollama auth does not get shadowed by stale local-only markers.
@@ -29,12 +38,54 @@ Docs: https://docs.openclaw.ai
- Channels/passive hooks: emit passive message hooks for mention-skipped Telegram and Signal group messages when `ingest` is enabled, including wildcard/default fallback and per-group override handling. (#60018) Thanks @obviyus.
- Plugins/manifest registry: stop warning when an explicit manifest `id` intentionally differs from the discovery hint. (#59185) Thanks @samzong.
- WhatsApp/streaming: honor `channels.whatsapp.blockStreaming` again for inbound auto-replies so progressive block replies can be enabled explicitly instead of being forced to final-only delivery. Thanks @mcaxtr.
- Auth/failover: shorten `auth_permanent` lockouts, add dedicated config knobs for permanent-auth backoff, and downgrade ambiguous auth-ish upstream incidents to retryable auth failures so providers recover automatically after transient outages. (#60404) Thanks @extrasmall0.
- Providers/GitHub Copilot: route Claude models through Anthropic Messages with Copilot-compatible headers and Anthropic prompt-cache markers instead of forcing the OpenAI Responses transport.
- Plugins/runtime: reuse compatible active registries for `web_search` and `web_fetch` provider snapshot resolution so repeated runtime reads do not re-import the same bundled plugin set on each agent message. Related #48380.
- Infra/tailscale: ignore `OPENCLAW_TEST_TAILSCALE_BINARY` outside explicit test environments and block it from workspace `.env`, so test-only binary overrides cannot be injected through trusted repository state. (#58468) Thanks @eleqtrizit.
- Plugins/OpenAI: enable reference-image edits for `gpt-image-1` by routing edit calls to `/images/edits` with multipart image uploads, and update image-generation capability/docs metadata accordingly. Thanks @steipete.
- Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19.
- Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus.
- Plugins/startup: migrate legacy `tools.web.search.<provider>` config before strict startup validation, and record plugin failure phase/timestamp so degraded plugin startup is easier to diagnose from logs and `plugins list`.
- Plugins/Google: separate OAuth CSRF state from PKCE code verifier during Gemini browser sign-in so state validation and token exchange use independent values. (#59116) Thanks @eleqtrizit.
- Agents/subagents: honor `agents.defaults.subagents.allowAgents` for `sessions_spawn` and `agents_list`, so default cross-agent allowlists work without duplicating per-agent config. (#59944) Thanks @hclsys.
- Agents/tools: normalize only truly empty MCP tool schemas to `{ type: "object", properties: {} }` so OpenAI accepts parameter-free tools without rewriting unrelated conditional schemas. (#60176) Thanks @Bartok9.
- Update/npm: prefer the npm binary that owns the installed global OpenClaw prefix during package self-update, so mixed Homebrew-plus-nvm setups update the right install. (#60153) Thanks @jayeshp19.
- Plugins/browser: block SSRF redirect bypass by installing a real-time Playwright route handler before `page.goto()` so navigation to private/internal IPs is intercepted and aborted mid-redirect instead of checked post-hoc. (#58771) Thanks @pgondhi987.
- Android/gateway: require TLS for non-loopback remote gateway endpoints while still allowing local loopback and emulator cleartext setup flows. (#58475) Thanks @eleqtrizit.
- Exec/Windows: hide transient console windows for `runExec` and `runCommandWithTimeout` child-process launches, matching other Windows exec paths and stopping visible shell flashes during tool runs. (#59466) Thanks @lawrence3699.
- Zalo/webhook: scope replay-dedupe cache key to path and account using `JSON.stringify` so multi-account deployments do not silently drop events due to cross-account cache poisoning. (#59387) Thanks @pgondhi987.
- Exec/Windows: reject malformed drive-less rooted executable paths like `:\Users\...` so approval and allowlist candidate resolution no longer treat them as cwd-relative commands. (#58040) Thanks @SnowSky1.
- Exec/preflight: fail closed on complex interpreter invocations that would otherwise skip script-content validation, and correctly inspect quoted script paths before host execution. Thanks @pgondhi987.
- Exec/Windows: include Windows-compatible env override keys like `ProgramFiles(x86)` in system-run approval binding so changed approved values are rejected instead of silently passing unbound. (#59182) Thanks @pgondhi987.
- ACP/Windows spawn: fail closed on unresolved `.cmd` and `.bat` OpenClaw wrappers unless a caller explicitly opts into shell fallback, so Windows ACP launches do not silently drop into shell-mediated execution when wrapper unwrapping fails. (#58436) Thanks @eleqtrizit.
- Exec/Windows: prefer strict-inline-eval denial over generic allowlist prompts for interpreter carriers, while keeping persisted Windows allow-always approvals argv-bound. (#59780) Thanks @luoyanglang.
- Gateway/connect: omit admin-scoped config and auth metadata from lower-privilege `hello-ok` snapshots while preserving those fields for admin reconnects. (#58469) Thanks @eleqtrizit.
- iOS/canvas: restrict A2UI bridge trust to the bundled scaffold and exact capability-backed remote canvas URLs, so generic `canvas.navigate` and `canvas.present` loads no longer gain action-dispatch authority. (#58471) Thanks @eleqtrizit.
- Agents/tool policy: preserve restrictive plugin-only allowlists instead of silently widening access to core tools, and keep allowlist warnings aligned with the enforced policy. (#58476) Thanks @eleqtrizit.
- Hooks/session_end: preserve deterministic reason metadata for custom reset aliases and overlapping idle-plus-daily rollovers so plugins can rely on lifecycle reason reporting. (#59715) Thanks @jalehman.
- Tools/image generation: stop inferring unsupported resolution overrides for OpenAI reference-image edits when no explicit `size` or `resolution` is provided, so default edit flows no longer fail before the provider request is sent.
- Agents/sessions: release embedded runner session locks even when teardown cleanup throws, so timed-out or failed cleanup paths no longer leave sessions wedged until the stale-lock watchdog recovers them. (#59194) Thanks @samzong.
- Slack/app manifest: add the missing `groups:read` scope to the onboarding and example Slack app manifest so apps copied from the OpenClaw templates can resolve private group conversations reliably.
- Mobile pairing/Android: stop generating Tailscale and public mobile setup codes that point at unusable cleartext remote gateways, keep private LAN pairing allowed, and make Android reject insecure remote endpoints with clearer guidance while mixed bootstrap approvals honor operator scopes correctly. (#60128) Thanks @obviyus.
- Telegram/media: add `channels.telegram.network.dangerouslyAllowPrivateNetwork` for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve `api.telegram.org` to private/internal/special-use addresses.
- Discord/proxy: keep Carbon REST, monitor startup, and webhook sends on the configured Discord proxy while falling back cleanly when the proxy URL is invalid, so Discord replies and deploys do not hard-fail on malformed proxy config. (#57465) Thanks @geekhuashan.
- Discord/components: keep modal-trigger and spoiler-file component messages on the component path when sending media, so classic-message fallback does not silently drop component-only behavior. (#60361) Thanks @geekhuashan.
- Mobile pairing/device approval: mint both node and operator device tokens when one approval grants merged roles, so mixed mobile bootstrap pairings stop reconnecting as operator-only and showing the node offline. (#60208) Thanks @obviyus.
- Agents/tool policy: stop `tools.profile` warnings from flagging runtime-gated baseline core tools as unknown when the coding profile is missing tools like `code_execution`, `x_search`, `image`, or `image_generate`, while still warning on explicit extra allowlist entries. Thanks @vincentkoc.
- Sessions/resolution: collapse alias-duplicate session-id matches before scoring, keep distinct structural ties ambiguous, and prefer current-store reuse when resolving equal cross-store duplicates so follow-up turns stop dropping or duplicating sessions on timestamp ties.
- Mobile pairing/bootstrap: keep setup bootstrap tokens alive through the initial node auto-pair so the same QR bootstrap token can finish operator approval, then revoke it after the full issued profile connects successfully. (#60221) Thanks @obviyus.
- Plugins/allowlists: let explicit bundled chat channel enablement bypass `plugins.allow`, while keeping auto-enabled channel activation and startup sidecars behind restrictive allowlists. (#60233) Thanks @dorukardahan.
- Allowlist/commands: require owner access for `/allowlist add` and `/allowlist remove` so command-authorized non-owners cannot mutate persisted allowlists. (#59836) Thanks @eleqtrizit.
- Control UI/skills: clear stale ClawHub results immediately when the search query changes, so debounced searches cannot keep outdated install targets visible. Related #60134.
- Discord/ack reactions: keep automatic ACK reaction auth on the active hydrated Discord account so SecretRef-backed and non-default-account reactions stop falling back to stale default config resolution. (#60081) Thanks @FunJim.
- Telegram/model switching: render non-default `/model` callback confirmations with HTML formatting so Telegram shows the selected model in bold instead of raw `**...**` markers. (#60042) Thanks @GitZhangChi.
- Plugins/update: allow `openclaw plugins update` to use `--dangerously-force-unsafe-install` for built-in dangerous-code false positives during plugin updates. (#60066) Thanks @huntharo.
- Gateway/auth: disconnect shared-auth websocket sessions only for effective auth rotations on restart-capable config writes, and keep `config.set` auth edits from dropping still-valid live sessions. (#60387) Thanks @mappel-nv.
- Control UI/chat: keep the Stop button visible during tool-only execution so abortable runs do not fall back to Send while tools are still running. (#54528) thanks @chziyue.
- Discord/voice: make READY auto-join fire-and-forget while keeping the shorter initial voice-connect timeout separate from the longer playback-start wait. (#60345) Thanks @geekhuashan.
- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras.
- Matrix/Telegram exec approvals: recover stored same-channel account bindings even when session reply state drifted to another channel, so foreign-channel approvals route to the bound account instead of fanning out or being rejected as ambiguous. (#60417) thanks @gumadeiras.
- Sessions/forking: fall back to transcript-estimated parent token counts when cached totals are stale or missing, so oversized thread forks start fresh instead of cloning the full parent transcript. (#60463) Thanks @jalehman.
## 2026.4.2
@@ -65,6 +116,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Providers/transport policy: centralize request auth, proxy, TLS, and header shaping across shared HTTP, stream, and websocket paths, block insecure TLS/runtime transport overrides, and keep proxy-hop TLS separate from target mTLS settings. (#59682) Thanks @vincentkoc.
- Providers/OpenRouter: gate documented OpenRouter attribution to native OpenRouter endpoints or the default route so custom proxy base URLs do not inherit OpenRouter request headers.
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. (#59644) Thanks @vincentkoc.
- Providers/streaming headers: centralize default and attribution header merging across OpenAI websocket, embedded-runner, and proxy stream paths so provider-specific headers stay consistent and caller overrides only win where intended. (#59542) Thanks @vincentkoc.
- Providers/media HTTP: centralize base URL normalization, default auth/header injection, and explicit header override handling across shared OpenAI-compatible audio, Deepgram audio, Gemini media/image, and Moonshot video request paths. (#59469) Thanks @vincentkoc.
@@ -113,23 +165,8 @@ Docs: https://docs.openclaw.ai
- Telegram/exec approvals: fall back to the origin session key for async approval followups and keep resume-failure status delivery sanitized so Telegram followups still land without leaking raw exec metadata. (#59351) Thanks @seonang.
- Node-host/exec approvals: bind `pnpm dlx` invocations through the approval planner's mutable-script path so the effective runtime command is resolved for approval instead of being left unbound. (#58374)
- Exec/node hosts: stop forwarding the gateway workspace cwd to remote node exec when no workdir was explicitly requested, so cross-platform node approvals fall back to the node default cwd instead of failing with `SYSTEM_RUN_DENIED`. (#58977) Thanks @Starhappysh.
- TUI/chat: keep pending local sends visible and reconciled across history reloads, make busy/error recovery clearer through fallback and terminal-error paths, and reclaim transcript width for long links and paths. (#59800) Thanks @vincentkoc.
- Exec approvals/channels: decouple initiating-surface approval availability from native delivery enablement so Telegram, Slack, and Discord still expose approvals when approvers exist and native target routing is configured separately. (#59776) Thanks @joelnishanth.
- Plugins/runtime: reuse compatible active registries for `web_search` and `web_fetch` provider snapshot resolution so repeated runtime reads do not re-import the same bundled plugin set on each agent message. Related #48380.
- Plugins/OpenAI: enable reference-image edits for `gpt-image-1` by routing edit calls to `/images/edits` with multipart image uploads, and update image-generation capability/docs metadata accordingly.
- Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus.
- Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19.
- Plugins/Google: separate OAuth CSRF state from PKCE code verifier during Gemini browser sign-in so state validation and token exchange use independent values. (#59116) Thanks @eleqtrizit.
- Plugins/browser: block SSRF redirect bypass by installing a real-time Playwright route handler before `page.goto()` so navigation to private/internal IPs is intercepted and aborted mid-redirect instead of checked post-hoc. (#58771) Thanks @pgondhi987.
- Gateway/connect: omit admin-scoped config and auth metadata from lower-privilege `hello-ok` snapshots while preserving those fields for admin reconnects. (#58469) Thanks @eleqtrizit.
- iOS/canvas: restrict A2UI bridge trust to the bundled scaffold and exact capability-backed remote canvas URLs, so generic `canvas.navigate` and `canvas.present` loads no longer gain action-dispatch authority. (#58471) Thanks @eleqtrizit.
- Android/gateway: require TLS for non-loopback remote gateway endpoints while still allowing local loopback and emulator cleartext setup flows. (#58475) Thanks @eleqtrizit.
- Zalo/webhook: scope replay-dedupe cache key to path and account using `JSON.stringify` so multi-account deployments do not silently drop events due to cross-account cache poisoning. (#59387) Thanks @pgondhi987.
- Exec/Windows: hide transient console windows for `runExec` and `runCommandWithTimeout` child-process launches, matching other Windows exec paths and stopping visible shell flashes during tool runs. (#59466) Thanks @lawrence3699.
- Exec/Windows: reject malformed drive-less rooted executable paths like `:\Users\...` so approval and allowlist candidate resolution no longer treat them as cwd-relative commands. (#58040) Thanks @SnowSky1.
- Exec/preflight: fail closed on complex interpreter invocations that would otherwise skip script-content validation, and correctly inspect quoted script paths before host execution. Thanks @pgondhi987.
- Exec/Windows: include Windows-compatible env override keys like `ProgramFiles(x86)` in system-run approval binding so changed approved values are rejected instead of silently passing unbound. (#59182) Thanks @pgondhi987.
- ACP/Windows spawn: fail closed on unresolved `.cmd` and `.bat` OpenClaw wrappers unless a caller explicitly opts into shell fallback, so Windows ACP launches do not silently drop into shell-mediated execution when wrapper unwrapping fails. (#58436) Thanks @eleqtrizit.
- Exec/Windows: prefer strict-inline-eval denial over generic allowlist prompts for interpreter carriers, while keeping persisted Windows allow-always approvals argv-bound. (#59780) Thanks @luoyanglang.
## 2026.4.1
@@ -158,6 +195,7 @@ Docs: https://docs.openclaw.ai
- Telegram/local Bot API: preserve media MIME types for absolute-path downloads so local audio files still trigger transcription and other MIME-based handling. (#54603) Thanks @jzakirov
- Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae
- QQBot/voice: lazy-load `silk-wasm` in `audio-convert.ts` so qqbot still starts when the optional voice dependency is missing, while voice encode/decode degrades gracefully instead of crashing at module load time. (#58829) Thanks @WideLee.
- WhatsApp/groups: fix bot waking up on self-number quoted replies in groups with `selfChatMode` enabled. (#60148) Thanks @lurebat
## 2026.3.31
@@ -906,6 +944,9 @@ Docs: https://docs.openclaw.ai
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
- Feishu/media: keep native image, file, audio, and video/media handling aligned across outbound sends, inbound downloads, thread replies, directory/action aliases, and capability docs so unsupported areas are explicit instead of implied. (#47968) Thanks @Takhoffman.
- Feishu/webhooks: harden signed webhook verification to use constant-time signature comparison and keep malformed short signatures fail-closed in webhook E2E coverage.
- Ollama/tool replay: deserialize stringified tool-call arguments on native and OpenAI-compatible Ollama paths while preserving unsafe integer ids across round-trips. (#52253) Thanks @Adam-Researchh.
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) Thanks @MonkeyLeeT.
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274) Thanks @obviyus.
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) Thanks @obviyus.

View File

@@ -16,6 +16,8 @@ import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewayDiscovery
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
import ai.openclaw.app.node.*
import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction
@@ -44,7 +46,7 @@ import java.util.concurrent.atomic.AtomicLong
class NodeRuntime(
context: Context,
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
private val tlsFingerprintProbe: suspend (String, Int) -> String? = ::probeGatewayTlsFingerprint,
private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint,
) {
data class GatewayConnectAuth(
val token: String?,
@@ -839,8 +841,9 @@ class NodeRuntime(
// First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect.
_statusText.value = "Verify gateway TLS fingerprint…"
scope.launch {
val fp = tlsFingerprintProbe(endpoint.host, endpoint.port) ?: run {
_statusText.value = "Failed: can't read TLS fingerprint"
val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port)
val fp = tlsProbe.fingerprintSha256 ?: run {
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
return@launch
}
_pendingGatewayTrust.value =
@@ -888,6 +891,15 @@ class NodeRuntime(
_statusText.value = "Offline"
}
private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String {
return when (failure) {
GatewayTlsProbeFailure.TLS_UNAVAILABLE ->
"Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected."
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, null ->
"Failed: couldn't reach the secure gateway endpoint for this host."
}
}
private fun hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==

View File

@@ -18,9 +18,7 @@ internal fun isLoopbackGatewayHost(
host = host.dropLast(1)
}
val zoneIndex = host.indexOf('%')
if (zoneIndex >= 0) {
host = host.substring(0, zoneIndex)
}
if (zoneIndex >= 0) return false
if (host.isEmpty()) return false
if (host == "localhost") return true
if (allowEmulatorBridgeAlias && host == "10.0.2.2") return true
@@ -46,6 +44,52 @@ internal fun isLoopbackGatewayHost(
return isMappedIpv4 && address[12] == 127.toByte()
}
internal fun isPrivateLanGatewayHost(
rawHost: String?,
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
): Boolean {
var host =
rawHost
?.trim()
?.lowercase(Locale.US)
?.trim('[', ']')
.orEmpty()
if (host.endsWith(".")) {
host = host.dropLast(1)
}
val zoneIndex = host.indexOf('%')
if (zoneIndex >= 0) {
host = host.substring(0, zoneIndex)
}
if (host.isEmpty()) return false
if (isLoopbackGatewayHost(host, allowEmulatorBridgeAlias = allowEmulatorBridgeAlias)) return true
if (host.endsWith(".local")) return true
if (!host.contains('.') && !host.contains(':')) return true
parseIpv4Address(host)?.let { ipv4 ->
val first = ipv4[0].toInt() and 0xff
val second = ipv4[1].toInt() and 0xff
return when {
first == 10 -> true
first == 172 && second in 16..31 -> true
first == 192 && second == 168 -> true
first == 169 && second == 254 -> true
else -> false
}
}
if (!host.contains(':') || !host.all(::isIpv6LiteralChar)) return false
val address = runCatching { InetAddress.getByName(host) }.getOrNull() ?: return false
return when {
address.isLinkLocalAddress -> true
address.isSiteLocalAddress -> true
else -> {
val bytes = address.address
bytes.size == 16 && (bytes[0].toInt() and 0xfe) == 0xfc
}
}
}
private fun isAndroidEmulatorRuntime(): Boolean {
val fingerprint = Build.FINGERPRINT?.lowercase(Locale.US).orEmpty()
val model = Build.MODEL?.lowercase(Locale.US).orEmpty()

View File

@@ -3,7 +3,11 @@ package ai.openclaw.app.gateway
import android.annotation.SuppressLint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.EOFException
import java.net.ConnectException
import java.net.InetSocketAddress
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
@@ -12,6 +16,7 @@ import java.util.Locale
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLException
import javax.net.ssl.SSLParameters
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.SNIHostName
@@ -32,6 +37,16 @@ data class GatewayTlsConfig(
val hostnameVerifier: HostnameVerifier,
)
enum class GatewayTlsProbeFailure {
TLS_UNAVAILABLE,
ENDPOINT_UNREACHABLE,
}
data class GatewayTlsProbeResult(
val fingerprintSha256: String? = null,
val failure: GatewayTlsProbeFailure? = null,
)
fun buildGatewayTlsConfig(
params: GatewayTlsParams?,
onStore: ((String) -> Unit)? = null,
@@ -85,10 +100,10 @@ suspend fun probeGatewayTlsFingerprint(
host: String,
port: Int,
timeoutMs: Int = 3_000,
): String? {
): GatewayTlsProbeResult {
val trimmedHost = host.trim()
if (trimmedHost.isEmpty()) return null
if (port !in 1..65535) return null
if (trimmedHost.isEmpty()) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
if (port !in 1..65535) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
return withContext(Dispatchers.IO) {
val trustAll =
@@ -121,10 +136,21 @@ suspend fun probeGatewayTlsFingerprint(
}
socket.startHandshake()
val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null
sha256Hex(cert.encoded)
} catch (_: Throwable) {
null
val cert =
socket.session.peerCertificates.firstOrNull() as? X509Certificate
?: return@withContext GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE)
GatewayTlsProbeResult(fingerprintSha256 = sha256Hex(cert.encoded))
} catch (err: Throwable) {
val failure =
when (err) {
is SSLException,
is EOFException -> GatewayTlsProbeFailure.TLS_UNAVAILABLE
is ConnectException,
is SocketTimeoutException,
is UnknownHostException -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
else -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
}
GatewayTlsProbeResult(failure = failure)
} finally {
try {
socket.close()

View File

@@ -34,10 +34,10 @@ class ConnectionManager(
val stableId = endpoint.stableId
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
val isManual = stableId.startsWith("manual|")
val isLoopback = isLoopbackGatewayHost(endpoint.host)
val cleartextAllowedHost = isLoopbackGatewayHost(endpoint.host)
if (isManual) {
if (!manualTlsEnabled && isLoopback) return null
if (!manualTlsEnabled && cleartextAllowedHost) return null
if (!stored.isNullOrBlank()) {
return GatewayTlsParams(
required = true,
@@ -75,7 +75,7 @@ class ConnectionManager(
)
}
if (!isLoopback) {
if (!cleartextAllowedHost) {
return GatewayTlsParams(
required = true,
expectedFingerprint = null,

View File

@@ -256,9 +256,23 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
if (config == null) {
validationText =
if (inputMode == ConnectInputMode.SetupCode) {
"Paste a valid setup code to connect."
val parsedSetup = decodeGatewaySetupCode(setupCode)
if (parsedSetup == null) {
"Paste a valid setup code to connect."
} else {
val parsedGateway = parseGatewayEndpointResult(parsedSetup.url)
gatewayEndpointValidationMessage(
parsedGateway.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.SETUP_CODE,
)
}
} else {
"Enter a valid manual host and port to connect."
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)
val parsedGateway = manualUrl?.let(::parseGatewayEndpointResult)
gatewayEndpointValidationMessage(
parsedGateway?.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.MANUAL,
)
}
return@Button
}
@@ -386,6 +400,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
Text("Run these on the gateway host:", style = mobileCallout, color = mobileTextSecondary)
CommandBlock("openclaw qr --setup-code-only")
CommandBlock("openclaw qr --json")
Text(
"For Tailscale or public hosts, use wss:// or Tailscale Serve. Private LAN ws:// remains supported.",
style = mobileCaption1,
color = mobileTextSecondary,
)
if (inputMode == ConnectInputMode.SetupCode) {
Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
@@ -468,7 +487,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = mobileHeadline, color = mobileText)
Text("Switch to secure websocket (`wss`).", style = mobileCallout, color = mobileTextSecondary)
Text(
"Turn this on for Tailscale or public hosts. Private LAN ws:// remains supported.",
style = mobileCallout,
color = mobileTextSecondary,
)
}
Switch(
checked = manualTlsInput,

View File

@@ -33,7 +33,32 @@ internal data class GatewayConnectConfig(
val password: String,
)
internal enum class GatewayEndpointValidationError {
INVALID_URL,
INSECURE_REMOTE_URL,
}
internal enum class GatewayEndpointInputSource {
SETUP_CODE,
MANUAL,
QR_SCAN,
}
internal data class GatewayEndpointParseResult(
val config: GatewayEndpointConfig? = null,
val error: GatewayEndpointValidationError? = null,
)
internal data class GatewayScannedSetupCodeResult(
val setupCode: String? = null,
val error: GatewayEndpointValidationError? = null,
)
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
private const val remoteGatewaySecurityRule =
"Non-loopback mobile nodes require wss:// or Tailscale Serve. ws:// is allowed only for localhost and the Android emulator."
private const val remoteGatewaySecurityFix =
"Use a private LAN host/address, or enable Tailscale Serve / expose a wss:// gateway URL."
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
@@ -50,7 +75,7 @@ internal fun resolveGatewayConnectConfig(
): GatewayConnectConfig? {
if (useSetupCode) {
val setup = decodeGatewaySetupCode(setupCode) ?: return null
val parsed = parseGatewayEndpoint(setup.url) ?: return null
val parsed = parseGatewayEndpointResult(setup.url).config ?: return null
val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
val sharedToken =
when {
@@ -75,10 +100,10 @@ internal fun resolveGatewayConnectConfig(
}
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput) ?: return null
val parsed = parseGatewayEndpoint(manualUrl) ?: return null
val parsed = parseGatewayEndpointResult(manualUrl).config ?: return null
val savedManualEndpoint =
composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls)
?.let(::parseGatewayEndpoint)
?.let { parseGatewayEndpointResult(it).config }
val preserveBootstrapToken =
savedManualEndpoint != null &&
savedManualEndpoint.host == parsed.host &&
@@ -97,13 +122,19 @@ internal fun resolveGatewayConnectConfig(
}
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
return parseGatewayEndpointResult(rawInput).config
}
internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult {
val raw = rawInput.trim()
if (raw.isEmpty()) return null
if (raw.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val normalized = if (raw.contains("://")) raw else "https://$raw"
val uri = runCatching { URI(normalized) }.getOrNull() ?: return null
val uri =
runCatching { URI(normalized) }.getOrNull()
?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val host = uri.host?.trim()?.trim('[', ']').orEmpty()
if (host.isEmpty()) return null
if (host.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty()
val tls =
@@ -112,7 +143,9 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
"wss", "https" -> true
else -> true
}
if (!tls && !isLoopbackGatewayHost(host)) return null
if (!tls && !isLoopbackGatewayHost(host)) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
}
val defaultPort =
when (scheme) {
"wss", "https" -> 443
@@ -134,7 +167,9 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
"${if (tls) "https" else "http"}://$displayHost:$port"
}
return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl)
return GatewayEndpointParseResult(
config = GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl),
)
}
internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
@@ -165,9 +200,44 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
internal fun resolveScannedSetupCode(rawInput: String): String? {
val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null
val decoded = decodeGatewaySetupCode(setupCode) ?: return null
return setupCode.takeIf { parseGatewayEndpoint(decoded.url) != null }
return resolveScannedSetupCodeResult(rawInput).setupCode
}
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
val setupCode =
resolveSetupCodeCandidate(rawInput)
?: return GatewayScannedSetupCodeResult(error = GatewayEndpointValidationError.INVALID_URL)
val decoded =
decodeGatewaySetupCode(setupCode)
?: return GatewayScannedSetupCodeResult(error = GatewayEndpointValidationError.INVALID_URL)
val parsed = parseGatewayEndpointResult(decoded.url)
if (parsed.config == null) {
return GatewayScannedSetupCodeResult(error = parsed.error)
}
return GatewayScannedSetupCodeResult(setupCode = setupCode)
}
internal fun gatewayEndpointValidationMessage(
error: GatewayEndpointValidationError,
source: GatewayEndpointInputSource,
): String {
return when (error) {
GatewayEndpointValidationError.INSECURE_REMOTE_URL ->
when (source) {
GatewayEndpointInputSource.SETUP_CODE ->
"Setup code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix"
GatewayEndpointInputSource.QR_SCAN ->
"QR code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix"
GatewayEndpointInputSource.MANUAL ->
"$remoteGatewaySecurityRule $remoteGatewaySecurityFix"
}
GatewayEndpointValidationError.INVALID_URL ->
when (source) {
GatewayEndpointInputSource.SETUP_CODE -> "Setup code has invalid gateway URL."
GatewayEndpointInputSource.QR_SCAN -> "QR code did not contain a valid setup code."
GatewayEndpointInputSource.MANUAL -> "Enter a valid manual host and port to connect."
}
}
}
internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? {

View File

@@ -49,6 +49,7 @@ internal fun buildGatewayDiagnosticsReport(
Please:
- pick one route only: same machine, same LAN, Tailscale, or public URL
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
- remember: Tailscale/public mobile routes require wss:// or Tailscale Serve; private LAN ws:// is still allowed
- quote the exact app status/error below
- tell me whether `openclaw devices list` should show a pending pairing request
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`

View File

@@ -566,12 +566,16 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (contents.isEmpty()) {
return@addOnSuccessListener
}
val scannedSetupCode = resolveScannedSetupCode(contents)
if (scannedSetupCode == null) {
gatewayError = "QR code did not contain a valid setup code."
val scannedSetupCode = resolveScannedSetupCodeResult(contents)
if (scannedSetupCode.setupCode == null) {
gatewayError =
gatewayEndpointValidationMessage(
scannedSetupCode.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.QR_SCAN,
)
return@addOnSuccessListener
}
setupCode = scannedSetupCode
setupCode = scannedSetupCode.setupCode
gatewayInputMode = GatewayInputMode.SetupCode
gatewayError = null
attemptedConnect = false
@@ -799,9 +803,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
gatewayError = "Scan QR code first, or use Advanced setup."
return@Button
}
val parsedGateway = parseGatewayEndpoint(parsedSetup.url)
if (parsedGateway == null) {
gatewayError = "Setup code has invalid gateway URL."
val parsedGateway = parseGatewayEndpointResult(parsedSetup.url)
if (parsedGateway.config == null) {
gatewayError =
gatewayEndpointValidationMessage(
parsedGateway.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.SETUP_CODE,
)
return@Button
}
gatewayUrl = parsedSetup.url
@@ -819,12 +827,16 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
} else {
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls)
val parsedGateway = manualUrl?.let(::parseGatewayEndpoint)
if (parsedGateway == null) {
gatewayError = "Manual endpoint is invalid."
val parsedGateway = manualUrl?.let(::parseGatewayEndpointResult)
if (parsedGateway?.config == null) {
gatewayError =
gatewayEndpointValidationMessage(
parsedGateway?.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.MANUAL,
)
return@Button
}
gatewayUrl = parsedGateway.displayUrl
gatewayUrl = parsedGateway.config.displayUrl
viewModel.setGatewayBootstrapToken("")
}
step = OnboardingStep.Permissions
@@ -863,19 +875,23 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
} else {
Button(
onClick = {
val parsed = parseGatewayEndpoint(gatewayUrl)
if (parsed == null) {
val parsed = parseGatewayEndpointResult(gatewayUrl)
if (parsed.config == null) {
step = OnboardingStep.Gateway
gatewayError = "Invalid gateway URL."
gatewayError =
gatewayEndpointValidationMessage(
parsed.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.MANUAL,
)
return@Button
}
val token = persistedGatewayToken.trim()
val password = gatewayPassword.trim()
attemptedConnect = true
viewModel.setManualEnabled(true)
viewModel.setManualHost(parsed.host)
viewModel.setManualPort(parsed.port)
viewModel.setManualTls(parsed.tls)
viewModel.setManualHost(parsed.config.host)
viewModel.setManualPort(parsed.config.port)
viewModel.setManualTls(parsed.config.tls)
if (gatewayInputMode == GatewayInputMode.Manual) {
viewModel.setGatewayBootstrapToken("")
}
@@ -886,7 +902,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
viewModel.setGatewayPassword(password)
viewModel.connect(
GatewayEndpoint.manual(host = parsed.host, port = parsed.port),
GatewayEndpoint.manual(host = parsed.config.host, port = parsed.config.port),
token = token.ifEmpty { null },
bootstrapToken =
if (gatewayInputMode == GatewayInputMode.SetupCode) {
@@ -1040,7 +1056,7 @@ private fun GatewayStep(
StepShell(title = "Gateway Connection") {
Text(
"Run `openclaw qr` on your gateway host, then scan the code with this device.",
"Run `openclaw qr` on your gateway host, then scan the code with this device. For Tailscale or public hosts, use wss:// or Tailscale Serve.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
@@ -1072,7 +1088,7 @@ private fun GatewayStep(
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Advanced setup", style = onboardingHeadlineStyle, color = onboardingText)
Text("Paste setup code or enter host/port manually.", style = onboardingCaption1Style, color = onboardingTextSecondary)
Text("Paste setup code or enter host/port manually. Private LAN ws:// is supported; Tailscale/public hosts need wss://.", style = onboardingCaption1Style, color = onboardingTextSecondary)
}
Icon(
imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
@@ -1153,7 +1169,11 @@ private fun GatewayStep(
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText)
Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
Text(
"Turn this on for Tailscale or public hosts. Private LAN ws:// remains supported.",
style = onboardingCalloutStyle.copy(lineHeight = 18.sp),
color = onboardingTextSecondary,
)
}
Switch(
checked = manualTls,

View File

@@ -2,6 +2,8 @@ package ai.openclaw.app
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -77,7 +79,7 @@ class GatewayBootstrapAuthTest {
NodeRuntime(
app,
prefs,
tlsFingerprintProbe = { _, _ -> "fp-1" },
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp-1") },
)
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
val explicitAuth =
@@ -98,6 +100,29 @@ class GatewayBootstrapAuthTest {
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "operatorSession"))
}
@Test
fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() {
val app = RuntimeEnvironment.getApplication()
val runtime =
NodeRuntime(
app,
tlsFingerprintProbe = { _, _ ->
GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE)
},
)
runtime.connect(
GatewayEndpoint.manual(host = "gateway.example", port = 18789),
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null),
)
assertEquals(
"Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.",
waitForStatusText(runtime),
)
assertNull(runtime.pendingGatewayTrust.value)
}
private fun waitForGatewayTrustPrompt(runtime: NodeRuntime): NodeRuntime.GatewayTrustPrompt {
repeat(50) {
runtime.pendingGatewayTrust.value?.let { return it }
@@ -106,6 +131,17 @@ class GatewayBootstrapAuthTest {
error("Expected pending gateway trust prompt")
}
private fun waitForStatusText(runtime: NodeRuntime): String {
repeat(50) {
val status = runtime.statusText.value
if (status != "Verify gateway TLS fingerprint…") {
return status
}
Thread.sleep(10)
}
error("Expected status text update")
}
private fun desiredBootstrapToken(runtime: NodeRuntime, sessionFieldName: String): String? {
val session = readField<GatewaySession>(runtime, sessionFieldName)
val desired = readField<Any?>(session, "desired") ?: return null

View File

@@ -11,6 +11,7 @@ import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -107,12 +108,52 @@ class ConnectionManagerTest {
}
@Test
fun resolveTlsParamsForEndpoint_discoveryNonLoopbackWithoutHintsStillRequiresTls() {
fun resolveTlsParamsForEndpoint_manualPrivateLanRequiresTlsWhenToggleIsOff() {
val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertEquals(true, params?.required)
assertNull(params?.expectedFingerprint)
assertEquals(false, params?.allowTOFU)
}
@Test
fun resolveTlsParamsForEndpoint_discoveryTailnetWithoutHintsStillRequiresTls() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "10.0.0.2",
host = "100.64.0.9",
port = 18789,
tlsEnabled = false,
tlsFingerprintSha256 = null,
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertEquals(true, params?.required)
assertNull(params?.expectedFingerprint)
assertEquals(false, params?.allowTOFU)
}
@Test
fun resolveTlsParamsForEndpoint_discoveryPrivateLanWithoutHintsRequiresTls() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "192.168.1.20",
port = 18789,
tlsEnabled = false,
tlsFingerprintSha256 = null,
@@ -202,6 +243,14 @@ class ConnectionManagerTest {
assertFalse(isLoopbackGatewayHost("10.0.2.2", allowEmulatorBridgeAlias = false))
}
@Test
fun isPrivateLanGatewayHost_acceptsLanHostsButRejectsTailnetHosts() {
assertTrue(isPrivateLanGatewayHost("192.168.1.20"))
assertTrue(isPrivateLanGatewayHost("gateway.local"))
assertFalse(isPrivateLanGatewayHost("100.64.0.9"))
assertFalse(isPrivateLanGatewayHost("gateway.tailnet.ts.net"))
}
@Test
fun resolveTlsParamsForEndpoint_discoveryIpv6LoopbackWithoutHintsCanStayCleartext() {
val endpoint =

View File

@@ -31,6 +31,13 @@ class GatewayConfigResolverTest {
assertNull(parsed)
}
@Test
fun parseGatewayEndpointRejectsTailnetCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://100.64.0.9:18789")
assertNull(parsed)
}
@Test
fun parseGatewayEndpointOmitsExplicitDefaultTlsPortFromDisplayUrl() {
val parsed = parseGatewayEndpoint("https://gateway.example:443")
@@ -91,6 +98,36 @@ class GatewayConfigResolverTest {
)
}
@Test
fun parseGatewayEndpointAllowsPrivateLanCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://192.168.1.20:18789")
assertEquals(
GatewayEndpointConfig(
host = "192.168.1.20",
port = 18789,
tls = false,
displayUrl = "http://192.168.1.20:18789",
),
parsed,
)
}
@Test
fun parseGatewayEndpointAllowsMdnsCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://gateway.local:18789")
assertEquals(
GatewayEndpointConfig(
host = "gateway.local",
port = 18789,
tls = false,
displayUrl = "http://gateway.local:18789",
),
parsed,
)
}
@Test
fun parseGatewayEndpointAllowsIpv6LoopbackCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://[::1]")
@@ -126,10 +163,23 @@ class GatewayConfigResolverTest {
}
@Test
fun parseGatewayEndpointRejectsLinkLocalIpv6ZoneCleartextWsUrls() {
fun parseGatewayEndpointAllowsLinkLocalIpv6ZoneCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://[fe80::1%25eth0]")
assertNull(parsed)
assertEquals("fe80::1%25eth0", parsed?.host)
assertEquals(18789, parsed?.port)
assertEquals(false, parsed?.tls)
assertEquals("http://[fe80::1%25eth0]:18789", parsed?.displayUrl)
}
@Test
fun parseGatewayEndpointAllowsSecureIpv6ZoneUrls() {
val parsed = parseGatewayEndpoint("wss://[fe80::1%25wlan0]:443")
assertEquals("fe80::1%25wlan0", parsed?.host)
assertEquals(443, parsed?.port)
assertEquals(true, parsed?.tls)
assertEquals("https://[fe80::1%25wlan0]", parsed?.displayUrl)
}
@Test
@@ -220,6 +270,33 @@ class GatewayConfigResolverTest {
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultFlagsInsecureRemoteGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
}
@Test
fun parseGatewayEndpointResultFlagsInsecureRemoteGateway() {
val parsed = parseGatewayEndpointResult("ws://gateway.example:18789")
assertNull(parsed.config)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
}
@Test
fun parseGatewayEndpointResultFlagsInsecureLanCleartextGateway() {
val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789")
assertNull(parsed.config)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
}
@Test
fun decodeGatewaySetupCodeParsesBootstrapToken() {
val setupCode =
@@ -358,7 +435,7 @@ class GatewayConfigResolverTest {
}
@Test
fun resolveGatewayConnectConfigRejectsNonLoopbackManualCleartextEndpoint() {
fun resolveGatewayConnectConfigAllowsPrivateLanManualCleartextEndpoint() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
@@ -374,7 +451,9 @@ class GatewayConfigResolverTest {
fallbackPassword = "",
)
assertNull(resolved)
assertEquals("192.168.31.100", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
}
private fun encodeSetupCode(payloadJson: String): String {

View File

@@ -1,196 +0,0 @@
import SwiftUI
private struct ExecApprovalPromptDialogModifier: ViewModifier {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content
.overlay {
if let prompt = self.appModel.pendingExecApprovalPrompt {
ZStack {
Color.black.opacity(0.38)
.ignoresSafeArea()
ExecApprovalPromptCard(
prompt: prompt,
isResolving: self.appModel.pendingExecApprovalPromptResolving,
errorText: self.appModel.pendingExecApprovalPromptErrorText,
brighten: self.colorScheme == .light,
onAllowOnce: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once")
}
},
onAllowAlways: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
}
},
onDeny: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny")
}
},
onCancel: {
self.appModel.dismissPendingExecApprovalPrompt()
})
.padding(.horizontal, 20)
.frame(maxWidth: 460)
.transition(.scale(scale: 0.98).combined(with: .opacity))
}
.zIndex(1)
}
}
.animation(.easeInOut(duration: 0.18), value: self.appModel.pendingExecApprovalPrompt?.id)
}
}
private struct ExecApprovalPromptCard: View {
let prompt: NodeAppModel.ExecApprovalPrompt
let isResolving: Bool
let errorText: String?
let brighten: Bool
let onAllowOnce: () -> Void
let onAllowAlways: () -> Void
let onDeny: () -> Void
let onCancel: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Exec approval required")
.font(.headline)
Text("OpenClaw opened from a notification. Review this exec request before continuing.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text(self.prompt.commandText)
.font(.system(size: 15, weight: .regular, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.black.opacity(0.14), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
VStack(alignment: .leading, spacing: 8) {
if let host = self.normalized(self.prompt.host) {
ExecApprovalPromptMetadataRow(label: "Host", value: host)
}
if let nodeId = self.normalized(self.prompt.nodeId) {
ExecApprovalPromptMetadataRow(label: "Node", value: nodeId)
}
if let agentId = self.normalized(self.prompt.agentId) {
ExecApprovalPromptMetadataRow(label: "Agent", value: agentId)
}
if let expiresText = self.expiresText(self.prompt.expiresAtMs) {
ExecApprovalPromptMetadataRow(label: "Expires", value: expiresText)
}
}
if let errorText = self.normalized(self.errorText) {
Text(errorText)
.font(.footnote)
.foregroundStyle(.red)
}
if self.isResolving {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Resolving…")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
VStack(spacing: 10) {
Button {
self.onAllowOnce()
} label: {
Text("Allow Once")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(self.isResolving)
if self.prompt.allowsAllowAlways {
Button {
self.onAllowAlways()
} label: {
Text("Allow Always")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
}
HStack(spacing: 10) {
Button(role: .destructive) {
self.onDeny()
} label: {
Text("Deny")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
Button(role: .cancel) {
self.onCancel()
} label: {
Text("Cancel")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
}
}
.controlSize(.large)
.frame(maxWidth: .infinity)
}
.statusGlassCard(brighten: self.brighten, verticalPadding: 18, horizontalPadding: 18)
}
private func normalized(_ value: String?) -> String? {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func expiresText(_ expiresAtMs: Int?) -> String? {
guard let expiresAtMs else { return nil }
let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970)
if remainingSeconds <= 0 {
return "expired"
}
if remainingSeconds < 60 {
return "under a minute"
}
if remainingSeconds < 3600 {
let minutes = Int(ceil(Double(remainingSeconds) / 60.0))
return minutes == 1 ? "about 1 minute" : "about \(minutes) minutes"
}
let hours = Int(ceil(Double(remainingSeconds) / 3600.0))
return hours == 1 ? "about 1 hour" : "about \(hours) hours"
}
}
private struct ExecApprovalPromptMetadataRow: View {
let label: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(self.label)
.font(.caption)
.foregroundStyle(.secondary)
Text(self.value)
.font(.footnote)
.textSelection(.enabled)
}
}
}
extension View {
func execApprovalPromptDialog() -> some View {
self.modifier(ExecApprovalPromptDialogModifier())
}
}

View File

@@ -61,35 +61,11 @@ final class NodeAppModel {
let request: AgentDeepLink
}
struct ExecApprovalPrompt: Identifiable, Equatable {
let id: String
let commandText: String
let allowedDecisions: [String]
let host: String?
let nodeId: String?
let agentId: String?
let expiresAtMs: Int?
var allowsAllowAlways: Bool {
self.allowedDecisions.contains("allow-always")
}
}
private enum ExecApprovalResolutionOutcome {
case resolved
case stale
case unavailable
case failed(message: String)
}
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
private let execApprovalNotificationLogger = Logger(
subsystem: "ai.openclaw.ios",
category: "ExecApprovalNotification")
enum CameraHUDKind {
case photo
case recording
@@ -122,9 +98,6 @@ final class NodeAppModel {
var lastShareEventText: String = "No share events yet."
var openChatRequestID: Int = 0
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
private(set) var pendingExecApprovalPrompt: ExecApprovalPrompt?
private(set) var pendingExecApprovalPromptResolving: Bool = false
private(set) var pendingExecApprovalPromptErrorText: String?
private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt?
private var lastAgentDeepLinkPromptAt: Date = .distantPast
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
@@ -1843,7 +1816,7 @@ private extension NodeAppModel {
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
}
nonisolated static func shouldStartOperatorGatewayLoop(
static func shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
@@ -1864,7 +1837,7 @@ private extension NodeAppModel {
return hasStoredOperatorToken
}
nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
guard let config else { return nil }
let trimmedBootstrapToken = config.bootstrapToken?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -1905,36 +1878,6 @@ private extension NodeAppModel {
GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId)
}
private func handleSuccessfulBootstrapGatewayOnboarding(
url: URL,
stableID: String,
token: String?,
password: String?,
nodeOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?) async
{
self.clearPersistedGatewayBootstrapTokenIfNeeded()
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: nil,
password: password,
stableID: stableID)
{
self.startOperatorGatewayLoop(
url: url,
stableID: stableID,
token: token,
bootstrapToken: nil,
password: password,
nodeOptions: nodeOptions,
sessionBox: sessionBox)
}
// QR bootstrap onboarding should surface the system notification permission
// prompt immediately so visible APNs alerts work without a second manual step.
_ = await self.requestNotificationAuthorizationIfNeeded()
}
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
guard self.isBackgrounded else { return }
guard !self.backgroundReconnectSuppressed else { return }
@@ -2106,14 +2049,13 @@ private extension NodeAppModel {
fallbackToken: token,
fallbackBootstrapToken: bootstrapToken,
fallbackPassword: password)
let connectedOptions = currentOptions
GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)")
try await self.nodeGateway.connect(
url: url,
token: reconnectAuth.token,
bootstrapToken: reconnectAuth.bootstrapToken,
password: reconnectAuth.password,
connectOptions: connectedOptions,
connectOptions: currentOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
@@ -2129,13 +2071,24 @@ private extension NodeAppModel {
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty == false
if usedBootstrapToken {
await self.handleSuccessfulBootstrapGatewayOnboarding(
url: url,
stableID: stableID,
token: reconnectAuth.token,
password: reconnectAuth.password,
nodeOptions: connectedOptions,
sessionBox: sessionBox)
await MainActor.run {
self.clearPersistedGatewayBootstrapTokenIfNeeded()
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
token: reconnectAuth.token,
bootstrapToken: nil,
password: reconnectAuth.password,
stableID: stableID)
{
self.startOperatorGatewayLoop(
url: url,
stableID: stableID,
token: reconnectAuth.token,
bootstrapToken: nil,
password: reconnectAuth.password,
nodeOptions: currentOptions,
sessionBox: sessionBox)
}
}
}
let relayData = await MainActor.run {
(
@@ -2296,7 +2249,7 @@ private extension NodeAppModel {
func makeOperatorConnectOptions(clientId: String, displayName: String?) -> GatewayConnectOptions {
GatewayConnectOptions(
role: "operator",
scopes: ["operator.read", "operator.write", "operator.approvals", "operator.talk.secrets"],
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
caps: [],
commands: [],
permissions: [:],
@@ -2594,19 +2547,6 @@ extension NodeAppModel {
+ "backgrounded=\(self.isBackgrounded) "
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
userInfo: userInfo,
notificationCenter: self.notificationCenter)
{
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
self.clearPendingExecApprovalPromptIfMatches(approvalId)
}
self.execApprovalNotificationLogger.info(
"Handled exec approval cleanup push wakeId=\(wakeId, privacy: .public)")
return true
}
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
let outcomeMessage =
"Silent push outcome wakeId=\(wakeId) "
@@ -2779,203 +2719,6 @@ extension NodeAppModel {
return "unknown"
}
private struct ExecApprovalGetRequest: Encodable {
var id: String
}
private struct ExecApprovalResolveRequest: Encodable {
var id: String
var decision: String
}
private struct ExecApprovalGetResponse: Decodable {
var id: String
var commandText: String
var allowedDecisions: [String]
var host: String?
var nodeId: String?
var agentId: String?
var expiresAtMs: Int?
}
func presentExecApprovalNotificationPrompt(_ prompt: ExecApprovalNotificationPrompt) async {
let approvalId = prompt.approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !approvalId.isEmpty else { return }
self.pendingExecApprovalPromptResolving = true
self.pendingExecApprovalPromptErrorText = nil
let fetchedPrompt = await self.fetchExecApprovalPrompt(approvalId: approvalId)
self.pendingExecApprovalPromptResolving = false
switch fetchedPrompt {
case let .loaded(fetchedPrompt):
self.presentFetchedExecApprovalPrompt(fetchedPrompt)
case .stale:
await ExecApprovalNotificationBridge.removeNotifications(
forApprovalID: approvalId,
notificationCenter: self.notificationCenter)
self.dismissPendingExecApprovalPrompt()
case let .failed(message):
self.execApprovalNotificationLogger.error(
"Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
}
}
private enum ExecApprovalPromptFetchOutcome {
case loaded(ExecApprovalPrompt)
case stale
case failed(message: String)
}
private func presentFetchedExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
self.pendingExecApprovalPrompt = prompt
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = nil
}
private static func makeExecApprovalPrompt(from details: ExecApprovalGetResponse) -> ExecApprovalPrompt? {
let approvalId = details.id.trimmingCharacters(in: .whitespacesAndNewlines)
let commandText = details.commandText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !approvalId.isEmpty, !commandText.isEmpty else { return nil }
return ExecApprovalPrompt(
id: approvalId,
commandText: commandText,
allowedDecisions: details.allowedDecisions.compactMap { decision in
let trimmed = decision.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
},
host: details.host?.trimmingCharacters(in: .whitespacesAndNewlines),
nodeId: details.nodeId?.trimmingCharacters(in: .whitespacesAndNewlines),
agentId: details.agentId?.trimmingCharacters(in: .whitespacesAndNewlines),
expiresAtMs: details.expiresAtMs)
}
private func fetchExecApprovalPrompt(approvalId: String) async -> ExecApprovalPromptFetchOutcome {
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
guard connected else {
return .failed(message: "operator_not_connected")
}
do {
let payloadJSON = try Self.encodePayload(ExecApprovalGetRequest(id: approvalId))
let response = try await self.operatorGateway.request(
method: "exec.approval.get",
paramsJSON: payloadJSON,
timeoutSeconds: 12)
let details = try JSONDecoder().decode(ExecApprovalGetResponse.self, from: response)
guard let prompt = Self.makeExecApprovalPrompt(from: details) else {
return .failed(message: "invalid_prompt_payload")
}
return .loaded(prompt)
} catch {
if Self.isApprovalNotificationStaleError(error) {
return .stale
}
return .failed(message: error.localizedDescription)
}
}
func dismissPendingExecApprovalPrompt() {
self.pendingExecApprovalPrompt = nil
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = nil
}
func dismissPendingExecApprovalPrompt(approvalId: String) {
self.clearPendingExecApprovalPromptIfMatches(approvalId)
}
func resolvePendingExecApprovalPrompt(decision: String) async {
guard let prompt = self.pendingExecApprovalPrompt else { return }
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedDecision.isEmpty else { return }
self.pendingExecApprovalPromptResolving = true
self.pendingExecApprovalPromptErrorText = nil
let outcome = await self.resolveExecApprovalNotificationDecision(
approvalId: prompt.id,
decision: normalizedDecision)
switch outcome {
case .resolved, .stale, .unavailable:
break
case let .failed(message):
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = message
}
}
private func resolveExecApprovalNotificationDecision(
approvalId: String,
decision: String
) async -> ExecApprovalResolutionOutcome {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty, !normalizedDecision.isEmpty else {
return .failed(message: "Invalid approval request.")
}
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
guard connected else {
self.execApprovalNotificationLogger.error(
"Exec approval action failed id=\(normalizedApprovalID, privacy: .public): operator not connected")
return .failed(message: "OpenClaw couldn't connect to the gateway operator session.")
}
do {
let payloadJSON = try Self.encodePayload(
ExecApprovalResolveRequest(id: normalizedApprovalID, decision: normalizedDecision))
_ = try await self.operatorGateway.request(
method: "exec.approval.resolve",
paramsJSON: payloadJSON,
timeoutSeconds: 12)
await ExecApprovalNotificationBridge.removeNotifications(
forApprovalID: normalizedApprovalID,
notificationCenter: self.notificationCenter)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
return .resolved
} catch {
if Self.isApprovalNotificationStaleError(error) {
await ExecApprovalNotificationBridge.removeNotifications(
forApprovalID: normalizedApprovalID,
notificationCenter: self.notificationCenter)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
return .stale
}
if Self.isApprovalNotificationUnavailableError(error) {
await ExecApprovalNotificationBridge.removeNotifications(
forApprovalID: normalizedApprovalID,
notificationCenter: self.notificationCenter)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
return .unavailable
}
let logMessage =
"Exec approval action failed id=\(normalizedApprovalID) error=\(error.localizedDescription)"
self.execApprovalNotificationLogger.error("\(logMessage, privacy: .public)")
return .failed(
message: "OpenClaw couldn't resolve this approval right now. Try again.")
}
}
private func clearPendingExecApprovalPromptIfMatches(_ approvalId: String) {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.pendingExecApprovalPrompt?.id == normalizedApprovalID else { return }
self.dismissPendingExecApprovalPrompt()
}
private static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false }
let message = gatewayError.message.lowercased()
return gatewayError.code == "INVALID_REQUEST"
&& (message.contains("unknown or expired approval id") || message.contains("approval_not_found"))
}
private static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false }
let message = gatewayError.message.lowercased()
return gatewayError.code == "INVALID_REQUEST"
&& message.contains("allow-always is unavailable")
}
private struct SilentPushWakeAttemptResult {
var applied: Bool
var reason: String
@@ -2987,51 +2730,14 @@ extension NodeAppModel {
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
while Date() < deadline {
if Task.isCancelled {
return false
}
if await self.isGatewayConnected() {
return true
}
do {
try await Task.sleep(nanoseconds: pollIntervalNs)
} catch {
return false
}
try? await Task.sleep(nanoseconds: pollIntervalNs)
}
return await self.isGatewayConnected()
}
private func waitForOperatorConnection(timeoutMs: Int, pollMs: Int) async -> Bool {
let clampedTimeoutMs = max(0, timeoutMs)
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
while Date() < deadline {
if Task.isCancelled {
return false
}
if await self.isOperatorConnected() {
return true
}
do {
try await Task.sleep(nanoseconds: pollIntervalNs)
} catch {
return false
}
}
return await self.isOperatorConnected()
}
private func ensureOperatorApprovalConnection(timeoutMs: Int) async -> Bool {
if await self.isOperatorConnected() {
return true
}
if let cfg = self.activeGatewayConnectConfig {
self.applyGatewayConnectConfig(cfg)
}
return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250)
}
private func reconnectGatewaySessionsForSilentPushIfNeeded(
wakeId: String
) async -> SilentPushWakeAttemptResult {
@@ -3431,50 +3137,11 @@ extension NodeAppModel {
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
}
func _test_makeOperatorConnectOptions(
clientId: String,
displayName: String?
) -> GatewayConnectOptions {
self.makeOperatorConnectOptions(clientId: clientId, displayName: displayName)
}
func _test_presentExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
self.presentFetchedExecApprovalPrompt(prompt)
}
func _test_dismissPendingExecApprovalPrompt() {
self.dismissPendingExecApprovalPrompt()
}
func _test_pendingExecApprovalPrompt() -> ExecApprovalPrompt? {
self.pendingExecApprovalPrompt
}
static func _test_makeExecApprovalPrompt(
id: String,
commandText: String,
allowedDecisions: [String],
host: String?,
nodeId: String?,
agentId: String?,
expiresAtMs: Int?
) -> ExecApprovalPrompt? {
self.makeExecApprovalPrompt(
from: ExecApprovalGetResponse(
id: id,
commandText: commandText,
allowedDecisions: allowedDecisions,
host: host,
nodeId: nodeId,
agentId: agentId,
expiresAtMs: expiresAtMs))
}
static func _test_currentDeepLinkKey() -> String {
self.expectedDeepLinkKey()
}
nonisolated static func _test_shouldStartOperatorGatewayLoop(
static func _test_shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
@@ -3487,29 +3154,6 @@ extension NodeAppModel {
hasStoredOperatorToken: hasStoredOperatorToken)
}
nonisolated static func _test_clearingBootstrapToken(
in config: GatewayConnectConfig?
) -> GatewayConnectConfig? {
self.clearingBootstrapToken(in: config)
}
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
await self.handleSuccessfulBootstrapGatewayOnboarding(
url: URL(string: "wss://gateway.example")!,
stableID: "test-gateway",
token: nil,
password: nil,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: nil),
sessionBox: nil)
}
}
#endif
// swiftlint:enable type_body_length file_length

View File

@@ -13,8 +13,6 @@ private struct PendingWatchPromptAction {
var sessionKey: String?
}
private typealias PendingExecApprovalPrompt = ExecApprovalNotificationPrompt
@MainActor
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
@@ -23,7 +21,6 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
private var backgroundWakeTask: Task<Bool, Never>?
private var pendingAPNsDeviceToken: Data?
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
private var pendingExecApprovalPrompts: [PendingExecApprovalPrompt] = []
weak var appModel: NodeAppModel? {
didSet {
@@ -47,15 +44,6 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
}
}
if !self.pendingExecApprovalPrompts.isEmpty {
let pending = self.pendingExecApprovalPrompts
self.pendingExecApprovalPrompts.removeAll()
Task { @MainActor in
for prompt in pending {
await model.presentExecApprovalNotificationPrompt(prompt)
}
}
}
}
}
@@ -92,17 +80,6 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
{
self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)")
Task { @MainActor in
let notificationCenter = LiveNotificationCenter()
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
userInfo: userInfo,
notificationCenter: notificationCenter)
{
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
self.appModel?.dismissPendingExecApprovalPrompt(approvalId: approvalId)
}
completionHandler(.newData)
return
}
guard let appModel = self.appModel else {
self.logger.info("APNs wake skipped: appModel unavailable")
self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model")
@@ -239,14 +216,6 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
sessionKey: sessionKey)
}
private static func parseExecApprovalPrompt(
from response: UNNotificationResponse) -> PendingExecApprovalPrompt?
{
ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: response.actionIdentifier,
userInfo: response.notification.request.content.userInfo)
}
private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async {
guard let appModel = self.appModel else {
self.pendingWatchPromptActions.append(action)
@@ -260,25 +229,13 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
_ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action")
}
private func routeExecApprovalPrompt(_ prompt: PendingExecApprovalPrompt) {
guard let appModel = self.appModel else {
self.pendingExecApprovalPrompts.append(prompt)
return
}
Task { @MainActor in
await appModel.presentExecApprovalNotificationPrompt(prompt)
}
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
{
let userInfo = notification.request.content.userInfo
if Self.isWatchPromptNotification(userInfo)
|| ExecApprovalNotificationBridge.shouldPresentNotification(userInfo: userInfo)
{
if Self.isWatchPromptNotification(userInfo) {
completionHandler([.banner, .list, .sound])
return
}
@@ -290,29 +247,18 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void)
{
if let action = Self.parseWatchPromptAction(from: response) {
Task { @MainActor [weak self] in
guard let self else {
completionHandler()
return
}
await self.routeWatchPromptAction(action)
completionHandler()
}
guard let action = Self.parseWatchPromptAction(from: response) else {
completionHandler()
return
}
if let prompt = Self.parseExecApprovalPrompt(from: response) {
Task { @MainActor [weak self] in
guard let self else {
completionHandler()
return
}
self.routeExecApprovalPrompt(prompt)
Task { @MainActor [weak self] in
guard let self else {
completionHandler()
return
}
return
await self.routeWatchPromptAction(action)
completionHandler()
}
completionHandler()
}
}

View File

@@ -1,92 +0,0 @@
import Foundation
import UserNotifications
struct ExecApprovalNotificationPrompt: Sendable, Equatable {
let approvalId: String
}
enum ExecApprovalNotificationBridge {
static let requestedKind = "exec.approval.requested"
static let resolvedKind = "exec.approval.resolved"
private static let localRequestPrefix = "exec.approval."
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
self.payloadKind(userInfo: userInfo) == self.requestedKind
}
static func parsePrompt(
actionIdentifier: String,
userInfo: [AnyHashable: Any]
) -> ExecApprovalNotificationPrompt?
{
guard actionIdentifier == UNNotificationDefaultActionIdentifier else { return nil }
guard self.payloadKind(userInfo: userInfo) == self.requestedKind else { return nil }
guard let approvalId = self.approvalID(from: userInfo) else { return nil }
return ExecApprovalNotificationPrompt(approvalId: approvalId)
}
@MainActor
static func handleResolvedPushIfNeeded(
userInfo: [AnyHashable: Any],
notificationCenter: NotificationCentering
) async -> Bool
{
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
let approvalId = self.approvalID(from: userInfo)
else {
return false
}
await self.removeNotifications(forApprovalID: approvalId, notificationCenter: notificationCenter)
return true
}
@MainActor
static func removeNotifications(
forApprovalID approvalId: String,
notificationCenter: NotificationCentering
) async {
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { return }
await notificationCenter.removePendingNotificationRequests(
withIdentifiers: [self.localRequestIdentifier(for: normalizedID)])
let delivered = await notificationCenter.deliveredNotifications()
let identifiers = delivered.compactMap { snapshot -> String? in
guard self.approvalID(from: snapshot.userInfo) == normalizedID else { return nil }
return snapshot.identifier
}
await notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
}
static func approvalID(from userInfo: [AnyHashable: Any]) -> String? {
let raw = self.openClawPayload(userInfo: userInfo)?["approvalId"] as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func localRequestIdentifier(for approvalId: String) -> String {
"\(self.localRequestPrefix)\(approvalId)"
}
private static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
let raw = self.openClawPayload(userInfo: userInfo)?["kind"] as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "unknown" : trimmed
}
private static func openClawPayload(userInfo: [AnyHashable: Any]) -> [String: Any]? {
if let payload = userInfo["openclaw"] as? [String: Any] {
return payload
}
if let payload = userInfo["openclaw"] as? [AnyHashable: Any] {
return payload.reduce(into: [String: Any]()) { partialResult, pair in
guard let key = pair.key as? String else { return }
partialResult[key] = pair.value
}
}
return nil
}
}

View File

@@ -107,7 +107,6 @@ struct RootCanvas: View {
}
.gatewayTrustPromptAlert()
.deepLinkAgentPromptAlert()
.execApprovalPromptDialog()
.sheet(item: self.$presentedSheet) { sheet in
switch sheet {
case .settings:

View File

@@ -1,11 +1,6 @@
import Foundation
import UserNotifications
struct NotificationSnapshot: @unchecked Sendable {
let identifier: String
let userInfo: [AnyHashable: Any]
}
enum NotificationAuthorizationStatus: Sendable {
case notDetermined
case denied
@@ -18,9 +13,6 @@ protocol NotificationCentering: Sendable {
func authorizationStatus() async -> NotificationAuthorizationStatus
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
func add(_ request: UNNotificationRequest) async throws
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async
func deliveredNotifications() async -> [NotificationSnapshot]
}
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
@@ -63,27 +55,4 @@ struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
}
}
}
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async {
guard !identifiers.isEmpty else { return }
self.center.removePendingNotificationRequests(withIdentifiers: identifiers)
}
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async {
guard !identifiers.isEmpty else { return }
self.center.removeDeliveredNotifications(withIdentifiers: identifiers)
}
func deliveredNotifications() async -> [NotificationSnapshot] {
await withCheckedContinuation { continuation in
self.center.getDeliveredNotifications { notifications in
continuation.resume(
returning: notifications.map { notification in
NotificationSnapshot(
identifier: notification.request.identifier,
userInfo: notification.request.content.userInfo)
})
}
}
}
}

View File

@@ -1,86 +0,0 @@
import Foundation
import Testing
import UserNotifications
@testable import OpenClaw
private final class MockNotificationCenter: NotificationCentering, @unchecked Sendable {
var authorization: NotificationAuthorizationStatus = .authorized
var addedRequests: [UNNotificationRequest] = []
var pendingRemovedIdentifiers: [[String]] = []
var deliveredRemovedIdentifiers: [[String]] = []
var delivered: [NotificationSnapshot] = []
func authorizationStatus() async -> NotificationAuthorizationStatus {
self.authorization
}
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
true
}
func add(_ request: UNNotificationRequest) async throws {
self.addedRequests.append(request)
}
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async {
self.pendingRemovedIdentifiers.append(identifiers)
}
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async {
self.deliveredRemovedIdentifiers.append(identifiers)
}
func deliveredNotifications() async -> [NotificationSnapshot] {
self.delivered
}
}
@Suite(.serialized) struct ExecApprovalNotificationBridgeTests {
@Test func parsePromptMapsDefaultNotificationTap() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-123",
],
])
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-123"))
}
@Test @MainActor func handleResolvedPushRemovesMatchingNotifications() async {
let center = MockNotificationCenter()
center.delivered = [
NotificationSnapshot(
identifier: "remote-approval-1",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-123",
],
]),
NotificationSnapshot(
identifier: "remote-other",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-999",
],
]),
]
let handled = await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.resolvedKind,
"approvalId": "approval-123",
],
],
notificationCenter: center)
#expect(handled)
#expect(center.pendingRemovedIdentifiers == [["exec.approval.approval-123"]])
#expect(center.deliveredRemovedIdentifiers == [["remote-approval-1"]])
}
}

View File

@@ -70,19 +70,6 @@ import UIKit
}
}
@Test @MainActor func operatorConnectOptionsRequestApprovalScope() {
let appModel = NodeAppModel()
let options = appModel._test_makeOperatorConnectOptions(
clientId: "openclaw-ios",
displayName: "OpenClaw iOS")
#expect(options.role == "operator")
#expect(options.scopes.contains("operator.read"))
#expect(options.scopes.contains("operator.write"))
#expect(options.scopes.contains("operator.approvals"))
#expect(options.scopes.contains("operator.talk.secrets"))
}
@Test @MainActor func loadLastConnectionReadsSavedValues() {
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
defer {

View File

@@ -2,7 +2,6 @@ import OpenClawKit
import Foundation
import Testing
import UIKit
import UserNotifications
@testable import OpenClaw
private func makeAgentDeepLinkURL(
@@ -69,36 +68,6 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
}
}
private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable {
var status: NotificationAuthorizationStatus = .notDetermined
var requestAuthorizationResult = false
var requestAuthorizationCalls = 0
func authorizationStatus() async -> NotificationAuthorizationStatus {
self.status
}
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
self.requestAuthorizationCalls += 1
if self.requestAuthorizationResult {
self.status = .authorized
} else {
self.status = .denied
}
return self.requestAuthorizationResult
}
func add(_: UNNotificationRequest) async throws {}
func removePendingNotificationRequests(withIdentifiers _: [String]) async {}
func removeDeliveredNotifications(withIdentifiers _: [String]) async {}
func deliveredNotifications() async -> [NotificationSnapshot] {
[]
}
}
@Suite(.serialized) struct NodeAppModelInvokeTests {
@Test @MainActor func decodeParamsFailsWithoutJSON() {
#expect(throws: Error.self) {
@@ -127,44 +96,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(appModel.mainSessionKey == "agent:agent-123:main")
}
@Test @MainActor func execApprovalPromptPresentationTracksLatestNotificationTap() throws {
let appModel = NodeAppModel()
appModel._test_presentExecApprovalPrompt(
try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-1",
commandText: "echo first",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: "main",
expiresAtMs: 1)))
let firstPrompt = try #require(appModel._test_pendingExecApprovalPrompt())
#expect(firstPrompt.id == "approval-1")
#expect(firstPrompt.commandText == "echo first")
#expect(firstPrompt.allowsAllowAlways == false)
appModel._test_presentExecApprovalPrompt(
try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-2",
commandText: "echo second",
allowedDecisions: ["allow-once", "allow-always", "deny"],
host: "gateway",
nodeId: "node-2",
agentId: nil,
expiresAtMs: 2)))
let secondPrompt = try #require(appModel._test_pendingExecApprovalPrompt())
#expect(secondPrompt.id == "approval-2")
#expect(secondPrompt.commandText == "echo second")
#expect(secondPrompt.allowsAllowAlways)
appModel._test_dismissPendingExecApprovalPrompt()
#expect(appModel._test_pendingExecApprovalPrompt() == nil)
}
@Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() {
#expect(
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
@@ -196,15 +127,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
)
}
@Test @MainActor func successfulBootstrapOnboardingRequestsNotificationAuthorization() async {
let center = MockBootstrapNotificationCenter()
let appModel = NodeAppModel(notificationCenter: center)
await appModel._test_handleSuccessfulBootstrapGatewayOnboarding()
#expect(center.requestAuthorizationCalls == 1)
}
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() {
let config = GatewayConnectConfig(
url: URL(string: "wss://gateway.example")!,
@@ -223,7 +145,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
clientMode: "node",
clientDisplayName: nil))
let cleared = NodeAppModel._test_clearingBootstrapToken(in: config)
let cleared = NodeAppModel.clearingBootstrapToken(in: config)
#expect(cleared?.bootstrapToken == nil)
#expect(cleared?.url == config.url)
#expect(cleared?.stableID == config.stableID)

View File

@@ -110,6 +110,7 @@ enum HostEnvSecurityPolicy {
"PIP_TRUSTED_HOST",
"UV_INDEX",
"UV_INDEX_URL",
"UV_PYTHON",
"UV_EXTRA_INDEX_URL",
"UV_DEFAULT_INDEX",
"DOCKER_HOST",

View File

@@ -2893,6 +2893,78 @@ public struct SkillsBinsResult: Codable, Sendable {
}
}
public struct SkillsSearchParams: Codable, Sendable {
public let query: String?
public let limit: Int?
public init(
query: String?,
limit: Int?)
{
self.query = query
self.limit = limit
}
private enum CodingKeys: String, CodingKey {
case query
case limit
}
}
public struct SkillsSearchResult: Codable, Sendable {
public let results: [[String: AnyCodable]]
public init(
results: [[String: AnyCodable]])
{
self.results = results
}
private enum CodingKeys: String, CodingKey {
case results
}
}
public struct SkillsDetailParams: Codable, Sendable {
public let slug: String
public init(
slug: String)
{
self.slug = slug
}
private enum CodingKeys: String, CodingKey {
case slug
}
}
public struct SkillsDetailResult: Codable, Sendable {
public let skill: AnyCodable
public let latestversion: AnyCodable?
public let metadata: AnyCodable?
public let owner: AnyCodable?
public init(
skill: AnyCodable,
latestversion: AnyCodable?,
metadata: AnyCodable?,
owner: AnyCodable?)
{
self.skill = skill
self.latestversion = latestversion
self.metadata = metadata
self.owner = owner
}
private enum CodingKeys: String, CodingKey {
case skill
case latestversion = "latestVersion"
case metadata
case owner
}
}
public struct CronJob: Codable, Sendable {
public let id: String
public let agentid: String?

View File

@@ -542,52 +542,6 @@ public actor GatewayChannelActor {
authSource: authSource)
}
private func shouldPersistBootstrapHandoffTokens() -> Bool {
guard self.lastAuthSource == .bootstrapToken else { return false }
let scheme = self.url.scheme?.lowercased()
if scheme == "wss" {
return true
}
if let host = self.url.host, LoopbackHost.isLoopback(host) {
return true
}
return false
}
private func filteredBootstrapHandoffScopes(role: String, scopes: [String]) -> [String]? {
let normalizedRole = role.trimmingCharacters(in: .whitespacesAndNewlines)
switch normalizedRole {
case "node":
return []
case "operator":
let allowedOperatorScopes: Set<String> = [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]
return Array(Set(scopes.filter { allowedOperatorScopes.contains($0) })).sorted()
default:
return nil
}
}
private func persistBootstrapHandoffToken(
deviceId: String,
role: String,
token: String,
scopes: [String]
) {
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
return
}
_ = DeviceAuthStore.storeToken(
deviceId: deviceId,
role: role,
token: token,
scopes: filteredScopes)
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
@@ -618,34 +572,18 @@ public actor GatewayChannelActor {
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick)
}
if let auth = ok.auth, let identity, self.shouldPersistBootstrapHandoffTokens() {
if let deviceToken = auth["deviceToken"]?.value as? String {
let authRole = auth["role"]?.value as? String ?? role
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
self.persistBootstrapHandoffToken(
if let auth = ok.auth,
let deviceToken = auth["deviceToken"]?.value as? String {
let authRole = auth["role"]?.value as? String ?? role
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
if let identity {
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
}
if let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable] {
for entry in tokenEntries {
guard let rawEntry = entry.value as? [String: ProtoAnyCodable],
let deviceToken = rawEntry["deviceToken"]?.value as? String,
let authRole = rawEntry["role"]?.value as? String
else {
continue
}
let scopes = (rawEntry["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
self.persistBootstrapHandoffToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
}
}
}
self.lastTick = Date()
self.tickTask?.cancel()

View File

@@ -2893,6 +2893,78 @@ public struct SkillsBinsResult: Codable, Sendable {
}
}
public struct SkillsSearchParams: Codable, Sendable {
public let query: String?
public let limit: Int?
public init(
query: String?,
limit: Int?)
{
self.query = query
self.limit = limit
}
private enum CodingKeys: String, CodingKey {
case query
case limit
}
}
public struct SkillsSearchResult: Codable, Sendable {
public let results: [[String: AnyCodable]]
public init(
results: [[String: AnyCodable]])
{
self.results = results
}
private enum CodingKeys: String, CodingKey {
case results
}
}
public struct SkillsDetailParams: Codable, Sendable {
public let slug: String
public init(
slug: String)
{
self.slug = slug
}
private enum CodingKeys: String, CodingKey {
case slug
}
}
public struct SkillsDetailResult: Codable, Sendable {
public let skill: AnyCodable
public let latestversion: AnyCodable?
public let metadata: AnyCodable?
public let owner: AnyCodable?
public init(
skill: AnyCodable,
latestversion: AnyCodable?,
metadata: AnyCodable?,
owner: AnyCodable?)
{
self.skill = skill
self.latestversion = latestversion
self.metadata = metadata
self.owner = owner
}
private enum CodingKeys: String, CodingKey {
case skill
case latestversion = "latestVersion"
case metadata
case owner
}
}
public struct CronJob: Codable, Sendable {
public let id: String
public let agentid: String?

View File

@@ -13,7 +13,6 @@ private extension NSLock {
private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let lock = NSLock()
private let helloAuth: [String: Any]?
private var _state: URLSessionTask.State = .suspended
private var connectRequestId: String?
private var connectAuth: [String: Any]?
@@ -21,10 +20,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private var pendingReceiveHandler:
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
init(helloAuth: [String: Any]? = nil) {
self.helloAuth = helloAuth
}
var state: URLSessionTask.State {
get { self.lock.withLock { self._state } }
set { self.lock.withLock { self._state = newValue } }
@@ -84,11 +79,11 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
for _ in 0..<50 {
let id = self.lock.withLock { self.connectRequestId }
if let id {
return .data(Self.connectOkData(id: id, auth: self.helloAuth))
return .data(Self.connectOkData(id: id))
}
try await Task.sleep(nanoseconds: 1_000_000)
}
return .data(Self.connectOkData(id: "connect", auth: self.helloAuth))
return .data(Self.connectOkData(id: "connect"))
}
func receive(
@@ -115,8 +110,8 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
}
private static func connectOkData(id: String, auth: [String: Any]? = nil) -> Data {
var payload: [String: Any] = [
private static func connectOkData(id: String) -> Data {
let payload: [String: Any] = [
"type": "hello-ok",
"protocol": 2,
"server": [
@@ -142,9 +137,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
"tickIntervalMs": 30_000,
],
]
if let auth {
payload["auth"] = auth
}
let frame: [String: Any] = [
"type": "res",
"id": id,
@@ -157,14 +149,9 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let lock = NSLock()
private let helloAuth: [String: Any]?
private var tasks: [FakeGatewayWebSocketTask] = []
private var makeCount = 0
init(helloAuth: [String: Any]? = nil) {
self.helloAuth = helloAuth
}
func snapshotMakeCount() -> Int {
self.lock.withLock { self.makeCount }
}
@@ -177,7 +164,7 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
_ = url
return self.lock.withLock {
self.makeCount += 1
let task = FakeGatewayWebSocketTask(helloAuth: self.helloAuth)
let task = FakeGatewayWebSocketTask()
self.tasks.append(task)
return WebSocketTaskBox(task: task)
}
@@ -247,145 +234,6 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let identity = DeviceIdentityStore.loadOrCreate()
let session = FakeGatewayWebSocketSession(helloAuth: [
"deviceToken": "node-device-token",
"role": "node",
"scopes": [],
"issuedAtMs": 1000,
"deviceTokens": [
[
"deviceToken": "node-device-token",
"role": "node",
"scopes": ["operator.admin"],
"issuedAtMs": 1000,
],
[
"deviceToken": "operator-device-token",
"role": "operator",
"scopes": [
"operator.admin",
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
"issuedAtMs": 1001,
],
],
])
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
let nodeEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node"))
let operatorEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator"))
#expect(nodeEntry.token == "node-device-token")
#expect(nodeEntry.scopes == [])
#expect(operatorEntry.token == "operator-device-token")
#expect(operatorEntry.scopes.contains("operator.approvals"))
#expect(!operatorEntry.scopes.contains("operator.admin"))
await gateway.disconnect()
}
@Test
func nonBootstrapHelloDoesNotOverwriteStoredDeviceTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let identity = DeviceIdentityStore.loadOrCreate()
let session = FakeGatewayWebSocketSession(helloAuth: [
"deviceToken": "server-node-token",
"role": "node",
"scopes": [],
"deviceTokens": [
[
"deviceToken": "server-operator-token",
"role": "operator",
"scopes": ["operator.admin"],
],
],
])
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
token: "shared-token",
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node") == nil)
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") == nil)
await gateway.disconnect()
}
@Test
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
let normalized = canonicalizeCanvasHostUrl(

View File

@@ -3,7 +3,9 @@
These baseline artifacts are generated from the repo-owned OpenClaw config schema and bundled channel/plugin metadata.
- Do not edit `config-baseline.json` by hand.
- Do not edit `config-baseline.jsonl` by hand.
- Do not edit `config-baseline.core.json` by hand.
- Do not edit `config-baseline.channel.json` by hand.
- Do not edit `config-baseline.plugin.json` by hand.
- Do not edit `plugin-sdk-api-baseline.json` by hand.
- Do not edit `plugin-sdk-api-baseline.jsonl` by hand.
- Regenerate config baseline artifacts with `pnpm config:docs:gen`.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -320,6 +320,9 @@ Notes:
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
Common confusion: DM pairing approval is not the same as group authorization.
For channels that support DM pairing, the pairing store unlocks DMs only. Group commands still require explicit group sender authorization from config allowlists such as `groupAllowFrom` or the documented config fallback for that channel.
Common intents (copy/paste):
1. Disable all group replies

View File

@@ -654,7 +654,7 @@ Matrix can act as an exec approval client for a Matrix account.
- `channels.matrix.execApprovals.agentFilter`
- `channels.matrix.execApprovals.sessionFilter`
Matrix becomes an exec approval client when `enabled` is true and at least one approver can be resolved. Approvers must be Matrix user IDs such as `@owner:example.org`.
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.
Delivery rules:

View File

@@ -54,6 +54,9 @@ Account scoping behavior:
Treat these as sensitive (they gate access to your assistant).
Important: this store is for DM access. Group authorization is separate.
Approving a DM pairing code does not automatically allow that sender to run group commands or control the bot in groups. For group access, configure the channel's explicit group allowlists (for example `groupAllowFrom`, `groups`, or per-group/per-topic overrides depending on the channel).
## 2) Node device pairing (iOS/Android/macOS/headless nodes)
Nodes connect to the Gateway as **devices** with `role: node`. The Gateway

View File

@@ -430,6 +430,7 @@ Notes:
"files:read",
"files:write",
"groups:history",
"groups:read",
"im:history",
"im:read",
"im:write",

View File

@@ -121,6 +121,10 @@ Token resolution order is account-aware. In practice, config values win over env
For one-owner bots, prefer `dmPolicy: "allowlist"` with explicit numeric `allowFrom` IDs to keep access policy durable in config (instead of depending on previous pairing approvals).
Common confusion: DM pairing approval does not mean "this sender is authorized everywhere".
Pairing grants DM access only. Group sender authorization still comes from explicit config allowlists.
If you want "I am authorized once and both DMs and group commands work", put your numeric Telegram user ID in `channels.telegram.allowFrom`.
### Finding your Telegram user ID
Safer (no third-party bot):
@@ -159,6 +163,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
Non-numeric entries are ignored for sender authorization.
Security boundary (`2026.2.25+`): group sender auth does **not** inherit DM pairing-store approvals.
Pairing stays DM-only. For groups, set `groupAllowFrom` or per-group/per-topic `allowFrom`.
If `groupAllowFrom` is unset, Telegram falls back to config `allowFrom`, not the pairing store.
Practical pattern for one-owner bots: set your user ID in `channels.telegram.allowFrom`, leave `groupAllowFrom` unset, and allow the target groups under `channels.telegram.groups`.
Runtime note: if `channels.telegram` is completely missing, runtime defaults to fail-closed `groupPolicy="allowlist"` unless `channels.defaults.groupPolicy` is explicitly set.
Example: allow any member in one specific group:
@@ -914,6 +920,33 @@ channels:
autoSelectFamily: false
```
- RFC 2544 benchmark-range answers (`198.18.0.0/15`) are already allowed
for Telegram media downloads by default. If a trusted fake-IP or
transparent proxy rewrites `api.telegram.org` to some other
private/internal/special-use address during media downloads, you can opt
in to the Telegram-only bypass:
```yaml
channels:
telegram:
network:
dangerouslyAllowPrivateNetwork: true
```
- The same opt-in is available per account at
`channels.telegram.accounts.<accountId>.network.dangerouslyAllowPrivateNetwork`.
- If your proxy resolves Telegram media hosts into `198.18.x.x`, leave the
dangerous flag off first. Telegram media already allows the RFC 2544
benchmark range by default.
<Warning>
`channels.telegram.network.dangerouslyAllowPrivateNetwork` weakens Telegram
media SSRF protections. Use it only for trusted operator-controlled proxy
environments such as Clash, Mihomo, or Surge fake-IP routing when they
synthesize private or special-use answers outside the RFC 2544 benchmark
range. Leave it off for normal public internet Telegram access.
</Warning>
- Environment overrides (temporary):
- `OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY=1`
- `OPENCLAW_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY=1`
@@ -980,6 +1013,7 @@ Primary reference:
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.
- `channels.telegram.network.dnsResultOrder`: override DNS result order (`ipv4first` or `verbatim`). Defaults to `ipv4first` on Node 22+.
- `channels.telegram.network.dangerouslyAllowPrivateNetwork`: dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve `api.telegram.org` to private/internal/special-use addresses outside the default RFC 2544 benchmark-range allowance.
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
- `channels.telegram.webhookUrl`: enable webhook mode (requires `channels.telegram.webhookSecret`).
- `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set).
@@ -1006,7 +1040,7 @@ Telegram-specific high-signal fields:
- threading/replies: `replyToMode`
- streaming: `streaming` (preview), `blockStreaming`
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy`
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy`
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
- actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker`
- reactions: `reactionNotifications`, `reactionLevel`

View File

@@ -13,6 +13,7 @@ Related:
- Multi-agent routing: [Multi-Agent Routing](/concepts/multi-agent)
- Agent workspace: [Agent workspace](/concepts/agent-workspace)
- Skill visibility config: [Skills config](/tools/skills-config)
## Examples
@@ -31,6 +32,11 @@ openclaw agents delete work
Use routing bindings to pin inbound channel traffic to a specific agent.
If you also want different visible skills per agent, configure
`agents.defaults.skills` and `agents.list[].skills` in `openclaw.json`. See
[Skills config](/tools/skills-config) and
[Configuration Reference](/gateway/configuration-reference#agentsdefaultsskills).
List bindings:
```bash

View File

@@ -36,6 +36,8 @@ openclaw gateway run
Notes:
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.openclaw/openclaw.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
- `openclaw onboard --mode local` and `openclaw setup` are expected to write `gateway.mode=local`. If the file exists but `gateway.mode` is missing, treat that as a broken or clobbered config and repair it instead of assuming local mode implicitly.
- If the file exists and `gateway.mode` is missing, the Gateway treats that as suspicious config damage and refuses to “guess local” for you.
- Binding beyond loopback without auth is blocked (safety guardrail).
- `SIGUSR1` triggers an in-process restart when authorized (`commands.restart` is enabled by default; set `commands.restart: false` to block manual restart, while gateway tool/config apply/update remain allowed).
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they dont restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
@@ -50,7 +52,7 @@ Notes:
- `--password-file <path>`: read the gateway password from a file.
- `--tailscale <off|serve|funnel>`: expose the Gateway via Tailscale.
- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
- `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config.
- `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config. This bypasses the startup guard for ad-hoc/dev bootstrap only; it does not write or repair the config file.
- `--dev`: create a dev config + workspace if missing (skips BOOTSTRAP.md).
- `--reset`: reset dev config + credentials + sessions + workspace (requires `--dev`).
- `--force`: kill any existing listener on the selected port before starting.

View File

@@ -82,6 +82,8 @@ Gateway token options in non-interactive mode:
- With `--install-daemon`, when token auth requires a token, SecretRef-managed gateway tokens are validated but not persisted as resolved plaintext in supervisor service environment metadata.
- With `--install-daemon`, if token mode requires a token and the configured token SecretRef is unresolved, onboarding fails closed with remediation guidance.
- With `--install-daemon`, if both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, onboarding blocks install until mode is set explicitly.
- Local onboarding writes `gateway.mode="local"` into the config. If a later config file is missing `gateway.mode`, treat that as config damage or an incomplete manual edit, not as a valid local-mode shortcut.
- `--allow-unconfigured` is a separate gateway runtime escape hatch. It does not mean onboarding may omit `gateway.mode`.
Example:

View File

@@ -64,7 +64,7 @@ when the built-in scanner reports `critical` findings, but it does **not**
bypass plugin `before_install` hook policy blocks and does **not** bypass scan
failures.
This CLI flag applies to `openclaw plugins install`. Gateway-backed skill
This CLI flag applies to plugin install/update flows. Gateway-backed skill
dependency installs use the matching `dangerouslyForceUnsafeInstall` request
override, while `openclaw skills install` remains a separate ClawHub skill
download/install flow.
@@ -185,6 +185,7 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins update <id-or-npm-spec> --dry-run
openclaw plugins update @openclaw/voice-call@beta
openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install
```
Updates apply to tracked installs in `plugins.installs` and tracked hook-pack
@@ -203,6 +204,12 @@ When a stored integrity hash exists and the fetched artifact hash changes,
OpenClaw prints a warning and asks for confirmation before proceeding. Use
global `--yes` to bypass prompts in CI/non-interactive runs.
`--dangerously-force-unsafe-install` is also available on `plugins update` as a
break-glass override for built-in dangerous-code scan false positives during
plugin updates. It still does not bypass plugin `before_install` policy blocks
or scan-failure blocking, and it only applies to plugin updates, not hook-pack
updates.
### Inspect
```bash

View File

@@ -1,14 +1,14 @@
---
summary: "CLI reference for `openclaw qr` (generate iOS pairing QR + setup code)"
summary: "CLI reference for `openclaw qr` (generate mobile pairing QR + setup code)"
read_when:
- You want to pair the iOS app with a gateway quickly
- You want to pair a mobile node app with a gateway quickly
- You need setup-code output for remote/manual sharing
title: "qr"
---
# `openclaw qr`
Generate an iOS pairing QR and setup code from your current Gateway configuration.
Generate a mobile pairing QR and setup code from your current Gateway configuration.
## Usage
@@ -35,6 +35,7 @@ openclaw qr --url wss://gateway.example/ws
- `--token` and `--password` are mutually exclusive.
- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password.
- Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN `ws://` remains supported, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL.
- With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast.
- Without `--remote`, local gateway auth SecretRefs are resolved when no CLI auth override is passed:
- `gateway.auth.token` resolves when token auth can win (explicit `gateway.auth.mode="token"` or inferred mode where no password source wins).

View File

@@ -111,7 +111,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
- `skills/` (optional)
- Workspace-specific skills.
- Overrides managed/bundled skills when names collide.
- Highest-precedence skill location for that workspace.
- Overrides project agent skills, personal agent skills, managed skills, bundled skills, and `skills.load.extraDirs` when names collide.
- `canvas/` (optional)
- Canvas UI files for node displays (for example `canvas/index.html`).

View File

@@ -55,11 +55,14 @@ guidance for how _you_ want them used.
## Skills
OpenClaw loads skills from three locations (workspace wins on name conflict):
OpenClaw loads skills from these locations (highest precedence first):
- Bundled (shipped with the install)
- Managed/local: `~/.openclaw/skills`
- Workspace: `<workspace>/skills`
- Project agent skills: `<workspace>/.agents/skills`
- Personal agent skills: `~/.agents/skills`
- Managed/local: `~/.openclaw/skills`
- Bundled (shipped with the install)
- Extra skill folders: `skills.load.extraDirs`
Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)).

View File

@@ -27,8 +27,12 @@ Main agent credentials are **not** shared automatically. Never reuse `agentDir`
across agents (it causes auth/session collisions). If you want to share creds,
copy `auth-profiles.json` into the other agent's `agentDir`.
Skills are per-agent via each workspaces `skills/` folder, with shared skills
available from `~/.openclaw/skills`. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills).
Skills are loaded from each agent workspace plus shared roots such as
`~/.openclaw/skills`, then filtered by the effective agent skill allowlist when
configured. Use `agents.defaults.skills` for a shared baseline and
`agents.list[].skills` for per-agent replacement. See
[Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and
[Skills: agent skill allowlists](/tools/skills#agent-skill-allowlists).
The Gateway can host **one agent** (default) or **many agents** side-by-side.

View File

@@ -110,6 +110,10 @@ prompt instructs the model to use `read` to load the SKILL.md at the listed
location (workspace, managed, or bundled). If no skills are eligible, the
Skills section is omitted.
Eligibility includes skill metadata gates, runtime environment/config checks,
and the effective agent skill allowlist when `agents.defaults.skills` or
`agents.list[].skills` is configured.
```
<available_skills>
<skill>

View File

@@ -250,6 +250,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
"openai/gpt-5.2": { alias: "gpt" },
},
skills: ["github", "weather"], // inherited by agents that omit list[].skills
thinkingDefault: "low",
verboseDefault: "off",
elevatedDefault: "on",
@@ -308,12 +309,14 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
{
id: "main",
default: true,
// inherits defaults.skills -> github, weather
thinkingDefault: "high", // per-agent thinking override
reasoningDefault: "on", // per-agent reasoning visibility
fastModeDefault: false, // per-agent fast mode
},
{
id: "quick",
skills: [], // no skills for this agent
fastModeDefault: true, // this agent always runs fast
thinkingDefault: "off",
},
@@ -462,6 +465,27 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
## Common patterns
### Shared skill baseline with one override
```json5
{
agents: {
defaults: {
workspace: "~/.openclaw/workspace",
skills: ["github", "weather"],
},
list: [
{ id: "main", default: true },
{ id: "docs", workspace: "~/.openclaw/workspace-docs", skills: ["docs-search"] },
],
},
}
```
- `agents.defaults.skills` is the shared baseline.
- `agents.list[].skills` replaces that baseline for one agent.
- Use `skills: []` when an agent should see no skills.
### Multi-platform setup
```json5

View File

@@ -818,6 +818,30 @@ Optional repository root shown in the system prompt's Runtime line. If unset, Op
}
```
### `agents.defaults.skills`
Optional default skill allowlist for agents that do not set
`agents.list[].skills`.
```json5
{
agents: {
defaults: { skills: ["github", "weather"] },
list: [
{ id: "writer" }, // inherits github, weather
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
{ id: "locked-down", skills: [] }, // no skills
],
},
}
```
- Omit `agents.defaults.skills` for unrestricted skills by default.
- Omit `agents.list[].skills` to inherit the defaults.
- Set `agents.list[].skills: []` for no skills.
- A non-empty `agents.list[].skills` list is the final set for that agent; it
does not merge with defaults.
### `agents.defaults.skipBootstrap`
Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`).
@@ -1425,6 +1449,7 @@ scripts/sandbox-browser-setup.sh # optional browser image
reasoningDefault: "on", // per-agent reasoning visibility override
fastModeDefault: false, // per-agent fast mode override
params: { cacheRetention: "none" }, // overrides matching defaults.models params by key
skills: ["docs-search"], // replaces agents.defaults.skills when set
identity: {
name: "Samantha",
theme: "helpful sloth",
@@ -1459,6 +1484,7 @@ scripts/sandbox-browser-setup.sh # optional browser image
- `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default.
- `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`.
- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog.
- `skills`: optional per-agent skill allowlist. If omitted, the agent inherits `agents.defaults.skills` when set; an explicit list replaces defaults instead of merging, and `[]` means no skills.
- `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set.
- `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Applies when no per-message or session reasoning override is set.
- `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set.
@@ -1748,7 +1774,10 @@ Variables are case-insensitive. `{think}` is an alias for `{thinkingLevel}`.
- Per-channel overrides: `channels.<channel>.ackReaction`, `channels.<channel>.accounts.<id>.ackReaction`.
- Resolution order: account → channel → `messages.ackReaction` → identity fallback.
- Scope: `group-mentions` (default), `group-all`, `direct`, `all`.
- `removeAckAfterReply`: removes ack after reply (Slack/Discord/Telegram/Google Chat only).
- `removeAckAfterReply`: removes ack after reply on Slack, Discord, and Telegram.
- `messages.statusReactions.enabled`: enables lifecycle status reactions on Slack, Discord, and Telegram.
On Slack and Discord, unset keeps status reactions enabled when ack reactions are active.
On Telegram, set it explicitly to `true` to enable lifecycle status reactions.
### Inbound debounce
@@ -2118,6 +2147,7 @@ Notes:
agents: {
defaults: {
subagents: {
allowAgents: ["research"],
model: "minimax/MiniMax-M2.7",
maxConcurrent: 8,
runTimeoutSeconds: 900,
@@ -2129,6 +2159,7 @@ Notes:
```
- `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model.
- `allowAgents`: default allowlist of target agent ids for `sessions_spawn` when the requester agent does not set its own `subagents.allowAgents` (`["*"]` = any; default: same agent only).
- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn` when the tool call omits `runTimeoutSeconds`. `0` means no timeout.
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`.
@@ -3032,6 +3063,8 @@ Notes:
billingBackoffHours: 5,
billingBackoffHoursByProvider: { anthropic: 3, openai: 8 },
billingMaxHours: 24,
authPermanentBackoffMinutes: 10,
authPermanentMaxMinutes: 60,
failureWindowHours: 24,
overloadedProfileRotations: 1,
overloadedBackoffMs: 0,
@@ -3044,6 +3077,8 @@ Notes:
- `billingBackoffHours`: base backoff in hours when a profile fails due to billing/insufficient credits (default: `5`).
- `billingBackoffHoursByProvider`: optional per-provider overrides for billing backoff hours.
- `billingMaxHours`: cap in hours for billing backoff exponential growth (default: `24`).
- `authPermanentBackoffMinutes`: base backoff in minutes for high-confidence `auth_permanent` failures (default: `10`).
- `authPermanentMaxMinutes`: cap in minutes for `auth_permanent` backoff growth (default: `60`).
- `failureWindowHours`: rolling window in hours used for backoff counters (default: `24`).
- `overloadedProfileRotations`: maximum same-provider auth-profile rotations for overloaded errors before switching to model fallback (default: `1`).
- `overloadedBackoffMs`: fixed delay before retrying an overloaded provider/profile rotation (default: `0`).

View File

@@ -175,6 +175,33 @@ When validation fails:
</Accordion>
<Accordion title="Restrict skills per agent">
Use `agents.defaults.skills` for a shared baseline, then override specific
agents with `agents.list[].skills`:
```json5
{
agents: {
defaults: {
skills: ["github", "weather"],
},
list: [
{ id: "writer" }, // inherits github, weather
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
{ id: "locked-down", skills: [] }, // no skills
],
},
}
```
- Omit `agents.defaults.skills` for unrestricted skills by default.
- Omit `agents.list[].skills` to inherit the defaults.
- Set `agents.list[].skills: []` for no skills.
- See [Skills](/tools/skills), [Skills config](/tools/skills-config), and
the [Configuration Reference](/gateway/configuration-reference#agentsdefaultsskills).
</Accordion>
<Accordion title="Tune gateway channel health monitoring">
Control how aggressively the gateway restarts channels that look stale:

View File

@@ -75,7 +75,7 @@ Security notes:
- Bonjour/mDNS TXT records are **unauthenticated**. Clients must treat TXT values as UX hints only.
- Routing (host/port) should prefer the **resolved service endpoint** (SRV + A/AAAA) over TXT-provided `lanHost`, `tailnetDns`, or `gatewayPort`.
- TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin.
- iOS/Android nodes should treat discovery-based direct connects as **TLS-only** and require an explicit “trust this fingerprint” confirmation before storing a first-time pin (out-of-band verification).
- iOS/Android nodes should require an explicit “trust this fingerprint” confirmation before storing a first-time pin (out-of-band verification) whenever the chosen route is secure/TLS-based.
Disable/override:
@@ -95,6 +95,13 @@ If the gateway can detect it is running under Tailscale, it publishes `tailnetDn
The macOS app now prefers MagicDNS names over raw Tailscale IPs for gateway discovery. This improves reliability when tailnet IPs change (for example after node restarts or CGNAT reassignment), because MagicDNS names resolve to the current IP automatically.
For mobile node pairing, discovery hints do not relax transport security on tailnet/public routes:
- iOS/Android still require a secure first-time tailnet/public connect path (`wss://` or Tailscale Serve/Funnel).
- A discovered raw tailnet IP is a routing hint, not permission to use plaintext remote `ws://`.
- Private LAN direct-connect `ws://` remains supported.
- If you want the simplest Tailscale path for mobile nodes, use Tailscale Serve so discovery and the setup code both resolve to the same secure MagicDNS endpoint.
### 3) Manual / SSH target
When there is no direct route (or direct is disabled), clients can always connect via SSH by forwarding the loopback gateway port.
@@ -108,6 +115,7 @@ Recommended client behavior:
1. If a paired direct endpoint is configured and reachable, use it.
2. Else, if Bonjour finds a gateway on LAN, offer a one-tap “Use this gateway” choice and save it as the direct endpoint.
3. Else, if a tailnet DNS/IP is configured, try direct.
For mobile nodes on tailnet/public routes, direct means a secure endpoint, not plaintext remote `ws://`.
4. Else, fall back to SSH.
## Pairing + auth (direct transport)

View File

@@ -259,12 +259,12 @@ Events are not replayed. On sequence gaps, refresh state (`health`, `system-pres
## Common failure signatures
| Signature | Likely issue |
| -------------------------------------------------------------- | ---------------------------------------- |
| `refusing to bind gateway ... without auth` | Non-loopback bind without token/password |
| `another gateway instance is already listening` / `EADDRINUSE` | Port conflict |
| `Gateway start blocked: set gateway.mode=local` | Config set to remote mode |
| `unauthorized` during connect | Auth mismatch between client and gateway |
| Signature | Likely issue |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `refusing to bind gateway ... without auth` | Non-loopback bind without token/password |
| `another gateway instance is already listening` / `EADDRINUSE` | Port conflict |
| `Gateway start blocked: set gateway.mode=local` | Config set to remote mode, or local-mode stamp is missing from a damaged config |
| `unauthorized` during connect | Auth mismatch between client and gateway |
For full diagnosis ladders, use [Gateway Troubleshooting](/gateway/troubleshooting).

View File

@@ -477,12 +477,12 @@ Plugins run **in-process** with the Gateway. Treat them as trusted code:
- Prefer explicit `plugins.allow` allowlists.
- Review plugin config before enabling.
- Restart the Gateway after plugin changes.
- If you install plugins (`openclaw plugins install <package>`), treat it like running untrusted code:
- If you install or update plugins (`openclaw plugins install <package>`, `openclaw plugins update <id>`), treat it like running untrusted code:
- The install path is the per-plugin directory under the active plugin install root.
- OpenClaw runs a built-in dangerous-code scan before install. `critical` findings block by default.
- OpenClaw runs a built-in dangerous-code scan before install/update. `critical` findings block by default.
- OpenClaw uses `npm pack` and then runs `npm install --omit=dev` in that directory (npm lifecycle scripts can execute code during install).
- Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling.
- `--dangerously-force-unsafe-install` is break-glass only for built-in scan false positives. It does not bypass plugin `before_install` hook policy blocks and does not bypass scan failures.
- `--dangerously-force-unsafe-install` is break-glass only for built-in scan false positives on plugin install/update flows. It does not bypass plugin `before_install` hook policy blocks and does not bypass scan failures.
- Gateway-backed skill dependency installs follow the same dangerous/suspicious split: built-in `critical` findings block unless the caller explicitly sets `dangerouslyForceUnsafeInstall`, while suspicious findings still warn only. `openclaw skills install` remains the separate ClawHub skill download/install flow.
Details: [Plugins](/tools/plugin)
@@ -1015,7 +1015,7 @@ Important: `tools.elevated` is the global baseline escape hatch that runs exec o
If you allow session tools, treat delegated sub-agent runs as another boundary decision:
- Deny `sessions_spawn` unless the agent truly needs delegation.
- Keep `agents.list[].subagents.allowAgents` restricted to known-safe target agents.
- Keep `agents.defaults.subagents.allowAgents` and any per-agent `agents.list[].subagents.allowAgents` overrides restricted to known-safe target agents.
- For any workflow that must remain sandboxed, call `sessions_spawn` with `sandbox: "require"` (default is `inherit`).
- `sandbox: "require"` fails fast when the target child runtime is not sandboxed.

View File

@@ -170,7 +170,7 @@ Look for:
Common signatures:
- `Gateway start blocked: set gateway.mode=local` → local gateway mode is not enabled. Fix: set `gateway.mode="local"` in your config (or run `openclaw configure`). If you are running OpenClaw via Podman, the default config path is `~/.openclaw/openclaw.json`.
- `Gateway start blocked: set gateway.mode=local` or `existing config is missing gateway.mode` → local gateway mode is not enabled, or the config file was clobbered and lost `gateway.mode`. Fix: set `gateway.mode="local"` in your config, or re-run `openclaw onboard --mode local` / `openclaw setup` to restamp the expected local-mode config. If you are running OpenClaw via Podman, the default config path is `~/.openclaw/openclaw.json`.
- `refusing to bind gateway ... without auth` → non-loopback bind without token/password.
- `another gateway instance is already listening` / `EADDRINUSE` → port conflict.

View File

@@ -946,11 +946,11 @@ for usage/billing and raise limits as needed.
<AccordionGroup>
<Accordion title="How do I customize skills without keeping the repo dirty?">
Use managed overrides instead of editing the repo copy. Put your changes in `~/.openclaw/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.openclaw/openclaw.json`). Precedence is `<workspace>/skills` > `~/.openclaw/skills` > bundled, so managed overrides win without touching git. Only upstream-worthy edits should live in the repo and go out as PRs.
Use managed overrides instead of editing the repo copy. Put your changes in `~/.openclaw/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.openclaw/openclaw.json`). Precedence is `<workspace>/skills` → `<workspace>/.agents/skills` → `~/.agents/skills` → `~/.openclaw/skills` bundled → `skills.load.extraDirs`, so managed overrides still win over bundled skills without touching git. If you need the skill installed globally but only visible to some agents, keep the shared copy in `~/.openclaw/skills` and control visibility with `agents.defaults.skills` and `agents.list[].skills`. Only upstream-worthy edits should live in the repo and go out as PRs.
</Accordion>
<Accordion title="Can I load skills from a custom folder?">
Yes. Add extra directories via `skills.load.extraDirs` in `~/.openclaw/openclaw.json` (lowest precedence). Default precedence remains: `<workspace>/skills` → `~/.openclaw/skills` → bundled → `skills.load.extraDirs`. `clawhub` installs into `./skills` by default, which OpenClaw treats as `<workspace>/skills` on the next session.
Yes. Add extra directories via `skills.load.extraDirs` in `~/.openclaw/openclaw.json` (lowest precedence). Default precedence is `<workspace>/skills` → `<workspace>/.agents/skills` → `~/.agents/skills` → `~/.openclaw/skills` → bundled → `skills.load.extraDirs`. `clawhub` installs into `./skills` by default, which OpenClaw treats as `<workspace>/skills` on the next session. If the skill should only be visible to certain agents, pair that with `agents.defaults.skills` or `agents.list[].skills`.
</Accordion>
<Accordion title="How can I use different models for different tasks?">
@@ -1030,7 +1030,7 @@ for usage/billing and raise limits as needed.
openclaw skills update --all
```
Install the separate `clawhub` CLI only if you want to publish or sync your own skills.
Install the separate `clawhub` CLI only if you want to publish or sync your own skills. For shared installs across agents, put the skill under `~/.openclaw/skills` and use `agents.defaults.skills` or `agents.list[].skills` if you want to narrow which agents can see it.
</Accordion>
@@ -1106,7 +1106,7 @@ for usage/billing and raise limits as needed.
openclaw skills update --all
```
Native installs land in the active workspace `skills/` directory. For shared skills across agents, place them in `~/.openclaw/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawHub](/tools/clawhub).
Native installs land in the active workspace `skills/` directory. For shared skills across agents, place them in `~/.openclaw/skills/<name>/SKILL.md`. If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
</Accordion>

View File

@@ -24,6 +24,8 @@ Most days:
- Full gate (expected before push): `pnpm build && pnpm check && pnpm test`
- Faster local full-suite run on a roomy machine: `pnpm test:max`
- Direct Vitest watch loop (modern projects config): `pnpm test:watch`
- Direct file targeting now routes extension/channel paths too: `pnpm test -- extensions/discord/src/monitor/message-handler.preflight.test.ts`
When you touch tests or want extra confidence:
@@ -44,8 +46,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
### Unit / integration (default)
- Command: `pnpm test`
- Config: `scripts/test-parallel.mjs` (runs `vitest.unit.config.ts`, `vitest.extensions.config.ts`, `vitest.gateway.config.ts`)
- Files: `src/**/*.test.ts`, bundled plugin `**/*.test.ts`
- Config: native Vitest `projects` via `vitest.config.ts` (`unit` + `boundary`)
- Files: core/unit inventories under `src/**/*.test.ts`, `packages/**/*.test.ts`, `test/**/*.test.ts`, and the whitelisted `ui` node tests covered by `vitest.unit.config.ts`
- Scope:
- Pure unit tests
- In-process integration tests (gateway auth, routing, tooling, parsing, config)
@@ -54,22 +56,10 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Runs in CI
- No real keys required
- Should be fast and stable
- Scheduler note:
- `pnpm test` now keeps a small checked-in behavioral manifest for true pool/isolation overrides and a separate timing snapshot for the slowest unit files.
- Extension-only local runs now also use a checked-in extensions timing snapshot plus a slightly coarser shared batch target on high-memory hosts, so the shared extensions lane avoids spawning an extra batch when two measured shared runs are enough.
- High-memory local extension shared batches also run with a slightly higher worker cap than before, which shortened the two remaining shared extension batches without changing the isolated extension lanes.
- High-memory local channel runs now reuse the checked-in channel timing snapshot to split the shared channels lane into a few measured batches instead of one long shared worker.
- High-memory local channel shared batches also run with a slightly lower worker cap than shared unit batches, which helped targeted channel reruns avoid CPU oversubscription once isolated channel lanes are already in flight.
- Targeted local channel reruns now start splitting shared channel work a bit earlier, which keeps medium-sized targeted reruns from leaving one oversized shared channel batch on the critical path.
- Targeted local unit reruns also split medium-sized shared unit selections into measured batches, which helps large focused reruns overlap instead of waiting behind one long shared unit lane.
- High-memory local multi-surface runs also use slightly coarser shared `unit-fast` batches so the mixed planner spends less time spinning up extra shared unit workers before the later surfaces can overlap.
- Shared unit, extension, channel, and gateway runs all stay on Vitest `forks`.
- The wrapper keeps measured fork-isolated exceptions and heavy singleton lanes explicit in `test/fixtures/test-parallel.behavior.json`.
- The wrapper peels the heaviest measured files into dedicated lanes instead of relying on a growing hand-maintained exclusion list.
- CLI startup benchmarking now has distinct saved outputs: `pnpm test:startup:bench:smoke` writes the targeted smoke artifact at `.artifacts/cli-startup-bench-smoke.json`, `pnpm test:startup:bench:save` writes the full-suite artifact at `.artifacts/cli-startup-bench-all.json` with `runs=5` and `warmup=1`, and `pnpm test:startup:bench:update` refreshes the checked-in fixture at `test/fixtures/cli-startup-bench.json` with `runs=5` and `warmup=1`.
- For surface-only local runs, unit, extension, and channel shared lanes can overlap their isolated hotspots instead of waiting behind one serial prefix.
- For multi-surface local runs, the wrapper keeps the shared surface phases ordered, but batches inside the same shared phase now fan out together, deferred isolated work can overlap the next shared phase, and spare `unit-fast` headroom now starts that deferred work earlier instead of leaving those slots idle.
- Refresh the timing snapshots with `pnpm test:perf:update-timings` and `pnpm test:perf:update-timings:extensions` after major suite shape changes.
- Projects note:
- `pnpm test` and `pnpm test:watch` both use the same native Vitest `projects` config now.
- The tiny script wrapper still keeps scheduling native, but it now reroutes direct `extensions/...` and channel-surface test paths onto the matching Vitest lane automatically.
- If you target mixed suites in one command, the wrapper runs those lanes sequentially under the same local heavy-check lock.
- Embedded runner note:
- When you change message-tool discovery inputs or compaction runtime context,
keep both levels of coverage.
@@ -83,19 +73,17 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
sufficient substitute for those integration paths.
- Pool note:
- Base Vitest config still defaults to `forks`.
- Unit, channel, extension, and gateway wrapper lanes all default to `forks`.
- Unit and boundary projects stay on `forks`.
- Channel, extension, and gateway configs also stay on `forks`.
- Unit, channel, and extension configs default to `isolate: false` for faster file startup.
- `pnpm test` also passes `--isolate=false` at the wrapper level.
- Opt back into Vitest file isolation with `OPENCLAW_TEST_ISOLATE=1 pnpm test`.
- `pnpm test` inherits the isolation defaults from the root `vitest.config.ts` projects config.
- Opt back into unit-file isolation with `OPENCLAW_TEST_ISOLATE=1 pnpm test`.
- `OPENCLAW_TEST_NO_ISOLATE=0` or `OPENCLAW_TEST_NO_ISOLATE=false` also force isolated runs.
- Fast-local iteration note:
- `pnpm test:changed` runs the wrapper with `--changed origin/main`.
- `pnpm test:changed:max` keeps the same changed-file filter but uses the wrapper's aggressive local planner profile.
- `pnpm test:max` exposes that same planner profile for a full local run.
- On supported local Node versions, including Node 25, the normal profile can use top-level lane parallelism. `pnpm test:max` still pushes the planner harder when you want a more aggressive local run.
- The base Vitest config marks the wrapper manifests/config files as `forceRerunTriggers` so changed-mode reruns stay correct when scheduler inputs change.
- The wrapper keeps `OPENCLAW_VITEST_FS_MODULE_CACHE` enabled on supported hosts, but assigns a lane-local `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH` so concurrent Vitest processes do not race on one shared experimental cache directory.
- Set `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/abs/path` if you want one explicit cache location for direct single-run profiling.
- `pnpm test:changed` runs the native projects config with `--changed origin/main`.
- `pnpm test:max` and `pnpm test:changed:max` keep the same native projects config, just with a higher worker cap.
- The base Vitest config marks the projects/config files as `forceRerunTriggers` so changed-mode reruns stay correct when test wiring changes.
- The config keeps `OPENCLAW_VITEST_FS_MODULE_CACHE` enabled on supported hosts; set `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/abs/path` if you want one explicit cache location for direct profiling.
- Perf-debug note:
- `pnpm test:perf:imports` enables Vitest import-duration reporting plus import-breakdown output.
- `pnpm test:perf:imports:changed` scopes the same profiling view to files changed since `origin/main`.

View File

@@ -165,7 +165,7 @@ flowchart TD
Common log signatures:
- `Gateway start blocked: set gateway.mode=local` → gateway mode is unset/remote.
- `Gateway start blocked: set gateway.mode=local` or `existing config is missing gateway.mode` → gateway mode is remote, or the config file is missing the local-mode stamp and should be repaired.
- `refusing to bind gateway ... without auth` → non-loopback bind without token/password.
- `another gateway instance is already listening` or `EADDRINUSE` → port already taken.

View File

@@ -297,7 +297,7 @@ The lock file is at `/data/gateway.*.lock` (not in a subdirectory).
### Config Not Being Read
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/openclaw.json` should be read on restart.
`--allow-unconfigured` only bypasses the startup guard. It does not create or repair `/data/openclaw.json`, so make sure your real config exists and includes `gateway.mode="local"` when you want a normal local gateway start.
Verify the config exists:

View File

@@ -502,9 +502,7 @@ if (sandboxRoot) {
### Google/Gemini
- Turn ordering fixes (`applyGoogleTurnOrderingFix`)
- Tool schema sanitization (`sanitizeToolsForGoogle`)
- Session history sanitization (`sanitizeSessionHistory`)
- Plugin-owned tool schema sanitization
### OpenAI

View File

@@ -27,7 +27,13 @@ System control (launchd/systemd) lives on the Gateway host. See [Gateway](/gatew
Android node app ⇄ (mDNS/NSD + WebSocket) ⇄ **Gateway**
Android connects directly to the Gateway WebSocket (default `ws://<host>:18789`) and uses device pairing (`role: node`).
Android connects directly to the Gateway WebSocket and uses device pairing (`role: node`).
For Tailscale or public hosts, Android requires a secure endpoint:
- Preferred: Tailscale Serve / Funnel with `https://<magicdns>` / `wss://<magicdns>`
- Also supported: any other `wss://` Gateway URL with a real TLS endpoint
- Cleartext `ws://` remains supported on private LAN addresses / `.local` hosts, plus `localhost`, `127.0.0.1`, and the Android emulator bridge (`10.0.2.2`)
### Prerequisites
@@ -36,6 +42,7 @@ Android connects directly to the Gateway WebSocket (default `ws://<host>:18789`)
- Same LAN with mDNS/NSD, **or**
- Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or**
- Manual gateway host/port (fallback)
- Tailnet/public mobile pairing does **not** use raw tailnet IP `ws://` endpoints. Use Tailscale Serve or another `wss://` URL instead.
- You can run the CLI (`openclaw`) on the gateway machine (or via SSH).
### 1) Start the Gateway
@@ -48,10 +55,13 @@ Confirm in logs you see something like:
- `listening on ws://0.0.0.0:18789`
For tailnet-only setups (recommended for Vienna ⇄ London), bind the gateway to the tailnet IP:
For remote Android access over Tailscale, prefer Serve/Funnel instead of a raw tailnet bind:
- Set `gateway.bind: "tailnet"` in `~/.openclaw/openclaw.json` on the gateway host.
- Restart the Gateway / macOS menubar app.
```bash
openclaw gateway --tailscale serve
```
This gives Android a secure `wss://` / `https://` endpoint. A plain `gateway.bind: "tailnet"` setup is not enough for first-time remote Android pairing unless you also terminate TLS separately.
### 2) Verify discovery (optional)
@@ -65,7 +75,9 @@ More debugging notes: [Bonjour](/gateway/bonjour).
#### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
Android NSD/mDNS discovery wont cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
Android NSD/mDNS discovery wont cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead.
Discovery alone is not sufficient for tailnet/public Android pairing. The discovered route still needs a secure endpoint (`wss://` or Tailscale Serve):
1. Set up a DNS-SD zone (example `openclaw.internal.`) on the gateway host and publish `_openclaw-gw._tcp` records.
2. Configure Tailscale split DNS for your chosen domain pointing at that DNS server.
@@ -79,7 +91,7 @@ In the Android app:
- The app keeps its gateway connection alive via a **foreground service** (persistent notification).
- Open the **Connect** tab.
- Use **Setup Code** or **Manual** mode.
- If discovery is blocked, use manual host/port (and TLS/token/password when required) in **Advanced controls**.
- If discovery is blocked, use manual host/port in **Advanced controls**. For private LAN hosts, `ws://` still works. For Tailscale/public hosts, turn on TLS and use a `wss://` / Tailscale Serve endpoint.
After the first successful pairing, Android auto-reconnects on launch:

View File

@@ -196,6 +196,12 @@ We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled
plugins should import their own local runtime code directly from their
extension-owned modules.
The same boundary applies to provider-named SDK seams in general: core should
not import channel-specific convenience barrels for Slack, Discord, Signal,
WhatsApp, or similar extensions. If core needs a behavior, either consume the
bundled plugin's own `api.ts` / `runtime-api.ts` barrel or promote the need
into a narrow generic capability in the shared SDK.
For polls specifically, there are two execution paths:
- `outbound.sendPoll` is the shared baseline for channels that fit the common
@@ -998,8 +1004,10 @@ authoring plugins:
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.
- `openclaw/plugin-sdk/channel-runtime` remains only as a compatibility shim.
New code should import the narrower primitives instead.
- `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
shim.
- Bundled extension internals remain private. External plugins should use only
`openclaw/plugin-sdk/*` subpaths. OpenClaw core/test code may use the repo
public entry points under a plugin package root such as `index.js`, `api.js`,

View File

@@ -46,6 +46,14 @@ The old approach caused problems:
The modern plugin SDK fixes this: each import path (`openclaw/plugin-sdk/\<subpath\>`)
is a small, self-contained module with a clear purpose and documented contract.
Legacy provider convenience seams for bundled channels are also gone. Imports
such as `openclaw/plugin-sdk/slack`, `openclaw/plugin-sdk/discord`,
`openclaw/plugin-sdk/signal`, `openclaw/plugin-sdk/whatsapp`, and
`openclaw/plugin-sdk/telegram-core` were private mono-repo shortcuts, not
stable plugin contracts. Use narrow generic SDK subpaths instead. Inside the
bundled plugin workspace, keep provider-owned helpers in that plugin's own
`api.ts` or `runtime-api.ts`.
## How to migrate
<Steps>
@@ -147,7 +155,7 @@ is a small, self-contained module with a clear purpose and documented contract.
| `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types |
| `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` |
| `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` |
| `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities |
| `plugin-sdk/channel-runtime` | Deprecated compatibility shim | Legacy channel runtime utilities only |
| `plugin-sdk/channel-send-result` | Send result types | Reply result types |
| `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` |
| `plugin-sdk/approval-runtime` | Approval prompt helpers | Exec/plugin approval payload, approval capability/profile helpers, native approval routing/runtime helpers |

View File

@@ -32,6 +32,13 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
Each subpath is a small, self-contained module. This keeps startup fast and
prevents circular dependency issues.
Do not add or depend on provider-named convenience seams such as
`openclaw/plugin-sdk/slack`, `openclaw/plugin-sdk/discord`,
`openclaw/plugin-sdk/signal`, or `openclaw/plugin-sdk/whatsapp`. Bundled plugins should compose generic SDK
subpaths inside their own `api.ts` or `runtime-api.ts` barrels, and core should
either use those plugin-local barrels or add a narrow generic SDK contract when
the need is truly cross-channel.
## Subpath reference
The most commonly used subpaths, grouped by purpose. The full list of 100+

View File

@@ -252,7 +252,7 @@ pnpm test:coverage
If local runs cause memory pressure:
```bash
OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test
OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test
```
## Related

View File

@@ -68,5 +68,7 @@ openclaw models set github-copilot/gpt-4o
- Requires an interactive TTY; run it directly in a terminal.
- Copilot model availability depends on your plan; if a model is rejected, try
another ID (for example `github-copilot/gpt-4.1`).
- Claude model IDs use the Anthropic Messages transport automatically; GPT, o-series,
and Gemini models keep the OpenAI Responses transport.
- The login stores a GitHub token in the auth profile store and exchanges it for a
Copilot API token when OpenClaw runs.

View File

@@ -17,17 +17,27 @@ background.
</Warning>
## Recommended: Model Studio (Alibaba Cloud Coding Plan)
## Recommended: Model Studio (Alibaba Cloud)
Use [Model Studio](/providers/qwen_modelstudio) for officially supported access to
Qwen models (Qwen 3.5 Plus, GLM-4.7, Kimi K2.5, and more).
Qwen models (Qwen 3.6 Plus, Qwen 3.5 Plus, GLM-5, Kimi K2.5, and more).
If you want `qwen3.6-plus` directly from Alibaba Cloud, prefer the **Standard
(pay-as-you-go)** Model Studio endpoint. Coding Plan support can lag behind the
public Model Studio catalog.
```bash
# Global endpoint
# Global Coding Plan endpoint
openclaw onboard --auth-choice modelstudio-api-key
# China endpoint
# China Coding Plan endpoint
openclaw onboard --auth-choice modelstudio-api-key-cn
# Global Standard (pay-as-you-go) endpoint
openclaw onboard --auth-choice modelstudio-standard-api-key
# China Standard (pay-as-you-go) endpoint
openclaw onboard --auth-choice modelstudio-standard-api-key-cn
```
See [Model Studio](/providers/qwen_modelstudio) for full setup details.

View File

@@ -13,6 +13,15 @@ The Model Studio provider gives access to Alibaba Cloud models including Qwen
and third-party models hosted on the platform. Two billing plans are supported:
**Standard** (pay-as-you-go) and **Coding Plan** (subscription).
<Info>
If you need **`qwen3.6-plus`**, prefer **Standard (pay-as-you-go)**. Coding
Plan availability can lag behind the public Model Studio catalog, and the
Coding Plan API can reject a model until it appears in your plan's supported
model list.
</Info>
- Provider: `modelstudio`
- Auth: `MODELSTUDIO_API_KEY`
- API: OpenAI-compatible
@@ -71,12 +80,25 @@ override with a custom `baseUrl` in config.
## Available models
- **qwen3.5-plus** (default) — Qwen 3.5 Plus
- **qwen3.6-plus** — Qwen 3.6 Plus
- **qwen3-coder-plus**, **qwen3-coder-next** — Qwen coding models
- **GLM-5** — GLM models via Alibaba
- **Kimi K2.5** — Moonshot AI via Alibaba
- **MiniMax-M2.7** — MiniMax via Alibaba
- **MiniMax-M2.5** — MiniMax via Alibaba
Some models (qwen3.5-plus, kimi-k2.5) support image input. Context windows range from 200K to 1M tokens.
Some models (qwen3.5-plus, qwen3.6-plus, kimi-k2.5) support image input. Context windows range from 200K to 1M tokens. Availability can vary by endpoint and billing plan.
## Qwen 3.6 Plus availability
`qwen3.6-plus` is available on the Standard (pay-as-you-go) Model Studio
endpoints:
- China: `dashscope.aliyuncs.com/compatible-mode/v1`
- Global: `dashscope-intl.aliyuncs.com/compatible-mode/v1`
If the Coding Plan endpoints return an "unsupported model" error for
`qwen3.6-plus`, switch to Standard (pay-as-you-go) instead of the Coding Plan
endpoint/key pair.
## Environment note

View File

@@ -67,7 +67,7 @@ OpenClaw has three public release lanes:
so we do not ship an empty browser dashboard again
- If the release work touched CI planning, extension timing manifests, or fast
test matrices, regenerate and review the planner-owned `checks-fast-extensions`
shard plan via `node scripts/ci-write-manifest-outputs.mjs --workflow ci`
workflow matrix outputs from `.github/workflows/ci.yml`
before approval so release notes do not describe a stale CI layout
- Stable macOS release readiness also includes the updater surfaces:
- the GitHub release must end up with the packaged `.zip`, `.dmg`, and `.dSYM.zip`

View File

@@ -24,6 +24,17 @@ Scope intent:
- `models.providers.*.apiKey`
- `models.providers.*.headers.*`
- `models.providers.*.request.auth.token`
- `models.providers.*.request.auth.value`
- `models.providers.*.request.headers.*`
- `models.providers.*.request.proxy.tls.ca`
- `models.providers.*.request.proxy.tls.cert`
- `models.providers.*.request.proxy.tls.key`
- `models.providers.*.request.proxy.tls.passphrase`
- `models.providers.*.request.tls.ca`
- `models.providers.*.request.tls.cert`
- `models.providers.*.request.tls.key`
- `models.providers.*.request.tls.passphrase`
- `skills.entries.*.apiKey`
- `agents.defaults.memorySearch.remote.apiKey`
- `agents.list[].memorySearch.remote.apiKey`

View File

@@ -440,6 +440,83 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.auth.token",
"configFile": "openclaw.json",
"path": "models.providers.*.request.auth.token",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.auth.value",
"configFile": "openclaw.json",
"path": "models.providers.*.request.auth.value",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.headers.*",
"configFile": "openclaw.json",
"path": "models.providers.*.request.headers.*",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.proxy.tls.ca",
"configFile": "openclaw.json",
"path": "models.providers.*.request.proxy.tls.ca",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.proxy.tls.cert",
"configFile": "openclaw.json",
"path": "models.providers.*.request.proxy.tls.cert",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.proxy.tls.key",
"configFile": "openclaw.json",
"path": "models.providers.*.request.proxy.tls.key",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.proxy.tls.passphrase",
"configFile": "openclaw.json",
"path": "models.providers.*.request.proxy.tls.passphrase",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.tls.ca",
"configFile": "openclaw.json",
"path": "models.providers.*.request.tls.ca",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.tls.cert",
"configFile": "openclaw.json",
"path": "models.providers.*.request.tls.cert",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.tls.key",
"configFile": "openclaw.json",
"path": "models.providers.*.request.tls.key",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.tls.passphrase",
"configFile": "openclaw.json",
"path": "models.providers.*.request.tls.passphrase",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.brave.config.webSearch.apiKey",
"configFile": "openclaw.json",

View File

@@ -12,17 +12,16 @@ title: "Tests"
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests dont collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test:coverage:changed`: Runs unit coverage only for files changed since `origin/main`.
- `pnpm test:changed`: runs the wrapper with `--changed origin/main`. The base Vitest config treats the wrapper manifests/config files as `forceRerunTriggers` so scheduler changes still rerun broadly when needed.
- `pnpm test`: runs the full wrapper. It keeps only a small behavioral override manifest in git, then uses a checked-in timing snapshot to peel the heaviest measured unit files into dedicated lanes.
- Unit files default to `threads` in the wrapper; keep fork-only exceptions documented in `test/fixtures/test-parallel.behavior.json`.
- `pnpm test:channels` now defaults to `threads` via `vitest.channels.config.ts`; the March 22, 2026 direct full-suite control run passed clean without channel-specific fork exceptions.
- `pnpm test:extensions` runs through the wrapper and keeps documented extension fork-only exceptions in `test/fixtures/test-parallel.behavior.json`; the shared extension lane still defaults to `threads`.
- `pnpm test:changed`: runs the native Vitest projects config with `--changed origin/main`. The base config treats the projects/config files as `forceRerunTriggers` so wiring changes still rerun broadly when needed.
- `pnpm test`: runs the native Vitest projects config (`unit` + `boundary`) via a tiny passthrough wrapper so `pnpm test -- <filter>` keeps working.
- Unit, channel, and extension configs default to `pool: "forks"`.
- `pnpm test:channels` runs `vitest.channels.config.ts`.
- `pnpm test:extensions` runs `vitest.extensions.config.ts`.
- `pnpm test:extensions`: runs extension/plugin suites.
- `pnpm test:perf:imports`: enables Vitest import-duration + import-breakdown reporting for the wrapper.
- `pnpm test:perf:imports:changed`: same import profiling, but only for files changed since `origin/main`.
- `pnpm test:perf:profile:main`: writes a CPU profile for the Vitest main thread (`.artifacts/vitest-main-profile`).
- `pnpm test:perf:profile:runner`: writes CPU + heap profiles for the unit runner (`.artifacts/vitest-runner-profile`).
- `pnpm test:perf:update-timings`: refreshes the checked-in slow-file timing snapshot used by `scripts/test-parallel.mjs`.
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `forks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.
@@ -38,9 +37,9 @@ For local PR land/gate checks, run:
- `pnpm test`
- `pnpm check:docs`
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm vitest run <path/to/test>`. For memory-constrained hosts, use:
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm test -- <path/to/test>`. For memory-constrained hosts, use:
- `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test`
- `OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test`
- `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/tmp/openclaw-vitest-cache pnpm test:changed`
## Model latency bench (local keys)

View File

@@ -59,6 +59,7 @@ openclaw gateway --port 18789
```json5
{
gateway: { mode: "local" },
channels: { whatsapp: { allowFrom: ["+15555550123"] } },
}
```

View File

@@ -105,8 +105,10 @@ The YAML frontmatter supports these fields:
| Location | Precedence | Scope |
| ------------------------------- | ---------- | --------------------- |
| `\<workspace\>/skills/` | Highest | Per-agent |
| `\<workspace\>/.agents/skills/` | High | Per-workspace agent |
| `~/.agents/skills/` | Medium | Shared agent profile |
| `~/.openclaw/skills/` | Medium | Shared (all agents) |
| Bundled (shipped with OpenClaw) | Lowest | Global |
| Bundled (shipped with OpenClaw) | Low | Global |
| `skills.load.extraDirs` | Lowest | Custom shared folders |
## Related

View File

@@ -213,6 +213,7 @@ openclaw plugins install <path> # install from local path
openclaw plugins install -l <path> # link (no copy) for dev
openclaw plugins install <spec> --dangerously-force-unsafe-install
openclaw plugins update <id> # update one plugin
openclaw plugins update <id> --dangerously-force-unsafe-install
openclaw plugins update --all # update all
openclaw plugins enable <id>
@@ -220,14 +221,14 @@ openclaw plugins disable <id>
```
`--dangerously-force-unsafe-install` is a break-glass override for false
positives from the built-in dangerous-code scanner. It allows installs to
continue past built-in `critical` findings, but it still does not bypass plugin
`before_install` policy blocks or scan-failure blocking.
positives from the built-in dangerous-code scanner. It allows plugin installs
and plugin updates to continue past built-in `critical` findings, but it still
does not bypass plugin `before_install` policy blocks or scan-failure blocking.
This CLI flag applies to plugin installs only. Gateway-backed skill dependency
installs use the matching `dangerouslyForceUnsafeInstall` request override
instead, while `openclaw skills install` remains the separate ClawHub skill
download/install flow.
This CLI flag applies to plugin install/update flows only. Gateway-backed skill
dependency installs use the matching `dangerouslyForceUnsafeInstall` request
override instead, while `openclaw skills install` remains the separate ClawHub
skill download/install flow.
See [`openclaw plugins` CLI reference](/cli/plugins) for full details.

View File

@@ -8,7 +8,9 @@ title: "Skills Config"
# Skills Config
All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.json`.
Most skills loader/install configuration lives under `skills` in
`~/.openclaw/openclaw.json`. Agent-specific skill visibility lives under
`agents.defaults.skills` and `agents.list[].skills`.
```json5
{
@@ -51,6 +53,35 @@ Examples:
- Native Nano Banana-style setup: `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"`
- Native fal setup: `agents.defaults.imageGenerationModel.primary: "fal/fal-ai/flux/dev"`
## Agent skill allowlists
Use agent config when you want the same machine/workspace skill roots, but a
different visible skill set per agent.
```json5
{
agents: {
defaults: {
skills: ["github", "weather"],
},
list: [
{ id: "writer" }, // inherits defaults -> github, weather
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
{ id: "locked-down", skills: [] }, // no skills
],
},
}
```
Rules:
- `agents.defaults.skills`: shared baseline allowlist for agents that omit
`agents.list[].skills`.
- Omit `agents.defaults.skills` to leave skills unrestricted by default.
- `agents.list[].skills`: explicit final skill set for that agent; it does not
merge with defaults.
- `agents.list[].skills: []`: expose no skills for that agent.
## Fields
- Built-in skill roots always include `~/.openclaw/skills`, `~/.agents/skills`,
@@ -65,6 +96,10 @@ Examples:
This only affects **skill installs**; the Gateway runtime should still be Node
(Bun not recommended for WhatsApp/Telegram).
- `entries.<skillKey>`: per-skill overrides.
- `agents.defaults.skills`: optional default skill allowlist inherited by agents
that omit `agents.list[].skills`.
- `agents.list[].skills`: optional per-agent final skill allowlist; explicit
lists replace inherited defaults instead of merging.
Per-skill fields:

View File

@@ -43,6 +43,42 @@ If the same skill name exists in more than one place, the usual precedence
applies: workspace wins, then project agent skills, then personal agent skills,
then managed/local, then bundled, then extra dirs.
## Agent skill allowlists
Skill **location** and skill **visibility** are separate controls.
- Location/precedence decides which copy of a same-named skill wins.
- Agent allowlists decide which visible skills an agent can actually use.
Use `agents.defaults.skills` for a shared baseline, then override per agent with
`agents.list[].skills`:
```json5
{
agents: {
defaults: {
skills: ["github", "weather"],
},
list: [
{ id: "writer" }, // inherits github, weather
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
{ id: "locked-down", skills: [] }, // no skills
],
},
}
```
Rules:
- Omit `agents.defaults.skills` for unrestricted skills by default.
- Omit `agents.list[].skills` to inherit `agents.defaults.skills`.
- Set `agents.list[].skills: []` for no skills.
- A non-empty `agents.list[].skills` list is the final set for that agent; it
does not merge with defaults.
OpenClaw applies the effective agent skill set across prompt building, skill
slash-command discovery, sandbox sync, and skill snapshots.
## Plugins + skills
Plugins can ship their own skills by listing `skills` directories in
@@ -267,6 +303,10 @@ OpenClaw snapshots the eligible skills **when a session starts** and reuses that
Skills can also refresh mid-session when the skills watcher is enabled or when a new eligible remote node appears (see below). Think of this as a **hot reload**: the refreshed list is picked up on the next agent turn.
If the effective agent skill allowlist changes for that session, OpenClaw
refreshes the snapshot so the visible skills stay aligned with the current
agent.
## Remote macOS nodes (Linux gateway)
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Exec approvals security not set to `deny`), OpenClaw can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `exec` tool with `host=node`.

View File

@@ -126,6 +126,7 @@ See [Configuration Reference](/gateway/configuration-reference) and [Slash comma
Allowlist:
- `agents.list[].subagents.allowAgents`: list of agent ids that can be targeted via `agentId` (`["*"]` to allow any). Default: only the requester agent.
- `agents.defaults.subagents.allowAgents`: default target-agent allowlist used when the requester agent does not set its own `subagents.allowAgents`.
- Sandbox inheritance guard: if the requester session is sandboxed, `sessions_spawn` rejects targets that would run unsandboxed.
- `agents.defaults.subagents.requireAgentId` / `agents.list[].subagents.requireAgentId`: when true, block `sessions_spawn` calls that omit `agentId` (forces explicit profile selection). Default: false.

View File

@@ -2,8 +2,8 @@ import { spawn } from "node:child_process";
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createWindowsCmdShimFixture } from "openclaw/plugin-sdk/testing";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWindowsCmdShimFixture } from "../../../../src/test-helpers/windows-cmd-shim.js";
import {
resolveSpawnCommand,
spawnAndCollect,

View File

@@ -2,8 +2,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { runAcpRuntimeAdapterContract } from "openclaw/plugin-sdk/testing";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import { resolveAcpxPluginConfig } from "./config.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
import {
@@ -13,12 +13,13 @@ import {
readMockRuntimeLogEntries,
} from "./test-utils/runtime-fixtures.js";
let sharedFixture: Awaited<ReturnType<typeof createMockRuntimeFixture>> | null = null;
let missingCommandRuntime: AcpxRuntime | null = null;
afterEach(async () => {
vi.unstubAllEnvs();
await cleanupMockRuntimeFixtures();
});
beforeAll(async () => {
sharedFixture = await createMockRuntimeFixture();
missingCommandRuntime = new AcpxRuntime(
function createMissingCommandRuntime(): AcpxRuntime {
return new AcpxRuntime(
{
command: "/definitely/missing/acpx",
allowPluginLocalInstall: false,
@@ -34,13 +35,7 @@ beforeAll(async () => {
},
{ logger: NOOP_LOGGER },
);
});
afterAll(async () => {
sharedFixture = null;
missingCommandRuntime = null;
await cleanupMockRuntimeFixtures();
});
}
async function expectSessionEnsureFallback(params: {
sessionKey: string;
@@ -523,11 +518,7 @@ describe("AcpxRuntime", () => {
});
it("preserves leading spaces across streamed text deltas", async () => {
const runtime = sharedFixture?.runtime;
expect(runtime).toBeDefined();
if (!runtime) {
throw new Error("shared runtime fixture missing");
}
const { runtime } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:space",
agent: "codex",
@@ -565,11 +556,7 @@ describe("AcpxRuntime", () => {
});
it("emits done once when ACP stream repeats stop reason responses", async () => {
const runtime = sharedFixture?.runtime;
expect(runtime).toBeDefined();
if (!runtime) {
throw new Error("shared runtime fixture missing");
}
const { runtime } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:double-done",
agent: "codex",
@@ -591,11 +578,7 @@ describe("AcpxRuntime", () => {
});
it("maps acpx error events into ACP runtime error events", async () => {
const runtime = sharedFixture?.runtime;
expect(runtime).toBeDefined();
if (!runtime) {
throw new Error("shared runtime fixture missing");
}
const { runtime } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:456",
agent: "codex",
@@ -621,11 +604,7 @@ describe("AcpxRuntime", () => {
});
it("maps acpx permission-denied exits to actionable guidance", async () => {
const runtime = sharedFixture?.runtime;
expect(runtime).toBeDefined();
if (!runtime) {
throw new Error("shared runtime fixture missing");
}
const { runtime } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:permission-denied",
agent: "codex",
@@ -934,10 +913,7 @@ describe("AcpxRuntime", () => {
});
it("marks runtime unhealthy when command is missing", async () => {
expect(missingCommandRuntime).toBeDefined();
if (!missingCommandRuntime) {
throw new Error("missing-command runtime fixture missing");
}
const missingCommandRuntime = createMissingCommandRuntime();
await missingCommandRuntime.probeAvailability();
expect(missingCommandRuntime.isHealthy()).toBe(false);
});
@@ -985,10 +961,7 @@ describe("AcpxRuntime", () => {
});
it("returns doctor report for missing command", async () => {
expect(missingCommandRuntime).toBeDefined();
if (!missingCommandRuntime) {
throw new Error("missing-command runtime fixture missing");
}
const missingCommandRuntime = createMissingCommandRuntime();
const report = await missingCommandRuntime.doctor();
expect(report.ok).toBe(false);
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");

View File

@@ -105,6 +105,27 @@ describe("amazon-bedrock provider plugin", () => {
).toBeUndefined();
});
it("owns Anthropic-style replay policy for Claude Bedrock models", () => {
const provider = registerSingleProviderPlugin(amazonBedrockPlugin);
expect(
provider.buildReplayPolicy?.({
provider: "amazon-bedrock",
modelApi: "bedrock-converse-stream",
modelId: ANTHROPIC_MODEL,
} as never),
).toEqual({
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
dropThinkingBlocks: true,
});
});
it("disables prompt caching for non-Anthropic Bedrock models", () => {
const provider = registerSingleProviderPlugin(amazonBedrockPlugin);
const wrapped = provider.wrapStreamFn?.({

View File

@@ -46,6 +46,19 @@ function createGuardrailWrapStreamFn(
const PROVIDER_ID = "amazon-bedrock";
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
function buildAmazonBedrockReplayPolicy(modelId?: string) {
return {
sanitizeMode: "full" as const,
sanitizeToolCallIds: true,
toolCallIdMode: "strict" as const,
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
...((modelId?.toLowerCase() ?? "").includes("claude") ? { dropThinkingBlocks: true } : {}),
};
}
export default definePluginEntry({
id: PROVIDER_ID,
name: "Amazon Bedrock Provider",
@@ -87,10 +100,7 @@ export default definePluginEntry({
},
},
resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env),
capabilities: {
providerFamily: "anthropic",
dropThinkingBlockModelHints: ["claude"],
},
buildReplayPolicy: ({ modelId }) => buildAmazonBedrockReplayPolicy(modelId),
wrapStreamFn,
resolveDefaultThinkingLevel: ({ modelId }) =>
CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined,

View File

@@ -56,4 +56,25 @@ describe("anthropic-vertex provider plugin", () => {
},
});
});
it("owns Anthropic-style replay policy", () => {
const provider = registerSingleProviderPlugin(anthropicVertexPlugin);
expect(
provider.buildReplayPolicy?.({
provider: "anthropic-vertex",
modelApi: "anthropic-messages",
modelId: "claude-sonnet-4-6",
} as never),
).toEqual({
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
dropThinkingBlocks: true,
});
});
});

View File

@@ -7,6 +7,19 @@ import {
const PROVIDER_ID = "anthropic-vertex";
function buildAnthropicVertexReplayPolicy(modelId?: string) {
return {
sanitizeMode: "full" as const,
sanitizeToolCallIds: true,
toolCallIdMode: "strict" as const,
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
...((modelId?.toLowerCase() ?? "").includes("claude") ? { dropThinkingBlocks: true } : {}),
};
}
export default definePluginEntry({
id: PROVIDER_ID,
name: "Anthropic Vertex Provider",
@@ -35,10 +48,7 @@ export default definePluginEntry({
},
},
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
capabilities: {
providerFamily: "anthropic",
dropThinkingBlockModelHints: ["claude"],
},
buildReplayPolicy: ({ modelId }) => buildAnthropicVertexReplayPolicy(modelId),
});
},
});

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import anthropicPlugin from "./index.js";
describe("anthropic provider replay hooks", () => {
it("owns replay policy for Claude transports", () => {
const provider = registerSingleProviderPlugin(anthropicPlugin);
expect(
provider.buildReplayPolicy?.({
provider: "anthropic",
modelApi: "anthropic-messages",
modelId: "claude-sonnet-4-6",
} as never),
).toEqual({
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
dropThinkingBlocks: true,
});
});
});

View File

@@ -30,6 +30,7 @@ import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
import { buildAnthropicCliBackend } from "./cli-backend.js";
import { buildAnthropicCliMigrationResult, hasClaudeCliAuth } from "./cli-migration.js";
import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { buildAnthropicReplayPolicy } from "./replay-policy.js";
import {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,
@@ -446,10 +447,7 @@ export default definePluginEntry({
}),
],
resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx),
capabilities: {
providerFamily: "anthropic",
dropThinkingBlockModelHints: ["claude"],
},
buildReplayPolicy: (ctx) => buildAnthropicReplayPolicy(ctx),
isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId),
wrapStreamFn: (ctx) => {
let streamFn = ctx.streamFn;

View File

@@ -0,0 +1,22 @@
import type {
ProviderReplayPolicy,
ProviderReplayPolicyContext,
} from "openclaw/plugin-sdk/plugin-entry";
/**
* Returns the provider-owned replay policy for Anthropic transports.
*/
export function buildAnthropicReplayPolicy(ctx: ProviderReplayPolicyContext): ProviderReplayPolicy {
const modelId = ctx.modelId?.toLowerCase() ?? "";
return {
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
...(modelId.includes("claude") ? { dropThinkingBlocks: true } : {}),
};
}

View File

@@ -41,7 +41,14 @@
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.1"
},
"compat": {
"pluginApi": ">=2026.4.1"
},
"build": {
"openclawVersion": "2026.4.1"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}

View File

@@ -41,7 +41,9 @@ export function resolveBlueBubblesAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedBlueBubblesAccount {
const accountId = normalizeAccountId(params.accountId);
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultBlueBubblesAccountId(params.cfg),
);
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;

View File

@@ -0,0 +1,6 @@
export {
BLUEBUBBLES_ACTION_NAMES,
BLUEBUBBLES_ACTIONS,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
} from "./runtime-api.js";

View File

@@ -8,7 +8,7 @@ import {
setGroupIconBlueBubbles as setGroupIconBlueBubblesImpl,
unsendBlueBubblesMessage as unsendBlueBubblesMessageImpl,
} from "./chat.js";
import { resolveBlueBubblesMessageId as resolveBlueBubblesMessageIdImpl } from "./monitor.js";
import { resolveBlueBubblesMessageId as resolveBlueBubblesMessageIdImpl } from "./monitor-reply-cache.js";
import { sendBlueBubblesReaction as sendBlueBubblesReactionImpl } from "./reactions.js";
import {
resolveChatGuidForTarget as resolveChatGuidForTargetImpl,

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { editBlueBubblesMessage, setGroupIconBlueBubbles } from "./chat.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { resolveBlueBubblesMessageId } from "./monitor-reply-cache.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { sendBlueBubblesReaction } from "./reactions.js";
import type { OpenClawConfig } from "./runtime-api.js";
@@ -35,7 +35,7 @@ vi.mock("./attachments.js", () => ({
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
}));
vi.mock("./monitor.js", () => ({
vi.mock("./monitor-reply-cache.js", () => ({
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
}));
@@ -125,6 +125,28 @@ describe("bluebubblesMessageActions", () => {
expect(actions).toContain("unsend");
});
it("honors account-scoped action gates during discovery", () => {
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
actions: { reactions: false },
accounts: {
work: {
serverUrl: "http://localhost:5678",
password: "work-password",
actions: { reactions: true },
},
},
},
},
};
expect(describeMessageTool({ cfg, accountId: "default" })?.actions).not.toContain("react");
expect(describeMessageTool({ cfg, accountId: "work" })?.actions).toContain("react");
});
it("hides private-api actions when private API is disabled", () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
const cfg: OpenClawConfig = {

View File

@@ -1,19 +1,21 @@
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
} from "openclaw/plugin-sdk/channel-actions";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
import {
BLUEBUBBLES_ACTION_NAMES,
BLUEBUBBLES_ACTIONS,
createActionGate,
extractToolSend,
jsonResult,
readNumberParam,
readBooleanParam,
readReactionParams,
readStringParam,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
} from "./runtime-api.js";
} from "./actions-api.js";
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
import { normalizeSecretInputString } from "./secret-input.js";
import {
normalizeBlueBubblesHandle,
@@ -70,12 +72,12 @@ const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
]);
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: ({ cfg, currentChannelId }) => {
const account = resolveBlueBubblesAccount({ cfg: cfg });
describeMessageTool: ({ cfg, accountId, currentChannelId }) => {
const account = resolveBlueBubblesAccount({ cfg, accountId });
if (!account.enabled || !account.configured) {
return null;
}
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
const gate = createActionGate(account.config.actions);
const actions = new Set<ChannelMessageActionName>();
const macOS26 = isMacOS26OrHigher(account.accountId);
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);

View File

@@ -1,7 +1,7 @@
import { sendBlueBubblesMedia as sendBlueBubblesMediaImpl } from "./media-send.js";
import { resolveBlueBubblesMessageId as resolveBlueBubblesMessageIdImpl } from "./monitor-reply-cache.js";
import {
monitorBlueBubblesProvider as monitorBlueBubblesProviderImpl,
resolveBlueBubblesMessageId as resolveBlueBubblesMessageIdImpl,
resolveWebhookPathFromConfig as resolveWebhookPathFromConfigImpl,
} from "./monitor.js";
import { probeBlueBubbles as probeBlueBubblesImpl } from "./probe.js";

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