Compare commits

..

2001 Commits

Author SHA1 Message Date
Agustin Rivera
5d815b216b fix(browser): skip trusted cdp dns gate 2026-04-03 18:21:25 +00:00
Agustin Rivera
6072e462a2 fix(browser): enforce profile cdp policy 2026-04-03 18:04:10 +00: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
samzong
61fef8c689 [Fix] Isolate teardown steps so session lock release is unconditional (#59194)
Merged via squash.

Prepared head SHA: 52b3bb46bb
Co-authored-by: samzong <13782141+samzong@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-04-03 17:07:24 +08:00
Vincent Koc
44f1887f25 test(diffs): dispose highlighter after each render 2026-04-03 17:49:37 +09:00
Vincent Koc
9771d84c56 test: add report-on-signal diagnostics 2026-04-03 17:47:08 +09:00
Vincent Koc
97c542a67b test(memory-lancedb): avoid repeated dynamic imports 2026-04-03 17:47:08 +09:00
Vincent Koc
de2d4ecd9e test(nextcloud-talk): avoid per-test module resets 2026-04-03 17:47:08 +09:00
Vincent Koc
cb7f74b5eb perf(test): refresh extension memory hotspots from gh logs (#60159) 2026-04-03 17:43:44 +09:00
Vincent Koc
84970d325e docs(changelog): add missing media transport PR number 2026-04-03 17:41:48 +09:00
Vincent Koc
23719dd513 feat(media): add request transport overrides (#59848)
* style(providers): normalize request policy formatting

* style(providers): normalize request policy formatting

* feat(media): add request transport overrides

* fix(secrets): resolve media request secret refs

* fix(secrets): cover shared media request refs

* fix(secrets): scope media request ref activity

* fix(media): align request ref gating
2026-04-03 17:35:26 +09:00
Peter Steinberger
9e47c1a2c5 test: avoid windows task-owner tempdir hangs 2026-04-03 09:26:04 +01:00
Peter Steinberger
1849cf71c2 fix(image): skip inferred resolution for openai edits 2026-04-03 09:20:08 +01:00
@zimeg
dc45faaf4e docs(slack): order recommended scopes and events 2026-04-03 01:10:42 -07:00
@zimeg
6f23e513cc docs(slack): match recommended bot scopes in onboarding 2026-04-03 01:01:25 -07:00
Josh Lehman
2b28e75822 fix: enrich session_end lifecycle hooks (#59715)
Merged via squash.

Prepared head SHA: b3ef62b973
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-03 00:16:14 -07:00
hengm3467
52d8dc5b56 feat: add bundled StepFun provider plugin (#60032)
Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-04-02 23:53:50 -07:00
Peter Steinberger
a064da3bef docs(changelog): align 2026.4.x release notes 2026-04-03 15:23:22 +09:00
Peter Steinberger
051b5ddafe test: dedupe core test teardown paths 2026-04-03 07:14:58 +01:00
Peter Steinberger
b66197f932 test: reduce import wrapper reload churn 2026-04-03 07:14:58 +01:00
Peter Steinberger
9bba2ec0ad test: trim extension teardown churn 2026-04-03 07:14:58 +01:00
Peter Steinberger
376a042ba1 test: isolate runtime state in memory tests 2026-04-03 07:14:58 +01:00
Marcus Castro
d2d9a928b1 WhatsApp: honor block streaming config (#60069) 2026-04-03 03:12:37 -03:00
Brad Groux
dda53a2ff8 test: update gateway.mode test to match default-to-local behavior (#54801) (#60094)
The test previously asserted that a valid snapshot without gateway.mode
blocks startup. After defaulting gateway.mode to 'local' when unset,
the gateway should start successfully in this scenario — update the
test to verify the new expected behavior.

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
2026-04-03 00:59:51 -05:00
Brad Groux
9978d2276b fix: default gateway.mode to 'local' when unset (#54801) (#60085)
After v2026.3.24 introduced a gateway.mode guard, startup fails on
Windows (and other platforms) when the config file exists but doesn't
contain an explicit gateway.mode value. This happens after 'openclaw
onboard' writes a minimal config without gateway settings.

Default to 'local' when the mode is unset, restoring pre-3.24 behavior
where the gateway started without requiring an explicit mode.

Fixes #54801

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
2026-04-03 00:23:06 -05:00
Brad Groux
6e94b047e2 fix: improve WS handshake reliability on slow-startup environments (#60075)
* fix: import CHANNEL_IDS from leaf module to avoid TDZ on init (#48832)

schema.ts and validation.ts imported CHANNEL_IDS from channels/registry.js,
which re-exports from channels/ids.js but also imports plugins/runtime.js.
When the bundler resolves this dependency graph, the re-exported CHANNEL_IDS
can be undefined at the point config/validation.ts evaluates (temporal dead
zone), causing 'CHANNEL_IDS is not iterable' on startup.

Fix: import CHANNEL_IDS directly from channels/ids.js (the leaf module with
zero heavy dependencies) and normalizeChatChannelId from channels/chat-meta.js.

Fixes #48832

* fix: improve WS handshake reliability on slow-startup environments (#48736)

On Windows with large dist bundles (46MB/639 files), heavy synchronous
module loading blocks the event loop during CLI startup, preventing
timely processing of the connect.challenge frame and causing ~80%
handshake timeout failures.

Changes:
- Yield event loop (setImmediate) before starting WS connection in
  callGateway to let pending I/O drain after heavy module loading
- Add OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS env var override for
  client-side connect challenge timeout (server already has
  OPENCLAW_HANDSHAKE_TIMEOUT_MS)
- Include diagnostic timing in challenge timeout error messages
  (elapsed vs limit) for easier debugging
- Add tests for env var override and resolution logic

---------

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
2026-04-03 00:21:14 -05:00
Brad Groux
0aa98a8e3b fix: import CHANNEL_IDS from leaf module to avoid TDZ on init (#48832) (#60061)
schema.ts and validation.ts imported CHANNEL_IDS from channels/registry.js,
which re-exports from channels/ids.js but also imports plugins/runtime.js.
When the bundler resolves this dependency graph, the re-exported CHANNEL_IDS
can be undefined at the point config/validation.ts evaluates (temporal dead
zone), causing 'CHANNEL_IDS is not iterable' on startup.

Fix: import CHANNEL_IDS directly from channels/ids.js (the leaf module with
zero heavy dependencies) and normalizeChatChannelId from channels/chat-meta.js.

Fixes #48832

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
2026-04-03 00:20:17 -05:00
samzong
2b9981bd06 [Fix] Remove spurious plugin id mismatch warning (#59185)
Merged via squash.

Prepared head SHA: 82859a0f92
Co-authored-by: samzong <13782141+samzong@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-04-03 12:58:56 +08:00
Ayaan Zaidi
251fa72798 fix: emit passive hooks for mention-skipped group messages (#60018)
* fix(channels): emit passive hooks for mention-skipped group messages

* fix(channels): honor signal ingest overrides

* fix(channels): honor telegram ingest fallback

* fix: emit passive hooks for mention-skipped group messages (#60018)
2026-04-03 10:14:48 +05:30
Vincent Koc
9b80344e58 docs: rewrite automation decision guide with current nomenclature 2026-04-03 13:26:32 +09:00
Vincent Koc
a2b6fdc3df docs: rebuild automation section for coherence and readability 2026-04-03 13:11:23 +09:00
Ayaan Zaidi
d5ea5f27ac fix: parse kimi tagged tool calls (#60051)
* fix: parse kimi tagged tool calls

* fix: parse kimi tagged tool calls (#60051)

* fix: parse kimi tagged tool calls (#60051)
2026-04-03 09:39:19 +05:30
Vincent Koc
98137f7f80 perf(test): isolate memory-heavy extension hotspots (#59879)
* perf(test): isolate memory-heavy extension hotspots

* fix(test): pin extension memory hotspot threshold
2026-04-03 13:01:09 +09:00
Peter Steinberger
e3674bcc04 test: streamline runtime wrapper test reloads 2026-04-03 04:41:38 +01:00
Peter Steinberger
ffd34f8896 test: reduce agent test import churn 2026-04-03 04:41:09 +01:00
Peter Steinberger
847faa3d04 test: trim extension test import churn 2026-04-03 04:41:08 +01:00
Vincent Koc
2f013b68f8 docs: add missing changelog entries and update context visibility security docs 2026-04-03 12:39:45 +09:00
v1p0r
3a7555a27b "fix(telegram): surface media placeholder and file_id when download f… (#59948)
* "fix(telegram): surface media placeholder and file_id when download fails"

* fix: unify telegram media placeholder selection

* fix: preserve telegram media context on captioned download failures (#59948) (thanks @v1p0r)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-03 09:08:05 +05:30
samzong
0e0ad692b5 fix: persist Telegram reaction ownership across restart (#59207) (thanks @samzong) 2026-04-03 08:57:06 +05:30
Ayaan Zaidi
a5d6e519eb fix: preserve explicit Telegram topic targets on replies (#59634) (thanks @dashhuang) 2026-04-03 08:50:02 +05:30
Cindy
c001c090b9 fix(telegram): keep topic thread when replying with message tool 2026-04-03 08:50:02 +05:30
Ted-developer
dd080b6fb0 fix(msteams): download DM inline images via Graph API (#52212)
Fix three bugs preventing inline image downloads in Teams 1:1 DM chats: wrong conversation ID format for Graph API, missing media URL extraction, and incorrect content type detection.

Fixes #24797

Thanks @Ted-developer
2026-04-02 22:14:02 -05:00
saram ali
985533efbc fix: cover buffered Telegram apiRoot downloads (#59544) (thanks @SARAMALI15792)
* test(telegram): add URL construction tests for custom apiRoot

Add comprehensive test cases to verify that file download URLs are correctly
constructed when using a custom apiRoot configuration for local Bot API servers.

Tests validate:
- Document downloads use the custom apiRoot in the constructed URL
- Sticker downloads use the custom apiRoot in the constructed URL
- SSRF policy correctly includes the custom hostname

This ensures issue #59512 (Telegram file downloads with local Bot API) is
properly covered by regression tests.

* refactor(telegram): improve media resolution code quality

Apply KISS and YAGNI principles to reduce code duplication and improve maintainability:

1. Extract media metadata resolution
   - Consolidate resolveMediaFileRef(), resolveTelegramFileName(), and
     resolveTelegramMimeType() into single resolveMediaMetadata() function
   - Returns typed MediaMetadata object with fileRef, fileName, mimeType
   - Reduces duplication and improves readability

2. Add logging for apiRoot parsing failures
   - Log when custom apiRoot URL parsing fails in buildTelegramMediaSsrfPolicy()
   - Helps debug configuration issues with local Bot API servers

3. Fix missing apiRoot in buffered messages
   - Add telegramCfg.apiRoot parameter to resolveMedia() calls in
     bot-handlers.buffers.ts (lines 150-159, 189)
   - Ensures reply media in buffered contexts respects custom apiRoot config
   - Fixes inconsistency where runtime handler passed apiRoot but buffers didn't

These changes improve code quality while maintaining backward compatibility and
ensuring issue #59512 (Telegram file downloads with local Bot API) works correctly
in all contexts.

* fix(telegram): resolve bot review issues

Address critical issues identified by Greptile code review:

1. Define telegramCfg in bot-handlers.buffers.ts
   - Extract telegramCfg from cfg.channels?.telegram
   - Fixes ReferenceError when accessing telegramCfg.apiRoot
   - Ensures buffered message handlers can access apiRoot configuration

2. Restore type safety for MediaMetadata.fileRef
   - Change from 'unknown' to proper union type
   - Preserves type information for downstream file_id access
   - Prevents TypeScript strict mode compilation errors

These fixes ensure the PR compiles correctly and handles buffered
media downloads with custom apiRoot configuration.

* fix(telegram): use optional chaining for telegramCfg.apiRoot

TypeScript strict mode requires optional chaining when accessing
properties on potentially undefined objects. Changed telegramCfg.apiRoot
to telegramCfg?.apiRoot to handle cases where telegramCfg is undefined.

Fixes TypeScript errors:
- TS18048: 'telegramCfg' is possibly 'undefined' (line 160)
- TS18048: 'telegramCfg' is possibly 'undefined' (line 191)

* fix(telegram): add missing optional chaining on line 191

Complete the fix for telegramCfg optional chaining.
Previous commit only fixed line 160, but line 191 also needs
the same fix to prevent TS18048 error.

* fix: cover buffered Telegram apiRoot downloads (#59544) (thanks @SARAMALI15792)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-03 08:41:41 +05:30
Derek YU
5f6e3499f3 fix: detect PID recycling in gateway lock on Windows/macOS + startup progress (#59843)
Fix stale lock files from crashed gateway processes blocking new invocations on Windows/macOS. Detect PID recycling to avoid false positive lock conflicts, and add startup progress indicator.

Thanks @TonyDerek-dot
2026-04-02 22:07:35 -05:00
Neerav Makwana
7c7098fd1d fix: keep inbound images readable on upgraded installs (#59971) (thanks @neeravmakwana)
* fix(telegram): allow inbound media from config dir

Made-with: Cursor

* test: simplify local roots regression

* fix: keep inbound images readable on upgraded installs (#59971) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-03 08:24:29 +05:30
Gustavo Madeira Santana
0fdb55c4e4 Docs: refresh diffs plugin docs 2026-04-02 21:47:55 -04:00
Gustavo Madeira Santana
ebc9784f26 docs: fix Matrix plugin docs 2026-04-02 21:47:34 -04:00
Gustavo Madeira Santana
78a842d055 fix(matrix): align IDB snapshot lock timing 2026-04-02 21:44:08 -04:00
Gustavo Madeira Santana
1efa923ab8 Matrix: add native exec approvals (#58635)
Merged via squash.

Prepared head SHA: d9f048e827
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-02 21:08:54 -04:00
Gustavo Madeira Santana
9e2cbf9a30 chore: tighten pr push script 2026-04-02 20:56:51 -04:00
Alejandro Martinez
3a91a4f8d4 fix(matrix): add advisory file locking to IDB crypto persistence (#59851)
Merged via squash.

Prepared head SHA: 392e411ffd
Co-authored-by: al3mart <11448715+al3mart@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-02 20:19:40 -04:00
Alejandro Martinez
b894ca6702 fix(matrix): allow secret storage recreation during repair bootstrap (#59846)
Merged via squash.

Prepared head SHA: 06b10f61eb
Co-authored-by: al3mart <11448715+al3mart@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-02 20:11:58 -04:00
Hyojin Kwak
739ed1bf29 fix(msteams): preserve channel reply threading in proactive fallback (#55198)
When a thread reply's turn context is revoked and falls back to proactive messaging, the normalized conversation ID lost the thread suffix, causing replies to land in the channel root instead of the original thread.

Reconstructs the threaded conversation ID (`;messageid=<activityId>`) for channel conversations in the proactive fallback path, while correctly leaving group chat conversations flat.

Fixes #27189

Thanks @hyojin
2026-04-02 18:27:13 -05:00
Josh Lehman
ed8d5b3797 fix: add Telegram native progress placeholder opt-in for plugin commands (#59300)
Merged via squash.

Prepared head SHA: 4f5bc22a89
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-02 15:55:46 -07:00
Bruce MacDonald
5f4077cc7d fix(ollama): prefer real cloud auth over local marker 2026-04-02 15:51:57 -07:00
Peter Steinberger
64581c655d test: make plugin activation boundary env agnostic 2026-04-03 05:02:58 +09:00
Peter Steinberger
bff6025bde test: refresh generated baselines 2026-04-03 04:54:59 +09:00
Peter Steinberger
15b005489d chore: bump version to 2026.4.3 2026-04-03 04:54:59 +09:00
Peter Steinberger
49f67f54f4 docs: fix task flow release note command 2026-04-03 04:39:13 +09:00
Peter Steinberger
6f67347e00 ci: restore npm token auth for dist-tag promotion 2026-04-02 20:37:49 +01:00
Peter Steinberger
04cf29f613 refactor: speed up whatsapp inbound dispatch tests 2026-04-03 04:34:58 +09:00
Peter Steinberger
694d12a90b refactor: apply context visibility across channels 2026-04-03 04:34:57 +09:00
Peter Steinberger
35e1605147 feat: add configurable context visibility 2026-04-03 04:34:57 +09:00
Peter Steinberger
d4d2d9e479 ci: move npm promotion into trusted workflow 2026-04-02 20:29:57 +01:00
Peter Steinberger
658f0c5d2d ci: use oidc token for npm promotion 2026-04-02 20:23:56 +01:00
Peter Steinberger
dbfb13b93a build: update appcast for 2026.4.2 2026-04-02 20:08:40 +01:00
Vincent Koc
883df8c6a8 fix(plugins): reuse runtime registries for web provider snapshots (#59865)
* fix(plugins): reuse runtime registries for web providers

* test(plugins): clarify runtime reuse intent

* chore(changelog): note web provider runtime reuse
2026-04-03 04:07:43 +09:00
Agustin Rivera
193fdd6e3b fix(policy): preserve restrictive tool allowlists (#58476)
* fix(policy): preserve restrictive tool allowlists

Co-authored-by: David Silva <david.silva@gendigital.com>

* fix(policy): address review follow-ups

* fix(policy): restore additive alsoAllow semantics

* fix(policy): preserve optional tool opt-ins for allow-all configs

* fix(policy): narrow plugin-only allowlist warnings

* fix(policy): add changelog entry

* Revert "fix(policy): add changelog entry"

This reverts commit 4a996bf4caedfe8c9ff3a7f190816e657ead5d10.

* chore: add changelog for restrictive tool allowlists

---------

Co-authored-by: David Silva <david.silva@gendigital.com>
Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-02 12:55:36 -06:00
Peter Steinberger
9f85595d80 fix: pin anthropic sdk to patched version 2026-04-02 19:50:05 +01:00
Vincent Koc
d34bca3ce6 fix(plugins): reuse runtime registry for provider resolution (#59856)
* fix(plugins): reuse runtime registry for provider resolution

* test(plugins): align provider runtime helper names
2026-04-03 03:40:24 +09:00
Peter Steinberger
be4be5e783 fix: improve parallels smoke progress 2026-04-02 19:39:23 +01:00
Agustin Rivera
d631326c5e fix(tailscale): gate test binary override (#58468)
* fix(tailscale): gate test binary override

* fix(changelog): note tailscale override hardening

* fix(changelog): drop tailscale note from pr

* chore: add changelog for tailscale test binary gating

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-02 12:39:10 -06:00
Vincent Koc
f32a5b30db chore(skills): align taskflow skill with runtime 2026-04-03 03:38:02 +09:00
Peter Steinberger
d74a12264a fix: mirror bedrock runtime dep in root package 2026-04-02 19:26:56 +01:00
Vincent Koc
f911bbc353 refactor(plugins): separate activation from enablement (#59844)
* refactor(plugins): separate activation from enablement

* fix(cli): sanitize verbose plugin activation reasons
2026-04-03 03:22:37 +09:00
Vincent Koc
4aeb0255f3 docs: rename TaskFlow to Task Flow in prose 2026-04-03 03:22:01 +09:00
Vincent Koc
d9c662dc69 docs: restructure automation section as Automation & Tasks 2026-04-03 03:16:51 +09:00
Peter Steinberger
3bd2bbea34 docs: clarify npm release workflow inputs 2026-04-02 19:11:01 +01:00
Peter Steinberger
0ebb69b882 build: set release version to 2026.4.2 2026-04-02 19:09:58 +01:00
Peter Steinberger
38bd525888 test: align strict inline-eval awk denial expectation 2026-04-02 19:09:39 +01:00
Peter Steinberger
209535b7c7 build: make npm release tag configurable 2026-04-02 19:06:37 +01:00
Vincent Koc
bcd61e54e1 docs: fix TaskFlow CLI command path and CLI task notify policy 2026-04-03 03:03:00 +09:00
Peter Steinberger
9f3a26caa6 fix(discord): quiet Carbon reconcile log 2026-04-02 18:55:34 +01:00
Agustin Rivera
49d08382a9 iOS: restrict A2UI action dispatch to trusted canvas URLs (#58471)
* fix(ios): restrict a2ui bridge trust

* test(ios): cover fragment-strip trust and document raw-string equality

* fix(ios): normalize capability URL before trust comparison in canvas commands

* fix(ios): trim canvas.navigate url before trust comparison

* chore: add changelog for iOS A2UI trust boundary

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-02 11:51:09 -06:00
Peter Steinberger
00aa31a30c docs(changelog): remove duplicate entries 2026-04-02 18:48:27 +01:00
Vincent Koc
7aa22959e4 refactor(tasks): rename registry hooks to observers (#59829) 2026-04-03 02:42:59 +09:00
Agustin Rivera
676b748056 Limit connect snapshot metadata to admin-scoped clients (#58469)
* fix(gateway): gate connect snapshot metadata by scope

* fix(gateway): clarify connect snapshot trust boundary

* fix(gateway): note connect snapshot change in changelog

* fix(gateway): remove changelog changes from PR

* chore: add changelog for scoped gateway snapshot metadata

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-02 11:41:47 -06:00
Peter Steinberger
a4a372825e docs(changelog): reorder unreleased fixes 2026-04-02 18:39:31 +01:00
Peter Steinberger
45c8207ef2 fix(exec): clarify auto routing semantics (#58897) (thanks @vincentkoc) 2026-04-03 02:37:12 +09:00
Vincent Koc
938541999e Delete docs/internal/codex/2026-03-29-exec-target-override-fix.md 2026-04-03 02:37:12 +09:00
Vincent Koc
5dca81271c fix(exec): clarify and cover auto host override guard 2026-04-03 02:37:12 +09:00
Vincent Koc
dae6632da1 Security: block exec host overrides under auto target 2026-04-03 02:37:12 +09:00
Agustin Rivera
5874a387ae fix(windows): reject unresolved cmd wrappers (#58436)
* fix(windows): reject unresolved cmd wrappers

* fix(windows): add wrapper policy coverage

* fix(windows): document wrapper fallback migration

* fix(windows): drop changelog entry from pr

* chore: add changelog for Windows wrapper fail-closed behavior

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
2026-04-02 11:35:50 -06:00
Peter Steinberger
3e452f2671 fix: preserve strict inline-eval approval boundaries (#59780) (thanks @luoyanglang) 2026-04-02 18:30:29 +01:00
Peter Steinberger
f03d7c5a4c refactor: centralize Windows exec invocation 2026-04-02 18:27:53 +01:00
Peter Steinberger
d56415e353 fix(openai): support reference-image edits 2026-04-03 02:26:33 +09:00
luoyanglang
f0a4bbba33 test(tasks): close flow registry before temp-dir cleanup 2026-04-03 02:25:48 +09:00
luoyanglang
68d8e15a2e fix(exec): satisfy allowlist predicate type checks 2026-04-03 02:25:48 +09:00
luoyanglang
7c83cae425 fix(exec): keep strict inline-eval interpreter approvals reusable 2026-04-03 02:25:48 +09:00
Agustin Rivera
a941a4fef9 fix(android): require TLS for remote gateway endpoints (#58475)
* fix(android): require tls for remote gateway endpoints

* fix(android): expand loopback gateway coverage

* fix(android): validate scanned gateway endpoints

* fix(android): handle mapped loopback literals

* fix(android): allow emulator bridge host

* fix(changelog): note android gateway tls hardening

* fix(android): preserve first-time tls trust prompts

* fix(changelog): drop android gateway entry from pr

* fix(android): scope emulator bridge tls bypass

* fix(android): normalize ipv6 gateway hosts

* fix(android): preserve ipv6 gateway url brackets

* fix(android): preserve auth across tls trust prompt

* fix(android): normalize bracketed ipv6 gateway hosts

* chore: add changelog for Android remote gateway TLS

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
2026-04-02 11:23:51 -06:00
Peter Steinberger
2ea0ca08f6 test: add cross-provider approval availability coverage (#59776) (thanks @joelnishanth) 2026-04-03 02:21:17 +09:00
joelnishanth
d5865bbcc2 fix: decouple approval availability from native delivery enablement (#59620)
getActionAvailabilityState in createApproverRestrictedNativeApprovalAdapter
was gating on both hasApprovers AND isNativeDeliveryEnabled, causing
Telegram exec approvals to report "not allowed" when
channels.telegram.execApprovals.target was configured but
execApprovals.enabled was not explicitly true. The availability check
should only depend on whether approvers exist; native delivery mode is
a routing concern handled downstream.
2026-04-03 02:21:17 +09:00
Peter Steinberger
9b48a4d90a docs: fix changelog conflict markers (#59466) 2026-04-03 02:19:32 +09:00
Peter Steinberger
bacc938c2a docs: note windows exec landing (#59466) (thanks @lawrence3699) 2026-04-03 02:19:32 +09:00
lawrence3699
2fd7f7ca52 fix(exec): hide windows console windows 2026-04-03 02:19:32 +09:00
pgondhi987
7eb094a00d fix(infra): align env key normalization in approval binding path (#59182)
* fix: address issue

* fix: address PR review feedback

* fix: address review feedback

* fix: address review feedback

* chore: add changelog for Windows env approval binding

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-02 11:14:33 -06:00
Vincent Koc
774beb8e5c refactor(plugin-sdk): add task domain runtime surfaces (#59805)
* refactor(plugin-sdk): add task domain runtime views

* chore(plugin-sdk): refresh api baseline

* fix(plugin-sdk): preserve task runtime owner isolation
2026-04-03 02:11:21 +09:00
Peter Steinberger
f30b4bc717 fix: remove leaked changelog conflict marker 2026-04-02 18:07:39 +01:00
Peter Steinberger
fc76f667c2 test: isolate task flow link validation stores 2026-04-03 02:04:26 +09:00
Peter Steinberger
a406045f2f test: accept Windows exec approval denial path 2026-04-03 02:04:26 +09:00
Peter Steinberger
247a06813e fix: avoid gateway cwd for node exec (#58977) (thanks @Starhappysh) 2026-04-03 02:04:26 +09:00
jianxing zhang
50b270a86b fix: widen HostExecApprovalParams.cwd to string | undefined
Remote node exec may have no explicit cwd when the gateway's own
process.cwd() is omitted. Allow undefined to flow through the
approval request type.
2026-04-03 02:04:26 +09:00
jianxing zhang
302c6e30bb fix: resolve type errors where workdir (string | undefined) flows to string-only params
After the node early-return, narrow workdir back to string via
resolvedWorkdir for gateway/sandbox paths. Update
buildExecApprovalPendingToolResult and buildApprovalPendingMessage
to accept string | undefined for cwd since node execution may omit it.
2026-04-03 02:04:26 +09:00
jianxing zhang
3b3191ab3a fix(exec): skip gateway cwd injection for remote node host
When exec runs with host=node and no explicit cwd is provided, the
gateway was injecting its own process.cwd() as the default working
directory. In cross-platform setups (e.g. Linux gateway + Windows node),
this gateway-local path does not exist on the node, causing
"SYSTEM_RUN_DENIED: approval requires an existing canonical cwd".

This change detects when no explicit workdir was provided (neither via
the tool call params.workdir nor via agent defaults.cwd) and passes
undefined instead of the gateway cwd. This lets the remote node use its
own default working directory.

Changes:
- bash-tools.exec.ts: Track whether workdir was explicitly provided;
  when host=node and no explicit workdir, pass undefined instead of
  gateway process.cwd()
- bash-tools.exec-host-node.ts: Accept workdir as string | undefined;
  only send cwd to system.run.prepare when defined
- bash-tools.exec-approval-request.ts: Accept workdir as
  string | undefined in HostExecApprovalParams

Fixes #58934

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 02:04:26 +09:00
pgondhi987
8aceaf5d0f fix(security): close fail-open bypass in exec script preflight [AI] (#59398)
* fix: address issue

* fix: finalize issue changes

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* chore: add changelog for exec preflight fail-closed hardening

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-02 11:00:39 -06:00
Peter Steinberger
e36c563775 refactor(exec): dedupe executable candidate resolution 2026-04-03 01:58:37 +09:00
SudheerDev-AIML
48279dca84 UI: apply accent color to Settings page header and content headings
Fixes #52576 — the accent/theme color was not applied to the Settings
page title, breadcrumb, section headings, or theme card labels. Changed
four CSS rules from var(--text-strong) to var(--accent) so they reflect
the selected theme consistently.
2026-04-02 11:57:09 -05:00
Vincent Koc
990545181b fix(ci): preserve strict inline-eval denial after durable awk trust 2026-04-03 01:55:01 +09:00
Peter Steinberger
2170d36171 docs(changelog): add Windows drive-less exec fix note (#58040) (thanks @SnowSky1) 2026-04-03 01:53:25 +09:00
SnowSky1
e6ce31eb54 fix(exec): ignore malformed drive-less windows exec paths 2026-04-03 01:53:25 +09:00
Agustin Rivera
a26f4d0f3e Separate Gemini OAuth state from PKCE verifier (#59116)
* fix(google): separate oauth state from pkce verifier

* fix(google): drop unused oauth callback state arg

* docs(changelog): add #59116 google oauth state fix

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
2026-04-02 09:51:11 -07:00
Vincent Koc
367969759c perf(memory): trim matrix host validation imports 2026-04-03 01:48:09 +09:00
Vincent Koc
47f5d72931 chore(checks): serialize local heavy gates 2026-04-03 01:46:28 +09:00
Devin Robison
96b55821bc fix: share ACP owner-only approval classes (#201) (#59255)
Co-authored-by: OpenClaw Dummy Agent <octriage-dummy@example.invalid>
2026-04-02 10:45:41 -06:00
Jacob Tomlinson
176c059b05 node-host: bind pnpm dlx approval scripts (#58374)
* node-host: bind pnpm dlx approval scripts

* node-host: cover pnpm dlx package alias

* node-host: cover pnpm dlx flag forms

* node-host: fail closed on unsafe pnpm dlx flags

* node-host: narrow pnpm dlx fail-closed guard

* node-host: scan pnpm dlx past global --

* node-host: allow pnpm dlx file args

* node-host: allow pnpm dlx data args

* node-host: fail closed on unknown pnpm dlx flags

* node-host: support pnpm workspace-root flag

* node-host: restrict pnpm dlx tail scan

* node-host: support pnpm parallel flag

* changelog: node-host pnpm dlx approval binding (#58374)
2026-04-02 09:41:28 -07:00
pgondhi987
7cea7c2970 fix(zalo): scope replay dedupe cache key to path and account [AI] (#59387)
* fix: address issue #139

* changelog: add zalo replay dedupe fix entry

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
2026-04-02 09:36:35 -07:00
Peter Steinberger
d5b6bfc48c test(discord): align native approval fixture with auto mode 2026-04-02 17:33:35 +01:00
Vincent Koc
e4818a345e test(tasks): close flow registry before temp dir cleanup 2026-04-03 01:32:05 +09:00
Peter Steinberger
bf1fcf2e5f docs(approvals): clarify auto native approval routing 2026-04-02 17:31:02 +01:00
Peter Steinberger
17f6626ffe feat(approvals): auto-enable native chat approvals 2026-04-02 17:30:40 +01:00
Peter Steinberger
721cab2b8d refactor(exec): split allowlist segment evaluation helpers 2026-04-03 01:22:25 +09:00
Peter Steinberger
812a7636fb refactor: simplify exec approval followup delivery 2026-04-02 17:19:42 +01:00
Peter Steinberger
47dcfc49b8 fix: scope #57584 to shell allowlist changes 2026-04-03 01:11:20 +09:00
Ayaan Zaidi
34a5c47351 fix: preserve Android assistant auto-send queue 2026-04-02 21:39:24 +05:30
pgondhi987
462b4020bc fix(browser): block SSRF redirect bypass via real-time route interception (#58771)
Install a Playwright route handler before `page.goto()` so navigations
to private/internal IPs are intercepted and aborted mid-redirect instead
of being checked post-hoc after the request already reached the internal
host. Blocked targets are permanently marked and rejected for subsequent
tool calls.

Thanks @pgondhi987
2026-04-02 09:07:57 -07:00
biao
8d81e76f23 fix: evaluate shell wrapper inline commands against allowlist (#57377) (#57584)
When a skill constructs a compound command via a shell wrapper
(e.g. `sh -c "cat SKILL.md && gog-wrapper calendar events"`),
the allowlist check was comparing `/bin/sh` instead of the actual
target binaries, causing the entire command to be silently rejected.

This adds recursive inline command evaluation that:
- Detects chain operators (&&, ||, ;) in the -c payload
- Parses each sub-command independently via analyzeShellCommand
- Evaluates every sub-command against the allowlist
- Preserves per-sub-command segmentSatisfiedBy for accurate tracking
- Limits recursion depth to 3 to prevent abuse
- Skips recursion on Windows (no POSIX shell semantics)

Closes #57377

Co-authored-by: WZBbiao <wangzhenbiao326@gmail.com>
2026-04-03 01:06:40 +09:00
Peter Steinberger
578a0ed31a refactor(agent): dedupe tool error summary 2026-04-02 17:05:05 +01:00
Ayaan Zaidi
59bdf870b9 fix: add Android assistant auto-send changelog (#59721) 2026-04-02 21:27:14 +05:30
Ayaan Zaidi
5d524617e1 fix: clear stale Android assistant auto-send queue 2026-04-02 21:27:14 +05:30
Ayaan Zaidi
186647cb74 feat: auto-send Android assistant prompts 2026-04-02 21:27:14 +05:30
seonang
4207ca2eb8 Fix Telegram exec approval delivery and auto-resume fallback 2026-04-03 00:56:54 +09:00
Gustavo Madeira Santana
b5161042b7 Diffs: validate viewerBaseUrl in manifest schema
Reject invalid diffs viewerBaseUrl values during manifest config validation,
not later during plugin registration.

Keep runtime normalization intact and add manifest-level coverage so bad
protocols and query/hash values fail fast.
2026-04-02 11:55:05 -04:00
Priyansh Gupta
77e636cf78 fix(agents): include received keys in missing-param error for write tool (#55317)
Merged via squash.

Prepared head SHA: c1cf0691c9
Co-authored-by: priyansh19 <33621094+priyansh19@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-02 08:54:28 -07:00
Peter Steinberger
c0b6531ec7 docs: add changelog for cron exec timeout fix (#58247) (thanks @skainguyen1412) 2026-04-03 00:43:42 +09:00
spaceman1412
3b6825ab93 Cron: honor trigger for custom session timeouts 2026-04-03 00:43:42 +09:00
spaceman1412
102462b7a6 Cron: restrict exec visibility to timeouts 2026-04-03 00:43:42 +09:00
spaceman1412
d300a20440 Cron: surface exec timeouts in cron runs 2026-04-03 00:43:42 +09:00
Peter Steinberger
047b701859 refactor(telegram): unify callback-data byte limit checks 2026-04-03 00:38:44 +09:00
Peter Steinberger
7e2a450e31 docs: remove duplicated beta changelog fixes 2026-04-02 16:33:51 +01:00
Peter Steinberger
1f531d373b docs: dedupe changelog mirror fixes 2026-04-02 16:33:21 +01:00
Peter Steinberger
423f7c3487 build: prep 2026.4.2-beta.1 release 2026-04-02 16:33:21 +01:00
Vincent Koc
0ad2dbd307 fix(providers): route image generation through shared transport (#59729)
* fix(providers): route image generation through shared transport

* fix(providers): use normalized minimax image base url

* fix(providers): fail closed on image private routes

* fix(providers): bound shared HTTP fetches
2026-04-03 00:32:37 +09:00
Vincent Koc
d2ce3e9acc perf(plugins): keep gateway startup channel-only (#59754)
* perf(plugins): keep gateway startup channel-only

* fix(gateway): preserve startup sidecars in plugin scope
2026-04-03 00:28:15 +09:00
Peter Steinberger
988f7627de refactor(telegram): centralize approval callback shaping 2026-04-03 00:26:27 +09:00
Vincent Koc
efe9464f5f fix(tasks): tighten task-flow CLI surface (#59757)
* fix(tasks): tighten task-flow CLI surface

* fix(tasks): sanitize task-flow CLI text output
2026-04-03 00:25:10 +09:00
Peter Steinberger
0a76780f57 docs(changelog): mark 2026.4.1 as stable 2026-04-02 16:19:06 +01:00
Peter Steinberger
874a585d57 refactor(agent): share exec parser and runtime context codec 2026-04-03 00:15:43 +09:00
Vincent Koc
576337ef31 fix(tasks): use no-persist cleanup in executor tests 2026-04-03 00:15:02 +09:00
Peter Steinberger
8c3295038c test: harden task executor state-dir cleanup 2026-04-02 16:12:24 +01:00
Peter Steinberger
eb261fa690 fix: land Windows exec allowlist (#56285) (thanks @kpngr) 2026-04-03 00:09:28 +09:00
Peter Steinberger
36d953aab6 fix(exec): make Windows exec hints accurate and dynamic 2026-04-03 00:09:28 +09:00
Peter Steinberger
fff6333773 fix(exec): implement Windows argPattern allowlist flow 2026-04-03 00:09:28 +09:00
Vincent Koc
cc5146b9c6 fix(tasks): reset heartbeat and system event state in executor tests 2026-04-03 00:02:32 +09:00
Peter Steinberger
a5f99f4a30 test: stabilize docker test lanes 2026-04-02 15:59:23 +01:00
Vincent Koc
d46240090a test(tasks): add task-flow operator coverage (#59683) 2026-04-02 23:58:33 +09:00
Vincent Koc
3872a866a1 fix(xai): make x_search auth plugin-owned (#59691)
* fix(xai): make x_search auth plugin-owned

* fix(xai): restore x_search runtime migration fallback

* fix(xai): narrow legacy x_search auth migration

* fix(secrets): drop legacy x_search target registry entry

* fix(xai): no-op knob-only x_search migration fallback
2026-04-02 23:54:07 +09:00
Leo Zhang
b6debb4382 fix(agent): close remaining internal-context leak paths (#59649)
* fix(status): strip internal runtime context from task detail surfaces

* fix(agent): narrow legacy internal-context stripping

* fix(tasks): sanitize user-facing task status surfaces

* fix(agent): close remaining internal-context leak paths

* fix(agent): harden internal context delimiter sanitization

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-04-02 23:45:06 +09:00
Peter Steinberger
831729be4a docs(changelog): note telegram approval alias fix (#59217) (thanks @jameslcowan) 2026-04-02 23:41:12 +09:00
Peter Steinberger
52866656c3 fix(telegram): preserve allow-always callback alias 2026-04-02 23:41:12 +09:00
mappel-nv
53c29df2a9 Channel setup: ignore untrusted workspace shadows (#59158)
Keeps untrusted workspace channel metadata from overriding setup/login resolution for built-in channels. Workspace channel entries are only eligible during setup when the plugin is already explicitly trusted in config.

- Track discovered origin on channel catalog entries and add a setup-time catalog lookup that excludes workspace discoveries when needed
- Add resolver regression coverage for untrusted shadowing and trusted workspace overrides

Thanks @mappel-nv
2026-04-02 07:40:23 -07:00
Vincent Koc
4251ad6638 fix(telegram): allow trusted explicit proxy media fetches 2026-04-02 23:36:17 +09:00
James Cowan
7fea8250fb fix(approvals): use canonical decision values in interactive button payloads 2026-04-02 23:35:23 +09:00
Peter Steinberger
316d10637b refactor: canonicalize legacy x search secret target coverage 2026-04-02 15:30:05 +01:00
Peter Steinberger
65c1716ad4 refactor(infra): clarify jsonl socket contract 2026-04-02 15:20:37 +01:00
Peter Steinberger
ef86edacf7 fix: harden plugin auto-enable empty config handling 2026-04-02 15:19:53 +01:00
wangchunyue
b40ef364b7 fix: pin admin-only subagent gateway scopes (#59555) (thanks @openperf)
* fix(agents): pin subagent gateway calls to admin scope to prevent scope-upgrade pairing failures

callSubagentGateway forwards params to callGateway without explicit scopes,
so callGatewayLeastPrivilege negotiates the minimum scope per method
independently.  The first connection pairs the device at a lower tier and
every subsequent higher-tier call triggers a scope-upgrade handshake that
headless gateway-client connections cannot complete interactively
(close 1008 "pairing required").

Pin callSubagentGateway to operator.admin so the device is paired at the
ceiling scope on the very first (silent, local-loopback) handshake, avoiding
any subsequent scope-upgrade negotiation entirely.

Fixes #59428

* fix: pin admin-only subagent gateway scopes (#59555) (thanks @openperf)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-02 19:40:03 +05:30
Vincent Koc
4f692190b4 fix(config): tolerate missing facade boundary config 2026-04-02 23:04:53 +09:00
skernelx
e0d20966ae Refine JSONL socket EOF regression test 2026-04-02 23:03:36 +09:00
skernelx
0e3cc12900 Fix macOS exec-host JSONL socket deadlock 2026-04-02 23:03:36 +09:00
Peter Steinberger
c4fb15e492 test: make web fetch runtime env handling hermetic 2026-04-02 15:02:40 +01:00
jacky
ecf72319ed fix: use JSON5 parser for plugin manifest loading (#57734) [AI-assisted] (#59084)
Merged via squash.

Prepared head SHA: 58a4d537fc
Co-authored-by: singleGanghood <179357632+singleGanghood@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
2026-04-02 22:02:04 +08:00
Vincent Koc
bb3f17fc02 refactor(plugins): drop generic status report alias (#59700) 2026-04-02 22:59:25 +09:00
Vincent Koc
b0f94a227b refactor(providers): normalize transport policy wiring (#59682)
* refactor(providers): normalize transport policy wiring

* fix(providers): address transport policy review

* fix(providers): harden transport overrides

* fix(providers): keep env proxy tls separate

* fix(changelog): note provider transport policy hardening
2026-04-02 22:54:34 +09:00
Peter Steinberger
4269f40811 docs(security): clarify exec yolo default 2026-04-02 14:52:51 +01:00
Peter Steinberger
c678ae7e7a feat(exec): default host exec to yolo 2026-04-02 14:52:51 +01:00
Vincent Koc
0500b410c5 docs: update config paths for Firecrawl web_fetch and xAI x_search migrations, add Android assistant section, backfill PR numbers 2026-04-02 22:52:00 +09:00
Vincent Koc
def5b954a8 feat(plugins): surface imported runtime state in status tooling (#59659)
* feat(plugins): surface imported runtime state

* fix(plugins): keep status imports snapshot-only

* fix(plugins): keep status snapshots manifest-only

* fix(plugins): restore doctor load checks

* refactor(plugins): split snapshot and diagnostics reports

* fix(plugins): track imported erroring modules

* fix(plugins): keep hot metadata where required

* fix(plugins): keep hot doctor and write targeting

* fix(plugins): track throwing module imports
2026-04-02 22:50:17 +09:00
Peter Steinberger
1ecd92af89 chore: refresh deps and backfill changelog 2026-04-02 14:49:47 +01:00
Ayaan Zaidi
a1f95e5278 fix: land Android assistant entrypoints (#59596) 2026-04-02 19:16:34 +05:30
Ayaan Zaidi
41b81ca7f8 fix: address Android assistant review feedback 2026-04-02 19:16:34 +05:30
Ayaan Zaidi
59eccef768 feat: add Google Assistant App Actions entrypoint 2026-04-02 19:16:34 +05:30
Ayaan Zaidi
e45b29b247 feat: add Android assistant role entrypoint 2026-04-02 19:16:34 +05:30
Ayaan Zaidi
fcf708665c feat: route Android assistant launches into chat 2026-04-02 19:16:34 +05:30
Agustin Rivera
290e5bf219 fix(dotenv): block helper interpreter workspace overrides (#58473)
* fix(dotenv): block helper interpreter workspace overrides

* fix(dotenv): cover trusted helper interpreter envs

* fix(changelog): note dotenv helper override hardening

* fix(changelog): remove dotenv entry from pr

* changelog: note dotenv helper override hardening

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
2026-04-02 06:45:13 -07:00
Vincent Koc
52a6e354a8 fix(tasks): reset agent events in executor tests 2026-04-02 22:25:59 +09:00
Vincent Koc
ec6a07ef05 fix(secrets): add legacy x_search secret target 2026-04-02 22:24:08 +09:00
Jacob Tomlinson
3528e15817 changelog: add openshell mirror sync fix entry (#58515) 2026-04-02 13:23:58 +00:00
Peter Steinberger
3cca07a983 docs: reorder changelog entries by user interest 2026-04-02 14:22:19 +01:00
Agustin Rivera
b21c9840c2 OpenShell: constrain mirror sync roots (#58515)
* fix(openshell): constrain mirror sync roots

* fix(openshell): restore config test types

* fix(openshell): simplify managed root sync
2026-04-02 06:21:30 -07:00
Vincent Koc
3e4de956c0 !refactor(xai): move x_search config behind plugin boundary (#59674)
* refactor(xai): move x_search config behind plugin boundary

* chore(changelog): note x_search config migration

* fix(xai): include x_search migration helpers
2026-04-02 22:08:59 +09:00
Agustin Rivera
ef7c553dd1 fix(zalo): scope webhook replay dedupe (#58444)
* fix(zalo): scope webhook replay dedupe

* fix(zalo): harden replay metadata reads

* docs(changelog): add Zalo replay scope fix entry

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
2026-04-02 06:07:14 -07:00
Vincent Koc
12bd6b7bb9 fix(tasks): address task-flow audit review (#59672) 2026-04-02 22:02:00 +09:00
Devin Robison
7eae9c0e62 Block remaining host env override pivots (#59233)
* Blck remaining host env override pivots

* Feedback update
2026-04-02 06:00:26 -07:00
Agustin Rivera
54a0878517 fix(gateway): enforce session kill HTTP scopes (#59128)
* fix(gateway): enforce session kill HTTP scopes

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>

* fix(gateway): type session kill auth mock

* fix(gateway): gate session kill before lookup

* docs: add changelog entry for session kill HTTP scopes

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-02 05:56:17 -07:00
Agustin Rivera
be10ecef77 fix(compare): reuse shared secret comparison helper (#58432)
* fix(compare): reuse shared secret comparison helper

* fix(compare): reject empty bluebubbles auth tokens

* docs: add changelog entry for shared secret comparison fix

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
2026-04-02 13:53:19 +01:00
Vincent Koc
4c08b0bb08 fix(tasks): allow task-flow registry audit seams 2026-04-02 21:49:26 +09:00
Vincent Koc
cfbad0a4f9 fix(providers): unify request policy resolution (#59653)
* fix(providers): unify request policy resolution

* fix(providers): preserve request config SDK contract

* fix(providers): harden request header policy
2026-04-02 21:42:11 +09:00
Vincent Koc
d4f69878da fix(tasks): close registry stores on test resets 2026-04-02 21:40:40 +09:00
Vincent Koc
6f91f87f3b refactor(tasks): move task-flow ownership under tasks 2026-04-02 21:40:40 +09:00
Vincent Koc
0f45630d19 fix(tasks): harden task-flow restore and maintenance 2026-04-02 21:40:40 +09:00
mappel-nv
9c22d63669 Browser: normalize localhost absolute-form CDP hosts (#59236)
* Browser: normalize localhost absolute-form CDP hosts

* CHANGELOG: note localhost absolute-form CDP fix

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
2026-04-02 13:34:55 +01:00
Vincent Koc
e48ee8ae9e test(secrets): update inactive warning coverage 2026-04-02 21:21:38 +09:00
Vincent Koc
b18de06bff test(secrets): fix runtime coverage env allowlist 2026-04-02 21:10:30 +09:00
Vincent Koc
15e6a88c67 fix(config): sync generated base schema 2026-04-02 21:04:06 +09:00
gavyngong
761cdc967d fix(gateway): prune empty node-pending-work state entries to prevent memory leak (#58179)
Merged via squash.

Prepared head SHA: 1efee3099f
Co-authored-by: gavyngong <267269824+gavyngong@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
2026-04-02 20:00:18 +08:00
Vincent Koc
9823833383 fix(plugins): preserve activation provenance (#59641)
* fix(plugins): preserve activation provenance

* fix(gateway): preserve activation reason metadata

* fix(plugins): harden activation state policy
2026-04-02 20:57:14 +09:00
Vincent Koc
6eca1949d5 refactor(plugins): tighten web fetch provider boundary (#59646)
* refactor(plugins): tighten web fetch provider boundary

* fix(config): sync fetch secret parity and baseline

* fix(ci): enforce web fetch boundary guard
2026-04-02 20:53:57 +09:00
Vincent Koc
5abd5d889f fix(providers): classify copilot native endpoints (#59644)
* fix(providers): classify copilot native endpoints

* fix(changelog): add copilot endpoint note

* fix(providers): handle copilot proxy hints
2026-04-02 20:51:46 +09:00
Shakker
71d49012fc fix: align secretref web-fetch matrix 2026-04-02 12:47:09 +01:00
Vincent Koc
5639e8d242 fix(tasks): stabilize task-flow rename gates 2026-04-02 20:43:04 +09:00
Vincent Koc
e894c7e66e refactor(commands): switch flow tooling to task-flow names 2026-04-02 20:43:03 +09:00
Vincent Koc
b6c3ecedd8 refactor(tasks): update plugin and acp task-flow consumers 2026-04-02 20:43:03 +09:00
Vincent Koc
a7909d46d2 refactor(tasks): migrate task runtime callsites to task-flow 2026-04-02 20:43:03 +09:00
Vincent Koc
a51c976d27 refactor(tasks): rename flow registry modules to task-flow 2026-04-02 20:43:03 +09:00
Vincent Koc
c405bcfa98 refactor(providers): centralize request capabilities (#59636)
* refactor(providers): centralize request capabilities

* fix(providers): harden comparable base url parsing
2026-04-02 20:26:22 +09:00
Vincent Koc
38d2faee20 !feat(plugins): add web fetch provider boundary (#59465)
* feat(plugins): add web fetch provider boundary

* feat(plugins): add web fetch provider modules

* refactor(web-fetch): remove remaining core firecrawl fetch config

* fix(web-fetch): address review follow-ups

* fix(web-fetch): harden provider runtime boundaries

* fix(web-fetch): restore firecrawl compare helper

* fix(web-fetch): restore env-based provider autodetect

* fix(web-fetch): tighten provider hardening

* fix(web-fetch): restore fetch autodetect and compat args

* chore(changelog): note firecrawl fetch config break
2026-04-02 20:25:19 +09:00
Vincent Koc
82d5e6a2f7 fix(ci): isolate task executor delivery runtime cache 2026-04-02 20:21:23 +09:00
Mariano
bbf9800a8e Plugins: add bound TaskFlow runtime (#59622)
Merged via squash.

Prepared head SHA: b4649f3238
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-04-02 13:17:09 +02:00
Vincent Koc
474409deb5 fix(ci): reset flow registry in acp manager tests 2026-04-02 20:04:19 +09:00
Vincent Koc
d49460b417 fix(providers): centralize Anthropic endpoint classification (#59608)
* fix(providers): centralize Anthropic endpoint classification

* fix(agents): share Anthropic thinking recovery gating
2026-04-02 19:54:43 +09:00
Vincent Koc
d87bc6706c fix(plugin-sdk): narrow groups runtime discord seam (#59623) 2026-04-02 19:51:36 +09:00
Vincent Koc
707f5485b9 fix(ci): tighten thinking recovery stream types 2026-04-02 19:47:52 +09:00
Mariano
8bdca2323d TaskFlow: add managed child task execution (#59610)
Merged via squash.

Prepared head SHA: e6cdde6c21
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-04-02 12:45:03 +02:00
Vincent Koc
f65da8711a fix(plugin-sdk): narrow cli discord and matrix contract seams (#59565) 2026-04-02 19:40:04 +09:00
Vincent Koc
dfe95b1e1b fix(ci): align task store flow-link test with runtime guard 2026-04-02 19:32:07 +09:00
Vincent Koc
9aa2ef2736 fix(agents): recover Anthropic thinking after crash (#59062)
* fix(agents): recover Anthropic thinking after crash

* fix(agents): avoid duplicate Anthropic recovery chunks

* fix(agents): preserve Anthropic stream result
2026-04-02 19:30:25 +09:00
Vincent Koc
ec17260e26 docs: rename ClawFlow to TaskFlow and update references 2026-04-02 19:28:49 +09:00
Vincent Koc
f8e67ef698 docs: restore TaskFlow docs and fix Slack attribution 2026-04-02 19:24:36 +09:00
Vincent Koc
ecb4ea9830 fix(ci): restore exec approval masking semantics 2026-04-02 19:23:26 +09:00
Vincent Koc
0e9a9dae84 fix(providers): centralize Google endpoint classification (#59556)
* fix(providers): centralize Google endpoint classification

* fix(providers): tighten Google endpoint fallback parsing

* fix(security): harden provider endpoint fallback parsing
2026-04-02 19:21:31 +09:00
Mariano
2fa4c7cc61 TaskFlow: restore managed substrate (#58930)
Merged via squash.

Prepared head SHA: c99093838f
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-04-02 12:17:56 +02:00
Ayaan Zaidi
52d2bd5cc6 fix: reject stale ACP reconnect prompts 2026-04-02 15:32:46 +05:30
Jacob Tomlinson
ac5bc4fb37 Slack: filter thread context by allowlist (#58380)
* Slack: filter thread context by allowlist

* Slack: honor room thread allowlists

* Slack: keep open-room thread context

* Slack: keep non-room thread context

* Changelog: add Slack thread context fix
2026-04-02 11:01:11 +01:00
Sliverp
0e3da03193 fix(doc):update wecom doc and qq (#57641)
* fix(doc):update wecom doc and qq
doc

* Update CHANGELOG with recent changes and enhancements

Added various updates including new features, improvements, and documentation changes across multiple components.
2026-04-02 17:55:32 +08:00
Ayaan Zaidi
e3319b2a63 fix: guard ACP disconnect clears by epoch 2026-04-02 15:15:21 +05:30
Ayaan Zaidi
d983970704 fix: preserve ACP reconnect epochs 2026-04-02 15:15:21 +05:30
Ayaan Zaidi
73c1b45819 fix: keep active ACP runs alive after reconnect timeout 2026-04-02 15:15:21 +05:30
Ayaan Zaidi
e48a7b9be8 refactor: distill ACP reconnect prompt state 2026-04-02 15:15:21 +05:30
Jacob Tomlinson
657295c347 docs(changelog): add missing merged PR entries 2026-04-02 09:44:43 +00:00
mappel-nv
2eaf5a695e Mattermost: guard probe fetches (#58529) 2026-04-02 10:30:33 +01:00
Jacob Tomlinson
2c45b06afd fix(qqbot): restrict structured payload local paths (#58453)
* fix(qqbot): restrict structured payload local paths

* fix(qqbot): narrow structured payload file access

* test(qqbot): cover payload path traversal guards

* fix(qqbot): reduce structured payload log exposure

* fix(qqbot): preserve inline image payload URLs
2026-04-02 10:20:52 +01:00
Julia Bush
5c36c2d0d2 fix: install devDependencies during ui:build (#59267) (thanks @juliabush)
* fix(ui): install devDependencies during ui:build

* fix: keep ui:build self-heal documented (#59267) (thanks @juliabush)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-02 14:35:05 +05:30
Ayaan Zaidi
304da2cbd7 fix: keep ACP prompts alive across gateway reconnects (#59473)
* fix: keep acp prompts alive across gateway reconnects

* fix: bound ACP prompts after disconnect grace

* fix: preserve ACP send timeout semantics

* fix: defer pre-ack ACP disconnect failures

* fix: reconcile ACP runs after reconnect

* fix: keep ACP reconnect deadlines monotonic

* fix: keep pre-ack ACP deadlines after reconnect

* fix: keep ACP prompts alive across gateway reconnects (#59473)

* fix: reject superseded ACP pre-ack prompts (#59473)

* style: format ACP reconnect regression updates (#59473)

* style: format ACP reconnect regression updates (#59473)

* fix: guard ACP send acceptance by run id (#59473)

* fix: scope ACP reconnect deadline by prompt (#59473)

* fix: recheck ACP prompts at reconnect deadline (#59473)

* fix: key ACP reconnect deadline by run (#59473)
2026-04-02 14:34:11 +05:30
Ayaan Zaidi
c27b45fd12 fix: strip antml thinking tags (#59550) 2026-04-02 14:28:54 +05:30
Ayaan Zaidi
176ff18d18 fix: strip antml thinking tags 2026-04-02 14:28:54 +05:30
scoootscooob
251ba9b4d2 docs(changelog): note invalid exec approvals policy fix 2026-04-02 01:46:16 -07:00
wangchunyue
a597938be8 fix(exec): strip invalid approval policy enums during config normalization (#59112)
* fix(exec): strip invalid security/ask enum values during config normalization

* fix(exec): narrow invalid approvals config cleanup

---------

Co-authored-by: scoootscooob <zhentongfan@gmail.com>
2026-04-02 01:40:10 -07:00
Vincent Koc
d90c8db491 fix(plugin-sdk): narrow discord and matrix core seam imports (#59557) 2026-04-02 17:40:06 +09:00
Vincent Koc
331e835dab fix(providers): centralize stream request headers (#59542)
* fix(providers): centralize stream request headers

* Update src/agents/provider-request-config.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-02 17:21:46 +09:00
Vincent Koc
08962b6812 fix(browser): keep static helper seams cold (#59471)
* fix(browser): keep static helper seams cold

* fix(browser): narrow sandbox helper facade imports

* fix(browser): harden host inspection helpers
2026-04-02 17:12:32 +09:00
Ayaan Zaidi
b441cd2f4f fix: normalize kimi anthropic tool payloads (#59440)
* fix: normalize kimi anthropic tool payloads

* fix: normalize kimi anthropic tool payloads (#59440)
2026-04-02 13:39:51 +05:30
Vincent Koc
53f1c9968a fix(ci): restore model override and trash-path fallbacks 2026-04-02 16:59:27 +09:00
Gustavo Madeira Santana
68bb76519a Matrix: fix delayed draft block boundaries 2026-04-02 03:47:57 -04:00
Gustavo Madeira Santana
8748b7c54c Matrix: keep partial previews aligned with block streaming (#59384)
Merged via squash.

Prepared head SHA: 981aa35a7c
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-02 03:39:27 -04:00
wittam-01
ce0ff42ff5 fix: harden Feishu comment-thread delivery (#59129)
* fix: harden Feishu comment-thread delivery

* fix: harden Feishu comment-thread delivery (#59129) (thanks @wittam-01)

---------

Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-04-02 00:31:52 -07:00
Gustavo Madeira Santana
a5cd921053 revert: remove TinyFish bundled plugin 2026-04-02 03:07:33 -04:00
Mingkuan
c15cfeb21c fix(qqbot): lazy-load silk-wasm to avoid hard failure when package is missing (#58829)
* fix(qqbot): lazy-load silk-wasm to avoid hard failure when package is missing

Replace the static top-level import with a cached dynamic import helper.
If silk-wasm is unavailable the plugin loads normally; voice encode/decode
degrades gracefully instead of crashing the module at load time.

* fix(qqbot): store in-flight Promise in loadSilkWasm to prevent duplicate imports

Concurrent cold-start calls to loadSilkWasm() before the first import()
resolves would each fire a separate dynamic import. Storing the Promise
instead of the resolved value (matching the detectFfmpeg pattern in
platform.ts) ensures all concurrent callers await the same import,
keeping the codebase consistent and avoiding redundant parallel loads.

* QQBot: add changelog for silk-wasm lazy load

* QQBot: move changelog entry for PR #58829

---------

Co-authored-by: sliverp <870080352@qq.com>
Co-authored-by: Sliverp <38134380+sliverp@users.noreply.github.com>
2026-04-02 14:46:53 +08:00
Gustavo Madeira Santana
0809c8d29a fix(matrix): preserve legacy mention edits 2026-04-02 02:33:00 -04:00
Vincent Koc
3e52f5a021 docs(changelog): add TinyFish PR number and attribution 2026-04-02 15:29:36 +09:00
Vincent Koc
f28f0f29ba fix(providers): centralize media request shaping (#59469)
* fix(providers): centralize media request shaping

* style(providers): normalize shared request imports

* fix(changelog): add media request shaping entry

* fix(google): preserve private network guard
2026-04-02 15:28:57 +09:00
Gustavo Madeira Santana
9786946b2d fix(matrix): restore guided setup flow (#59462)
Merged via squash.

Prepared head SHA: 9b29023c68
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-02 02:15:32 -04:00
Gustavo Madeira Santana
5c331687ff fix(matrix): ignore escaped backticks in mention masking 2026-04-02 02:06:50 -04:00
Gustavo Madeira Santana
be52594766 fix(matrix): emit spec-compliant mentions (#59323)
Merged via squash.

Prepared head SHA: 4b641e35a2
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-02 02:00:24 -04:00
Vincent Koc
7b748a57f0 fix(contracts): activate runtime-backed binding suites 2026-04-02 14:57:11 +09:00
Simantak Dabhade
b880118d2d feat: add TinyFish as bundled browser automation plugin (#58645)
* feat: add TinyFish as bundled browser automation plugin

Add a default-off bundled `tinyfish` plugin with one tool
(`tinyfish_automation`) for hosted browser automation of complex public
web workflows. Follows the existing plugin architecture pattern.

- Plugin entry, manifest with contracts, config schema, SecretRef support
- SSE stream parser with COMPLETE-terminal, SSRF guards, credential rejection
- Bundled skill with escalation guidance (web_fetch -> web_search -> tinyfish -> browser)
- Docs page, labeler rule, glossary entry, changelog entry
- 21 tests covering request serialization, auth, security, streaming, and error paths

Closes #41300

* plugins: address review feedback and regenerate baselines

- Split API_INTEGRATION into TINYFISH_API_INTEGRATION and CLIENT_SOURCE
  for semantic clarity (Greptile P2)
- Wrap post-finally parseEventBlock in try/catch so trailing malformed
  data does not mask "stream ended before COMPLETE" error (Greptile P2)
- Regenerate config-baseline and plugin-sdk-api-baseline for new plugin

---------

Co-authored-by: Simantak Dabhade <simantak@mac.local>
2026-04-02 01:46:05 -04:00
Vincent Koc
93fa6920b4 perf(memory): lazy-load telegram message context runtime 2026-04-02 14:44:14 +09:00
Vincent Koc
16c5bd466c perf(memory): split telegram body helper surface 2026-04-02 14:41:26 +09:00
Vincent Koc
52a018680d fix(plugins): guard runtime facade activation (#59412)
* fix(plugins): guard runtime facade activation

* refactor(plugin-sdk): localize facade load policy

* fix(plugin-sdk): narrow facade activation guards

* fix(browser): keep cleanup helpers outside activation guard

* style(browser): apply formatter follow-ups

* chore(changelog): note plugin activation guard regressions

* fix(discord): keep cleanup thread unbinds outside activation guard

* fix(browser): fallback when trash exits non-zero
2026-04-02 14:37:12 +09:00
Vincent Koc
ed6012eb5b fix(agents): honor cacheRetention for custom anthropic providers (#59049)
* fix(agents): honor cacheRetention for custom anthropic providers

* docs(changelog): add cache retention entry

* Update CHANGELOG.md

* test(agents): add direct cache retention assertions
2026-04-02 14:34:01 +09:00
Sally O'Malley
41aac73590 chore(docs): sync config baseline (#59461) 2026-04-02 01:33:21 -04:00
Vincent Koc
703a363589 perf(memory): lazy-load telegram context session helpers 2026-04-02 14:31:48 +09:00
Vincent Koc
1707493be4 refactor(providers): add internal request config seam (#59454) 2026-04-02 14:28:25 +09:00
Gustavo Madeira Santana
f69570f820 Exec approvals: fix policy source attribution (#59367)
Merged via squash.

Prepared head SHA: 974945a9f0
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-02 01:28:14 -04:00
Sally O'Malley
ad6e42906f chore(docs): sync generated baselines (#59450) 2026-04-02 01:26:45 -04:00
Vincent Koc
0e8e986c95 perf(memory): narrow telegram bot deps skill/runtime imports 2026-04-02 14:16:13 +09:00
Vincent Koc
7dc065dab0 docs(changelog): add missing PR numbers 2026-04-02 14:12:28 +09:00
Vincent Koc
5b952836e3 perf(memory): trim telegram command runtime imports 2026-04-02 14:11:28 +09:00
Vincent Koc
1a037ff6cd refactor(providers): centralize request attribution policy (#59433)
* refactor(providers): centralize request attribution policy

* style(providers): normalize request policy formatting

* style(providers): normalize request policy formatting

* style(providers): normalize request policy formatting

* docs(changelog): note provider request policy fix

* fix(providers): tighten request policy gates
2026-04-02 14:10:53 +09:00
Vincent Koc
4309dc6d5e perf(memory): lazy-load telegram monitor runtime graphs 2026-04-02 14:07:35 +09:00
Vincent Koc
fcfb9ddb1d fix(matrix): preserve mocked auth context in bootstrap 2026-04-02 14:03:07 +09:00
Gustavo Madeira Santana
e718493ae6 test(matrix): cover draft overflow fallback 2026-04-02 01:02:18 -04:00
Vincent Koc
85928e29f1 perf(memory): lazy-load matrix bootstrap and probe runtimes 2026-04-02 13:57:03 +09:00
Vincent Koc
be1b4e6683 fix(ci): route matrix config helper through local barrel 2026-04-02 13:52:54 +09:00
Vincent Koc
4fd1e1c64f perf(memory): lazy-load matrix client runtime deps 2026-04-02 13:51:48 +09:00
Scott Glover
9bbbee32e1 Docs: replace personal device names with generic placeholders (#50825) 2026-04-02 00:50:25 -04:00
Vincent Koc
6dbdcbda58 perf(memory): lazy-load matrix shared client creation 2026-04-02 13:48:20 +09:00
Vincent Koc
bfa561b1a7 perf(memory): lazy-load matrix secret config input 2026-04-02 13:46:06 +09:00
Vincent Koc
a398520ac8 perf(memory): trim matrix resolved config imports 2026-04-02 13:42:58 +09:00
Priyansh Gupta
b9c74fc884 fix(image-tool): resolve relative paths against workspaceDir (#57222)
Relative paths like "inbox/receipt.png" were resolved against
process.cwd() instead of the agent's workspaceDir, causing the
allowlist check to fail with "Local media path is not under an
allowed directory". This matches how the read tool already behaves.

Fixes #57215

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 00:41:09 -04:00
Marcus Castro
e1c96785ac fix(whatsapp): gate connect-time presence on selfChatMode to preserve phone notifications (#59410) 2026-04-02 01:40:06 -03:00
Vincent Koc
df60fa8d49 perf(memory): trim matrix account resolution imports 2026-04-02 13:39:33 +09:00
Vincent Koc
45adba882f docs: add contributor attribution for diffs viewerBaseUrl 2026-04-02 13:35:23 +09:00
Gustavo Madeira Santana
19c954bd78 diffs: add configurable viewer base URL (#59341)
Merged via squash.

Prepared head SHA: 3c2a84849f
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-02 00:31:29 -04:00
Vincent Koc
d55cefac00 perf(memory): trim matrix config import graph 2026-04-02 13:21:44 +09:00
Vincent Koc
75b5a4c713 perf(memory): lazy-load matrix send client bootstrap 2026-04-02 13:10:24 +09:00
Vincent Koc
4a5102c1bb docs: fix changelog attribution and add missing WhatsApp MIME fix entry 2026-04-02 13:03:35 +09:00
Vincent Koc
d4c7ef3778 perf(memory): lazy-load matrix crypto runtime graph 2026-04-02 12:51:50 +09:00
Sally O'Malley
ee274dbdd1 remove noisy podman output from launch script and update doc (#59368)
Signed-off-by: sallyom <somalley@redhat.com>
2026-04-01 23:28:11 -04:00
bobbyt74
cae1d9bc6d fix(whatsapp): add HTML/XML/CSS to MIME map + fallback for unknown media types (#51562)
Merged via squash.

Prepared head SHA: 83f2eabd49
Co-authored-by: bobbyt74 <262672147+bobbyt74@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-04-02 00:25:12 -03:00
Vincent Koc
8c3167a7c7 perf(memory): trim matrix auth test sdk imports 2026-04-02 11:45:32 +09:00
Vincent Koc
534f0a644b fix(plugins): keep browser facade helpers cold 2026-04-02 11:44:49 +09:00
wangchunyue
51edd30bea fix: restore local loopback role upgrades (#59092) (thanks @openperf)
* fix(gateway ): allow silent role upgrades for local loopback clients

When a local loopback client connects with a role not covered by
existing device tokens, listEffectivePairedDeviceRoles incorrectly
returns an empty role set for devices whose tokens map is an empty
object. This triggers a role-upgrade pairing request that
shouldAllowSilentLocalPairing rejects because it does not recognise
the role-upgrade reason.

Fix listEffectivePairedDeviceRoles to fall back to legacy role fields
when the tokens map has no entries, and extend
shouldAllowSilentLocalPairing to accept role-upgrade for local
clients.

Fixes #59045

* fix: restore local loopback role upgrades (#59092) (thanks @openperf)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-02 08:13:45 +05:30
Vincent Koc
1ea901b107 perf(memory): lazy-load slack action runtime graph 2026-04-02 11:31:19 +09:00
Vincent Koc
a7e3c0b0e1 feat(slack): add scoped prompts and mrkdwn hints (#59100)
* feat(slack): add scoped prompts and mrkdwn hints

* refactor(slack): drop dm prompt override

* refactor(slack): drop exposed prompt config

* chore(changelog): note slack mrkdwn fix
2026-04-02 11:23:43 +09:00
Vincent Koc
7c913f2e13 chore(changelog): record plugin loading hardening 2026-04-02 11:20:50 +09:00
Vincent Koc
7771c69caf fix(plugins): enforce activation before shipped imports (#59136)
* fix(plugins): enforce activation before shipped imports

* fix(plugins): remove more ambient bundled loads

* fix(plugins): tighten scoped loader matching

* fix(plugins): remove channel-id scoped loader matches

* refactor(plugin-sdk): relocate ambient provider helpers

* fix(plugin-sdk): preserve unicode ADC credential paths

* fix(plugins): restore safe setup fallback
2026-04-02 11:18:49 +09:00
Vincent Koc
765e8fb713 perf(memory): trim matrix send media imports 2026-04-02 11:16:50 +09:00
Vincent Koc
f4e2240b85 perf(memory): trim matrix account config imports 2026-04-02 11:12:37 +09:00
Gustavo Madeira Santana
7514324510 Docs: fix plugin architecture table formatting 2026-04-01 22:07:15 -04:00
Brad Groux
03c64df39f fix(msteams): use formatUnknownError instead of String(err) for error logging (#59321)
Replaces String(err) with the existing formatUnknownError() utility across
the msteams extension to prevent [object Object] appearing in error logs
when non-Error objects are caught (e.g., Axios errors, Bot Framework SDK
error objects).

Fixes #53910

thanks @bradgroux
2026-04-01 21:06:44 -05:00
Vincent Koc
474693bdb2 perf(memory): trim matrix monitor allowlist imports 2026-04-02 11:05:27 +09:00
Gustavo Madeira Santana
ba735d0158 Exec approvals: unify effective policy reporting and actions (#59283)
Merged via squash.

Prepared head SHA: d579b97a93
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-01 22:02:39 -04:00
Vincent Koc
dc66c36b9e perf(memory): trim telegram monitor test module churn 2026-04-02 10:57:01 +09:00
Gustavo Madeira Santana
32fa5c3be5 fix(agents): resolve compaction wait before channel flush (#59308)
Merged via squash.

Prepared head SHA: bf17502df8
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-01 21:40:23 -04:00
Vincent Koc
326490ab76 docs: cover compaction notifyUser config and provider replay hooks 2026-04-02 10:23:33 +09:00
Vincent Koc
687030cbf2 perf(memory): trim matrix and telegram runtime seams 2026-04-02 10:18:56 +09:00
Vincent Koc
1cc5526f7f docs: fix Nostr inbound signature verification order in docs 2026-04-02 10:11:09 +09:00
joshavant
c22233d96c Revert "refactor(plugins): remove before_install hook" 2026-04-01 19:57:07 -05:00
Brad Groux
57949397fa fix(msteams): prevent duplicate text when stream exceeds 4000 char limit (#59297)
When a streamed response exceeds TEAMS_MAX_CHARS, the stream sets streamFailed=true and finalizes. Previously, hasContent returned false when streamFailed was true, causing preparePayload to pass through the full payload for block delivery, duplicating already-streamed text.

Now tracks streamed length and strips the already-delivered prefix from fallback payloads.

Fixes #58601

thanks @bradgroux
2026-04-01 19:03:19 -05:00
Gustavo Madeira Santana
560ea25294 Matrix: restore ordered progress delivery with explicit streaming modes (#59266)
Merged via squash.

Prepared head SHA: 523623b7e1
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-01 19:35:03 -04:00
Gustavo Madeira Santana
91a7505af6 fix(tests): serialize shared channel audit state cases 2026-04-01 19:12:05 -04:00
Gustavo Madeira Santana
a204f790ce fix(matrix): package verification bootstrap runtime (#59249)
Merged via squash.

Prepared head SHA: df5891b663
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-01 17:52:51 -04:00
Gustavo Madeira Santana
c87c8e66bf Refactor channel approval capability seams (#58634)
Merged via squash.

Prepared head SHA: c9ad4e4706
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-01 17:10:25 -04:00
Logan Ye
d9a7ffe003 failover: classify AbortError / stream-abort messages as timeout (#58315) (#58324)
Merged via squash.

Prepared head SHA: d8412f27e6
Co-authored-by: yelog <14227866+yelog@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-04-02 00:02:31 +03:00
zqchris
75ab5bce6b fix(bluebubbles): add enrichGroupParticipantsFromContacts to core Zod schema (#56889)
* fix(bluebubbles): add enrichGroupParticipantsFromContacts to core Zod schema

The field was added to the extension config schema in #54984 but not
synced to the core strict Zod validator, causing config validation to
reject the key at startup with 'Unrecognized key'.

* test(config): add BlueBubbles schema regression coverage

* fix(bluebubbles): accept enrichGroupParticipantsFromContacts config

---------

Co-authored-by: Chris Zhang <chris@ChrisdeMac-mini.local>
Co-authored-by: Altay <altay@uinaf.dev>
2026-04-01 23:55:58 +03:00
Josh Lehman
71346940ad refactor: add provider replay runtime hook surfaces (#59143)
Merged via squash.

Prepared head SHA: 56b41e87a5
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-01 13:45:41 -07:00
Bruno Lorente
ca76e2fedc fix(cron-tool): add typed properties to job/patch schemas (#55043)
Merged via squash.

Prepared head SHA: 979bb0e8b7
Co-authored-by: brunolorente <127802443+brunolorente@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-04-01 23:41:19 +03:00
Kris Wu
7027dda8cd fix(ui): prevent premature compaction status update on retry (#55132)
Merged via squash.

Prepared head SHA: e7e562f982
Co-authored-by: mpz4life <32388289+mpz4life@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-01 13:38:51 -07:00
Joshua Lelon Mitchell
7cb323d84f feat(plugins): add before_agent_reply hook (claiming pattern) (#20067)
Merged via squash.

Prepared head SHA: e40dfbdfb9
Co-authored-by: JoshuaLelon <23615754+JoshuaLelon@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-01 13:31:11 -07:00
Nimrod Gutman
017bc5261c fix(gateway): prefer bootstrap auth over tailscale (#59232)
* fix(gateway): prefer bootstrap auth over tailscale

* fix(gateway): prefer bootstrap auth over tailscale (#59232) (thanks @ngutman)
2026-04-01 23:20:10 +03:00
9ra55
5cf254a5f7 fix: honor authHeader provider config by injecting Authorization Bear… (#54390)
Merged via squash.

Prepared head SHA: 9889615571
Co-authored-by: lndyzwdxhs <16411017+lndyzwdxhs@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-01 13:18:37 -07:00
Josh Lehman
90eb5b073f fix: pass session identity to plugin commands (#59044)
Merged via squash.

Prepared head SHA: 0f7a23f139
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-01 13:07:17 -07:00
VACInc
711c9e7249 fix(gateway): emit before_reset on session reset (#53872)
Merged via squash.

Prepared head SHA: a47894ef16
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-01 12:46:53 -07:00
Oguri Cap
1f99c87a44 feat: add agents.defaults.compaction.notifyUser config option (default: false) [Fix #54249] (#54251)
Merged via squash.

Prepared head SHA: 6fd4cdb7c3
Co-authored-by: oguricap0327 <266246182+oguricap0327@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-01 12:29:17 -07:00
Luke
5b73108e58 fix: /context detail severely underestimates token count (#28391)
Merged via squash.

Prepared head SHA: 5ea6a074f3
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-01 12:11:08 -07:00
Josh Lehman
1c83e2eec7 fix: scope session create aliases to requested agent (#58207)
Merged via squash.

Prepared head SHA: 9462848777
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-01 11:39:31 -07:00
Peter Steinberger
8abba663c5 chore: bump version to 2026.4.2 2026-04-01 19:39:27 +01:00
Peter Steinberger
8f617bf4d7 fix: validate npm dist-tag auth before publish 2026-04-01 19:39:09 +01:00
Gustavo Madeira Santana
b24961c5d1 fix(matrix): tighten account scoping and default detection 2026-04-01 14:20:02 -04:00
Moliendo
d076153fc9 fix(config): coerce numeric Discord IDs to strings instead of rejecting (#45125)
Merged via squash.

Prepared head SHA: 099ba514a1
Co-authored-by: moliendocode <29582793+moliendocode@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-04-01 21:07:28 +03:00
Josh Lehman
4f407d2658 docs(changelog): move #55336 note to 2026.4.1 2026-04-01 10:57:35 -07:00
Onur
38faa3c767 Docs: clarify release preflight promotion 2026-04-01 19:55:21 +02:00
Daan van der Plas
7fa1a31094 fix(matrix): honor room account scoping (#58449)
Merged via squash.

Prepared head SHA: d83f06ee3f
Co-authored-by: Daanvdplas <93204684+Daanvdplas@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-01 13:49:22 -04:00
Doğu Abaris
5190b3b3fa fix: avoid locally caught ACP session init exception (#55136) (thanks @doguabaris) 2026-04-01 19:46:22 +02:00
Joshua McKiddy
dd7df0753f fix(security): prevent memory exhaustion in inline image decoding (#22325)
thanks @hackersifu
2026-04-01 12:44:05 -05:00
Peter Steinberger
5e3352f367 chore: update appcast for 2026.4.1 2026-04-01 18:33:09 +01:00
Onur
f1f5a3fcf4 Release: trim duplicate preflight work (#59117)
* Release: skip duplicate runtime-deps staging

* Release: trim public mac validation workflow

* Release: require promoted npm publish

* Release: verify promoted npm provenance

* Release: restore public mac validation build

* Release: skip pack check on npm promote

* Release: skip pack check on npm promote
2026-04-01 19:24:37 +02:00
Peter Steinberger
da64a978e5 chore: prepare 2026.4.1 release 2026-04-01 17:57:10 +01:00
Vincent Koc
34332257b0 perf(matrix): cut avoidable startup memory in client and monitor tests 2026-04-02 01:00:28 +09:00
Eric Wong
2427304654 fix(msteams): pass teamId and teamName to resolveAgentRoute() (#50214)
thanks @slbteam08
2026-04-01 10:50:41 -05:00
Lewis
e881e96bd0 fix(msteams): sanitize error messages sent to users (#33343)
thanks @lewiswigmore
2026-04-01 10:44:24 -05:00
Vincent Koc
8c6f31cc6b perf(test): narrow discord chunking imports 2026-04-02 00:41:57 +09:00
Peter Steinberger
79d0c92f3d ci: prefix reused npm tarball paths 2026-04-01 16:39:08 +01:00
Vincent Koc
67f8dc5712 fix(discord): avoid duplicate component id exports 2026-04-02 00:34:48 +09:00
Vincent Koc
0b06c4b352 perf(test): narrow telegram draft chunking imports 2026-04-02 00:32:13 +09:00
Vincent Koc
cb7e391285 fix(discord): restore component custom id barrel exports 2026-04-02 00:31:13 +09:00
Vincent Koc
ec426ac356 perf(test): narrow slack string normalization imports 2026-04-02 00:29:50 +09:00
Peter Steinberger
b569f5d313 fix: normalize local npm publish tarball paths 2026-04-01 16:25:37 +01:00
Vincent Koc
b6ba45c4a4 perf(test): lazy-load discord reply runtime 2026-04-02 00:18:45 +09:00
Vincent Koc
19724340f8 perf(test): narrow line reply context import 2026-04-02 00:16:03 +09:00
Peter Steinberger
2988a68b70 ci: skip reused npm publish metadata check 2026-04-01 16:12:26 +01:00
Vincent Koc
913a9ba367 perf(test): narrow discord auth runtime imports 2026-04-02 00:00:26 +09:00
Menglin Li
d5554491a8 fix(msteams): prevent path-to-regexp crash with express 5 (#55440)
Fixes #55250, #54960, #54889, #54852, #54703

thanks @lml2468
2026-04-01 10:00:06 -05:00
Peter Steinberger
181aef5cbe ci: skip npm publish validation when reusing preflight 2026-04-01 15:59:04 +01:00
Vincent Koc
c942812e5d perf(test): narrow discord component import path 2026-04-01 23:58:06 +09:00
Vincent Koc
0453d355fd perf(test): narrow discord monitor runtime seams 2026-04-01 23:47:22 +09:00
LawrenceLuo
83149ed046 fix(msteams): clear pending upload timeout on removal (#32555)
thanks @PinoHouse
2026-04-01 09:46:24 -05:00
Vincent Koc
cfe4b720a4 docs: add glm-5.1 and glm-5v-turbo to GLM model examples 2026-04-01 23:41:46 +09:00
Vincent Koc
3d647f14d0 perf(test): lazy-load discord preflight runtimes 2026-04-01 23:37:06 +09:00
Vincent Koc
3a7d0938c6 perf(test): keep discord model picker provider runtime lazy 2026-04-01 23:32:33 +09:00
Peter Steinberger
c3490b3c70 fix: keep prepared prepack logs off stdout 2026-04-01 15:31:02 +01:00
Vincent Koc
76c4ecd651 perf(test): narrow sdk seams for channel hotspots 2026-04-01 23:14:48 +09:00
Peter Steinberger
8988894ff7 build: prepare 2026.4.1-beta.1 release 2026-04-01 15:09:19 +01:00
Peter Steinberger
47be52e2cb build: bump version to 2026.4.1-beta.1 2026-04-01 15:09:19 +01:00
Vincent Koc
5a0fd1cffc fix(matrix): narrow lazy test module access 2026-04-01 23:05:51 +09:00
Vincent Koc
1d05cbba7a perf(test): trim matrix setup overhead 2026-04-01 22:56:13 +09:00
Gustavo Madeira Santana
2dab0c518a fix(regression): ship diffs viewer runtime asset 2026-04-01 09:56:07 -04:00
Peter Steinberger
4de1606f4c fix: drain discord startup restart sockets 2026-04-01 22:55:58 +09:00
Vincent Koc
78a58726e7 perf(test): trim more matrix import churn 2026-04-01 22:54:22 +09:00
Peter Steinberger
9065243729 fix: stop stale Windows gateway before upgrade onboard 2026-04-01 14:52:44 +01:00
Vincent Koc
38410705bf perf(test): trim matrix directory reload churn 2026-04-01 22:51:08 +09:00
Vincent Koc
da9ffad368 fix(channels): restore target parsing barrel seam 2026-04-01 22:46:10 +09:00
Ayaan Zaidi
095e7b830a fix: let carbon own gateway reconnects (#59019)
* refactor(discord): let carbon own gateway reconnects

* fix: finalize Discord gateway reconnect landing (#59019)
2026-04-01 19:12:35 +05:30
Vincent Koc
c5cfc05104 perf(test): trim more sdk and telegram reload churn 2026-04-01 22:40:44 +09:00
Vincent Koc
cb7c0e24d0 perf(test): trim more browser reload churn 2026-04-01 22:40:44 +09:00
Vincent Koc
e1b6c9b29b perf(test): trim more matrix and telegram reload churn 2026-04-01 22:40:44 +09:00
Vincent Koc
dd5bf6b1d0 perf(test): cut more hotspot reload churn 2026-04-01 22:40:44 +09:00
Vincent Koc
1673d969e8 test: document reload-churn guardrail 2026-04-01 22:40:31 +09:00
Peter Steinberger
b0ef107b56 docs(changelog): add missing thanks 2026-04-01 14:33:19 +01:00
Peter Steinberger
9cfb792dba docs: fix docs formatting drift 2026-04-01 14:31:28 +01:00
Vincent Koc
fd4dbad38c docs: cover cron --tools allowlist and agents.defaults.params config reference 2026-04-01 22:29:53 +09:00
Peter Steinberger
00218ac8a4 fix(auth): persist codex oauth refresh tokens 2026-04-01 14:25:33 +01:00
Peter Steinberger
af5f4f6716 docs(changelog): drop docs-only breaking note 2026-04-01 14:23:42 +01:00
Vincent Koc
c42659176a docs: cover unreleased feature gaps (Telegram errorPolicy, Android notifications, node pairing, Slack approvals, MCP transport, reactions) 2026-04-01 22:20:20 +09:00
Vincent Koc
7a7549f12f perf(test): reduce hotspot reload churn (#59033) 2026-04-01 22:19:19 +09:00
Peter Steinberger
adb961e056 docs(changelog): refresh unreleased ordering 2026-04-01 14:13:11 +01:00
Jacob Tomlinson
14a779ee8d revert: sandbox: block sensitive external bind sources (#59016)
This reverts commit 8db20c1965.
2026-04-01 14:01:05 +01:00
Ayaan Zaidi
7096819f2b fix(acpx): retry queue-owner repair without resume-session (thanks @obviyus) 2026-04-01 18:30:38 +05:30
Peter Steinberger
fc745db76d ci: remove bun workflow 2026-04-01 21:58:46 +09:00
Peter Steinberger
131f6dac37 refactor: unify failover signal classification 2026-04-01 21:50:58 +09:00
Neerav Makwana
ed482b1ce7 fix: repair queue owner session recovery (#58669) (thanks @neeravmakwana)
* fix(acpx): repair queue owner session recovery

* fix(acpx): avoid duplicate queue owner recovery

* fix: repair queue owner session recovery (#58669) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-01 18:20:23 +05:30
Neerav Makwana
cd07ebef99 fix: correct flows docs to tasks (#58690) (thanks @neeravmakwana)
* Docs: fix stale flows command references

* Docs: address flows review comments

* docs: remove stale flows subtree from cli index

* fix: correct flows docs to tasks (#58690) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-01 18:14:31 +05:30
Peter Steinberger
25e2934809 refactor: route session target matching through plugin parsers 2026-04-01 13:42:57 +01:00
Tomsun28
6433e923d4 fix: add ZAI GLM-5.1 and GLM-5V Turbo support (#58793) (thanks @tomsun28)
* provider(zai): add GLM-5.1 and GLM-5V Turbo models

* feat(zai): extract model definition builder for glm-5 forward compat

* test(zai): cover persisted glm-5 dynamic metadata

* fix: add ZAI GLM-5.1 and GLM-5V Turbo support (#58793) (thanks @tomsun28)

* fix: preserve ZAI dynamic model transport config (#58793) (thanks @tomsun28)

---------

Co-authored-by: gongchao <chao.gong@aminer.cn>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-01 18:09:02 +05:30
Peter Steinberger
33fbd9b770 refactor: dedupe auth profile store normalization 2026-04-01 13:37:37 +01:00
Peter Steinberger
ab3c646bb1 fix: preserve telegram exec approval topic routing 2026-04-01 13:34:50 +01:00
Peter Steinberger
f55b6b1acf fix: add changelog for auth profile store load crash (#58923) (thanks @openperf) 2026-04-01 21:33:10 +09:00
openperf
ac68321d4d fix(auth-profiles ): ensure credential key and token are strings to prevent crash
Fixes #58861
2026-04-01 21:33:10 +09:00
Peter Steinberger
cd4b03c568 fix: unify structured provider failover classification (#58856) (thanks @aaron-he-zhu) 2026-04-01 21:31:18 +09:00
Aaron Zhu
c96ee42300 fix(agents): normalize provider errors for better failover
Add provider-specific error patterns for AWS Bedrock, Ollama, Mistral,
Cohere, DeepSeek, Together AI, and Cloudflare Workers AI. These providers
return errors in non-standard formats that the generic classifiers miss,
causing incorrect failover behavior (e.g., context overflow misclassified
as format error, ThrottlingException not recognized as rate limit).

Wire provider patterns into isContextOverflowError() and
classifyFailoverReason() as catch-all layers after generic classifiers.
2026-04-01 21:31:18 +09:00
Chinar Amrutkar
74b9f22a42 fix: add Telegram error suppression controls (#51914) (thanks @chinar-amrutkar)
* feat(telegram): add error policy for suppressing repetitive error messages

Introduces per-account error policy configuration that can suppress
repetitive error messages (e.g., 429 rate limit, ECONNRESET) to
prevent noisy error floods in Telegram channels.

Closes #34498

* fix(telegram): track error cooldown per message

* fix(telegram): prune expired error cooldowns

* fix: add Telegram error suppression controls (#51914) (thanks @chinar-amrutkar)

---------

Co-authored-by: chinar-amrutkar <chinar-amrutkar@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-01 17:52:28 +05:30
Ayaan Zaidi
ef286987e7 test: fix context pruning runtime fixtures 2026-04-01 16:48:00 +05:30
Ayaan Zaidi
f70ad924a6 fix: align cache-ttl pruning with thinking replay sanitization 2026-04-01 16:33:57 +05:30
Vincent Koc
c7510e0f1a fix(hooks): skip full gate for docs-only commits 2026-04-01 20:02:54 +09:00
Ayaan Zaidi
c65e152b39 fix: preserve anthropic thinking replay (#58916)
* test: add anthropic thinking replay regressions

* fix: preserve anthropic thinking blocks on replay

* fix: preserve anthropic thinking replay (#58916)

* fix: move anthropic replay changelog entry (#58916)
2026-04-01 16:23:47 +05:30
Vincent Koc
00a49fe8b4 docs: add gateway.webchat.chatHistoryMaxChars config reference 2026-04-01 19:25:17 +09:00
Charles Dusek
32ae841098 feat(web-search): add SearXNG as bundled web search provider plugin (#57317)
* feat(web-search): add bundled searxng plugin

* test(web-search): cover searxng config wiring

* test(web-search): include searxng in bundled provider inventory

* test(web-search): keep searxng ordering aligned

* fix(web-search): sanitize searxng result rows

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-01 19:24:33 +09:00
Chinar Amrutkar
3f67581e50 fix: retry safe wrapped Telegram send failures (#51895) (thanks @chinar-amrutkar)
* fix(telegram): traverse error .cause chain in formatErrorMessage and match grammY HttpError

grammY wraps network failures in HttpError with message
'Network request for ... failed!' and the original error in .cause.
formatErrorMessage only checked err.message, so shouldRetry never
fired for the most common transient failure class.

Changes:
- formatErrorMessage now traverses .cause chain, appending nested
  error messages (with cycle protection)
- Added 'Network request' to TELEGRAM_RETRY_RE as belt-and-suspenders
- Added tests for .cause traversal, circular references, and grammY
  HttpError retry behavior

Fixes #51525

* style: fix oxfmt formatting in retry-policy.ts

* fix: add braces to satisfy oxlint requirement

* fix(telegram): keep send retries strict

* test(telegram): cover wrapped retry paths

* fix(telegram): retry rate-limited sends safely

* fix: retry safe wrapped Telegram send failures (#51895) (thanks @chinar-amrutkar)

* fix: preserve wrapped Telegram rate-limit retries (#51895) (thanks @chinar-amrutkar)

---------

Co-authored-by: chinar-amrutkar <chinar-amrutkar@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-01 15:54:29 +05:30
Vincent Koc
5c8d9da749 docs: add SearXNG web search provider page and navigation 2026-04-01 19:18:15 +09:00
Vincent Koc
f1595f59b4 fix(ci): allow plugin npm preview without publish token (#58929) 2026-04-01 19:16:46 +09:00
upupc
d766bfc6b2 fix(memory): preserve session indexing during full reindex (#39732)
Merged via squash.

Prepared head SHA: 0dbaf5fffb
Co-authored-by: upupc <12829489+upupc@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-04-01 13:12:30 +03:00
Luke
1654c3a851 feat(gateway): make chat history max chars configurable (#58900)
* feat(gateway): make chat history max chars configurable

* fix(gateway): address review feedback

* docs(changelog): note configurable chat history limits
2026-04-01 21:08:37 +11:00
ImLukeF
b2bb129e2c docs(changelog): note chat error fallback fix 2026-04-01 20:53:35 +11:00
ImLukeF
78b48735fa test: restore fetch stubs in embedding suites 2026-04-01 20:53:16 +11:00
ImLukeF
101c31f5e1 test: harden ci-sensitive unit suites 2026-04-01 20:53:16 +11:00
ImLukeF
4e63dc0b1c fix: hide raw provider errors from chat replies 2026-04-01 20:53:16 +11:00
ryanlee-gemini
fbe3ca4d7d fix(plugins): pass dangerouslyForceUnsafeInstall through archive and … (#58879)
Merged via squash.

Prepared head SHA: 87eb27d902
Co-authored-by: ryanlee-gemini <181323138+ryanlee-gemini@users.noreply.github.com>
Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com>
Reviewed-by: @odysseus0
2026-04-01 02:52:01 -07:00
Vincent Koc
72af92ba4e qqbot: require explicit allowlist for /bot-logs to prevent info disclosure (#58895)
* qqbot: harden /bot-logs authorization fallback

* fix(qqbot): harden bot logs allowlist guard

* fix(qqbot): normalize bot logs allowlist entries
2026-04-01 18:40:46 +09:00
Vincent Koc
07c60ae461 fix(agent): treat webchat exec approvals as native UI (#58904)
* fix(agent): treat webchat exec approvals as native UI

* docs(changelog): note webchat exec approval UI fix

* test(agent): cover webchat native approval guidance
2026-04-01 18:36:21 +09:00
Peter Steinberger
8b2d24b62b docs(security): clarify node pairing trust boundary 2026-04-01 18:27:23 +09:00
Peter Steinberger
29784af1e2 style(gateway): normalize node reconnect formatting 2026-04-01 18:27:06 +09:00
Peter Steinberger
f6317fb747 fix(gateway): stop pinning node commands to pairing state 2026-04-01 18:27:06 +09:00
Peter Steinberger
fe57ee513f test: drop stale task boundary allowlist entries 2026-04-01 10:17:47 +01:00
Peter Steinberger
d005cc8b42 test: align cron abort regression with #58833 2026-04-01 10:17:47 +01:00
Peter Steinberger
92f1772e93 test: allow boundary test on main 2026-04-01 10:17:47 +01:00
Peter Steinberger
f559ea126d fix: land slash command metadata parsing (#58725) (thanks @Mlightsnow) 2026-04-01 10:17:47 +01:00
HansY
3b1f8e3461 fix: strip inbound metadata before slash command detection (#58674)
Slash commands like /model and /new were silently ignored when the inbound
message body included metadata prefix blocks (Conversation info, Sender info,
timestamps) injected by buildInboundUserContextPrefix. The command detection
functions (hasControlCommand, isControlCommandMessage, parseSendPolicyCommand)
now call stripInboundMetadata before normalizeCommandBody so embedded slash
commands are correctly recognized.
2026-04-01 10:17:20 +01:00
Ayaan Zaidi
fb28b02540 fix: preserve bundled channel plugin compat (#58873)
* fix: preserve bundled channel plugin compat

* fix: preserve bundled channel plugin compat (#58873)

* fix: scope channel plugin compat to bundled plugins (#58873)
2026-04-01 14:42:36 +05:30
Vincent Koc
2d53ffdec1 fix(exec): resolve remote approval regressions (#58792)
* fix(exec): restore remote approval policy defaults

* fix(exec): handle headless cron approval conflicts

* fix(exec): make allow-always durable

* fix(exec): persist exact-command shell trust

* fix(doctor): match host exec fallback

* fix(exec): preserve blocked and inline approval state

* Doctor: surface allow-always ask bypass

* Doctor: match effective exec policy

* Exec: match node durable command text

* Exec: tighten durable approval security

* Exec: restore owner approver fallback

* Config: refresh Slack approval metadata

---------

Co-authored-by: scoootscooob <zhentongfan@gmail.com>
2026-04-01 02:07:20 -07:00
Peter Steinberger
4ceb01f9ed docs(gateway): document node pairing repair flow 2026-04-01 18:02:56 +09:00
Peter Steinberger
db0cea5689 refactor(gateway): extract node pairing reconciliation 2026-04-01 18:02:31 +09:00
Peter Steinberger
4590ac31cc fix: note exec approval continuation in changelog (#58860) (thanks @Nanako0129) 2026-04-01 17:56:55 +09:00
nanakotsai
7f53c1ca00 test(exec): cover delayed Discord approval continuation 2026-04-01 17:56:55 +09:00
nanakotsai
63da2c7034 fix(exec): resume agent session after approval completion 2026-04-01 17:56:55 +09:00
Forgely3D
4fa11632b4 fix: escalate to model fallback after rate-limit profile rotation cap (#58707)
* fix: escalate to model fallback after rate-limit profile rotation cap

Per-model rate limits (e.g. Anthropic Sonnet-only quotas) are not
relieved by rotating auth profiles — if all profiles share the same
model quota, cycling between them loops forever without falling back
to the next model in the configured fallbacks chain.

Apply the same rotation-cap pattern introduced for overloaded_error
(#58348) to rate_limit errors:

- Add `rateLimitedProfileRotations` to auth.cooldowns config (default: 1)
- After N profile rotations on a rate_limit error, throw FailoverError
  to trigger cross-provider model fallback
- Add `resolveRateLimitProfileRotationLimit` helper following the same
  pattern as `resolveOverloadProfileRotationLimit`

Fixes #58572

* fix: cap prompt-side rate-limit failover (#58707) (thanks @Forgely3D)

* fix: restore latest-main gates for #58707

---------

Co-authored-by: Ember (Forgely3D) <ember@forgely.co>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-04-01 17:54:10 +09:00
Vincent Koc
8fce663861 fix(subagents): harden task-registry lifecycle writes (#58869)
* fix(subagents): harden task-registry lifecycle writes

* chore(changelog): note subagent task-registry hardening

* fix(subagents): align lifecycle terminal timestamps

* fix(subagents): sanitize lifecycle warning metadata
2026-04-01 17:50:52 +09:00
sandpile
1ce410a7be fix(sandbox): use browser image for browser runtime matching (#58759)
* fix(sandbox): compare browser runtimes against sandbox browser image

* refactor(sandbox): route docker runtime matching by config label kind

* fix(sandbox): tag browser runtime removals correctly

* test(sandbox): share docker backend test config

* fix(sandbox): normalize browser runtime image matching

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-01 17:44:52 +09:00
scoootscooob
10750fb80e Cron: avoid busy-wait drift for recurring main jobs (#58872) 2026-04-01 01:31:21 -07:00
Peter Steinberger
2b67f96895 docs(anthropic): note oauth context1m fallback 2026-04-01 09:21:49 +01:00
Peter Steinberger
32f392eda4 refactor(core): drop old anthropic stream wrapper file 2026-04-01 09:21:48 +01:00
Peter Steinberger
59c23dee09 refactor(anthropic): move stream wrappers into plugin 2026-04-01 09:21:48 +01:00
Peter Steinberger
2bc8a0d67c refactor: add doctor cron migration helpers 2026-04-01 17:06:24 +09:00
Peter Steinberger
19d0c2dd1d refactor: remove cron legacy delivery from runtime 2026-04-01 17:06:01 +09:00
Vincent Koc
2d79c9cb16 docs: add WhatsApp reactionLevel and Feishu Drive comment actions 2026-04-01 16:56:47 +09:00
Peter Steinberger
95182d51cc fix: harden bundled plugin runtime deps 2026-04-01 08:55:00 +01:00
Vincent Koc
edfac5f2df docs(changelog): note line runtime packaging fix 2026-04-01 13:20:50 +05:30
Vincent Koc
d4643e06bd fix(line): resolve dist runtime contract path 2026-04-01 13:20:50 +05:30
Vincent Koc
facdeb3432 feat(tasks): add chat-native task board (#58828) 2026-04-01 16:48:36 +09:00
Peter Steinberger
7cf8ccf9b3 fix: avoid startup gateway reload loop (#58678) (thanks @yelog) 2026-04-01 16:47:55 +09:00
Vincent Koc
71f341c4b4 docs: add /tasks chat command, cleanup-aware status, and QQ Bot troubleshooting 2026-04-01 16:46:04 +09:00
Peter Steinberger
f5431bc07e docs: clarify doctor cron migration guidance 2026-04-01 16:44:10 +09:00
Peter Steinberger
802bdb099e refactor: move cron legacy delivery migration to doctor 2026-04-01 16:44:10 +09:00
Peter Steinberger
31ed09bc96 fix: run bundled deps postinstall for global npm 2026-04-01 08:38:24 +01:00
Vincent Koc
cfa307baed fix(status): keep task snapshots pure 2026-04-01 16:36:57 +09:00
Ayaan Zaidi
5a95d65f1e fix: restore bundled runtime dependency provisioning (#58782) (thanks @obviyus)
* fix: restore bundled runtime dependency provisioning

* fix: ship npm runner in packed installs

* fix: address bundled runtime staging review feedback

* fix: include npm runner in docker build contexts

* fix: restore bundled runtime dependency provisioning (#58782) (thanks @obviyus)

* fix: allow caret specs through windows npm cmd (#58782) (thanks @obviyus)
2026-04-01 13:03:36 +05:30
Peter Steinberger
86b519850e refactor: consolidate cron delivery boundary parsing 2026-04-01 16:31:51 +09:00
Vincent Koc
340c99d657 fix(status): filter stale task rows from status cards (#58810)
* fix(status): filter stale task rows

* test(status): use real task snapshot semantics

* fix(status): prefer failure task context in recent failures
2026-04-01 16:19:02 +09:00
Peter Steinberger
9ab3352b1a fix: avoid duplicate discord resolve logs 2026-04-01 08:14:54 +01:00
Peter Steinberger
622b91d04e fix: queue model switches behind busy runs 2026-04-01 16:14:10 +09:00
Peter Steinberger
6776306387 fix: preserve telegram topic delivery routing (#58489) (thanks @cwmine) 2026-04-01 16:13:24 +09:00
yi-bot
e643ba2f5e fix: preserve telegram topic routing in announce and delivery context 2026-04-01 16:13:24 +09:00
wittam-01
1b94e8ca14 feat: feishu comment event (#58497)
Merged via squash.

Prepared head SHA: a9dfeb0d62
Co-authored-by: wittam-01 <271711640+wittam-01@users.noreply.github.com>
Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com>
Reviewed-by: @odysseus0
2026-04-01 00:12:38 -07:00
chi
cad3da52c9 fix(memory): prefer --mask over --glob for qmd collection pattern flag (#58736)
* fix(memory): prefer --mask over --glob for qmd collection pattern flag

qmd 2.0.1 silently ignores the --glob flag when creating collections,
causing all patterns (e.g. MEMORY.md, memory.md) to fall back to the
default **/*.md glob. This leads to collection conflicts when multiple
collections target the same workspace directory with different patterns.

The existing flag negotiation logic in addCollection() tries --glob
first (when collectionPatternFlag is null), and since qmd accepts the
flag without error, OpenClaw never falls back to --mask. The result is
that memory-root-{agent} gets created with **/*.md instead of MEMORY.md,
and memory-alt-{agent} fails with a duplicate path+pattern conflict.

Fix: default collectionPatternFlag to '--mask' so the working flag is
tried first. The fallback to --glob is preserved for older qmd versions
that may not support --mask.

* docs(changelog): note qmd collection flag fix

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-01 16:11:56 +09:00
Peter Steinberger
bd6c017192 fix: skip failing gateway HTTP stages (#58746) (thanks @yelog) 2026-04-01 16:09:36 +09:00
yelog
ffa1e5fa92 test: assert console.error in async-rejection stage test 2026-04-01 16:09:36 +09:00
yelog
0a636aef24 fix: catch per-stage errors in HTTP request pipeline to prevent cascade 500s (#58689) 2026-04-01 16:09:36 +09:00
Peter Steinberger
d9a2690535 test: trim mattermost setup cases 2026-04-01 08:03:26 +01:00
Peter Steinberger
035028208f test: trim line webhook/slack setup prompts 2026-04-01 08:02:26 +01:00
Peter Steinberger
e441e8bb17 test: stabilize timed-heavy channel planner checks 2026-04-01 07:46:25 +01:00
Peter Steinberger
709668ccd1 test: trim line/twitch setup validations 2026-04-01 07:46:25 +01:00
Peter Steinberger
add54e1d26 test: trim low-signal matrix monitor tests 2026-04-01 07:46:25 +01:00
Peter Steinberger
25eaebb9b6 test: drop duplicate telegram/discord command tests 2026-04-01 07:46:25 +01:00
Peter Steinberger
c130ebad35 fix: verify linux gateway in parallels smoke 2026-04-01 07:38:49 +01:00
Tars
5f3737f229 fix: auto-enable minimax plugin for API key auth route (#57127)
Merged via squash.

Prepared head SHA: 5782b26738
Co-authored-by: tars90percent <252094836+tars90percent@users.noreply.github.com>
Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com>
Reviewed-by: @odysseus0
2026-03-31 22:52:19 -07:00
Neerav Makwana
26a891aaeb fix: preserve rewritten stream snapshots in webchat (#58641) (thanks @neeravmakwana) 2026-04-01 11:09:19 +05:30
Kenny Xie
e1d963ed2e fix: bound discord inbound media downloads (#58593) (thanks @aquaright1)
* fix(discord): bound attachment downloads by timeout

* fix(ci): unblock check and clarify discord timeouts

* fix: bound discord inbound media downloads (#58593) (thanks @aquaright1)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-01 10:59:58 +05:30
Josh Lehman
5836ddea3f test: fix amazon-bedrock extension test boundary (#58753)
* Tests: stop amazon-bedrock from importing private core plugin types

* Config: refresh generated doc baseline
2026-03-31 22:24:38 -07:00
Marcus Castro
ac6db066d3 feat(whatsapp): add reaction guidance levels (#58622)
* WhatsApp: add reaction guidance policy

* WhatsApp: expose reaction guidance to agents
2026-04-01 01:42:10 -03:00
Owen Wang
21403a3898 fix(whatsapp): pass Timestamp to finalizeInboundContext (#58590)
Merged via squash.

Prepared head SHA: 74aa9a1408
Co-authored-by: Maninae <9339187+Maninae@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-04-01 01:02:23 -03:00
Ayaan Zaidi
2c5796c924 fix(tasks): recheck current state during maintenance sweep 2026-04-01 09:25:38 +05:30
joshavant
ccb67bd4bf config: regenerate base config schema baseline 2026-03-31 22:54:36 -05:00
joshavant
ed83d79a05 fix: tighten reply payload typing and safe text coercion 2026-03-31 22:54:36 -05:00
Ayaan Zaidi
05c311e67d fix: record task sweep gateway hang fix (#58670) (thanks @openperf) 2026-04-01 09:12:57 +05:30
Ayaan Zaidi
2dbfd4ebe2 refactor(tasks): distill task registry sweep scheduling 2026-04-01 09:12:57 +05:30
openperf
97fd6c27a1 fix(tasks): prevent synchronous task registry sweep from blocking event loop 2026-04-01 09:12:57 +05:30
Jamil Zakirov
69685f99fe fix: preserve Telegram local Bot API MIME types (#54603) (thanks @jzakirov)
* fix(telegram): preserve content type for local Bot API media files

* fix: preserve Telegram local Bot API MIME types (#54603) (thanks @jzakirov)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-01 09:08:31 +05:30
Peter Steinberger
098125e998 test: merge channel reply pipeline typing cases 2026-04-01 03:26:24 +01:00
Peter Steinberger
7ae093cf0f test: merge command auth cases 2026-04-01 03:25:39 +01:00
Peter Steinberger
ba808573ef test: merge allowlist config helper cases 2026-04-01 03:24:41 +01:00
Peter Steinberger
a217e97fe5 test: merge approval renderer cases 2026-04-01 03:23:41 +01:00
Peter Steinberger
cf3d7c8d57 test: merge account status helper cases 2026-04-01 03:22:33 +01:00
Peter Steinberger
d11df8e13e test: merge approval auth helper cases 2026-04-01 03:21:32 +01:00
Peter Steinberger
d65c290748 test: merge temp download path cases 2026-04-01 03:20:28 +01:00
Peter Steinberger
fbca5bcc12 test: merge status helper default/explicit cases 2026-04-01 03:19:35 +01:00
Peter Steinberger
cb131a7938 test: merge dm allowlist pairing policy cases 2026-04-01 03:18:35 +01:00
Peter Steinberger
54f2c8e939 test: merge mattermost setup registration checks 2026-04-01 03:17:10 +01:00
Peter Steinberger
655d52815d test: merge channel send result stamping coverage 2026-04-01 03:16:06 +01:00
Peter Steinberger
6e2738ef00 test: merge kilocode provider registration coverage 2026-04-01 03:13:26 +01:00
Peter Steinberger
a59f2f43b6 test: drop thread-ownership hook registration smoke 2026-04-01 03:12:19 +01:00
Peter Steinberger
3c6e0cfe25 test: drop feishu plugin registration smoke 2026-04-01 03:11:33 +01:00
Peter Steinberger
8076c78b2e test: drop subagent hook registration smokes 2026-04-01 03:10:47 +01:00
Peter Steinberger
5e371fe875 test: drop discord command registration smoke 2026-04-01 03:09:52 +01:00
Peter Steinberger
6e773cc3b6 test: drop webhook registration smokes 2026-04-01 03:08:33 +01:00
Peter Steinberger
35c9372dc4 test: merge diffs registration smoke into config defaults 2026-04-01 03:05:46 +01:00
Peter Steinberger
5c27f15fe6 test: drop browser plugin registration smoke 2026-04-01 03:03:27 +01:00
Peter Steinberger
4765ce3ad7 test: drop low-signal extension registration smokes 2026-04-01 03:02:40 +01:00
Peter Steinberger
042a9ab48a test: fix plugin-sdk subpaths contract imports 2026-04-01 03:02:34 +01:00
Peter Steinberger
73ead2425b test: drop redundant web search registration smokes 2026-04-01 02:57:08 +01:00
Peter Steinberger
49ac85b56d test: merge secret input schema coverage 2026-04-01 02:53:40 +01:00
Peter Steinberger
5816294b4c test: merge request-url coverage into fetch auth 2026-04-01 02:52:44 +01:00
Peter Steinberger
08bbb51bf7 test: merge allowlist resolution coverage 2026-04-01 02:51:26 +01:00
Peter Steinberger
f5a23b710c test: move plugin-sdk index and root alias guardrails 2026-04-01 02:50:22 +01:00
Peter Steinberger
016f065d7e test: move remaining plugin-sdk guardrails to contracts 2026-04-01 02:46:50 +01:00
Peter Steinberger
7e02005ca9 test: move plugin-sdk guardrails to contracts suite 2026-04-01 02:41:02 +01:00
Peter Steinberger
219116e862 test: drop redundant status-issues skip checks 2026-04-01 02:32:55 +01:00
Peter Steinberger
09c03fcfed test: drop low-signal memory plugin metadata check 2026-04-01 02:30:53 +01:00
Peter Steinberger
3c69e1ea4e test: drop low-signal plugin runtime type contract 2026-04-01 02:29:18 +01:00
Peter Steinberger
0614d992a4 test: drop redundant openai registration smoke 2026-04-01 02:26:50 +01:00
Peter Steinberger
f9c18186a8 test: move openai live smoke to live suite 2026-04-01 02:24:12 +01:00
Peter Steinberger
1226361c6d test: move memory lancedb live smoke to live suite 2026-04-01 02:18:20 +01:00
Peter Steinberger
beb2171ab5 test: move openrouter live test to live suite 2026-04-01 02:15:35 +01:00
Morrow
be5a035d97 fix: harden embedded text normalization (#58555)
Co-authored-by: Morrow <271612559+agent-morrow@users.noreply.github.com>
2026-03-31 21:10:49 -04:00
Owen Wang
50cc28c559 fix: differentiate overloaded vs rate-limit user-facing error messages (#58562) 2026-03-31 21:10:38 -04:00
dudu1111685
ed8e6b0a74 plugins: suppress provenance warning for allowlisted local plugins (#58604)
Co-authored-by: me <shlomo@vmi1916417.contaboserver.net>
2026-03-31 21:10:30 -04:00
Zhang
d2663262d4 Fix broken URL in Twitch extension README (#58563)
Remove stray `%20` (URL-encoded space) from the StreamWeasels username-to-ID
converter link, which caused a 404 when clicked.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:10:28 -04:00
Andy Tien
6c3eea3ce9 fix(session): prevent heartbeat/cron/exec events from triggering session reset (#58605)
Fixes #58409 - Heartbeat system causes silent session reset leading to user data loss.

The issue occurred when automated system events (heartbeat, cron-event, exec-event)
triggered the session initialization logic, which evaluated session freshness based on
idle/daily reset policies. Stale sessions were reset, causing complete context loss.

Changes:
- Detect system event providers (heartbeat, cron-event, exec-event) in initSessionState
- Force freshEntry=true for system events to skip reset policy evaluation
- Add comprehensive test coverage for heartbeat no-reset behavior

This ensures automated check-ins preserve session continuity and never cause
accidental data loss.
2026-03-31 21:10:24 -04:00
OfflynAI
b554516f21 routing: support wildcard peer bindings (peer.id="*") for multi-agent routing (#58609)
* routing: support wildcard peer bindings (peer.id="*") for multi-agent routing

Bindings with `peer: { kind: "direct", id: "*" }` were treated as a literal
peer ID "*" instead of a wildcard. This caused the binding to be indexed
exclusively in the byPeer map under key "direct:*", which never matches
actual incoming peer IDs like "direct:12345678". The binding silently fell
through to the default agent ("main"), breaking multi-agent setups that use
wildcard peer constraints to route all DMs on a named account to a specific
agent.

Add a "wildcard-kind" peer constraint state that restricts on chat type
(direct/group/channel) without requiring an exact peer ID match. Wildcard
peer bindings now fall through to the byAccount/byChannel index tiers and
correctly match via matchesBindingScope with kind-only filtering.

Resolves #58546

Made-with: Cursor

* routing: add dedicated binding.peer.wildcard tier for clarity

Address Greptile feedback: wildcard-peer bindings now report
matchedBy: "binding.peer.wildcard" instead of "binding.account",
making logs/debugging clearer for operators.

- Add byPeerWildcard index bucket
- Add binding.peer.wildcard tier between peer.parent and guild+roles
- Update tests to expect the new matchedBy value

Made-with: Cursor
2026-03-31 21:10:18 -04:00
Reed
b86f5d5ea4 fix(sandbox): resolve pinned fs helper python without PATH (#58573) 2026-03-31 21:10:17 -04:00
zssggle-rgb
a37c66906c fix(acpx): retry backend health probes after ensure (#58612)
* fix(acpx): retry backend health probes after ensure

* fix(acpx): keep doctor checks diagnostic-only
2026-03-31 21:10:09 -04:00
zssggle-rgb
8e0f495197 fix(acpx): preserve control command error details (#58613) 2026-03-31 21:10:04 -04:00
Sharoon Sharif
7941f21bef fix(voice-call): clear connection timeout on successful STT connect (#58586)
The 10-second connection timeout in OpenAIRealtimeSTTSession.doConnect()
was never cleared on success or teardown, leaking a timer on every
connection and accumulating stale timers across reconnect cycles.

Store the timeout handle and clear it in both the open handler and
close(), matching the existing clearTimeout pattern in
waitForTranscript().

Co-authored-by: Sharoon Sharif <ssharif@Hosanna.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:10:02 -04:00
zssggle-rgb
187d3ed053 fix(acpx): fall back to PATH node for shebang wrappers (#58614) 2026-03-31 21:09:58 -04:00
Michael Flanagan
eee185af99 feat(amazon-bedrock): add Bedrock Guardrails support (#58588)
* feat(amazon-bedrock): just the kiro plans, need to remove before PR

* docs(bedrock-guardrails): add environment setup instructions

* docs(bedrock-guardrails): mark environment setup tasks as completed

* feat(amazon-bedrock): add trace configuration to guardrail settings

* feat(amazon-bedrock): implement guardrail wrapper factory and wire into registration

* test(amazon-bedrock): add comprehensive guardrail configuration tests

* docs(bedrock): add guardrails configuration documentation

* docs(bedrock-guardrails): add comprehensive manual testing guide for Docker deployment

* docs(bedrock-guardrails): expand manual testing guide with STS credentials and config options

* docs(bedrock-guardrails): complete manual testing verification with 8 test scenarios

* chore: remove kiro spec files from PR

* fix(docs): correct guardrail config path to plugins.entries.*.config

* style: format docs and test files
2026-03-31 21:09:52 -04:00
狼哥
40b24dfa6b fix(session-status): infer custom runtime providers from config (#58474)
* fix(session-status): infer custom runtime providers from config

* test(session-status): satisfy custom provider type checks
2026-03-31 21:09:42 -04:00
Han Yang
547154865b Fix: live session model switch no longer blocks failover (Resolves #58466) (#58589)
* fix: prevent infinite retry loop when live session model switch blocks failover (#58466)

* fix: remove unused resolveOllamaBaseUrlForRun import after rebase
2026-03-31 21:09:41 -04:00
Phillip Hampton
350fe63bbf feat(macos): Voice Wake option to trigger Talk Mode (#58490)
* feat(macos): add "Trigger Talk Mode" option to Voice Wake settings

When enabled, detecting a wake phrase activates Talk Mode (full voice
conversation: STT -> LLM -> TTS playback) instead of sending a text
message to the chat. Enables hands-free voice assistant UX.

Implementation:
- Constants.swift: new `openclaw.voiceWakeTriggersTalkMode` defaults key
- AppState.swift: new property with UserDefaults persistence + runtime
  refresh on change so the toggle takes effect immediately
- VoiceWakeSettings.swift: "Trigger Talk Mode" toggle under Voice Wake,
  disabled when Voice Wake is off
- VoiceWakeRuntime.swift: `beginCapture` checks `triggersTalkMode` and
  activates Talk Mode directly, skipping the capture/overlay/forward
  flow. Pauses the wake listener via `pauseForPushToTalk()` to prevent
  two audio pipelines competing on the mic.
- TalkModeController.swift: resumes voice wake listener when Talk Mode
  exits by calling `VoiceWakeRuntime.refresh`, mirroring PTT teardown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback

- TalkModeController: move VoiceWakeRuntime.refresh() after
  TalkModeRuntime.setEnabled(false) completes, preventing mic
  contention race where wake listener restarts before Talk Mode
  finishes tearing down its audio engine (P1)
- VoiceWakeRuntime: remove redundant haltRecognitionPipeline()
  before pauseForPushToTalk() which already calls it via stop() (P2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resume wake listener based on enabled state, not toggle value

Check swabbleEnabled instead of voiceWakeTriggersTalkMode when deciding
whether to resume the wake listener after Talk Mode exits. This ensures
the paused listener resumes even if the user toggled "Trigger Talk Mode"
off during an active session. (P2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: play trigger chime on Talk Mode activation + send chime before TTS

- VoiceWakeRuntime: play the configured trigger chime in the
  triggersTalkMode branch before pausing the wake listener. The early
  return was skipping the chime that plays in the normal capture flow.
- TalkModeRuntime: play the Voice Wake "send" chime before TTS playback
  starts, giving audible feedback that the AI is about to respond. Talk
  Mode never used this chime despite it being configurable in settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move send chime to transcript finalization (on send, not on reply)

The send chime now plays when the user's speech is finalized and about
to be sent to the AI, not when the TTS response starts. This matches
the semantics of "send sound" -- it confirms your input was captured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:09:36 -04:00
Han Yang
68ee3113a9 Fix: CDP profiles prefer cdpPort over stale WebSocket cdpUrl (Resolves #58494) (#58499)
* fix(browser): prefer cdpPort over stale WebSocket cdpUrl for attach-only profiles

* fix(browser): preserve profile host when dropping stale devtools WS path
2026-03-31 21:09:31 -04:00
Kenny Xie
2650ce31fc fix(media): resolve relative MEDIA paths against agent workspace (#58624)
* fix(media): resolve relative MEDIA: paths against agent workspace dir

* fix(agents): remove stale ollama compat import

* fix(media): preserve workspace dir in outbound access
2026-03-31 21:09:28 -04:00
ryanngit
0b3d31c0ce feat(auth): WHAM-aware Codex cooldown for multi-profile setups (#58625)
Instead of exponential backoff guesses on Codex 429, probe the WHAM
usage API to determine real availability and write accurate cooldowns.

- Burst/concurrency contention: 15s circuit-break
- Genuine rate limit: proportional to real reset time (capped 2-4h)
- Expired token: 12h cooldown
- Dead account: 24h cooldown
- Probe failure: 30s fail-open

This prevents cascade lockouts when multiple agents share a pool of
Codex profiles, and avoids wasted retries on genuinely exhausted
profiles.

Closes #26329, relates to #1815, #1522, #23996, #54060

Co-authored-by: ryanngit <ryanngit@users.noreply.github.com>
2026-03-31 21:09:25 -04:00
Jalen
915e15c13d fix(gateway): skip restart when config.patch has no actual changes (#58502)
config.patch unconditionally writes the config file and sends SIGUSR1
even when diffConfigPaths detects zero changed paths. This causes a
full gateway restart (~10s downtime, all SSE/WebSocket connections
dropped) on every control-plane config.patch call, even when the
config is identical — e.g. a model hot-apply that doesn't change any
gateway.* paths.

Fix: when changedPaths is empty, return early with `noop: true`
without writing the file or scheduling SIGUSR1. The validated config
is still returned so the caller knows the current state.

This lets control-plane clients safely call config.patch for
idempotent updates without triggering unnecessary restarts.
2026-03-31 21:09:23 -04:00
Rico0919x
ee42e44d88 fix(auth): add qwen-dashscope and anthropic-openai to known API key env vars (#58503) 2026-03-31 21:09:20 -04:00
Andy
4d8c07b97c feat(cron): add --tools flag for per-job tool allow-list (#58504)
Add toolsAllow field to cron agent-turn payloads, enabling users to
restrict which tool schemas are sent to the model for a given cron job.

When --tools is set:
- Only listed tools are included in the provider request
- promptMode is forced to 'minimal' (strips skills catalog, reply tags,
  heartbeat, messaging, docs, memory, model aliases, silent replies)
- Dramatically reduces input tokens for small local models (~16K to ~800)

CLI surface:
- openclaw cron add --tools exec,read,write
- openclaw cron edit <id> --tools exec
- openclaw cron edit <id> --clear-tools (remove allow-list)

Closes #58435

Co-authored-by: andyk-ms <andyk-ms@users.noreply.github.com>
2026-03-31 21:09:17 -04:00
Qkal
3a52b475ab Docs: clarify first-contribution fallback when no good-first-issue labels are open (#58530) 2026-03-31 21:09:10 -04:00
Lee Pender
8b6b4b18a8 feat: add agents.defaults.params for global default provider params (#58548)
Allow setting provider params (e.g. cacheRetention) once at the
agents.defaults level instead of repeating them per-model. The merge
order is now: defaults.params -> defaults.models[key].params ->
list[agentId].params.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:09:07 -04:00
hcl
b8fea43bf2 fix(gateway): return default scopes when trusted HTTP request has no scope header (#58603)
resolveTrustedHttpOperatorScopes() returns [] when the x-openclaw-scopes
header is absent, even for trusted requests (--auth none). This causes
403 "missing scope: operator.write" on /v1/chat/completions.

Root cause: src/gateway/http-utils.ts:138-140. PR #57783 (f0af18672)
replaced the old resolveGatewayRequestedOperatorScopes which had an
explicit fallback to CLI_DEFAULT_OPERATOR_SCOPES when no header was
present. The new function treats absent header the same as empty header
— both return [].

Fix: distinguish absent header (undefined → return defaults) from empty
header ("" → return []). Trusted clients without an explicit scope
header get the default operator scopes, matching pre-#57783 behavior.

Closes #58357

Signed-off-by: HCL <chenglunhu@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 21:09:05 -04:00
Peter Steinberger
5b8f0cf1d5 test: centralize inbound contract suites 2026-04-01 02:04:53 +01:00
Peter Steinberger
b59adf9d2a test: fix outbound payload contract import 2026-04-01 02:02:07 +01:00
Peter Steinberger
051e31fb55 test: centralize outbound payload contracts 2026-04-01 02:01:48 +01:00
Peter Steinberger
ddf39180a4 test: remove extension dm policy wrappers 2026-04-01 01:59:22 +01:00
Peter Steinberger
2db2b078ca test: remove extension group policy wrappers 2026-04-01 01:57:18 +01:00
Peter Steinberger
aea016d02c test: fix registry-backed contract import 2026-04-01 01:54:03 +01:00
Peter Steinberger
1f97f907b2 test: centralize registry-backed channel contracts 2026-04-01 01:53:23 +01:00
Peter Steinberger
7614c45980 test: fix channel catalog contract import 2026-04-01 01:52:01 +01:00
Peter Steinberger
ba5b373ad4 test: centralize channel catalog contracts 2026-04-01 01:51:26 +01:00
Peter Steinberger
85679252c4 test: remove extension provider contract wrappers 2026-04-01 01:49:55 +01:00
Peter Steinberger
4af52a7428 test: centralize provider runtime, discovery, and auth contracts 2026-04-01 01:49:11 +01:00
Peter Steinberger
78be556299 test: consolidate plugin registration contracts 2026-04-01 01:46:56 +01:00
Peter Steinberger
63819bb383 test: consolidate provider and web-search contracts 2026-04-01 01:44:43 +01:00
Peter Steinberger
b910cc5869 test: remove extension manifest and core-extension wrappers 2026-04-01 01:44:43 +01:00
Peter Steinberger
c41df4873e test: consolidate package manifest and core-extension contracts 2026-04-01 01:44:43 +01:00
Mingxuan
302c047d86 hotfix(ollama): Show only Ollama models after provider selection (#55290)
* Fix: Add ollama to NON_PI_NATIVE_MODEL_PROVIDERS to ensure correct model selection

* Add test coverage for ollama provider to ensure models are merged correctly

* Fix test case for ollama provider by adding required model properties

* Changelog: add Ollama model picker fix

* Changelog: move Ollama fix entry to section tail

---------

Co-authored-by: Bruce MacDonald <brucewmacdonald@gmail.com>
2026-03-31 16:48:22 -07:00
Gustavo Madeira Santana
bea53d7a3f Fix: move bootstrap session grammar into plugin-owned session-key surfaces (#58400)
Merged via squash.

Prepared head SHA: b062b18b03
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-31 19:41:01 -04:00
oliviareid-svg
bf0f33db32 fix(compaction): resolve model override in runtime context for all context engines (#56710)
Merged via squash.

Prepared head SHA: 72550aa5f0
Co-authored-by: oliviareid-svg <269669958+oliviareid-svg@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-31 15:25:16 -07:00
Peter Steinberger
78d1120a41 test: retry gateway acp bind warmup 2026-03-31 23:20:25 +01:00
Peter Steinberger
d771f7dcb7 fix: harden acpx live startup 2026-03-31 23:20:24 +01:00
Peter Steinberger
f21abb2151 fix: harden queue cleanup lane resolution 2026-03-31 23:20:24 +01:00
Peter Steinberger
cdaf6d5749 docs: allow sponsor table markup in markdownlint 2026-03-31 23:18:55 +01:00
Josh Lehman
adc329b26b test: dedupe extension-owned coverage (#58554)
* test: dedupe extension-owned coverage

* test: remove duplicate coverage files

* test: move helper coverage into extensions

* test: trim duplicate helper assertions

* test: remove cloudflare helper import from agent test

* test: align stale expectations with current main
2026-03-31 15:18:29 -07:00
Peter Steinberger
d7e9d341cc fix: require npm auth for dist-tag mirror 2026-03-31 23:14:19 +01:00
Peter Steinberger
4f83409345 docs: add theme-aware sponsor logos 2026-04-01 07:14:05 +09:00
Peter Steinberger
091c6105a4 style(test): format skills install test 2026-03-31 23:11:53 +01:00
Peter Steinberger
aa6cf87814 refactor(approvals): share origin target reconciliation 2026-03-31 23:11:53 +01:00
Peter Steinberger
ddce362d34 refactor(approvals): share native delivery runtime 2026-03-31 23:11:53 +01:00
Peter Steinberger
5997317c09 docs: add NVIDIA sponsor logo 2026-04-01 06:59:06 +09:00
Peter Steinberger
9ea7e06460 build: bump version to 2026.4.1 2026-03-31 22:53:17 +01:00
Peter Steinberger
313a27d82f docs: update appcast for 2026.3.31 2026-03-31 22:51:49 +01:00
Vincent Koc
11318ef9b9 fix(status): align session_status with /status 2026-04-01 06:40:50 +09:00
Vincent Koc
94d72efedc fix(slack): accept bare approve fallback 2026-04-01 06:34:01 +09:00
Vincent Koc
ee8baf6766 fix(reply): stop mention-wrapped status double replies 2026-04-01 06:32:34 +09:00
Peter Steinberger
ad06d5ab4d build: reuse release preflight artifacts 2026-04-01 06:30:36 +09:00
Gustavo Madeira Santana
6679690737 fix(regression): restore diffs viewer toolbar buttons 2026-03-31 17:26:20 -04:00
Vincent Koc
db0f7c2cd5 changelog: note discord and telegram approval UX fixes 2026-04-01 06:21:56 +09:00
Vincent Koc
b73dd9b326 fix(approvals): suppress manual native approval narration 2026-04-01 06:21:56 +09:00
Vincent Koc
211b5a51af docs(changelog): note status followups 2026-04-01 06:16:15 +09:00
Vincent Koc
e1d2b299f6 fix(reply): avoid double status replies 2026-04-01 06:12:47 +09:00
Peter Steinberger
968bc3d5b0 fix(ci): preserve workspace openclaw plugin links 2026-03-31 22:05:59 +01:00
Vincent Koc
f85aba43a9 fix(approvals): restore native DM approval behavior 2026-04-01 06:02:04 +09:00
Vincent Koc
fdad8ea3b0 fix(status): show agent-local task counts when session tasks are empty 2026-04-01 06:01:29 +09:00
Vincent Koc
93e2d0e3de build(bluebubbles): align openclaw dependency specifiers 2026-04-01 06:00:55 +09:00
Peter Steinberger
213a704b71 fix: unblock 2026.3.31 release preflight 2026-03-31 21:54:12 +01:00
Vincent Koc
44baf3bb2b build(pnpm): exclude openclaw from minimum release age 2026-04-01 05:50:35 +09:00
Vincent Koc
107fefd255 docs(changelog): note pi tui reply flush fix 2026-04-01 05:47:10 +09:00
Peter Steinberger
6f111516ef docs: refresh plugin sdk api baseline 2026-03-31 21:46:21 +01:00
Vincent Koc
f425ea06bf fix(pi): flush message-boundary block replies on message end 2026-04-01 05:44:10 +09:00
Vincent Koc
0a7024e209 test(tasks): allow status command task-registry import 2026-04-01 05:42:45 +09:00
Peter Steinberger
418fa12dfa fix: make overload failover configurable 2026-03-31 21:34:35 +01:00
Peter Steinberger
2a60e34f2a build: prepare 2026.3.31 stable release 2026-03-31 21:32:38 +01:00
Peter Steinberger
eee37bf836 fix(slack): prevent duplicate draft replies 2026-03-31 21:22:50 +01:00
Vincent Koc
fc169215d7 fix(exec): deliver approval followups directly to chat 2026-04-01 05:22:35 +09:00
Vincent Koc
58ee76fc84 feat(status): show session task counts in slash status 2026-04-01 05:09:37 +09:00
Peter Steinberger
913e7d5eba fix: correct callable plugin sdk facades 2026-03-31 21:00:52 +01:00
Vincent Koc
a4f45c55b2 fix(slack): restore callable directory facade exports 2026-04-01 04:51:36 +09:00
Peter Steinberger
ffa2143d20 build: refresh slack facade output 2026-03-31 20:40:31 +01:00
Peter Steinberger
49458fc50e fix: pin parallels version checks to packed build commit 2026-03-31 20:39:21 +01:00
Peter Steinberger
0d742c3c1b test: skip unavailable live model providers 2026-03-31 20:37:42 +01:00
Peter Steinberger
64091caf8f fix: preserve cli and slack fallback behavior 2026-03-31 20:37:42 +01:00
Peter Steinberger
9d1b443542 fix: harden live docker auth harness 2026-03-31 20:37:42 +01:00
Jacob Tomlinson
1216ecbe58 Docs: add missing changelog entries 2026-03-31 19:30:43 +00:00
Vincent Koc
6f5f0c065b fix(slack): restore callable directory facade exports 2026-04-01 04:29:10 +09:00
Vincent Koc
2e530fc2e1 revert(amazon-bedrock): restore locked aws sdk version 2026-04-01 04:26:27 +09:00
Peter Steinberger
ce58f55fe0 fix: require doctor migration for legacy web search config 2026-04-01 04:22:41 +09:00
Peter Steinberger
2001603020 fix(amazon-bedrock): pin installable aws sdk build 2026-03-31 20:19:33 +01:00
Vincent Koc
b441e59d25 fix(build): isolate bundled runtime dependency staging 2026-04-01 04:15:08 +09:00
Peter Steinberger
cc278a76a4 test(tasks): update registry seam allowlist 2026-03-31 20:08:42 +01:00
Peter Steinberger
a23b4dd5bc fix(ci): route task executor through runtime seam 2026-03-31 20:02:10 +01:00
Vincent Koc
2a72a6d507 test(tasks): allow task executor registry seam 2026-04-01 04:01:06 +09:00
Peter Steinberger
d0bcd86348 fix(tasks): restore registry build compatibility 2026-03-31 19:59:21 +01:00
Vincent Koc
bb912daaed test(openai): fix ModelRegistry constructor typing 2026-04-01 03:58:55 +09:00
Vincent Koc
1816d6a4ed fix(tasks): normalize task create compatibility 2026-04-01 03:54:42 +09:00
Vincent Koc
80ed55332d fix(tasks): restore owner-key task scope 2026-04-01 03:53:12 +09:00
Peter Steinberger
5fdde9b237 fix(tasks): restore session key registry compatibility 2026-03-31 19:48:51 +01:00
Peter Steinberger
2d2c271cdf build: exclude source maps from release tarball 2026-03-31 19:42:56 +01:00
Vincent Koc
338d313043 fix(tasks): scope shared run updates by session 2026-04-01 03:41:29 +09:00
Peter Steinberger
8fa5ac5a96 build: refresh plugin sdk api baseline 2026-03-31 19:37:05 +01:00
Peter Steinberger
6f7629995c build: refresh beta release generated artifacts 2026-03-31 19:32:55 +01:00
Peter Steinberger
91be36ca4f build: prepare 2026.3.31-beta.1 release 2026-03-31 19:32:49 +01:00
Peter Steinberger
62a39381da fix: land codex native search follow-ups (#46579) (thanks @Evizero) 2026-04-01 03:30:06 +09:00
Christof Salis
13f1190149 CLI: suppress Codex native search summary when web search is off 2026-04-01 03:30:06 +09:00
Christof Salis
0a891543c9 Codex: use model-level API for native search relevance 2026-04-01 03:30:06 +09:00
Christof Salis
797a70fd95 Codex: add native web search for embedded Pi runs 2026-04-01 03:30:06 +09:00
Peter Steinberger
b87f33c920 test(ci): deflake windows npm exec coverage 2026-03-31 19:28:11 +01:00
Vincent Koc
66413487c8 fix(tasks): make task-store writes atomic (#58521) 2026-04-01 03:23:06 +09:00
Peter Steinberger
0abd143d37 test: fix CI flakes and registry test API 2026-03-31 19:20:07 +01:00
Peter Steinberger
b7013ec207 docs: fix changelog release placement 2026-03-31 19:14:57 +01:00
Vincent Koc
cb661122e2 test(bluebubbles): trim webhook auth import cost 2026-04-01 03:12:56 +09:00
Vincent Koc
e5537f8b64 test(ci): rebalance bluebubbles webhook auth timing 2026-04-01 03:12:56 +09:00
Vincent Koc
6d59ce366e test(ci): rebalance matrix extension timings 2026-04-01 03:12:56 +09:00
Vincent Koc
3ec143254f test(ci): rebalance discord webhook activity timing 2026-04-01 03:12:56 +09:00
Vincent Koc
7cd0ff2d88 refactor(tasks): add owner-key task access boundaries (#58516)
* refactor(tasks): add owner-key task access boundaries

* test(acp): update task owner-key assertion

* fix(tasks): align owner key checks and migration scope
2026-04-01 03:12:33 +09:00
Nimrod Gutman
69fe999373 fix(pairing): restore qr bootstrap onboarding handoff (#58382) (thanks @ngutman)
* fix(pairing): restore qr bootstrap onboarding handoff

* fix(pairing): tighten bootstrap handoff follow-ups

* fix(pairing): migrate legacy gateway device auth

* fix(pairing): narrow qr bootstrap handoff scope

* fix(pairing): clear ios tls trust on onboarding reset

* fix(pairing): restore qr bootstrap onboarding handoff (#58382) (thanks @ngutman)
2026-03-31 21:11:35 +03:00
Peter Steinberger
693d17c4a2 fix: support edit tool edits[] payloads 2026-03-31 19:05:44 +01:00
Peter Steinberger
ae730d9a86 fix: cover Azure disabled reasoning omission (#58208) (thanks @jalehman) 2026-04-01 02:47:29 +09:00
Josh Lehman
a1cb2bdc57 OpenAI: omit disabled reasoning payloads
Strip `reasoning.effort: "none"` from OpenAI-compatible payloads so GPT-5 models do not receive the unsupported value when thinking is off. Cover both the shared payload wrapper path and the websocket `response.create` builder with regression tests.

Regeneration-Prompt: |
  Fix the OpenAI request-building bug where thinking set to off still forwards
  a reasoning payload with effort "none". GPT-5 mini rejects that value with a
  400, so disabled reasoning must be represented by omitting the reasoning
  parameter entirely. Keep the change additive: sanitize OpenAI-compatible
  payloads in OpenClaw’s wrapper layer, make the websocket request builder stop
  emitting a reasoning block for "none", and add focused regression tests for
  both code paths.
2026-04-01 02:47:29 +09:00
Peter Steinberger
62e13bbf21 style: format sandbox and helper files 2026-03-31 18:44:39 +01:00
Peter Steinberger
5e0e46f405 style: format config UI templates 2026-03-31 18:44:05 +01:00
Peter Steinberger
b4433a1bfe fix: normalize raw MCP schemas for OpenAI Responses (#58299) (thanks @yelog) 2026-04-01 02:30:45 +09:00
yelog
dd3796aef3 fix: normalize MCP tool schemas missing properties field for OpenAI Responses API
Tools with no parameters produce { type: "object" } schemas without a
properties field. The OpenAI Responses API rejects these, silently
crashing entire sessions.

Add properties: {} injection in normalizeToolParameters() and
convertTools() to ensure all object-type schemas include a properties
field.

Closes #58246
2026-04-01 02:30:45 +09:00
Vincent Koc
fcb802e826 refactor(plugins): remove before_install hook 2026-04-01 02:28:06 +09:00
Vincent Koc
1a313caff3 refactor(tasks): remove flow registry layer 2026-04-01 02:25:13 +09:00
Vincent Koc
76b3235207 chore(changelog): soften sdk removal note 2026-04-01 02:21:24 +09:00
Vincent Koc
5c9408d3ca docs: update docs for unreleased channel and gateway changes
Cover Teams member-info action, Teams/Matrix sender-allowlist
context filtering, macOS MagicDNS discovery preference, and
trusted-proxy mixed token config hardening.
2026-04-01 02:20:44 +09:00
Peter Steinberger
e039c72a76 fix: unblock onboard docker smoke 2026-03-31 18:12:00 +01:00
Vincent Koc
bfba84a69d chore(changelog): attribute task entries 2026-04-01 02:10:39 +09:00
Peter Steinberger
4b1c15d059 docs: simplify install policy changelog note 2026-03-31 18:09:33 +01:00
Vincent Koc
c03e2beca1 chore(changelog): rename flow entries to tasks 2026-04-01 02:08:46 +09:00
Peter Steinberger
539ba0d244 docs: merge install breaking change notes 2026-03-31 18:07:35 +01:00
Peter Steinberger
b9f8bb6308 docs: tune unreleased changelog priorities 2026-03-31 18:04:22 +01:00
Vincent Koc
d794c5ca56 chore(changelog): add missing breaking notes 2026-04-01 02:01:48 +09:00
Vincent Koc
8ef81cc983 chore(changelog): reorder unreleased sections 2026-04-01 02:00:14 +09:00
Vincent Koc
dc948bb4eb chore(changelog): sort unreleased entries 2026-04-01 01:59:05 +09:00
Peter Steinberger
cebe697082 docs: update qq bot channel docs 2026-03-31 17:55:41 +01:00
Peter Steinberger
7672e48c19 docs: fill changelog gaps since last release 2026-03-31 17:50:00 +01:00
Peter Steinberger
1e17a96983 chore: surface parallels npm-update progress 2026-03-31 17:49:24 +01:00
Peter Steinberger
28673a9388 fix: restore bundled plugin runtime deps after global install 2026-03-31 17:49:24 +01:00
Peter Steinberger
9e8129907e test(tasks): fix relative mocks in task runtime tests 2026-03-31 17:42:52 +01:00
Peter Steinberger
83038fcaf2 docs: add missing unreleased changelog entries 2026-03-31 17:42:05 +01:00
Vincent Koc
35c6b3f648 test(ci): mock googlechat action media loader 2026-04-01 01:32:24 +09:00
Peter Steinberger
8bf8baef87 Revert "refactor: move tasks into bundled plugin"
This reverts commit c75f4695b7.
2026-04-01 01:30:22 +09:00
Peter Steinberger
759d37635d Revert "refactor: move tasks behind plugin-sdk seam"
This reverts commit da6e9bb76f.
2026-04-01 01:30:22 +09:00
Vincent Koc
6f74a572d9 test(ci): fix outbound media loader seams 2026-04-01 01:17:08 +09:00
Jacob Tomlinson
8a563d603b fix(matrix): filter fetched room context by sender allowlist (#58376)
* fix(matrix): filter fetched room context by sender allowlist

* style(matrix): normalize reply context guard formatting

* fix(matrix): drop raw ids from allowlist context logs
2026-03-31 17:09:03 +01:00
Jacob Tomlinson
6c679e5f04 Gateway: reject mixed trusted-proxy token config (#58371)
* Gateway: reject mixed trusted-proxy token config

Co-authored-by: boy-hack <w8ay@qq.com>

* Gateway: fail closed for loopback trusted-proxy auth

---------

Co-authored-by: boy-hack <w8ay@qq.com>
2026-03-31 17:05:03 +01:00
Peter Steinberger
aab7335236 fix(media): restore whatsapp outbound compatibility 2026-04-01 01:00:27 +09:00
Jacob Tomlinson
78e74d4a64 Plugins: preserve prompt build system prompt precedence (#58375) 2026-03-31 16:52:09 +01:00
Peter Steinberger
1a4c9c3e85 fix: repair extension media ci coverage 2026-03-31 16:47:13 +01:00
Peter Steinberger
7d2b4ed4e1 fix: restore whatsapp runtime seams 2026-03-31 16:47:13 +01:00
Peter Steinberger
6eddd55393 test: accept media loader option expansion 2026-03-31 16:47:13 +01:00
Peter Steinberger
a842e34f15 test: require Claude 4.6 for Anthropic live selection 2026-03-31 16:41:50 +01:00
Peter Steinberger
8f2e1194b7 docs: reorder changelog by user interest 2026-03-31 16:34:45 +01:00
Peter Steinberger
43ef8a5a86 refactor(media): centralize outbound access plumbing 2026-04-01 00:32:53 +09:00
Vincent Koc
c416527df6 fix(whatsapp): restore runtime send and action seam 2026-04-01 00:25:35 +09:00
Peter Steinberger
015ab98591 fix: restore ci status fast path and whatsapp tests 2026-03-31 16:21:55 +01:00
Vincent Koc
2a1db0c0f1 fix(gateway): narrow plugin route runtime scopes (#58167)
* wip(gateway): preserve plugin route scope progress

* test(gateway): cover plugin route runtime scopes

* test(gateway): finish plugin route scope rebase

* fix(gateway): drop scopes from plugin-auth routes
2026-04-01 00:20:49 +09:00
Peter Steinberger
85611f0021 fix: tighten gateway startup plugin loading 2026-04-01 00:20:06 +09:00
Vincent Koc
1ca12ec8bf fix(hooks): rebind hook agent session keys to the target agent (#58225)
* fix(hooks): rebind hook agent session keys

* fix(hooks): preserve scoped hook session keys

* fix(hooks): validate normalized dispatch keys
2026-04-01 00:16:39 +09:00
Peter Steinberger
fc5a2f9293 fix(media): add host media read helper 2026-04-01 00:08:20 +09:00
Peter Steinberger
3bb02d3338 fix(media): align outbound sends with fs read capability 2026-04-01 00:07:50 +09:00
openperf
56b5ba0dcb fix: address security and review feedback
- Fix CWE-209: use static safe message instead of raw provider error text
- Fix CWE-117: sanitize provider/model in logs via sanitizeForLog
- Hide CLI hints from external channels via shouldSurfaceToControlUi
- Move overload cap check before advanceAuthProfile to save setup latency
- Export MAX_LIVE_SWITCH_RETRIES as module-level constant
- Use exact toBe() assertions in tests
- Correct failover decision label to fallback_model
2026-03-31 20:25:09 +05:30
openperf
1fcd179d8c fix(gateway): prevent session death loop on overloaded fallback
- Add MAX_LIVE_SWITCH_RETRIES=2 guard in agent-runner-execution.ts
- Add MAX_OVERLOAD_PROFILE_ROTATIONS=1 cap in run.ts for overloaded errors
- Return kind:final with user-visible error on retry exhaustion
- Escalate to cross-provider fallback instead of exhausting same-provider profiles

Fixes #58348
2026-03-31 20:25:09 +05:30
Peter Steinberger
bf96c67fd1 fix: align skill install security gate 2026-03-31 15:53:29 +01:00
Peter Steinberger
192484ed0a fix: log malformed tool parameters on failure 2026-03-31 15:50:14 +01:00
Peter Steinberger
7dffd8160a test(extensions): use ModelRegistry factory 2026-03-31 23:50:03 +09:00
Peter Steinberger
a1e2d2bf42 test: repair stale task and image mocks 2026-03-31 15:48:00 +01:00
Peter Steinberger
c425ef3e74 build: bump version to 2026.3.31 2026-03-31 15:48:00 +01:00
Peter Steinberger
5e30da3cad fix(exec): restore strict inline-eval allow-always reuse 2026-03-31 23:45:22 +09:00
Vincent Koc
5aac609e08 test(ci): rebalance telegram thread binding timing 2026-03-31 23:42:05 +09:00
Peter Steinberger
ac6f025c43 refactor(approvals): share telegram account binding 2026-03-31 15:39:59 +01:00
Vincent Koc
b3a2734cc9 test(ci): rebalance telegram acp binding timing 2026-03-31 23:38:45 +09:00
Vincent Koc
983891a603 fix(ci): narrow telegram route test seams 2026-03-31 23:37:18 +09:00
Peter Steinberger
461a3a4052 refactor(approvals): share request filter matching 2026-03-31 15:32:49 +01:00
Vincent Koc
7c4bffdecd fix(ci): rebalance telegram dm thread tests 2026-03-31 23:32:15 +09:00
Peter Steinberger
177687ae29 fix: adapt pi model registry calls to constructor API 2026-03-31 15:28:29 +01:00
Peter Steinberger
0d7f1e2c84 feat(security): fail closed on dangerous skill installs 2026-03-31 23:27:20 +09:00
Vincent Koc
98c0c38186 fix(ci): rebalance telegram channel tails 2026-03-31 23:24:16 +09:00
Peter Steinberger
da6e9bb76f refactor: move tasks behind plugin-sdk seam 2026-03-31 15:22:09 +01:00
Peter Steinberger
e1da91791a build: externalize bundled plugin runtime deps 2026-03-31 15:22:08 +01:00
Peter Steinberger
9537094841 test: refresh plugin sdk baseline 2026-03-31 15:22:08 +01:00
Peter Steinberger
c75f4695b7 refactor: move tasks into bundled plugin 2026-03-31 15:22:08 +01:00
Peter Steinberger
584db0aff2 fix(approvals): centralize native request binding 2026-03-31 15:20:47 +01:00
Vincent Koc
2523e25c93 test(ci): rebalance telegram implicit mention timing 2026-03-31 23:17:40 +09:00
Peter Steinberger
0ed7f1fd22 refactor: remove core WhatsApp runtime channel seam 2026-03-31 15:17:13 +01:00
Peter Steinberger
e8cb0b3659 fix: tighten live gateway empty-response skips and outbound harness typing 2026-03-31 15:17:13 +01:00
Peter Steinberger
d90b627e1b build: copy bundled plugin postinstall script into cleanup smoke image 2026-03-31 15:17:13 +01:00
Peter Steinberger
44b9936136 feat(plugins): add dangerous unsafe install override 2026-03-31 23:16:11 +09:00
Peter Steinberger
59866dd253 fix(memory): restore readonly recovery helper seams 2026-03-31 23:14:24 +09:00
Altay
ba4116e6a9 build: comment out pnpm release-age exclude 2026-03-31 23:10:07 +09:00
Altay
9407ac87df build: move pnpm minimum release age to workspace config 2026-03-31 23:10:07 +09:00
Peter Steinberger
8807b017d1 test: harden channel planner lane matching 2026-03-31 23:08:23 +09:00
Peter Steinberger
4fb373466e refactor: simplify memory recovery and test setup 2026-03-31 15:02:11 +01:00
Vincent Koc
6936033e98 test(telegram): stop overriding message-context session mocks 2026-03-31 23:01:21 +09:00
Peter Steinberger
0711cb4a05 fix(hooks): reduce registration log noise 2026-03-31 14:59:22 +01:00
Peter Steinberger
dc0e0b0f68 docs(security): mark shared-secret HTTP auth as designed 2026-03-31 22:58:09 +09:00
Jacob Tomlinson
a4d72a83f0 fix(tlon): preserve explicit empty settings during migration (#58370) 2026-03-31 14:57:03 +01:00
Peter Steinberger
c1ea0ae9c8 build: update deps and align pi sdk usage 2026-03-31 22:56:20 +09:00
Peter Steinberger
cbfeecfab4 fix(gateway): restore shared-secret HTTP tool invoke auth 2026-03-31 22:55:15 +09:00
Jacob Tomlinson
0c83754246 Exec approvals: reject shell init-file script matches (#58369) 2026-03-31 14:53:43 +01:00
Vincent Koc
0ed4f8a72b fix(media): reject oversized image inputs before decode (#58226)
* fix(media): cap oversized image inputs

* chore(changelog): add media input guard note

* fix(media): address input guard review feedback

* fix(media): fail closed on unknown sips dimensions

* fix(media): avoid sips fallback in input guard
2026-03-31 22:52:55 +09:00
Vincent Koc
aaf6077f27 test(telegram): skip session persistence in message-context harness 2026-03-31 22:51:25 +09:00
Vincent Koc
4ee742174f fix(nostr): verify inbound dm signatures before pairing replies (#58236)
* fix(nostr): verify inbound dm signatures before pairing

* fix(nostr): authorize senders before rate limiting

* test(nostr): cover pending auth rate-limit starvation

* fix(nostr): rate limit oversized inbound ciphertext

* fix(nostr): dedupe blocked inbound replays

* fix(nostr): rate limit before auth work
2026-03-31 22:51:22 +09:00
Peter Steinberger
5fc8f6ca8f test: align targeted channel batching expectation 2026-03-31 14:49:04 +01:00
Vincent Koc
29b9310319 fix(scripts): normalize bundled entry paths and planner counts 2026-03-31 22:47:12 +09:00
Vincent Koc
3be08454f4 test(telegram): narrow resolve-media retry imports 2026-03-31 22:45:39 +09:00
Vincent Koc
91115cdf61 test(telegram): stub menu sync in command harness 2026-03-31 22:31:12 +09:00
Vincent Koc
2df86cce1c refactor(telegram): narrow native command reply dispatch seam 2026-03-31 22:28:53 +09:00
Peter Steinberger
5a93344d82 fix: ship bundled runtime support packages 2026-03-31 14:25:32 +01:00
Vincent Koc
5b7443d175 perf(whatsapp): narrow reply chunking imports 2026-03-31 22:25:14 +09:00
Peter Steinberger
e7e383b7cf build: exclude @mariozechner packages from pnpm release age 2026-03-31 22:23:30 +09:00
Vincent Koc
ff36bc314d test(telegram): use shared delivery mock in registry test 2026-03-31 22:18:29 +09:00
Vincent Koc
3f2fb73cfe perf(slack): avoid module resets in outbound adapter test 2026-03-31 22:13:39 +09:00
Frank Yang
dbe6663c34 fix(qqbot): align speech schema and setup validation (#58253)
* fix(qqbot): align speech schema and setup validation

* fix(qqbot): preserve use-env setup flow

* fix(qqbot): reject use-env on named accounts

* fix(qqbot): restore default account schema support
2026-03-31 21:11:45 +08:00
Gustavo Madeira Santana
8dbba7d17c fix(scripts/pr): make cleanup worktree-safe 2026-03-31 09:07:42 -04:00
Gustavo Madeira Santana
27b9665871 chore: clarify test performance guardrail 2026-03-31 09:07:42 -04:00
Vincent Koc
d369c9373b perf(whatsapp): avoid module resets in poll adapter test 2026-03-31 22:06:01 +09:00
Vincent Koc
37099dae3e fix(ci): restore matrix monitor import guards and windows npm exit codes 2026-03-31 22:04:35 +09:00
Vincent Koc
35072c4751 perf(discord): avoid broad send barrel in webhook activity test 2026-03-31 22:02:01 +09:00
Vincent Koc
675b80c4a4 perf(slack): narrow send chunking imports 2026-03-31 21:58:00 +09:00
Gustavo Madeira Santana
4ea1ca4849 Sessions: parse thread suffixes by channel (#58100)
Merged via squash.

Prepared head SHA: 2829b9c5b5
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-31 08:54:16 -04:00
Vincent Koc
11590eb6ce fix(ci): restore dotenv trust boundary and windows npm exit handling 2026-03-31 21:51:17 +09:00
Gustavo Madeira Santana
3ceec929df Matrix: narrow monitor runtime seam 2026-03-31 08:46:53 -04:00
Vincent Koc
7710579a82 perf(telegram): narrow native command import surface 2026-03-31 21:43:16 +09:00
Vincent Koc
b19e28a85e fix(telegram): lazy-load sticker vision model lookup 2026-03-31 21:31:05 +09:00
Vincent Koc
dba96e7507 fix(discord): gate voice ingress by allowlists (#58245)
* fix(discord): gate voice ingress by allowlists

* fix(discord): preserve voice allowlist context

* fix(discord): fetch guild metadata for voice allowlists

* fix(discord): reuse voice speaker context

* fix(discord): preserve cached speaker context

* fix(discord): tighten voice ingress authorization
2026-03-31 21:29:13 +09:00
Vincent Koc
25a3d37970 fix(ci): restore matrix guardrails and windows exec shim 2026-03-31 21:27:43 +09:00
Gustavo Madeira Santana
f8af407c86 build: pin axios to 1.13.6
Pin axios through pnpm overrides and collapse the lockfile to a single
1.13.6 resolution.

This avoids accidental adoption of the compromised axios releases called
out in the ongoing supply chain attack reports while upstream guidance
settles.
2026-03-31 08:27:00 -04:00
Vincent Koc
4d912e0451 fix(exec): block proxy-style env overrides (#58202)
* fix(exec): block proxy-style env overrides

* fix(exec): keep trusted host proxy env inherited

* fix(exec): block git tls override env vars

* fix(skills): block dangerous env override keys
2026-03-31 21:25:36 +09:00
Gustavo Madeira Santana
28bb8c600e Matrix: narrow thread binding runtime seam 2026-03-31 08:12:46 -04:00
Gustavo Madeira Santana
305977571d Matrix: narrow storage and routing imports 2026-03-31 08:12:46 -04:00
Vincent Koc
e6441760d2 test(telegram): normalize message-context timing inputs 2026-03-31 21:10:43 +09:00
Vincent Koc
415e7d941b test(slack): remove slash metadata polling 2026-03-31 21:02:06 +09:00
Vincent Koc
730ba40763 fix(exec): unwrap arch and xcrun dispatch wrappers (#58203)
* fix(exec): unwrap arch and xcrun dispatch wrappers

* fix(infra): scope arch wrapper unwrapping to macos

* fix(exec): scope arch wrapper unwrapping to macos

* fix(infra): validate macos arch wrapper selectors

* test(infra): cover invalid arch name wrappers
2026-03-31 21:00:14 +09:00
Jacob Tomlinson
2ce44ca6a1 fix(plugins): guard marketplace archive downloads (#58267)
* Plugins: guard marketplace archive downloads

* Plugins: harden marketplace download cleanup

* Plugins: bound marketplace archive downloads

* Plugins: harden marketplace archive failures

* Plugins: reject drive-relative marketplace archives

* Plugins: stream marketplace archive downloads
2026-03-31 12:59:42 +01:00
Mariano
607076d164 ClawFlow: add runtime substrate (#58336)
Merged via squash.

Prepared head SHA: 6a6158179e
Reviewed-by: @mbelinky
2026-03-31 13:58:29 +02:00
Vincent Koc
f2d4089ca2 test(discord): remove monitor polling overhead 2026-03-31 20:56:37 +09:00
Vincent Koc
334085fbe9 test(channels): inject telegram reply pipeline for dispatch tests 2026-03-31 20:54:30 +09:00
Vincent Koc
5474796735 docs(security): clarify acpx yolo mode 2026-03-31 20:54:30 +09:00
pgondhi987
d8c68c8d42 fix: migrate Telegram pairing allowFrom to default account only (#58165)
* fix: migrate Telegram pairing allowFrom to default account only

* fix: address PR review feedback

* fix: address PR review feedback
2026-03-31 12:51:38 +01:00
Vincent Koc
62c28c0708 test(discord): isolate ACP binding routing seam 2026-03-31 20:49:31 +09:00
Vincent Koc
b4ac69c652 docs(acp): align approval policy wording 2026-03-31 20:49:31 +09:00
Vincent Koc
cd5179314d fix(acp): use semantic approval classes 2026-03-31 20:49:31 +09:00
Gustavo Madeira Santana
d077faab1a Matrix: narrow monitor runtime imports 2026-03-31 07:29:47 -04:00
Gustavo Madeira Santana
2bdf2fbf14 Matrix: trim storage test import churn 2026-03-31 07:29:47 -04:00
Vincent Koc
225dfe0094 fix(ci): stabilize planner executor fallback tests 2026-03-31 20:26:28 +09:00
Gustavo Madeira Santana
8c0245f57b fix(matrix): tighten DM invite promotion state (#58099)
Merged via squash.

Prepared head SHA: 6638d4b505
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-31 07:09:18 -04:00
Vincent Koc
1243e2c0b6 fix(telegram): keep test harness CJS-safe 2026-03-31 20:04:21 +09:00
Vincent Koc
e704323ff3 fix(media): drop auth headers on cross-origin redirects (#58224)
* fix(media): drop auth headers on cross-origin redirects

* chore(changelog): sync unreleased context

* fix(media): keep fetch-guard redirect helper working
2026-03-31 19:57:42 +09:00
Vincent Koc
3d5af14984 fix(agents): reject escaping symlinks in ssh sandbox uploads (#58220)
* fix(agents): reject escaping ssh sandbox upload symlinks

* fix(agents): allow safe ssh upload symlink aliases

* test(ssh): keep upload stdin open in fake ssh

* Update CHANGELOG.md
2026-03-31 19:56:45 +09:00
FMLS
44caf1ee3d fix(browser): prevent cross-origin images from disappearing in CDP screenshots (#54358)
fromSurface: true + captureBeyondViewport: true triggers a Chromium compositor
bug where cross-origin image textures are lost when extending the capture
surface. Switch to fromSurface: false to use the software rendering path.

For full-page captures, temporarily expand the viewport via
Emulation.setDeviceMetricsOverride, preserving the current mobile/DPR/screen
state during capture and restoring it afterward so pre-existing device
emulation is not lost.

Made-with: Cursor

Co-authored-by: hakunaliu <hakunaliu@tencent.com>
2026-03-31 18:55:25 +08:00
Vincent Koc
57700d716f fix(config): redact Nostr privateKey in config views (#58177)
* wip(config): preserve nostr redaction progress

* fix(config): add private key redaction fallback

* fix(config): align nostr privateKey secret input handling

* fix(config): require resolved nostr private keys
2026-03-31 19:55:03 +09:00
Vincent Koc
efe9183f9d fix(voice-call): pin plivo callback origins (#58238) 2026-03-31 19:50:35 +09:00
Vincent Koc
cf3ae2612b fix(ci): reduce slow channel test skew 2026-03-31 19:49:40 +09:00
Vincent Koc
da7f016db6 fix(doctor): align qmd probe cwd with runtime 2026-03-31 19:49:40 +09:00
Vincent Koc
6b3f99a11f fix(gateway): enforce trusted-proxy HTTP origin checks (#58229)
* fix(gateway): enforce trusted-proxy HTTP origin checks

* Update CHANGELOG.md
2026-03-31 19:49:26 +09:00
Vincent Koc
9abcfdadf5 fix(voice-call): reject oversized pre-start media frames (#58241)
* fix(voice-call): reject oversized pre-start frames

* fix(voice-call): avoid normalizing oversized frames

* chore(changelog): remove stray spacing

* fix(voice-call): remove dead inbound size guard
2026-03-31 19:47:10 +09:00
Vincent Koc
9bc1f896c8 fix(pairing): scope pending request caps per account (#58239)
* fix(pairing): scope pending pairing caps per account

* fix(pairing): count legacy default-account requests
2026-03-31 19:45:45 +09:00
Vincent Koc
f45e5a6569 fix(feishu): filter fetched group thread context (#58237)
* fix(feishu): filter fetched group thread context

* fix(feishu): preserve filtered thread bootstrap
2026-03-31 19:43:54 +09:00
Vincent Koc
2194587d70 fix(tlon): cap inbound image downloads (#58223) 2026-03-31 19:40:15 +09:00
Vincent Koc
9023a0436c fix(exec): unwrap transparent approval wrappers (#58215)
* fix(exec): unwrap transparent approval wrappers

* fix(exec): normalize sandbox-exec -D wrapper parsing
2026-03-31 19:38:34 +09:00
Vincent Koc
eb8de6715f fix(exec): block risky host env overrides (#58209)
* fix(exec): block risky host env overrides

* fix(exec): block GOPRIVATE host env overrides
2026-03-31 19:37:43 +09:00
Vincent Koc
57c47d8c7f fix(line): bound preverify webhook concurrency (#58199)
* fix(line): bound preverify webhook concurrency

* test(line): cover preauth release timing

* fix(line): release webhook preauth slots earlier
2026-03-31 19:34:25 +09:00
Vincent Koc
4d038bb242 fix(zalo): scope webhook replay dedupe per target (#58196) 2026-03-31 19:33:57 +09:00
Vincent Koc
57fccca2dc fix(exec): keep awk and sed out of safeBins fast path (#58175)
* wip(exec): preserve safe-bin semantics progress

* test(exec): cover safe-bin semantic variants

* fix(exec): address safe-bin review follow-up
2026-03-31 19:29:53 +09:00
Vincent Koc
330a9f98cb fix(config): block workspace bundled-root dotenv overrides (#58170)
* wip(config): preserve bundled hooks root progress

* test(config): cover bundled trust-root dotenv blocking
2026-03-31 19:25:12 +09:00
Vincent Koc
b9f857708c wip(config): preserve bundled plugins root progress (#58168) 2026-03-31 19:23:11 +09:00
Jacob Tomlinson
781775ec08 Media: secure image temp dirs (#58270) 2026-03-31 11:12:47 +01:00
Ayaan Zaidi
6be0c7ef09 fix(android): drop bootstrap auth after manual endpoint changes 2026-03-31 15:32:36 +05:30
Jacob Tomlinson
7bd2761b92 Exec approvals: detect command carriers in strict inline eval (#57842)
* Exec approvals: detect command carriers in strict inline eval

* Exec approvals: cover carrier option edge cases

* Exec approvals: cover make and find carriers

* Exec approvals: catch attached eval flags

* Exec approvals: keep sed -E out of inline eval

* Exec approvals: treat sed in-place flags as optional
2026-03-31 10:58:17 +01:00
Ayaan Zaidi
cbc75f13b2 test(android): cover node-only onboarding state 2026-03-31 15:21:39 +05:30
Ayaan Zaidi
132208c01f fix(android): require node connection before onboarding finish 2026-03-31 15:21:39 +05:30
Ayaan Zaidi
c1269eddb8 fix(android): preserve bootstrap auth for manual reconnect 2026-03-31 15:21:39 +05:30
Jacob Tomlinson
eb84d91a80 UI: build delete confirm popover without HTML strings (#58269)
* UI: build delete confirm popover safely

* UI: share delete confirm storage key
2026-03-31 10:42:07 +01:00
Jacob Tomlinson
df0e136bc7 Canvas Host: build default status with DOM nodes (#58266) 2026-03-31 10:29:28 +01:00
Vincent Koc
e95f786aa2 fix(dev): sync run-node test types 2026-03-31 18:04:22 +09:00
Jacob Tomlinson
a23c33a681 macOS: use MagicDNS for wide-area gateway discovery (#57833)
* macOS: use MagicDNS for wide-area gateway discovery

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>

* macOS: tighten wide-area discovery review follow-ups

---------

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>
2026-03-31 10:04:11 +01:00
Vincent Koc
f288ff3f9f fix(tests): stabilize cron and blocked-flow assertions 2026-03-31 17:58:41 +09:00
Vincent Koc
cd8d0881ed fix(dev): classify dirty-tree watch invalidations 2026-03-31 17:54:05 +09:00
Vincent Koc
622bdfdad1 docs(memory): clarify qmd symlink traversal limits 2026-03-31 17:54:00 +09:00
Vincent Koc
2befbc5e60 fix(matrix): restore local helper seams 2026-03-31 17:42:37 +09:00
Vincent Koc
4e2a072b5b fix(memory): add qmd degraded status changelog 2026-03-31 17:37:12 +09:00
Vincent Koc
d27165f5de fix(tasks): allow new task read-model importers 2026-03-31 17:35:39 +09:00
Vincent Koc
3a5042b6cc fix(memory): surface qmd degraded vector status 2026-03-31 17:35:36 +09:00
Vincent Koc
af37fca556 fix(qqbot): mark npm-publishable package public 2026-03-31 17:33:48 +09:00
Vincent Koc
0b76d85509 fix(qqbot): declare silk-wasm codec types 2026-03-31 17:30:22 +09:00
Vincent Koc
72d1725fcf fix(tasks): preserve nullable flow patch clears 2026-03-31 17:24:54 +09:00
zsxsoft
d15d7d0962 fix(scripts/pr): shell-escape env file values to prevent command injection via branch names 2026-03-31 17:24:19 +09:00
pgondhi987
f865a5455e fix(media): drop sensitive headers on cross-origin redirects [AI] (#58156) 2026-03-31 09:22:11 +01:00
Vincent Koc
549169f746 fix(docs): format memory config reference 2026-03-31 17:18:21 +09:00
Vincent Koc
d2dcd6fca6 fix(memory): stagger qmd embed maintenance across agents (#58180)
* fix(memory): stagger qmd embed maintenance across agents

* fix(memory): keep qmd embed serialization in-process

* fix(memory): extend qmd embed lock wait budget
2026-03-31 17:17:20 +09:00
Sliverp
bf6f506dfa Feature/add qq channel (#52986)
* feat: add QQ Bot channel extension

* fix(qqbot): add setupWizard to runtime plugin for onboard re-entry

* fix: fix review

* fix: fix review

* chore: sync lockfile and config-docs baseline for qqbot extension

* refactor: 移除图床服务器相关代码

* fix

* docs: 新增 QQ Bot 插件文档并修正链接路径

* refactor: remove credential backup functionality and update setup logic

- Deleted the credential backup module to streamline the codebase.
- Updated the setup surface to handle client secrets more robustly, allowing for configured secret inputs.
- Simplified slash commands by removing unused hot upgrade compatibility checks and related functions.
- Adjusted types to use SecretInput for client secrets in QQBot configuration.
- Modified bundled plugin metadata to allow additional properties in the config schema.

* feat: 添加本地媒体路径解析功能,修正 QQBot 媒体路径处理

* feat: 添加本地媒体路径解析功能,修正 QQBot 媒体路径处理

* feat: remove qqbot-media and qqbot-remind skills, add tests for config and setup

- Deleted the qqbot-media and qqbot-remind skills documentation files.
- Added unit tests for qqbot configuration and setup processes, ensuring proper handling of SecretRef-backed credentials and account configurations.
- Implemented tests for local media path remapping, verifying correct resolution of media file paths.
- Removed obsolete channel and remind tools, streamlining the codebase.

* feat: 更新 QQBot 配置模式,添加音频格式和账户定义

* feat: 添加 QQBot 频道管理和定时提醒技能,更新媒体路径解析功能

* fix

* feat: 添加 /bot-upgrade 指令以查看 QQBot 插件升级指引

* feat: update reminder and qq channel skills

* feat: 更新remind工具投递目标地址格式

* feat: Refactor QQBot payload handling and improve code documentation

- Simplified and clarified the structure of payload interfaces for Cron reminders and media messages.
- Enhanced the parsing function to provide clearer error messages and improved validation.
- Updated platform utility functions for better cross-platform compatibility and clearer documentation.
- Improved text parsing utilities for better readability and consistency in emoji representation.
- Optimized upload cache management with clearer comments and reduced redundancy.
- Integrated QQBot plugin into the bundled channel plugins and updated metadata for installation.

* OK apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift

> openclaw@2026.3.26 check:bundled-channel-config-metadata /Users/yuehuali/code/PR/openclaw
> node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check

[bundled-channel-config-metadata] stale generated output at src/config/bundled-channel-config-metadata.generated.ts
 ELIFECYCLE  Command failed with exit code 1.
 ELIFECYCLE  Command failed with exit code 1.

* feat: 添加 QQBot 渠道配置及相关账户设置

* fix(qqbot): resolve 14 high-priority bugs from PR #52986 review

DM routing (7 fixes):
- #1: DM slash-command replies use sendDmMessage(guildId) instead of sendC2CMessage(senderId)
- #2: DM qualifiedTarget uses qqbot:dm:${guildId} instead of qqbot:c2c:${senderId}
- #3: sendTextChunks adds DM branch
- #4: sendMarkdownReply adds DM branch for text and Base64 images
- #5: parseAndSendMediaTags maps DM to targetType:dm + guildId
- #6: sendTextToTarget DM branch uses sendDmMessage; MessageTarget adds guildId field
- #7: handleImage/Audio/Video/FilePayload add DM branches

Other high-priority fixes:
- #8: Fix sendC2CVoiceMessage/sendGroupVoiceMessage parameter misalignment
- #9: broadcastMessage uses groupOpenid instead of member_openid for group users
- #10: Unify KnownUser storage - proactive.ts delegates to known-users.ts
- #11: Remove invalid recordKnownUser calls for guild/DM users
- #12: sendGroupMessage uses sendAndNotify to trigger onMessageSent hook
- #13: sendPhoto channel unsupported returns error field
- #14: sendTextAfterMedia adds channel and dm branches

Type fixes:
- DeliverEventContext adds guildId field
- MediaTargetContext.targetType adds dm variant
- sendPlainTextReply imgMediaTarget adds DM branch

* fix(qqbot): resolve 2 blockers + 7 medium-priority bugs from PR #52986 review

Blocker-1: Remove unused dmPolicy config knob
- dmPolicy was declared in schema/types/plugin.json but never consumed at runtime
- Removed from config-schema.ts, types.ts, and openclaw.plugin.json
- allowFrom remains active (already wired into framework command-auth)

Blocker-2: Gate sensitive slash commands with allowFrom authorization
- SlashCommand interface adds requireAuth?: boolean
- SlashCommandContext adds commandAuthorized: boolean
- /bot-logs set to requireAuth: true (reads local log files)
- matchSlashCommand rejects unauthorized senders for requireAuth commands
- trySlashCommandOrEnqueue computes commandAuthorized from allowFrom config

Medium-priority fixes:
- #15: Strip non-HTTP/non-local markdown image tags to prevent path leakage
- #16: applyQQBotAccountConfig clears clientSecret when setting clientSecretFile and vice versa
- #17: getAdminMarkerFile sanitizes accountId to prevent path traversal
- #18: URGENT_COMMANDS uses exact match instead of startsWith prefix match
- #19: isCronExpression validates each token starts with a cron-valid character
- #20: --token format validation rejects malformed input without colon separator
- #21: resolveDefaultQQBotAccountId checks QQBOT_APP_ID environment variable

* test(qqbot): add focused tests for slash command authorization path

- Unauthorized sender rejected for /bot-logs (requireAuth: true)
- Authorized sender allowed for /bot-logs
- Non-requireAuth commands (/bot-ping, /bot-help, /bot-version) work for all senders
- Unknown slash commands return null (passthrough)
- Non-slash messages return null
- Usage query (/bot-logs ?) also gated by auth check

* fix(qqbot): align global TTS fallback with framework config resolution

- Extract isGlobalTTSAvailable to utils/audio-convert.ts, mirroring core
  resolveTtsConfig logic: check auto !== 'off', fall back to legacy
  enabled boolean, default to off when neither is set.
- Add pre-check in reply-dispatcher before calling globalTextToSpeech to
  avoid unnecessary TTS calls and noisy error logs when TTS is not
  configured.
- Remove inline as any casts; use OpenClawConfig type throughout.
- Refactor handleAudioPayload into flat early-return structure with
  unified send path (plugin TTS → global fallback → send).

* fix(qqbot): break ESM circular dependency causing multi-account startup crash

The bundled gateway chunk had a circular static import on the channel
chunk (gateway -> outbound-deliver -> channel, while channel dynamically
imports gateway). When two accounts start concurrently via Promise.all,
the first dynamic import triggers module graph evaluation; the circular
reference causes api exports (including runDiagnostics) to resolve as
undefined before the module finishes evaluating.

Fix: extract chunkText and TEXT_CHUNK_LIMIT from channel.ts into a new
text-utils.ts leaf module. outbound-deliver.ts now imports from
text-utils.ts, breaking the cycle. channel.ts re-exports for backward
compatibility.

* fix(qqbot): serialize gateway module import to prevent multi-account startup race

When multiple accounts start concurrently via Promise.all, each calls
await import('./gateway.js') independently. Due to ESM circular
dependencies in the bundled output, the first import can resolve
transitive exports as undefined before module evaluation completes.

Fix: cache the dynamic import promise in a module-level variable so all
concurrent startAccount calls share the same import, ensuring the
gateway module is fully evaluated before any account uses it.

* refactor(qqbot): remove startup greeting logic

Remove getStartupGreetingPlan and related startup greeting delivery:
- Delete startup-greeting.ts (greeting plan, marker persistence)
- Delete admin-resolver.ts (admin resolution, greeting dispatch)
- Remove startup greeting calls from gateway READY/RESUMED handlers
- Remove isFirstReadyGlobal flag and adminCtx

* fix(qqbot): skip octal escape decoding for Windows local paths

Windows paths like C:\Users\1\file.txt contain backslash-digit sequences
that were incorrectly matched as octal escape sequences and decoded,
corrupting the file path. Detect Windows local paths (drive letter or UNC
prefix) and skip the octal decoding step for them.

* fix bot issue

* feat: 支持 TTS 自动开关并清理配置中的 clientSecretFile

* docs: 添加 QQBot 配置和消息处理的设计说明

* rebase

* fix(qqbot): align slash-command auth with shared command-auth model

Route requireAuth:true slash commands (e.g. /bot-logs) through the
framework's api.registerCommand() so resolveCommandAuthorization()
applies commands.allowFrom.qqbot precedence and qqbot: prefix
normalization before any handler runs.

- slash-commands.ts: registerCommand() now auto-routes by requireAuth
  into two maps (commands / frameworkCommands); getFrameworkCommands()
  exports the auth-required set for framework registration; bot-help
  lists both maps
- index.ts: registerFull() iterates getFrameworkCommands() and calls
  api.registerCommand() for each; handler derives msgType from ctx.from,
  sends file attachments via sendDocument, supports multi-account via
  ctx.accountId
- gateway.ts (inbound): replace raw allowFrom string comparison with
  qqbotPlugin.config.formatAllowFrom() to strip qqbot: prefix and
  uppercase before matching event.senderId
- gateway.ts (pre-dispatch): remove stale auth computation; commandAuthorized
  is true (requireAuth:true commands never reach matchSlashCommand)
- command-auth.test.ts: add regression tests for qqbot: prefix
  normalization in the inbound commandAuthorized computation
- slash-commands.test.ts: update /bot-logs tests to expect null
  (command routed to framework, not in local registry)

* rebase and solve conflict

* fix(qqbot): preserve mixed env setup credentials

---------

Co-authored-by: yuehuali <yuehuali@tencent.com>
Co-authored-by: walli <walli@tencent.com>
Co-authored-by: WideLee <limkuan24@gmail.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
2026-03-31 16:13:16 +08:00
Mariano
f86e5c0a08 ClawFlow: add linear flow control surface (#58227)
* ClawFlow: add linear flow control surface

* Flows: clear blocked metadata on resume
2026-03-31 10:08:50 +02:00
Vincent Koc
ab4ddff7f1 feat(memory): add per-agent QMD extra collections for cross-agent session search (#58211)
* feat(memory): add per-agent qmd extra collections

* test(config): cover qmd extra collections schema outputs

* docs(config): refresh qmd extra collections baseline

* docs(config): regenerate qmd extra collections baselines

* docs(config): clarify qmd extra collection naming
2026-03-31 17:08:18 +09:00
Vincent Koc
5707038e6c fix(memory): preserve qmd query semantics and collection recovery (#58183)
* fix(memory): preserve qmd search queries and repair collection rebuilds

* fix(qmd): cover null-byte rebuild cycle
2026-03-31 17:07:35 +09:00
Vincent Koc
f96e150450 fix(doctor): suppress qmd session orphan cleanup (#58182) 2026-03-31 17:06:24 +09:00
Vincent Koc
075645f5cb fix(memory): use explicit qmd snippet line metadata (#58181)
* fix(memory): preserve qmd snippet line metadata

* Memory/QMD: preserve snippet span with partial line metadata
2026-03-31 17:05:53 +09:00
Vincent Koc
fcc2488579 fix(tasks): align flow patch optionals 2026-03-31 17:04:20 +09:00
Vincent Koc
34ae78bfee fix(tests): reduce matrix extension import churn 2026-03-31 16:59:38 +09:00
Vincent Koc
dfc124c772 fix(matrix): reduce extension test import churn 2026-03-31 16:54:04 +09:00
Peter Steinberger
0633406ff6 fix(gateway): restore compat HTTP operator auth 2026-03-31 16:49:30 +09:00
Vincent Koc
6eb42593fa fix(slack): restore plugin approval auth 2026-03-31 16:45:46 +09:00
Josh Avant
788f56f30f Secrets: hard-fail unsupported SecretRef policy and fix gateway restart token drift (#58141)
* Secrets: enforce C2 SecretRef policy and drift resolution

* Tests: add gateway auth startup/reload SecretRef runtime coverage

* Docs: sync C2 SecretRef policy and coverage matrix

* Config: hard-fail parent SecretRef policy writes

* Secrets: centralize unsupported SecretRef policy metadata

* Daemon: test service-env precedence for token drift refs

* Config: keep per-ref dry-run resolvability errors

* Docs: clarify config-set parent-object policy checks

* Gateway: fix drift fallback and schema-key filtering

* Gateway: align drift fallback with credential planner

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-31 02:37:31 -05:00
Mariano
8d942000c9 Tasks: add blocked flow retry state (#58204) 2026-03-31 09:33:26 +02:00
sudie-codes
4e67e7c02c msteams: add member-info action via Graph API (#57528) 2026-03-31 02:24:33 -05:00
Vincent Koc
5ec362fe0b feat(slack): add native exec approvals (#58155)
* feat(slack): add native exec approvals

* feat(slack): wire native exec approvals

* Update CHANGELOG.md

* fix(slack): gate native approvals by request filters

* fix(slack): keep local approval prompt path
2026-03-31 16:20:57 +09:00
Vincent Koc
2feb83babb fix(ci): shard fast extension checks 2026-03-31 15:58:50 +09:00
Vincent Koc
a6046c94f7 fix(ci): speed up fast extension scheduling 2026-03-31 15:52:40 +09:00
James L. Cowan Jr.
3bed73dc36 fix(config): migrate removed telegram groupMentionsOnly key (#55336)
Merged via squash.

Prepared head SHA: 23731e27bf
Co-authored-by: jameslcowan <112015792+jameslcowan@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-30 23:11:44 -07:00
Vincent Koc
8dfbcaa200 fix(ci): default local low-memory checks 2026-03-31 15:05:04 +09:00
Josh Lehman
3a87783632 test: avoid extra plugin-sdk guardrail analysis 2026-03-30 22:51:18 -07:00
Vincent Koc
8ef9e7f159 docs: add Related sections to install and help pages
- install/docker.md: link to podman, clawdock, updating, config
- install/node.md: link to overview, updating, getting-started
- install/updating.md: link to overview, doctor, migrating
- help/troubleshooting.md: link to FAQ, gateway/channel/automation troubleshooting, doctor
2026-03-31 14:38:46 +09:00
Vincent Koc
5ee054e9db docs: merge network-model stub into network hub, improve bridge deprecation
- network.md: add Core model prose (loopback-first, canvas host, remote access)
  from the 22-line network-model.md stub
- network-model.md: add redirect note pointing to /network#core-model
- bridge-protocol.md: replace scattered deprecation notes with prominent
  <Warning> callout at the top
2026-03-31 14:37:43 +09:00
Vincent Koc
b970187379 docs: fix oxfmt formatting in remote.md and THREAT-MODEL-ATLAS.md 2026-03-31 14:36:49 +09:00
Vincent Koc
9f0845137a docs: add Related sections to plugin and web interface pages
- building-plugins.md, manifest.md: link to architecture, SDK, channel/provider plugins
- control-ui.md, tui.md: link to sibling web interfaces and CLI
2026-03-31 14:34:56 +09:00
Vincent Koc
74830c7bac docs: add Related sections to 6 major tool pages
Add cross-linking Related sections to tool pages that were dead ends:
- exec, exec-approvals, browser, pdf, skills, lobster

Each page now links to 2-4 related topics for navigation continuity.
2026-03-31 14:34:56 +09:00
Vincent Koc
ff1ae5df22 docs: add 8 missing doctor checks and --generate-gateway-token flag 2026-03-31 14:34:56 +09:00
Vincent Koc
641a6880cf docs: add Related sections to 10 concept pages
Add cross-linking Related sections to concept pages that were dead ends:
- model-providers, models, context, context-engine, agent-workspace,
  architecture, messages, streaming, compaction, oauth

Each page now links to 3-4 related topics for navigation continuity.
2026-03-31 14:34:56 +09:00
Vincent Koc
1bf8fb26f4 docs: fix config examples -- perSession deprecation and dmScope guidance
- Replace perSession: true with scope: "session" (preferred syntax)
- Add dmScope: "per-channel-peer" to expanded example for multi-user safety
2026-03-31 14:34:56 +09:00
Vincent Koc
4ab7947ec0 docs: merge remote-gateway-readme content into remote.md 2026-03-31 14:34:56 +09:00
Ayaan Zaidi
3059eadca2 test: fix provider runtime mocks and test planner load shedding 2026-03-31 11:04:28 +05:30
Vincent Koc
aebdb8f8cf fix(lint): scope oxlint type-aware tsconfig 2026-03-31 14:28:41 +09:00
Vincent Koc
637f15375b docs: fix Gateway & Ops audit findings (7 pages)
- cli-backends.md: remove duplicate modelAliases key
- discovery.md: add missing transport=gateway and displayName TXT keys
- authentication.md: retitle to "Authentication (Model Providers)", add
  disambiguation Note pointing to gateway connection auth docs
- health.md: expand frontmatter scope, add --probe flag and response shape docs
- gateway-lock.md: remove stale hardcoded date, add Related section
- troubleshooting.md: fix wrong auth cross-link (model auth -> gateway config)
- logging.md: add Related section linking to gateway logging internals
2026-03-31 14:24:19 +09:00
Vincent Koc
6c6792446b docs: fix THREAT-MODEL-ATLAS pairing TTLs and invalid file paths 2026-03-31 14:24:19 +09:00
Vincent Koc
d352bd050a docs: fix tools-invoke default deny list (was missing 8 of 13 entries) 2026-03-31 14:24:19 +09:00
Vincent Koc
ab8d999917 docs: fix sandbox scope default (session -> agent per resolveSandboxScope) 2026-03-31 14:24:19 +09:00
Ayaan Zaidi
e42330eff7 fix: remove duplicate sandbox browser start branch 2026-03-31 10:34:09 +05:30
Ayaan Zaidi
aeee17a689 fix(acp): preserve Telegram topic-bound conversation ids 2026-03-31 10:31:01 +05:30
Josh Avant
81b777c768 fix(config): harden SecretRef round-trip handling in Control UI and RPC writes (#58044)
* Config: harden SecretRef round-trip handling

* Gateway: test SecretRef preflight on config writes

* Agents: align skill loader with upstream Skill type

* Docs: align SecretRef write semantics with Control UI and RPC behavior

* Config: add UI and gateway regression evidence for SecretRef hardening

* Config: add token SecretRef restore regression and skill sourceInfo compat

* UI: scope structured-value lockout to SecretRef fields

* Agents: remove out-of-scope skill loader compat edits

* UI: reduce app-render churn to rawAvailable-only changes

* Gateway: scope SecretRef preflight to submitted config

* Docs: clarify config write SecretRef preflight scope

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-30 23:55:03 -05:00
Gabriel M.
f7ced438f7 fix: restore Telegram forum-topic routing (#56060) (thanks @one27001)
* feat(telegram): add child thread-binding placement via createForumTopic

Enable ACP subagent spawn on Telegram by adding "child" placement
support to the thread-bindings adapter. When a child binding is
requested, the adapter creates a new forum topic via the Telegram
Bot API and binds the subagent session to it using the canonical
chatId:topic:topicId conversation ID format.

When the ACP spawn context provides only a topic ID (not a full
group chat ID), the adapter resolves the group from the configured
Telegram groups in openclaw.json.

This mirrors the Discord adapter's child placement behavior
(thread creation + session binding) and unblocks the orchestrator
pattern on Telegram forum-enabled groups.

Closes #5737
Ref #23414

* fix(telegram): return null with warning instead of silent group fallback for bare topic IDs in child bind

* telegram: fix ACP child thread spawn with group chat ID from agentGroupId

* telegram: scope agentGroupId substitution to telegram channel only

* Telegram: fix forum topic replies routing to root chat instead of topic thread

* fix: clean up dead guard in child bind + add explicit threadId override test

- Simplify bare-topic-ID guards in thread-bindings.ts: split into
  separate !chatId and !chatId.startsWith("-") checks, removing
  unreachable second condition
- Add regression test confirming explicit turnSourceThreadId overrides
  session lastThreadId on same channel

* fix: guard threadId fallback against shared-session race

Codex review P1: when turnSourceTo differs from the session's stored
to, the session threadId may belong to a different chat/topic. Only
fall back to context.threadId when the destination also matches.

* fix(telegram): enable ACP spawn from forum topics without thread binding

extractExplicitGroupId returned topic-qualified IDs (-100...:topic:1264)
instead of bare group chat IDs, breaking agentGroupId resolution.
agentGroupId was also never wired in the inline actions path.

For Telegram forum topics, skip thread binding entirely — the delivery
plan already routes correctly via requester origin (to + threadId).
Creating new forum topics per child session is unnecessary; output goes
back to the same topic the user asked from.

* fix(acp): bind Telegram forum sessions to current topic

* fix: restore Telegram forum-topic routing (#56060) (thanks @one27001)

---------

Co-authored-by: openclaw <mgabrie.dev@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-31 10:18:09 +05:30
Neerav Makwana
54c69414ad fix: normalize xai tool result image replay (#58017) (thanks @neeravmakwana)
* fix(xai): normalize image tool results for responses

* fix(xai): handle reviewed tool result payload cases

* fix: normalize xai tool result image replay (#58017) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-31 10:10:51 +05:30
Neerav Makwana
763d5cea44 fix: hydrate WhatsApp participating groups on connect (#58007) (thanks @neeravmakwana)
* Web: hydrate participating groups on connect

* Web: avoid blocking inbox listeners during group hydration
2026-03-31 10:09:18 +05:30
fuller-stack-dev
235908c30e fix: support multi-kind plugins for dual slot ownership (#57507) (thanks @fuller-stack-dev)
* feat(plugins): support multi-kind plugins for dual slot ownership

* fix: address review feedback on multi-kind plugin support

- Use sorted normalizeKinds() for kind-mismatch comparison in loader.ts
  (fixes order-sensitive JSON.stringify for arrays)
- Derive slot-to-kind reverse mapping from SLOT_BY_KIND in slots.ts
  (removes hardcoded ternary that would break for future slot types)
- Use shared hasKind() helper in config-state.ts instead of inline logic

* fix: don't disable dual-kind plugin that still owns another slot

When a new plugin takes over one slot, a dual-kind plugin that still
owns the other slot must not be disabled — otherwise context engine
resolution fails at runtime.

* fix: exempt dual-kind plugins from memory slot disablement

A plugin with kind: ["memory", "context-engine"] must stay enabled even
when it loses the memory slot, so its context engine role can still load.

* fix: address remaining review feedback

- Pass manifest kind (not hardcoded "memory") in early memory gating
- Extract kindsEqual() helper for DRY kind comparison in loader.ts
- Narrow slotKeyForPluginKind back to single PluginKind with JSDoc
- Reject empty array in parsePluginKind
- Add kindsEqual tests

* fix: use toSorted() instead of sort() per lint rules

* plugins: include default slot ownership in disable checks and gate dual-kind memory registration
2026-03-31 10:06:48 +05:30
issaba1
10ac6ead6b fix: complete cron isolated model-switch retry (#57972) (thanks @issaba1)
* fix: handle LiveSessionModelSwitchError in cron isolated sessions

The main agent runner catches LiveSessionModelSwitchError and retries
with the requested model, but cron isolated sessions hit this error
and fail immediately. This extends the retry to cover cron execution.

When a cron job with `sessionTarget: 'isolated'` specifies a `model`
different from the agent's primary, the embedded runner throws
LiveSessionModelSwitchError (because the session initialized with the
wrong model). The fix wraps the initial runPrompt call in a retry loop
that catches this error, updates provider/model state, and re-runs —
mirroring the existing retry logic in agent-runner-execution.ts.

Fixes #57206

* fix: carry auth profile through cron model retry

* fix: complete cron isolated model-switch retry (#57972) (thanks @issaba1)

---------

Co-authored-by: Isaac Saba <isaacsaba@Isaacs-Mac-mini.local>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-31 10:03:37 +05:30
Neerav Makwana
7516b423eb fix(sandbox): relabel managed workspace mounts for SELinux (#58025) 2026-03-31 00:30:34 -04:00
ToToKr
e89bd883d8 fix: allow Telegram RFC2544 media downloads (#57624) (thanks @MoerAI)
* fix(telegram): allow RFC 2544 benchmark IPs in media download SSRF policy (#57452)

Telegram CDN file servers may resolve to IPs in the RFC 2544 benchmark range (198.18.0.0/15). The SSRF policy blocked these downloads while Discord and Slack correctly allowed them. Set allowRfc2544BenchmarkRange to true to match other channel plugins.

* fix: note Telegram media RFC2544 CDN downloads (#57624) (thanks @MoerAI)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-31 09:53:31 +05:30
Ayaan Zaidi
9d9ee0f313 fix(security): restore strict SSRF pinning 2026-03-31 09:41:19 +05:30
Gustavo Madeira Santana
28ede9a23e Matrix: isolate verification events hotspot 2026-03-31 00:00:25 -04:00
Gustavo Madeira Santana
1346e6668e Matrix: trim file sync store imports 2026-03-31 00:00:25 -04:00
Gustavo Madeira Santana
57003ffddf Matrix: narrow client auth imports 2026-03-31 00:00:25 -04:00
Josh Avant
44674525f2 feat(tts): add structured provider diagnostics and fallback attempt analytics (#57954)
* feat(tts): add structured fallback diagnostics and attempt analytics

* docs(tts): document attempt-detail and provider error diagnostics

* TTS: harden fallback loops and share error helpers

* TTS: bound provider error-body reads

* tts: add double-prefix regression test and clean baseline drift

* tests(tts): satisfy error narrowing in double-prefix regression

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-03-30 22:55:28 -05:00
Gustavo Madeira Santana
329d4bf1a8 Matrix: trim sdk test import churn 2026-03-30 23:25:39 -04:00
Kiryl Kavalenka
082778df1a fix: respect hostname-scoped proxy bypass (#50650) (thanks @kkav004)
* fix(infra/net): route through env proxy in STRICT mode while preserving DNS pinning

When HTTP_PROXY/HTTPS_PROXY env vars are configured, the SSRF guard's
pinned dispatcher connects directly to the DNS-resolved IP, bypassing the
proxy. This fails in environments where direct outbound connections are
blocked (OpenShell sandboxes, Docker containers, corporate networks).

Use `createPinnedDispatcher` with `mode: "env-proxy"` when
`hasEnvHttpProxyConfigured()` returns true. This preserves DNS-pinning
(the resolved IP is threaded into the connect option via
`EnvHttpProxyAgent`) while routing through the proxy.

- Uses `hasEnvHttpProxyConfigured()` (not `hasProxyEnvConfigured()`) to
  avoid the ALL_PROXY edge case where EnvHttpProxyAgent ignores ALL_PROXY
- Preserves STRICT mode's anti-DNS-rebinding guarantee
- TRUSTED_ENV_PROXY remains the explicit opt-in for unpinned proxy routing
- No change when proxy env vars are not set

Fixes #47598, #49948, #32947, #46306
Related: #45248

* test(infra): stabilize fetch guard proxy assertions

* fix: respect hostname-scoped proxy bypass (#50650) (thanks @kkav004)

---------

Co-authored-by: Kiryl Kavalenka <kiryl.kavalenka@whiparound.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-31 08:40:45 +05:30
Neerav Makwana
e394262bd8 Agents: fix subagent model precedence 2026-03-31 08:38:24 +05:30
BUGKillerKing
d4cccda570 fix: add requireAgentId to block sessions_spawn without explicit agen… (#29380)
* fix: add requireAgentId to block sessions_spawn without explicit agentId (#29368)

* Config: regenerate base schema for requireAgentId

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: 周鹤0668001310 <zhou.he3@xydigit.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-03-30 23:06:59 -04:00
Josh Avant
c918ab4faf fix(tts): restore 3.28 schema compatibility and fallback observability (#57953)
* fix(tts): restore legacy config compatibility and fallback observability

* fix(tts): surface fallback attempts in status and telephony

* test(tts): cover /tts audio to /tts status fallback flow

* docs(tts): align migration and fallback observability guidance

* TTS: redact fallback logs and scope legacy plugin migration

* Infra: dedupe UV_EXTRA_INDEX_URL in host env policy

* Docs: scope doctor TTS migration to voice-call

* voice-call: restore strict known TTS provider validation
2026-03-30 22:05:03 -05:00
Teconomix
697dddbeb6 feat(matrix): thread-isolated sessions and per-chat-type threadReplies (#57995)
Merged via squash.

Prepared head SHA: 9ed96dd063
Co-authored-by: teconomix <6959299+teconomix@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 22:45:32 -04:00
Gustavo Madeira Santana
d859746862 tests: fix matrix test typing 2026-03-30 22:39:25 -04:00
Gustavo Madeira Santana
47136536c8 tests: use multi-sample CLI startup baselines 2026-03-30 22:35:50 -04:00
Gustavo Madeira Santana
20481d424c cli: clarify cron channel help 2026-03-30 22:33:44 -04:00
Gustavo Madeira Santana
ef6250d9a0 docs: refresh channel delivery examples 2026-03-30 22:33:44 -04:00
Gustavo Madeira Santana
b3f894ea7e fix(matrix): repair fresh invited DMs (#58024)
Merged via squash.

Prepared head SHA: 69b5229632
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 22:30:47 -04:00
Gustavo Madeira Santana
68e49fa791 tests: standardize CLI startup benchmarks 2026-03-30 22:15:56 -04:00
jlxyfll
1c95c41c37 fix(acpx): retain named sessions on queue owner unavailable (#56232) thanks @jlxyfll
Co-authored-by: jl <jlxyfllz@gmail.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-03-30 22:14:59 -04:00
Peter Steinberger
2cb15255a7 test: fix ci regressions 2026-03-31 02:42:13 +01:00
Shadow
d8d13f2bde Update Discord channel for sharing showcase 2026-03-30 20:39:46 -05:00
Shadow
4bec7622ab Update Discord channel for project submissions 2026-03-30 20:39:02 -05:00
Vincent Koc
67bb3454ee fix(openshell): support remote fs read mutation in tests 2026-03-31 10:30:37 +09:00
Peter Steinberger
3f1d6fe147 test: speed up cli and command suites 2026-03-31 02:25:02 +01:00
Peter Steinberger
6b6ddcd2a6 test: speed up core runtime suites 2026-03-31 02:25:02 +01:00
Peter Steinberger
f7285e0a9e test: speed up extension suites 2026-03-31 02:25:02 +01:00
Vincent Koc
1f6a964e57 fix(ci): handle missing native command capabilities 2026-03-31 10:16:06 +09:00
Vincent Koc
5d8ca42c7d fix(ci): regenerate mac host env policy 2026-03-31 10:12:20 +09:00
Gustavo Madeira Santana
bf6d3176fc scripts: preserve changelog subsection detection 2026-03-30 21:05:31 -04:00
Vincent Koc
2412357bb7 docs: fix QMD install command to use npm package instead of git URL 2026-03-31 10:05:22 +09:00
Vincent Koc
9b6ebc1992 fix(test): trim browser runtime gateway session mocks 2026-03-31 10:02:24 +09:00
Peter Steinberger
4f2df617fe fix: handle Telegram audio auto-transcription 2026-03-31 02:01:01 +01:00
Vincent Koc
121870a085 fix(sandbox): pin remote fs bridge reads (#58016)
* fix(sandbox): pin remote fs bridge reads

* fix(sandbox): reject mount-root reads in remote fs bridge

* fix(sandbox): reject non-regular targets in pinned reads
2026-03-31 09:55:51 +09:00
Vincent Koc
7ae1bb0c77 fix(host-env): block Python package index redirection env vars (#58011)
* fix(host-env): block Python package index redirection vars

* docs(changelog): note Python index override block

* Update src/infra/host-env-security-policy.json

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(exec): block remaining uv index override env vars

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-31 09:53:32 +09:00
Vincent Koc
873549c8f1 fix(perf): bypass speech facade in core tts runtime 2026-03-31 09:51:47 +09:00
Vincent Koc
fc4ef34478 fix(config): accept block markdown table mode safely 2026-03-31 09:50:41 +09:00
Vincent Koc
8bcaf1a147 fix(test): trim reply command plugin imports 2026-03-31 09:43:54 +09:00
Shakker
81e65e119f test: mock supervisor timeout flows 2026-03-31 01:40:55 +01:00
Shakker
ee38d13f33 test: remove gateway flake from channel mcp notifications 2026-03-31 01:40:55 +01:00
Shakker
a966630a91 test: mock gateway reads in channel mcp tools 2026-03-31 01:40:55 +01:00
Shakker
6d39209430 test: split channel mcp event waiting coverage 2026-03-31 01:40:55 +01:00
Shakker
f8c7512ca5 test: stabilize channel event wait mcp flow 2026-03-31 01:40:55 +01:00
Shakker
afe4a4b260 test: clean test planner typing edges 2026-03-31 01:40:55 +01:00
Shakker
d46f64199a fix: retry bundled runtime dependency staging 2026-03-31 01:40:55 +01:00
Shakker
cefa191417 test: stabilize gateway and session cleanup flows 2026-03-31 01:40:55 +01:00
Shakker
82695bb24d test: remove timeout-prone windows ci waits 2026-03-31 01:40:55 +01:00
Shakker
da03d857f9 test: stabilize recurring windows ci suites 2026-03-31 01:40:55 +01:00
Shakker
6ab0f62b3b test: stabilize remaining windows ci timeouts 2026-03-31 01:40:55 +01:00
Shakker
7d70b1b51e test: stabilize windows registry cleanup flows 2026-03-31 01:40:55 +01:00
Shakker
72cb2a88f1 test: fix planner typing assertions 2026-03-31 01:40:55 +01:00
Shakker
5fb19f296a test: complete exec timeout child lifecycle 2026-03-31 01:40:55 +01:00
Shakker
1dda032531 style: format rebased main files 2026-03-31 01:40:55 +01:00
Shakker
020858647d test: fix qmd and discord ci regressions 2026-03-31 01:40:55 +01:00
Shakker
a8ba6f2c03 test: stabilize channel lifecycle timers in ci 2026-03-31 01:40:55 +01:00
Shakker
82681ba215 test: stabilize exec timeout assertions on windows 2026-03-31 01:40:55 +01:00
Shakker
56c9e2493b test: harden windows timeout-sensitive suites 2026-03-31 01:40:55 +01:00
Shakker
b878a34591 test: stabilize windows flow and session cleanup tests 2026-03-31 01:40:55 +01:00
Shakker
2ff7bb604c test: update planner expectations for current catalog 2026-03-31 01:40:55 +01:00
Shakker
9590e2ccae test: stabilize windows task registry and exec timeouts 2026-03-31 01:40:55 +01:00
Shakker
7ec3674b46 test: stabilize discord and channel mcp ci coverage 2026-03-31 01:40:55 +01:00
Shakker
ab0af5997d test: isolate browser snapshot navigation from proxy env 2026-03-31 01:40:55 +01:00
Shakker
4892c60ee5 test: avoid suite gateway hooks in channel mcp 2026-03-31 01:40:55 +01:00
Gustavo Madeira Santana
8d4040af58 fix(tests): align matrix verification DM fixtures 2026-03-30 20:32:49 -04:00
Gustavo Madeira Santana
31a4b45db0 Maintainer: split PR workflow script modules 2026-03-30 20:28:32 -04:00
scoootscooob
eba41dae4f fix(exec): dedupe Discord approval delivery (#58002)
* fix(exec): dedupe Discord approval delivery

* Update extensions/discord/src/approval-native.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-30 17:27:34 -07:00
Gustavo Madeira Santana
7b7d7cc743 Matrix: short-circuit aborted monitor startup 2026-03-30 20:13:33 -04:00
Gustavo Madeira Santana
61ae6d7201 Matrix: trim shared test import churn 2026-03-30 20:13:32 -04:00
Gustavo Madeira Santana
f96e5bec39 Diffs: normalize viewer payload languages 2026-03-30 20:12:19 -04:00
Vincent Koc
af0c0862f2 fix(gateway): preserve shared-auth rate limits during mixed handshakes (#57647)
* fix(gateway): preserve shared-auth handshake rate limits

* fix(gateway): scope shared-auth lockouts to shared-auth handshakes
2026-03-31 09:08:57 +09:00
Vincent Koc
a30214a624 fix(heartbeat): block owner-only auth inheritance for exec events (#57652) 2026-03-31 09:06:51 +09:00
Vincent Koc
91f7a6b0fd fix(gateway): revoke active sessions on token rotation (#57646) 2026-03-31 09:05:34 +09:00
Gustavo Madeira Santana
bd957a3a8b Matrix: trim test import breadth 2026-03-30 19:54:40 -04:00
Gustavo Madeira Santana
fa2e051bb6 Maintainer: tighten PR workflow script
Reduce prep and merge friction in the PR wrapper by keeping rebases explicit, reusing doc-only gate results, and making review output terminal-first.

Also add clearer baseline-noise guidance for unrelated local gate failures plus worktree listing and cleanup helpers.
2026-03-30 19:48:46 -04:00
Gustavo Madeira Santana
e11b5d584c Tests: isolate Matrix extension hotspots 2026-03-30 19:36:29 -04:00
Gustavo Madeira Santana
ca6432b0d9 Skills: harden heap snapshot diffing 2026-03-30 19:36:12 -04:00
Gustavo Madeira Santana
bbd495ed63 plugins: quiet scoped manifest id warnings 2026-03-30 19:35:09 -04:00
end
2b2edaa01d fix(matrix): correct DM classification with three-tier is_direct logic and 2-member guard (#57124)
Merged via squash.

Prepared head SHA: e2ff0d5e96
Co-authored-by: w-sss <204439273+w-sss@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 18:56:00 -04:00
scoootscooob
dd9d0bdd8e fix(exec): harden shell-side approval guardrails (#57839)
* fix(exec): harden approval handling

* fix(exec): tighten approval guardrails

* fix(exec): reject prefixed approval commands

* fix(exec): isolate shell approval guardrails

* fix(exec): recurse through wrapped approval commands

* fix(exec): restore allowlist wrapper import

* fix(exec): strip env wrappers before approval detection

* fix(exec): inspect nested shell wrapper options
2026-03-30 15:49:24 -07:00
scoootscooob
9ff57ac479 refactor(exec): unify channel approvals and restore routing/auth (#57838)
* fix(exec): add shared approval runtime

* fix(exec): harden shared approval runtime

* fix(exec): guard approval expiration callbacks

* fix(exec): handle approval runtime races

* fix(exec): clean up failed approval deliveries

* fix(exec): restore channel approval routing

* fix(exec): scope telegram legacy approval fallback

* refactor(exec): centralize native approval delivery

* fix(exec): harden approval auth and account routing

* test(exec): align telegram approval auth assertions

* fix(exec): align approval rebase followups

* fix(exec): clarify plugin approval not-found errors

* fix(exec): fall back to session-bound telegram accounts

* fix(exec): detect structured telegram approval misses

* test(exec): align discord approval auth coverage

* fix(exec): ignore discord dm origin channel routes

* fix(telegram): skip self-authored message echoes

* fix(exec): keep implicit approval auth non-explicit
2026-03-30 15:49:02 -07:00
Gustavo Madeira Santana
e7e15b92bd Chore: remove orphaned agent workflow 2026-03-30 18:43:14 -04:00
Gustavo Madeira Santana
b9f5d02f04 fix(matrix): restore E2EE for one-off CLI sends (#57936)
Merged via squash.

Prepared head SHA: 4b79fbea22
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 17:28:33 -04:00
mappel-nv
5cc0bc936c Gateway: open config files without shell interpolation (#57921)
* Gateway: open config files without shell interpolation

Co-authored-by: peteryuqin <peter.yuqin@gmail.com>

* Gateway: align config opener review fixes

* Gateway: tidy config opener logging

* Gateway: simplify config opener error path

* Gateway: cover Windows config opener test path

* Gateway: use literal Windows config open path

---------

Co-authored-by: peteryuqin <peter.yuqin@gmail.com>
2026-03-30 15:21:25 -06:00
Dinakar Sarbada
62d6cfedee fix(doctor/plugins): skip unused Matrix inspector loads and honor enabledByDefault startup plugins (#57931)
Merged via squash.

Prepared head SHA: 634794b954
Co-authored-by: dinakars777 <250428393+dinakars777@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 17:06:04 -04:00
Gustavo Madeira Santana
9a94578d47 Diffs: fall back on invalid language hints (#57902)
Merged via squash.

Prepared head SHA: 567ca3a56f
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 16:30:05 -04:00
Gustavo Madeira Santana
66777e140e Diffs: return schema-shaped plugin config (#57904)
Merged via squash.

Prepared head SHA: df95f53aaa
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 16:27:11 -04:00
Gustavo Madeira Santana
07900facf6 Diffs: skip unused render targets (#57909)
Merged via squash.

Prepared head SHA: 9972f3029f
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 16:21:08 -04:00
Agustin Rivera
30a1690323 fix(diffs): harden viewer proxy access (#57912)
* fix(diffs): harden viewer proxy access

* fix(diffs): restore mapped loopback access
2026-03-30 14:17:27 -06:00
Altay
910134b702 fix(memory): stabilize qmd collection scoping 2026-03-30 22:41:21 +03:00
Altay
9c25544e6c test(ci): fix stale regression expectations (#57899) 2026-03-30 22:31:13 +03:00
Gustavo Madeira Santana
4a6267bfe1 Diffs: preserve base paths for viewer assets 2026-03-30 15:28:16 -04:00
Gustavo Madeira Santana
b96b1efc69 Changelog: restore Matrix history entry 2026-03-30 15:14:53 -04:00
chain710
943163a419 feat(matrix): add group chat history context for agent triggers (#57022)
Merged via squash.

Prepared head SHA: b6f88b72e8
Co-authored-by: chain710 <486539+chain710@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 15:10:43 -04:00
Jacob Tomlinson
8deb9522f3 Guard marketplace and Ollama network requests (#57850)
* Plugins: guard marketplace and Ollama fetches

* Ollama: pin guarded host allowlist
2026-03-30 20:08:38 +01:00
Jacob Tomlinson
e277a37f89 Infra: block compiler env overrides (#57832) 2026-03-30 20:06:32 +01:00
Jacob Tomlinson
cfe1445953 Sandbox: sanitize SSH subprocess env (#57848)
* Sandbox: sanitize SSH subprocess env

* Sandbox: add sanitize env undefined test
2026-03-30 20:05:57 +01:00
Jacob Tomlinson
f0af186726 gateway: ignore bearer-declared HTTP operator scopes (#57783)
* gateway: ignore bearer-declared HTTP operator scopes

* gateway: key HTTP bearer guards to auth mode

* gateway: refresh rebased HTTP regression expectations

* gateway: honor resolved HTTP auth method

* gateway: remove duplicate openresponses owner flags
2026-03-30 20:04:33 +01:00
Jacob Tomlinson
2a75416634 CLI: reset remote URL after trust decline (#57828)
Co-authored-by: zsxsoft <git@zsxsoft.com>
2026-03-30 20:03:06 +01:00
Jacob Tomlinson
ad77666054 fix(voice-call): canonicalize Telnyx replay request keys (#57829) 2026-03-30 20:01:43 +01:00
Agustin Rivera
e65c265e89 Security: block exec approval shell carrier targets (#57871)
* Security: block exec approval shell carrier targets

* Tests: tighten exec approval carrier regression assertions
2026-03-30 12:35:04 -06:00
Mariano
9d9cf0d8ff Tasks: route one-task emergence through parent flows (#57874) 2026-03-30 20:25:01 +02:00
Mariano
7590c22db7 Tasks: add minimal flow registry scaffold (#57865) 2026-03-30 19:57:26 +02:00
Devin Robison
8c83128fc3 Discord: fix Group DM component interaction routing and auth (#57763)
* Discord: fix Group DM component interaction routing and auth

* Update tests
2026-03-30 11:17:53 -06:00
Devin Robison
8fdb19676a Fix Discord native commands bypassing group DM channel allowlist (#57735)
* Fix Discord native commands bypassing group DM channel allowlist

* Fix linting

* Update tests
2026-03-30 11:17:36 -06:00
Gustavo Madeira Santana
dd17dae3e5 Matrix: drop unused MatrixClient constructor params 2026-03-30 13:17:02 -04:00
Gustavo Madeira Santana
1ea85a5d0b Matrix: remove stale monitor mention regex param 2026-03-30 13:17:02 -04:00
Shakker
e8b0d57eb6 test: isolate browser navigation tests from host proxy env 2026-03-30 18:10:08 +01:00
Shakker
8746e2e216 fix: restore cli registry side-effect option 2026-03-30 18:10:08 +01:00
Shakker
ba7c98ab51 fix: align outbound media root tests with config-derived tmp paths 2026-03-30 18:10:08 +01:00
Ayaan Zaidi
1b557ffe65 fix(plugins): keep snapshot hook loads isolated 2026-03-30 22:00:54 +05:30
joelnishanth
f849b8de97 hooks: default hooks.internal.enabled to true so bundled hooks load on fresh installs
Made-with: Cursor
2026-03-30 22:00:54 +05:30
Jacob Tomlinson
3886b65ef2 fix(gateway): require node pairing before enabling node commands (#57777)
* Gateway: require node pairing for node commands

* Gateway: request node pairing on initial connect

* Gateway: filter pending node pairing commands
2026-03-30 17:29:28 +01:00
Jacob Tomlinson
6b38815f86 fix(gateway): tighten tools invoke HTTP guardrails (#57771)
* fix(gateway): tighten tools invoke HTTP guardrails

Co-authored-by: Brian Mendonca <208517100+bmendonca3@users.noreply.github.com>

* fix(security): centralize gateway HTTP deny defaults

* fix(gateway): drop duplicate scope guard after rebase

---------

Co-authored-by: Brian Mendonca <208517100+bmendonca3@users.noreply.github.com>
2026-03-30 17:16:33 +01:00
Jacob Tomlinson
1ca4261d7e fix(media): keep local roots configuration-derived (#57770)
* fix(media): keep local roots configuration-derived

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>

* fix(media): simplify local root lookup

* fix(media): keep legacy local roots export
2026-03-30 17:15:03 +01:00
Shakker
aff6883f93 fix: avoid over-sharding single include-file test batches 2026-03-30 17:14:02 +01:00
Shakker
c22edbb8ee test: align ci regression stubs with production behavior 2026-03-30 17:11:06 +01:00
Shakker
555a4d896c test: stabilize media attachment cache path assertions 2026-03-30 17:11:06 +01:00
Shakker
4c45fc3575 test: remove telegram extension dependency from reply command tests 2026-03-30 17:11:06 +01:00
Jacob Tomlinson
17d0be02f2 fix(gateway): bind OpenResponses HTTP ingress as non-owner (#57778)
* fix(gateway): bind OpenResponses HTTP ingress as non-owner

Co-authored-by: bmendonca3 <208517100+bmendonca3@users.noreply.github.com>

* test(gateway): cover streaming OpenResponses non-owner ingress

---------

Co-authored-by: bmendonca3 <208517100+bmendonca3@users.noreply.github.com>
2026-03-30 17:05:29 +01:00
Jacob Tomlinson
1a75906a6f Exec approvals: prevent interpreter allow-always persistence (#57772)
* Exec approvals: block interpreter allow-always persistence

* Exec approvals: normalize interpreter allowlist formatting

* Exec approvals: normalize interpreter allowlist wrapping

* Exec approvals: tighten awk regression coverage

* Exec approvals: harden awk interpreter coverage
2026-03-30 17:03:54 +01:00
pgondhi987
b7b46ad185 fix(skills): replace readFileSync with symlink-safe, root-confined skill file loader (#57519)
* fix: replace readFileSync with symlink-safe, root-confined skill file loader

* fix(skills): preserve directory-name fallback when frontmatter omits name

* fix: harden skill loader path containment

---------

Co-authored-by: Jacob Tomlinson <jacobtomlinson@users.noreply.github.com>
2026-03-30 17:03:05 +01:00
Jacob Tomlinson
7a5c5f33d0 Infra: block auth env vars from workspace dotenv (#57767)
* Infra: block auth env vars from workspace dotenv

* Infra: block workspace dotenv auth key variants

* Infra: block workspace dotenv live auth keys
2026-03-30 17:01:22 +01:00
Jacob Tomlinson
29cb1e3c7e Gateway: tighten HTTP tool invoke authorization (#57773)
* Gateway: harden HTTP tool invoke access

* Gateway: strengthen HTTP tools invoke regression coverage

* Gateway: keep owner-only tools off HTTP
2026-03-30 16:59:40 +01:00
Jacob Tomlinson
ae703ab0e7 infra: harden identifier entropy and delay jitter (#57744)
* infra: harden identifier entropy and delay jitter

* test: make randomness hardening deterministic in CI
2026-03-30 16:57:30 +01:00
Jacob Tomlinson
32a4a47d60 Agents: pin apply-patch workspace mutations (#56016)
* Agents: pin apply-patch file ops to workspace

* Agents: resolve apply-patch review feedback

* Infra: fallback pinned path helper spawn failures
2026-03-30 16:49:49 +01:00
pgondhi987
6d341cf366 fix(auto-reply): thread per-agent tools.exec defaults into reply directives (#57689)
* fix(auto-reply): thread per-agent tools.exec defaults into exec overrides

* test(auto-reply): add session-override and inline-directive priority tests for exec agent defaults
2026-03-30 16:46:54 +01:00
samzong
09bb93c6e0 fix(subagents): correct duration display showing 5-6x inflated runtime (#57739)
Merged via squash.

Prepared head SHA: 018bbbca4d
Co-authored-by: samzong <13782141+samzong@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-30 23:44:36 +08:00
Jacob Tomlinson
f011d0be28 fix(gateway): treat OpenAI HTTP ingress as non-owner (#57769)
Co-authored-by: Brian Mendonca <208517100+bmendonca3@users.noreply.github.com>
2026-03-30 16:26:53 +01:00
Sean
c6f2db1506 fix: prevent gateway attachment offload regressions (#55513) (thanks @Syysean)
* feat(gateway): implement claim check pattern to prevent OOM on large attachments

* fix: sanitize mediaId, refine trimEnd, remove warn log, add threshold and absolute path

* fix: enforce maxBytes before decoding and use dynamic path from saveMediaBuffer

* fix: enforce absolute maxBytes limit before Buffer allocation and preserve file extensions

* fix: align saveMediaBuffer arguments and satisfy oxfmt linter

* chore: strictly enforce linting rules (curly braces, unused vars, and error typing)

* fix: restrict offload to mainstream mimes to avoid extension-loss bug in store.ts for BMP/TIFF

* fix: restrict offload to mainstream mimes to bypass store.ts extension-loss bug

* chore: document bmp/tiff exclusion from offload whitelist in MIME_TO_EXT

* feat: implement agent-side resolver for opaque media URIs and finalize contract

* fix: support unicode media URIs and allow consecutive dots in safe IDs based on Codex review

* fix(gateway): enforce strict fail-fast for oversized media to prevent OOM bypass

* refactor(gateway): harden media offload with performance and security optimizations

This update refines the Claim Check pattern with industrial-grade guards:

- Performance: Implemented sampled Base64 validation for large payloads (>4KB) to prevent event loop blocking.
- Security: Added null-byte (\u0000) detection and reinforced path traversal guards.
- I18n: Updated media-uri regex to a blacklist-based character class for Unicode/Chinese filename support, with oxlint bypass for intentional control regex.
- Robustness: Enhanced error diagnostics with JSON-serialized IDs.

* fix: add HEIC/HEIF to offload allowlist and pass maxBytes to saveMediaBuffer

* fix(gateway): clean up offloaded media files on attachment parse failure

Address Codex review feedback: track saved media IDs and implement best-effort cleanup via deleteMediaBuffer if subsequent attachments fail validation, preventing orphaned files on disk.

* fix(gateway): enforce full base64 validation to prevent whitespace padding bypass

Address Codex review feedback: remove early return in isValidBase64 so padded payloads cannot bypass offload thresholds and reintroduce memory pressure. Updated related comments.

* fix(gateway): preserve offloaded media metadata and fix validation error mapping

Address Codex review feedback:
- Add \offloadedRefs\ to \ParsedMessageWithImages\ to expose structured metadata for offloaded attachments, preventing transcript media loss.
- Move \erifyDecodedSize\ outside the storage try-catch block to correctly surface client base64 validation failures as 4xx errors instead of 5xx \MediaOffloadError\.
- Add JSDoc TODOs indicating that upstream callers (chat.ts, agent.ts, server-node-events.ts) must explicitly pass the \supportsImages\ flag.

* fix(agents): explicitly allow media store dir when loading offloaded images

Address Codex review feedback: Pass getMediaDir() to loadWebMedia's localRoots for media-uri refs to prevent legacy path resolution mismatches from silently dropping large attachments.

* fix(gateway): resolve attachment offload regressions and error mapping

Address Codex review feedback:
- Pass \supportsImages\ dynamically in \chat.ts\ and \gent.ts\ based on model catalog, and explicitly in \server-node-events.ts\.
- Persist \offloadedRefs\ into the transcript pipeline in \chat.ts\ to preserve media metadata for >2MB attachments.
- Correctly map \MediaOffloadError\ to 5xx (UNAVAILABLE) to differentiate server storage faults from 4xx client validation errors.

* fix(gateway): dynamically compute supportsImages for overrides and node events

Address follow-up Codex review feedback:

- Use effective model (including overrides) to compute \supportsImages\ in \gent.ts\.

- Move session load earlier in \server-node-events.ts\ to dynamically compute \supportsImages\ rather than hardcoding true.

* fix(gateway): resolve capability edge cases reported by codex

Address final Codex edge cases:
- Refactor \gent.ts\ to compute \supportsImages\ even when no session key is present, ensuring text-only override requests without sessions safely drop attachments.
- Update catalog lookups in \chat.ts\, \gent.ts\, and \server-node-events.ts\ to strictly match both \id\ and \provider\ to prevent cross-provider model collisions.

* fix(agents): restore before_install hook for skill installs

Restore the plugin scanner security hook that was accidentally dropped during merge conflict resolution.

* fix: resolve attachment pathing, defer parsing after auth gates, and clean up node-event mocks

* fix: resolve syntax errors in test-env, fix missing helper imports, and optimize parsing sequence in node events

* fix(gateway): re-enforce message length limit after attachment parsing

Adds a secondary check to ensure the 20,000-char cap remains effective even after media markers are appended during the offload flow.

* fix(gateway): prevent dropping valid small images and clean up orphaned media on size rejection

* fix(gateway): share attachment image capability checks

* fix(gateway): preserve mixed attachment order

* fix: fail closed on unknown image capability (#55513) (thanks @Syysean)

* fix: classify offloaded attachment refs explicitly (#55513) (thanks @Syysean)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-30 20:54:40 +05:30
Shakker
3ad747e25f style: apply formatter cleanups 2026-03-30 16:20:27 +01:00
Shakker
ab141df4b5 Skills: tighten env path guidance 2026-03-30 16:10:13 +01:00
Shakker
a3de1f5f55 Skills: prefer active OpenClaw paths 2026-03-30 16:10:13 +01:00
Ayaan Zaidi
08d365f481 test: pin android explicit setup auth selection 2026-03-30 20:39:20 +05:30
Ayaan Zaidi
fa150f8828 fix: use explicit setup auth for android gateway connect 2026-03-30 20:39:20 +05:30
Ayaan Zaidi
deead11dcd fix(android): restore setup-code operator bootstrap connect 2026-03-30 20:39:19 +05:30
Ayaan Zaidi
2dced6b4a0 fix: allow setup-code bootstrap auth for operator pairing 2026-03-30 20:39:19 +05:30
Ayaan Zaidi
e0281849c0 fix: unblock android onboarding after bootstrap pairing 2026-03-30 20:39:19 +05:30
Ayaan Zaidi
fec329ce8d fix: handle android bootstrap-only setup codes 2026-03-30 20:39:19 +05:30
Robin Waslander
4d369a3400 harden session-status tool visibility guard for all callers 2026-03-30 16:48:12 +02:00
Jacob Tomlinson
5cca380840 msteams: filter thread history by sender allowlist (#57723)
* msteams: filter thread history by sender allowlist

* tests: merge msteams thread authz coverage

* msteams: preserve thread allowlist fallback matching
2026-03-30 15:38:26 +01:00
Jacob Tomlinson
7e08669715 synology-chat: add webhook in-flight guard (#57722)
* synology-chat: add webhook in-flight guard

* tests: clarify synology in-flight limit assertion

* synology-chat: scope webhook in-flight budget per account
2026-03-30 15:37:02 +01:00
Jacob Tomlinson
7a953a5227 Plugins: block install when source scan fails (#57729)
* Plugins: block unsafe install scan fallthrough

* Tests: normalize install scanner formatting

* Plugins: avoid duplicate scan failure messaging

* Plugins: preserve hook install block codes
2026-03-30 15:36:08 +01:00
Jacob Tomlinson
8db20c1965 sandbox: block sensitive external bind sources (#56024)
* sandbox: block sensitive external bind sources

* sandbox: cache blocked bind paths

* sandbox: harden blocked bind path aliases

* sandbox: block os-home bind secrets

* sandbox: refresh blocked bind path aliases
2026-03-30 15:34:53 +01:00
Jacob Tomlinson
3216df7923 gateway: enforce embeddings HTTP write scope (#57721) 2026-03-30 15:32:03 +01:00
Robin Waslander
85647949a4 tighten phone-control scope helper extraction 2026-03-30 16:17:17 +02:00
Jacob Tomlinson
c5c10adc02 gateway: trim control UI bootstrap payload (#57727) 2026-03-30 15:08:19 +01:00
Robin Waslander
847912f3e2 harden phone-control command scope checks 2026-03-30 15:52:55 +02:00
Jacob Tomlinson
3b9dab0ece OpenShell: harden mirror sync boundaries (#57693)
* OpenShell: harden mirror sync boundaries

* OpenShell: polish mirror hardening tests

* OpenShell: preserve trusted mirror symlinks

* OpenShell: bound mirror fs work globally
2026-03-30 14:51:44 +01:00
Robin Waslander
a4e447a16e harden talk-voice config persistence scope checks 2026-03-30 15:38:37 +02:00
Jacob Tomlinson
ee52f64226 Discord: gate audio preflight on member access (#57695)
* Discord: gate audio preflight on member access

* Discord: trim unauthorized sender logging

* CI: retrigger after review follow-up

* Discord: document blocked-sender log privacy
2026-03-30 14:38:22 +01:00
Jacob Tomlinson
a77928b108 Gateway: harden node event trust boundaries (#57691)
* Gateway: harden node event trust boundaries

* Gateway: preserve trusted summary prefixes

* Gateway: prefix multiline channel summaries
2026-03-30 14:22:15 +01:00
Ayaan Zaidi
9d5c5230c5 fix: restore default HTTP operator scopes (#57596) (thanks @openperf) 2026-03-30 18:51:13 +05:30
openperf
3d659fd356 refactor(gateway ): remove unreachable null check in resolveGatewayRequestedOperatorScopes 2026-03-30 18:51:13 +05:30
openperf
fe2eb185ff fix(gateway ): restore default operator scopes for pure HTTP token auth 2026-03-30 18:51:13 +05:30
Jacob Tomlinson
8b88b927cb gateway: clear unbound scopes for trusted-proxy auth (#57692)
* gateway: clear unbound scopes for trusted-proxy auth

* gateway: isolate trusted-proxy scope test branch
2026-03-30 14:19:00 +01:00
Jacob Tomlinson
566fb73d9d reply: enforce ACP attachment roots (#57690)
* reply: enforce ACP attachment roots

* media: harden local attachment cache reads

* reply: clarify ACP attachment skip logs

* reply: keep ACP attachments path-only
2026-03-30 14:04:02 +01:00
Jacob Tomlinson
3834d47099 MS Teams: validate webhook auth before JSON parsing (#57686) 2026-03-30 13:46:40 +01:00
pgondhi987
bc3b05dce4 fix(infra): block BROWSER, GIT_EDITOR, GIT_SEQUENCE_EDITOR from inherited host env (#57559) 2026-03-30 12:31:04 +01:00
pgondhi987
c4fa8635d0 fix(telegram): gate audio preflight transcription on sender authorization (#57566)
* Telegram: gate audio preflight transcription on sender authorization

* fix: honor telegram audio preflight command auth
2026-03-30 12:19:31 +01:00
Vincent Koc
348b094fe8 fix(test): satisfy telegram pairing seam 2026-03-30 20:05:29 +09:00
Kunal Karmakar
34b0a19a16 fix: use azure-openai-responses for Azure custom providers (#50851) (thanks @kunalk16)
* Add azure-openai-responses

* Unit tests update for updated API

* Add entry for PR #50851

* Add comma to address PR comment

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Address PR comment on sanitization of output

* Address review comment

* Revert commits

* Revert commit

* Update changelog stating Azure OpenAI only

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Add references

* Address PR comment on sanitization of output

* Address review comment

* Revert commits

* Revert commit

* Address PR comment on sanitization of output

* Address review comment

* Revert commits

* Revert commit

* Fix generated file

* Add azure openai responses to OPENAI_RESPONSES_APIS

* Add azure openai responses to createParallelToolCallsWrapper

* Adding azure openai responses to attempt.ts

* Add azure openai responses to google.ts

* Address PR comment on sanitization of output

* Revert commit

* Address PR comment on sanitization of output

* Revert commit

* Address PR comment on sanitization of output

* Revert commit

* Fix changelog

* Fix linting

* fix: cover azure responses wrapper path (#50851) (thanks @kunalk16)

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-30 16:17:03 +05:30
Vincent Koc
2fbd5e3f5f fix(test): trim telegram command registry imports 2026-03-30 19:34:47 +09:00
Vincent Koc
69916e8082 fix(twitch): align markdown table mode type 2026-03-30 19:32:14 +09:00
Vincent Koc
b7de04f23f fix(memory): preserve shared qmd collection names (#57628)
* fix(memory): preserve shared qmd collection names

* fix(memory): canonicalize qmd path containment
2026-03-30 19:29:35 +09:00
Vincent Koc
85f3136cfc fix(test): use plugin public surfaces in reply command tests 2026-03-30 19:28:10 +09:00
Vincent Koc
54f7221465 fix(slack): restore table block mode seam (#57591)
* fix(slack): restore table block mode seam

Restore the shared markdown/config seam needed for Slack Block Kit table support, while coercing non-Slack block mode back to code.

* fix(slack): narrow table block seam defaults

Keep Slack table block mode opt-in in this seam-only PR, clamp collected placeholder offsets, and align fallback-table rendering with Slack block limits.

* fix(slack): bound table fallback rendering

Avoid spread-based maxima and bound Slack table fallback rendering by row, column, cell-width, and total-output limits to prevent resource exhaustion.

* fix(slack): keep block mode inactive in seam PR

Keep markdown table block mode schema-valid but runtime-resolved to code until the Slack send path is wired to emit table attachments.

* fix(slack): normalize configured block mode safely

Accept configured markdown table block mode at parse time, then normalize it back to code during runtime resolution so seam-only branches do not drop table content.
2026-03-30 19:25:01 +09:00
Vincent Koc
56be744a7a docs: simplify automation decision flowchart to linear path 2026-03-30 19:22:56 +09:00
Vincent Koc
b0738210ff docs: replace ASCII decision tree with mermaid flowchart on automation hub 2026-03-30 19:20:13 +09:00
Vincent Koc
256e3b9b5f docs: add automation overview links to all automation page Related sections
- cron-jobs.md: add Related section (was missing entirely)
- cron-vs-heartbeat.md: add automation overview link, normalize dash style
- tasks.md: add automation overview link
- standing-orders.md: add automation overview, hooks, webhooks links

All automation pages now link back to /automation for navigation.
2026-03-30 19:18:44 +09:00
Vincent Koc
f80310e617 docs: Batch 5 — restructure Nodes group with subgroups
Rename "Nodes and devices" to "Nodes and media" and split into
two subgroups for better navigation:
- Media capabilities: media-understanding, images, audio, camera, tts
- Node features: talk, voicewake, location-command
2026-03-30 19:13:00 +09:00
Vincent Koc
69b72cd977 docs: Batch 4 — create image generation tool page
New page: docs/tools/image-generation.md covering:
- image_generate tool parameters and usage
- Supported providers (OpenAI, Google, fal, MiniMax) with capability matrix
- Config for imageGenerationModel (string and object forms)
- Provider selection order and fallback behavior
- Image editing with reference images
- Provider-specific capabilities (size, aspect ratio, resolution)

Add to Mintlify nav under Tools > Tools group.
2026-03-30 19:12:35 +09:00
Vincent Koc
fa23b5e5a5 docs: Batch 3 — fix misplaced nav entries
- Move plugins/voice-call from Channels > Messaging platforms to
  Tools & Plugins > Plugins (it's a plugin, not a channel integration)
- Add install/clawdock to Install > Containers nav (was orphaned)
2026-03-30 19:09:40 +09:00
Vincent Koc
2b4f600f9c docs: Batch 2 — add Related sections to all channel pages
Add consistent Related sections to 17 channel pages that had none,
linking to: Channels Overview, Pairing, Groups, Channel Routing, Security.

Add Groups and Security links to 4 channel pages (discord, slack,
telegram, whatsapp) that already had partial Related sections.
2026-03-30 19:08:22 +09:00
Vincent Koc
e682b72154 docs: Batch 1 — create automation hub + add Related sections
New page: docs/automation/index.md — single entry point for all automation
mechanisms (heartbeat, cron, tasks, hooks, standing-orders, webhooks) with
a decision flowchart and comparison table.

Add "Related" sections to 5 high-traffic pages that were dead ends:
- gateway/heartbeat.md → links to tasks, cron-vs-heartbeat, timezone, troubleshooting
- concepts/session.md → links to multi-agent, tasks, channel-routing
- concepts/multi-agent.md → links to channel-routing, subagents, ACP, presence, session
- concepts/agent-loop.md → links to tools, hooks, compaction, exec-approvals, thinking
- concepts/timezone.md → links to heartbeat, cron-jobs, date-time

Add automation/index to Mintlify nav as first item in Automation group.
2026-03-30 19:07:18 +09:00
Vincent Koc
104f006916 fix(test): trim reply command registry imports 2026-03-30 19:02:40 +09:00
Vincent Koc
b4ecf2bc33 fix(test): trim matrix bind registry cleanup 2026-03-30 19:02:31 +09:00
Vincent Koc
118a497496 fix(memory): keep qmd session paths roundtrip-safe (#57560) 2026-03-30 18:57:03 +09:00
Vincent Koc
c7d0beb98d fix(ci): harden Windows test cleanup 2026-03-30 18:56:29 +09:00
Frank Yang
43cd29c4af fix(agents): dispose bundled MCP runtime after local runs (#57520)
* fix(agents): dispose bundled MCP runtime after local runs

* fix(agents): scope bundle MCP cleanup to local one-shots

* fix(agents): dispose bundle MCP after local runs

* docs(changelog): note local bundle MCP cleanup fix
2026-03-30 17:12:59 +08:00
Peter Steinberger
926693e993 fix(ci): restore docs formatting and slack test typing 2026-03-30 17:55:02 +09:00
Vincent Koc
9670bd0823 fix(test): trim session binding registry imports 2026-03-30 17:46:44 +09:00
Peter Steinberger
7633b6fe0b docs: add exec approval troubleshooting 2026-03-30 09:45:20 +01:00
Vincent Koc
a804d234cd docs: fix incorrect claim that main-session cron jobs don't create tasks
Source code verified: tryCreateCronTaskRun() in src/cron/service/timer.ts
is called unconditionally for ALL cron job executions (both main-session
and isolated). Main-session cron tasks use silent notify policy by default.

Fixed in:
- automation/tasks.md: update table, TL;DR, Note callout, cron section
- automation/cron-vs-heartbeat.md: fix distinction callout and comparison table
- automation/cron-jobs.md: fix intro and main-session section
2026-03-30 17:32:57 +09:00
Vincent Koc
ad2c3f28bd docs(changelog): summarize background tasks rollout 2026-03-30 17:32:34 +09:00
Peter Steinberger
8a0c377a2f fix: stabilize ci task and docs checks 2026-03-30 09:25:01 +01:00
Ayaan Zaidi
0b632dde8c fix: add facade recursion regression coverage (#57508) (thanks @openperf) 2026-03-30 13:48:21 +05:30
openperf
9a03fe8181 fix(facade-runtime): add recursion guard to facade module loader to prevent infinite stack overflow
Place a sentinel object in the loadedFacadeModules cache before the Jiti
sync load begins.  Re-entrant calls (caused by circular facade references
from constant exports evaluated at module-evaluation time) now receive the
sentinel instead of recursing infinitely.  Once the real module finishes
loading, Object.assign() back-fills the sentinel so any references
captured during the circular load phase see the final exports.

The Jiti load is wrapped in try/catch: on failure the sentinel is removed
from the cache so that subsequent retry attempts re-execute the load
instead of silently returning an empty object.  The function returns the
sentinel (not the raw loaded module) to guarantee a single object identity
for all callers, including those that captured a reference during the
circular load phase.

Also tightens the generic constraint from <T> to <T extends object> so
Object.assign() is type-safe, and propagates the constraint to the
test-utils callers in bundled-plugin-public-surface.ts.

Fixes #57394
2026-03-30 13:48:21 +05:30
Vincent Koc
ae0e1ecf5c docs: add background tasks cross-references across 6 doc pages
Link to /automation/tasks from all pages that mention subagent runs,
ACP runs, or detached background work:

- tools/subagents.md: note that each sub-agent run is tracked as a background task
- tools/acp-agents.md: note that ACP session spawns are tracked as background tasks
- cli/index.md: link tasks section to doc page, add tasks audit subcommand
- concepts/queue.md: note that detached lane runs are tracked as background tasks
- gateway/configuration-reference.md: cron section cross-ref to tasks
- help/faq.md: add tasks link to sub-agent offloading FAQ answer
2026-03-30 16:42:47 +09:00
Vincent Koc
aba220a6aa docs: rewrite tasks page for readability, add mermaid lifecycle diagram
- Rewrite docs/automation/tasks.md to match repo doc style:
  - Add cross-ref blockquote, TL;DR bullets, proper section pacing
  - Replace ASCII lifecycle with a mermaid stateDiagram-v2
  - Add <Tip> for heartbeat wake behavior
  - Split CLI into individual subcommand sections (list/show/cancel/notify/audit)
  - Tighten prose to 3-6 lines per section before a break
  - Add "Status integration" section for task pressure
  - Restructure "How tasks relate" with shorter, punchier subsections
- Fix comparison table link in cron-vs-heartbeat.md
2026-03-30 16:38:59 +09:00
Vincent Koc
77c7eb346b fix(ci): repair docs and task-registry guard 2026-03-30 16:35:18 +09:00
Vincent Koc
8a916652e8 docs: add Background Tasks page and clean up cross-references
New page: docs/automation/tasks.md — comprehensive reference for the task
system covering lifecycle, delivery, notifications, audit, CLI commands,
storage, maintenance, and how tasks relate to cron/heartbeat/sessions.

- Add to Mintlify navigation (docs.json) under Automation group
- Clean up engineer's earlier scattered additions in cron-jobs.md,
  cron-vs-heartbeat.md, and heartbeat.md to be concise and link to the
  new canonical tasks page
- Replace verbose inline explanations with cross-reference links
2026-03-30 16:26:13 +09:00
Vincent Koc
12ae4eee7e fix(slack): complete interactive block delivery (#57473)
* fix(slack): complete interactive block delivery

Related #12602
Related #49528

* docs(changelog): add Slack interactive delivery note

Related #12602

* fix(slack): add reply-blocks helper and tighten directives

Related #12602
Related #49528

* fix(slack): scope style parsing and recheck merged blocks

Related #12602
Related #49528
2026-03-30 16:25:51 +09:00
Vincent Koc
e4e732a77b fix(tasks): remove sqlite merge marker 2026-03-30 16:19:28 +09:00
Vincent Koc
e624fdcf0a docs(tasks): clarify heartbeat, cron, and background runs 2026-03-30 16:19:28 +09:00
Vincent Koc
0a014ca63a perf(tasks): optimize session lookups and sqlite upserts 2026-03-30 16:19:28 +09:00
Vincent Koc
89dbaa87aa fix(memory): add cli qmd session context (#57493) 2026-03-30 16:18:56 +09:00
Patrick Yingxi Pan
1ad88b58d1 feat(matrix): add explicit channels.matrix.proxy config (#56930) (#56931)
Merged via squash.

Prepared head SHA: facdf94b65
Co-authored-by: patrick-yingxi-pan <5210631+patrick-yingxi-pan@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 02:51:33 -04:00
Ayaan Zaidi
88716f02de fix: align android sms permission UI state 2026-03-30 11:28:42 +05:30
Ayaan Zaidi
df385a7ed6 test: tighten android node contracts 2026-03-30 11:22:20 +05:30
Ayaan Zaidi
f1e7a5ce5f test: broaden android node advertisement matrix 2026-03-30 11:20:27 +05:30
Ayaan Zaidi
05762ed8d7 test: broaden android nodes tool round trips 2026-03-30 11:18:14 +05:30
Ayaan Zaidi
833d0e3d6f test: broaden android notification handlers 2026-03-30 11:15:44 +05:30
Ayaan Zaidi
94279d09ca test: broaden android location and call log handlers 2026-03-30 11:12:35 +05:30
Ayaan Zaidi
7c93a2bae2 test: broaden android node capability advertisement coverage 2026-03-30 11:10:00 +05:30
Ayaan Zaidi
7304ef6630 test: cover android invoke availability gates 2026-03-30 11:08:05 +05:30
Douglas Lardo
bb2c010e07 fix(delivery): treat Matrix "User not in room" as permanent delivery error (#57426)
Merged via squash.

Prepared head SHA: 6a777197cb
Co-authored-by: dlardo <5000601+dlardo@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-30 01:35:15 -04:00
Ayaan Zaidi
96ddf30cf1 test: cover android sms permission payloads in nodes tool 2026-03-30 11:04:10 +05:30
Ayaan Zaidi
403cf9070e test: cover android sms send dispatch gating 2026-03-30 11:01:10 +05:30
Ayaan Zaidi
a58ff25769 test: cover android sms search dispatch gating 2026-03-30 11:00:07 +05:30
Ayaan Zaidi
e5fa976f5c test: harden android sms capability coverage 2026-03-30 10:53:55 +05:30
Gustavo Madeira Santana
0b16443fa4 Tests: close ACP manager task registry before temp dir cleanup 2026-03-30 01:17:47 -04:00
Gustavo Madeira Santana
7668793e6c Tests: close task registry before temp dir cleanup 2026-03-30 01:08:54 -04:00
Vincent Koc
1fd8164f01 fix(test): trim acp command registry imports 2026-03-30 14:08:48 +09:00
Vincent Koc
572ed05219 fix(tasks): restore user-facing task wording 2026-03-30 14:08:25 +09:00
Ayaan Zaidi
0462a7fd8c fix: finalize android sms search (#50146) (thanks @scaryshark124) 2026-03-30 10:36:43 +05:30
Gustavo Madeira Santana
74ea42e210 Tests: relax targeted unit planner split assertion 2026-03-30 01:06:32 -04:00
Vincent Koc
4a1f231f1e test(tasks): guard task-registry import boundary (#57487)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor

* refactor(tasks): route subagents through executor

* refactor(cron): split main and detached dispatch

* refactor(tasks): guard executor-only producer writes

* refactor(tasks): clarify detached run surfaces

* test(tasks): guard task-registry import boundary
2026-03-30 14:02:48 +09:00
Vincent Koc
3a37421251 refactor(tasks): clarify detached run surfaces (#57485)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor

* refactor(tasks): route subagents through executor

* refactor(cron): split main and detached dispatch

* refactor(tasks): guard executor-only producer writes

* refactor(tasks): clarify detached run surfaces
2026-03-30 14:02:13 +09:00
Vincent Koc
8fb247c528 refactor(tasks): guard executor-only producer writes (#57486)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor

* refactor(tasks): route subagents through executor

* refactor(cron): split main and detached dispatch

* refactor(tasks): guard executor-only producer writes
2026-03-30 14:00:25 +09:00
Vincent Koc
1c9053802a refactor(cron): split main and detached dispatch (#57482)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor

* refactor(tasks): route subagents through executor

* refactor(cron): split main and detached dispatch
2026-03-30 13:59:55 +09:00
Vincent Koc
4be290c15f fix(test): trim onboarding registry imports 2026-03-30 13:59:37 +09:00
Gustavo Madeira Santana
10723a0013 Tests: tighten scoped channel account fixtures 2026-03-30 00:59:26 -04:00
Gustavo Madeira Santana
fca8880968 Tests: reuse QMD availability mock type 2026-03-30 00:59:26 -04:00
Vincent Koc
ec13f6d73e refactor(tasks): route subagents through executor (#57481)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor

* refactor(tasks): route subagents through executor
2026-03-30 13:59:23 +09:00
Vincent Koc
126f77315f refactor(tasks): route acp through executor (#57478)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy

* refactor(tasks): route acp through executor
2026-03-30 13:58:51 +09:00
Gustavo Madeira Santana
0e078e8bc0 Runtime: dedupe typing lease logic 2026-03-30 00:58:04 -04:00
Gustavo Madeira Santana
73b128e37d Tests: trim channels add registry imports 2026-03-30 00:54:41 -04:00
Gustavo Madeira Santana
16b452040b Memory: fix QMD doctor contract typing 2026-03-30 00:54:41 -04:00
Gustavo Madeira Santana
b33a18e280 Runtime: remove dead telegram typing lease 2026-03-30 00:52:57 -04:00
Vincent Koc
c842ca0166 fix(test): trim channel account registry imports 2026-03-30 13:50:39 +09:00
Vincent Koc
6a3c68d470 fix(test): trim channels add registry imports 2026-03-30 13:47:25 +09:00
Gustavo Madeira Santana
9d05db7be7 WhatsApp: move heartbeat recipient test into plugin 2026-03-30 00:46:50 -04:00
Gustavo Madeira Santana
6a37ecad82 Supervisor: unblock waits after forced child kill 2026-03-30 00:45:22 -04:00
Gustavo Madeira Santana
6c66d1009b BlueBubbles: move status-issue test into plugin 2026-03-30 00:45:22 -04:00
Vincent Koc
817ac551b6 refactor(tasks): extract delivery policy (#57475)
* refactor(tasks): add executor facade

* refactor(tasks): extract delivery policy
2026-03-30 13:44:59 +09:00
Vincent Koc
8623c28f1d fix(memory): warn when qmd binary is missing (#57467)
* fix(memory): warn when qmd binary is missing

* fix(memory): avoid probing cached qmd managers

* docs(memory): clarify qmd doctor probe behavior

* fix(memory): probe qmd from agent workspace
2026-03-30 13:44:41 +09:00
Vincent Koc
69793db948 fix(test): trim acp context registry imports 2026-03-30 13:39:29 +09:00
Vincent Koc
b26092cf01 fix(test): close task registry sqlite after reset 2026-03-30 13:38:22 +09:00
Gustavo Madeira Santana
c389b05d3c Tests: force-reset session cleanup state between runs 2026-03-30 00:36:43 -04:00
Vincent Koc
20e4d42db3 fix(test): trim onboarding post-write registry imports 2026-03-30 13:36:02 +09:00
Vincent Koc
5b2d9b6505 refactor(tasks): add executor facade (#57474) 2026-03-30 13:35:39 +09:00
Kris Wu
6b255b4dec fix(agents): prevent unhandled rejection when compaction retry times out [AI] (#57451)
* fix(agents): prevent unhandled rejection when compaction retry times out

* fix(agents): preserve compaction retry wait errors

* chore(changelog): add compaction retry timeout entry

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 13:30:13 +09:00
nimbleenigma
aee61dcee0 fix: finalize android notification forwarding controls (#40175) (thanks @nimbleenigma)
* android(gateway): always send string payloadJSON for node.event

(cherry picked from commit 5ca5a0663ad8fbd9f9f654c52a72b423e1e19605)
(cherry picked from commit bce87e7493e52b0e5959548b410500db8d545a50)

* android: restore notification forwarding controls

(cherry picked from commit 98c4486f83d165919d7f8f1d713ff79ec8126ce7)

* android: restore notification picker discovery UX

(cherry picked from commit 276fbe3f80e036b666070a636c89ee073cdaa934)

* android: enforce notification forwarding policy in listener

(cherry picked from commit 502fb761e05ff911ebde2771eebb1e175ec4dbeb)

* fix(android): harden notification quiet hours inputs

(cherry picked from commit 717d5016f52e98601e6b6d678c991c78fa5ca429)

* fix(android): polish notification forwarding settings

(cherry picked from commit ad667899dea45af70fabfd43f43fa38024def23f)

* test: normalize talk config fixture API key placeholders

* fix(android): use persisted recent packages and wall-clock policy time

* fix(android): keep notification forwarding settings editable

* fix: finalize android notification forwarding controls (#40175) (thanks @nimbleenigma)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-30 10:00:00 +05:30
Vincent Koc
6af52b4ce3 fix(test): trim outbound session registry imports 2026-03-30 13:29:10 +09:00
Vincent Koc
2849e613ee fix(test): trim target parsing registry imports 2026-03-30 13:19:50 +09:00
Gustavo Madeira Santana
d8ad72bf8d Tests: stabilize session-state cleanup mocks 2026-03-30 00:15:59 -04:00
Vincent Koc
c91f6944cb docs(changelog): add memory session indexer credit 2026-03-30 13:14:42 +09:00
Vincent Koc
ab43bbd62b fix(test): use minimal outbound binding registries 2026-03-30 13:08:13 +09:00
Vincent Koc
fa5827079f refactor(tasks): split delivery state from task runs 2026-03-30 13:03:54 +09:00
Gustavo Madeira Santana
ca2a67e07e Slack: keep auto-thread test context local 2026-03-29 23:56:32 -04:00
zijiess
dca7969b2e fix(agents): classify Anthropic "unexpected error" api_error as transient (#57441)
* fix(agents): classify Anthropic "unexpected error" api_error as transient (#57010)

Anthropic sometimes returns api_error payloads with message "An unexpected
error occurred while processing the response" during mid-stream failures.
This was not matched by API_ERROR_TRANSIENT_SIGNALS_RE, causing the error
to surface as terminal instead of triggering retry/fallback.

Add "unexpected error" to the transient signal regex. This follows the
same pattern as prior fixes for "Internal server error" (#23193) and
overloaded_error 529 (#34535).

Closes #57010

* chore(changelog): add anthropic failover entry

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 12:56:07 +09:00
Gustavo Madeira Santana
91ea844cc0 Slack: move auto-thread coverage into plugin tests 2026-03-29 23:53:48 -04:00
Vincent Koc
dd23e744f4 fix(test): bypass slack facade in message action params test 2026-03-30 12:51:02 +09:00
Vincent Koc
b7d59f7831 fix(memory): export archived qmd session transcripts (#57446)
* fix(memory): export archived qmd session transcripts

* test(memory): separate qmd session listing describe
2026-03-30 12:50:21 +09:00
Vincent Koc
c7106c4285 refactor(tasks): replace generic task mutation api 2026-03-30 12:49:36 +09:00
wangchunyue
16df3de098 fix: stabilize config default-leak landing tests (#56834) (thanks @openperf)
* fix(config): prevent AJV schema defaults from leaking into persisted config

Fixes #56772. Ensures that channel and plugin AJV validations respect the applyDefaults option, preventing runtime defaults from being written to openclaw.json during doctor/update flows.

* test: address review feedback on #56772 fix

- Split validation.channel-metadata.test.ts into applyDefaults true/false cases (fixes CI)

- Update io.write-config.test.ts regression test to use a mock plugin registry, ensuring it actually exercises the AJV default injection path

* fix(config): revert applyDefaults passthrough to prevent required-field regression

Codex-connector correctly identified that BlueBubbles channel schema marks

enrichGroupParticipantsFromContacts as both default:true and required.

Passing applyDefaults:false during write validation would cause required

checks to fail, breaking writeConfigFile entirely.

Reverted validation.ts to always use applyDefaults:true for channel/plugin

AJV validation. The protection against default leakage into persisted config

is fully handled by the persistCandidate change in io.ts (cfgToWrite uses

the pre-validation merge-patched value, not validated.config).

Updated validation.channel-metadata.test.ts to reflect this architecture.

* fix(config): apply legacy web-search normalization to persistCandidate

* fix: stabilize config default-leak landing tests (#56834) (thanks @openperf)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-30 09:19:17 +05:30
Vincent Koc
22f56433e0 fix(perf): bypass matrix facade for core helpers 2026-03-30 12:43:55 +09:00
Vincent Koc
c142a396f4 fix(memory): rebind qmd collections on pattern drift (#57438) 2026-03-30 12:43:44 +09:00
Gustavo Madeira Santana
313fdf5adf Memory Host: make backend-config path tests portable 2026-03-29 23:41:42 -04:00
Vincent Koc
8c163c14bc feat(tasks): harden maintenance repair paths (#57439)
* feat(tasks): harden maintenance repair paths

* fix(tasks): address follow-up maintenance review comments
2026-03-30 12:37:31 +09:00
Gustavo Madeira Santana
aaf47ca54b WhatsApp: align deliver-reply test mocks with imports 2026-03-29 23:36:38 -04:00
Gustavo Madeira Santana
8b0cbebe43 Telegram: align channel test with runtime probe precedence 2026-03-29 23:34:03 -04:00
Gustavo Madeira Santana
0fe193db6a Discord: restore message utils media-runtime mocks 2026-03-29 23:21:34 -04:00
Gustavo Madeira Santana
3efcc90034 Tests: read packed manifest without shell tar 2026-03-29 23:21:24 -04:00
Gustavo Madeira Santana
1482afae57 Status: keep fast JSON off task audit runtime 2026-03-29 23:17:28 -04:00
Gustavo Madeira Santana
e86a2183df Tests: type package contract npm pack helper 2026-03-29 23:10:58 -04:00
Vincent Koc
c52fac836c feat(tasks): add status health and maintenance command (#57423)
* feat(tasks): add status health and maintenance command

* fix(tasks): address status and maintenance review feedback
2026-03-30 12:10:44 +09:00
Vincent Koc
d28349c48e fix(test): align channel mocks with runtime exports 2026-03-30 12:08:27 +09:00
Vincent Koc
da35718cb2 fix(memory): add qmd mcporter search tool override (#57363)
* fix(memory): add qmd mcporter search tool override

* fix(memory): tighten qmd search tool override guards

* chore(config): drop generated docs baselines from qmd pr

* fix(memory): keep explicit qmd query override on v2 args

* docs(changelog): normalize qmd search tool attribution

* fix(memory): reuse v1 qmd tool after query fallback
2026-03-30 12:07:32 +09:00
Gustavo Madeira Santana
e7984272a7 Tests: make package contract guardrails Windows-safe 2026-03-29 23:07:27 -04:00
Vincent Koc
1421fced04 Update CHANGELOG.md 2026-03-30 12:06:19 +09:00
Yauheni Shauchenka
a6bc51f944 feat(openai): forward text verbosity (#47106)
* feat(openai): forward text verbosity across responses transports

* fix(openai): remove stale verbosity rebase artifact

* chore(changelog): add openai text verbosity entry

---------

Co-authored-by: Ubuntu <ubuntu@vps-1c82b947.vps.ovh.net>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 12:04:35 +09:00
Gustavo Madeira Santana
51e053d0e8 OpenAI: preserve oauth exports in index tests 2026-03-29 22:54:48 -04:00
Vincent Koc
03a03c2dc4 fix(ci): restore skill fixtures and security doc anchors 2026-03-30 11:41:08 +09:00
Gustavo Madeira Santana
f380305ee4 Tests: restore extension plugin test seams 2026-03-29 22:38:44 -04:00
Gustavo Madeira Santana
ecb14338d4 Matrix: keep runtime wrapper off monolithic sdk root 2026-03-29 22:38:44 -04:00
Vincent Koc
e57b3618fc feat(tasks): move task ledger to sqlite and add audit CLI (#57361)
* feat(tasks): move task ledger to sqlite

* feat(tasks): add task run audit command

* style(tasks): normalize audit command formatting

* fix(tasks): address audit summary and sqlite perms

* fix(tasks): avoid duplicate lost audit findings
2026-03-30 11:34:51 +09:00
Ayaan Zaidi
6f09a68ae7 fix: finalize LLM idle timeout landing (#55072) (thanks @liuy) 2026-03-30 08:04:42 +05:30
Ayaan Zaidi
179f713c88 fix: unify idle timeout with runner abort path 2026-03-30 08:04:42 +05:30
Liu Yuan
84b72e66b9 feat: add LLM idle timeout for streaming responses
Problem: When LLM stops responding, the agent hangs for ~5 minutes with no feedback.
Users had to use /stop to recover.

Solution: Add idle timeout detection for LLM streaming responses.
2026-03-30 08:04:42 +05:30
qsam
47839d3b9a fix(mattermost): detect stale websocket after bot disable/enable cycle (#53604)
Merged via squash.

Prepared head SHA: 818d437a54
Co-authored-by: Qinsam <19649380+Qinsam@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-03-30 07:54:59 +05:30
openperf
c6ded0fa54 fix(gateway): coerce streaming tool-call argument deltas to object in client tools 2026-03-30 07:53:51 +05:30
Gustavo Madeira Santana
6fe24a6f2c Matrix: fix source-checkout runtime wrapper 2026-03-29 22:20:41 -04:00
Robin Waslander
acf8470f09 fix(status): cap cache hit rate at 100% in status display (#57400)
formatTokensCompact() computed cache rate using totalTokens as the
denominator. Legacy rows with undersized totalTokens produced
impossible values like 120%. Use inputTokens + cacheRead + cacheWrite
when prompt-side fields are available, falling back to
max(totalTokens, cacheRead + cacheWrite) for legacy data.

Fixes #26643
2026-03-30 04:09:57 +02:00
Peter Steinberger
25074de838 fix: restore lightweight root help startup 2026-03-30 03:08:53 +01:00
Gustavo Madeira Santana
2481c0a9b6 Tests: keep channel helpers on public plugin surfaces 2026-03-29 22:08:19 -04:00
Ayaan Zaidi
b3c69b941e fix: add orphaned session key migration (#57217)
* fix: add orphaned session key migration

* fix: address session migration review comments
2026-03-30 07:36:46 +05:30
Peter Steinberger
9d005e6fbb fix: stabilize telegram contract runtime coverage 2026-03-30 03:02:25 +01:00
MerlinMiao88888888
9c2d22e77f scripts: respect gateway.bind config when OPENCLAW_GATEWAY_BIND not set (#55453)
* scripts: respect gateway.bind config when OPENCLAW_GATEWAY_BIND not set

* scripts: keep Podman bind precedence focused

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-03-29 22:00:21 -04:00
Gustavo Madeira Santana
acea28a9bb Telegram: fix extension-fast test seams 2026-03-29 21:54:47 -04:00
Josh Avant
5e4a64848f fix(exec): harden async approval followup delivery in webchat-only sessions (#57359)
* fix(exec): harden approval followup delivery fallback

* refactor(delivery): share best-effort followup routing helpers

* test(subagents): cover webchat-only completion announce delivery

* docs(exec): clarify async followup delivery behavior

* fix(exec): harden delivery downgrade logging

* test(gateway): cover multi-channel best-effort fallback

* fix(exec): preserve webchat origin on session-only followups

* fix(subagents): keep internal announces channel-less
2026-03-29 20:54:13 -05:00
Gustavo Madeira Santana
f44174cf61 Extensions: stabilize telegram registry contracts 2026-03-29 21:42:58 -04:00
Peter Steinberger
e92440e9f4 fix: replace weak randomness in feishu test support 2026-03-30 02:41:25 +01:00
Peter Steinberger
fec51572a3 fix: stabilize gate and extension boundary checks 2026-03-30 02:37:36 +01:00
Vincent Koc
66f8fb9e9b docs: fix P2 in security -- normalize Security audit checklist heading to sentence case 2026-03-30 10:19:51 +09:00
Vincent Koc
542f17a674 docs: fix P2s in hooks -- text language tag on log block, claude-sonnet-4-6 model example 2026-03-30 10:19:51 +09:00
Vincent Koc
7ed5a4a33d docs: fix P2s in faq -- remote mode accordion title, gpt-5.4 model refs, stable/beta cross-reference 2026-03-30 10:19:51 +09:00
Vincent Koc
d19ccde297 docs: fix P2s in configuration-reference -- built-in model catalog, PI_CODING_AGENT_DIR legacy note, deduplicate Identity section 2026-03-30 10:19:51 +09:00
Vincent Koc
0c94420164 docs: fix remaining CLI P1s -- global flags, onboard, logs options
- Add --container global flag
- onboard: add --skip-search, --cloudflare-ai-gateway-account-id, --cloudflare-ai-gateway-gateway-id
- logs: add full options list (--limit, --max-bytes, --follow, --interval, --local-time, etc.)
2026-03-30 10:19:51 +09:00
Vincent Koc
50d815579c docs: consolidate security page structure and add navigation
- Merge 3 duplicate trust-model sections into one (Scope first + Deployment/host trust)
- Promote "What the audit checks" from h3 to h2 (standalone topic, not child of Shared inbox)
- Add "On this page" navigation links at the top for the 1200+ line page
2026-03-30 10:19:51 +09:00
Vincent Koc
726ae0b8af docs: fix discord.md P1s -- internal terminology and wrong CLI command
- Replace "Carbon component instances" with public description
- Fix "openclaw gateway restart" (no such subcommand) with correct restart guidance
2026-03-30 10:19:51 +09:00
Vincent Koc
381bfdf031 docs: fix architecture.md P1s and P2s
- Remove stale "Qwen portal" reference (no such bundled plugin)
- Add activate hook note (legacy alias for register)
- Add api.runtime.imageGeneration documentation (generate, listProviders)
- Fix install command: document --omit=dev flag
- Document external catalog aliases (packages, plugins accepted alongside entries)
2026-03-30 10:19:51 +09:00
Vincent Koc
89f9433fbf docs: add complete plugin hook reference and fix context fields in hooks.md
- Add reference table for all 27 plugin hook names with execution model and return types
- Fix agent:bootstrap context: add missing sessionKey, sessionId, agentId fields
- Fix session patch context: add fastMode, spawnedWorkspaceDir, subagentRole, subagentControlScope
- Fix responseUsage: add backwards-compat "on" value
- Add session:compact:before and session:compact:after payload field documentation
- Remove internal PR reference (#20800)
- Fix handler file resolution: document handler.js and index.js fallbacks
2026-03-30 10:19:51 +09:00
Vincent Koc
cb428aca1c docs: add 11 missing config sections to configuration-reference
Add documentation for config schema sections that existed in source but had
zero coverage in the reference doc:

- diagnostics (otel, cacheTrace, flags, stuckSessionWarnMs)
- update (channel, checkOnStart, auto.*)
- acp (enabled, dispatch, backend, stream.*, runtime.*)
- gateway.tls (enabled, autoGenerate, certPath, keyPath, caPath)
- gateway.reload (mode, debounceMs, deferralTimeoutMs)
- cron.retry (maxAttempts, backoffMs, retryOn)
- cron.failureAlert (enabled, after, cooldownMs, mode)
- auth.cooldowns (billingBackoffHours, billingMaxHours, failureWindowHours)
- logging.maxFileBytes
- session.scope (per-sender vs global)
- session.agentToAgent.maxPingPongTurns (range 0-5)
2026-03-30 10:19:51 +09:00
Gustavo Madeira Santana
b952e404fa Control UI: clear queued connect timeout on stop (#57338)
Merged via squash.

Prepared head SHA: a359fe8367
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 20:54:21 -04:00
Vincent Koc
fb81e3fc7f docs: fix remaining CLI P1s -- doctor, sessions, agents, models auth
- doctor: add --repair/--fix, --force, --generate-gateway-token options
- sessions: add --agent, --all-agents options and cleanup subcommand
- agents: add bindings, bind, unbind, set-identity to command tree
- models auth: add login and login-github-copilot subcommands
2026-03-30 09:51:17 +09:00
Vincent Koc
169bbc82f2 docs: fix security page P1s -- dmScope, heading style, roadmap language
- Add missing per-peer dmScope value to isolation options
- Fix heading style: 3./4. -> 3)/4) for consistency with other numbered sections
- Add channel qualifier to 'Separate Numbers' heading (WhatsApp/Signal/Telegram)
- Remove roadmap speculation ('We may add readOnlyMode later')
2026-03-30 09:46:57 +09:00
Vincent Koc
d3429e0c70 docs: fix FAQ P1s -- model aliases and heartbeat OAuth default
- Remove /model haiku (not a built-in alias); add gemini-flash-lite
- Note custom aliases can be added via config
- Heartbeat: note 1h default for OAuth auth
2026-03-30 09:46:15 +09:00
Vincent Koc
445fed9dc5 docs: add missing field docs and fix config-reference P1s
- Document verboseDefault (off|on|full) and elevatedDefault (off|on|ask|full)
- Heartbeat every: note OAuth default (1h) and disable value (0m)
- Replace internal 'Nano Banana' code name with 'native Gemini image generation'
2026-03-30 09:45:48 +09:00
Vincent Koc
dc64280f1d docs: fix stale CLI reference -- auth choices, agent options, tasks command
- Rebuild --auth-choice enum from BuiltInAuthChoice source (remove ollama, minimax-api,
  minimax-api-lightning; add 15+ missing choices including deepseek, copilot, volcengine, etc.)
- Fix agent command: --verbose accepts on|off not on|full|off; add missing --agent, --reply-to,
  --reply-channel, --reply-account, -m/-t short flags; fix --thinking description
- Add tasks command to command tree and body (list/show/notify/cancel)
- Mark anthropic-cli as deprecated legacy alias
- Remove stale ollama references from custom-base-url/custom-model-id
2026-03-30 09:45:06 +09:00
Vincent Koc
9355925690 docs: fix Mintlify callout syntax in security page
Replace GitHub-flavor > [!WARNING] with Mintlify <Warning> component.
The old syntax renders as a plain blockquote in Mintlify, hiding the most
safety-critical content on the page.
2026-03-30 09:43:33 +09:00
Vincent Koc
b7e2e1b399 docs: fix stale Future Events and wrong log example in hooks.md
- Future Events: session:start/session:end are live plugin hooks, clarify they are planned for internal event stream only
- Log example: session-memory listens to command:new AND command:reset, not just command:new
2026-03-30 09:43:01 +09:00
Vincent Koc
82b6bd7457 docs: fix wrong imports and removed API in architecture.md
- Multi-capability example: use correct SDK subpaths (plugin-entry, media-understanding)
- Remove buildOpenAISpeechProvider (internal, not in public SDK)
- registerHttpHandler: 'obsolete' -> 'removed' (causes hard plugin-load error)
2026-03-30 09:42:11 +09:00
Vincent Koc
32ba94b7b3 docs: fix wrong defaults and config path in FAQ
- session.idleMinutes default: 60 -> 0 (disabled by default, per DEFAULT_IDLE_MINUTES)
- crossContext config path: agents.defaults.tools.message.crossContext -> tools.message.crossContext (matches schema)
- Remove incorrect per-agent tools.message override claim
2026-03-30 09:41:30 +09:00
Vincent Koc
12c92b5fb2 docs: fix wrong defaults and heading in configuration-reference
- maxConcurrent default: 1 -> 4 (matches DEFAULT_AGENT_MAX_CONCURRENT)
- subagents.maxConcurrent example: 1 -> 8 (matches DEFAULT_SUBAGENT_MAX_CONCURRENT)
- Fix section heading: tools.subagents -> agents.defaults.subagents (matches actual config path)
2026-03-30 09:41:09 +09:00
Vincent Koc
9b33380fb6 fix(test): harden qmd release callback typing 2026-03-30 09:37:21 +09:00
Michel Belleau
26f34be20c fix(gateway): /v1/responses tool schema should use flat Responses API format (#57166)
* gateway: fix /v1/responses tool schema to use flat Responses API format

* gateway: fix remaining stale wrapped-format tools in parity tests

* gateway: propagate strict flag through extractClientTools normalization

* fix(gateway): cover responses tool boundary

* Delete docs/internal/vincentkoc/2026-03-30-pr-57166-responses-tool-schema-followup.md

---------

Co-authored-by: Michel Belleau <mbelleau@Michels-MacBook-Pro.local>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 09:36:54 +09:00
Vincent Koc
3034adfdb3 fix(commands): harden fast status and Telegram callbacks 2026-03-30 09:32:53 +09:00
Vincent Koc
8657b65b05 fix(ci): rename extension test support for boundary guards 2026-03-30 09:31:33 +09:00
Vincent Koc
dce61dc920 fix(cli): import task summary helper 2026-03-30 09:31:33 +09:00
Vincent Koc
b82fd50472 fix(test): add extension-safe test helper bridges 2026-03-30 09:31:33 +09:00
Vincent Koc
5bac1aad04 fix(memory): support qmd glob collection flags (#57351) 2026-03-30 09:30:49 +09:00
Harold Hunt
8bf86b4cdf agents: remove xAI auth trace logging (#57342) 2026-03-29 20:29:51 -04:00
Peter Steinberger
f3bf7fe53a chore: bump version to 2026.3.30 2026-03-30 09:28:29 +09:00
Vincent Koc
d26d7c797b fix(memory): add QMD sync parity hooks (#57354)
* fix(memory): add qmd sync parity hooks

* fix(memory): avoid blocking qmd session warm searches
2026-03-30 09:25:37 +09:00
Peter Steinberger
9857d40923 fix(runtime): stabilize image generation auth/runtime loading 2026-03-30 01:14:29 +01:00
Gustavo Madeira Santana
bb42027699 Docs: hide ClawDock from install menu 2026-03-29 20:11:02 -04:00
Vincent Koc
e6445c22aa feat(status): surface task run pressure (#57350)
* feat(status): surface task run pressure

* Update src/commands/tasks.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-30 09:09:10 +09:00
Gustavo Madeira Santana
93dd25e6b2 Tests: drop dead telegram exec approval imports 2026-03-29 20:08:15 -04:00
Gustavo Madeira Santana
cc04153d01 Agents: reuse shared subagent hook runner type 2026-03-29 20:06:05 -04:00
Daniel Olshansky
6e1f00dc86 [ClawDock] Iteration on the first submission; bug fixes, UX improvements, etc (#23912)
Merged via squash.

Prepared head SHA: 30c5ef37a4
Co-authored-by: Olshansk <1892194+Olshansk@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 20:05:41 -04:00
Peter Steinberger
c2cbdea28c refactor: add approval auth capabilities to more channels 2026-03-30 09:04:08 +09:00
Peter Steinberger
63cbc097b5 refactor(channels): route core through registered plugin capabilities 2026-03-30 01:03:42 +01:00
Peter Steinberger
471e059b69 refactor(plugin-sdk): remove channel-specific sdk shims 2026-03-30 01:03:24 +01:00
Peter Steinberger
bff6a6a9c1 test(config): align optimistic write helpers 2026-03-30 01:02:25 +01:00
Peter Steinberger
47216702f4 refactor(config): use source snapshots for config mutations 2026-03-30 01:02:25 +01:00
Gustavo Madeira Santana
f9bf76067f Agents: fix subagent spawn hook typing 2026-03-29 20:00:52 -04:00
hnshah
19113637e8 fix(memory): keep qmd embeddings active in search mode (#54509)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 08:59:50 +09:00
yuna78
0033f64e19 gateway: narrow already-running exit code (#26718)
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
2026-03-30 10:59:32 +11:00
dadgo
2885c65c74 fix(memory): point qmd config dir at nested path (#39078)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 08:59:08 +09:00
Amine Harch el korane
219d4f03bd fix: wire memorySearch.extraPaths to QMD indexing (#57315)
* fix: wire memorySearch.extraPaths to QMD indexing

The 'agents.defaults.memorySearch.extraPaths' config field was documented
to add extra directories to the memory index, but the paths were never
actually passed to the QMD backend. Only 'memory.qmd.paths' worked.

This fix reads extraPaths from the memorySearch config and maps them
to QMD custom path collections, so users can simply configure:

  memorySearch:
    extraPaths:
      - odd-vault
      - /Users/odd/workspace
      - /Users/odd/docs

And have those directories indexed alongside the default memory files.

Closes #57302

* fix: handle per-agent memorySearch.extraPaths overrides + add tests

- Read per-agent overrides from agents.list[].memorySearch.extraPaths
- Agent-specific overrides take priority over defaults
- Falls back to defaults when agent has no overrides
- Added 3 test cases for the feature

* fix: merge defaults + agent overrides instead of replacing

* fix: remove any types from tests, fix merge behavior assertion

* fix(memory): merge qmd extra path collections

* fix(memory): normalize qmd extra path resolution

* fix(memory): type qmd extra path merge

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 08:58:42 +09:00
Peter Steinberger
cbceb1db76 docs(i18n): sync zh-CN node exec wording 2026-03-30 00:57:27 +01:00
Peter Steinberger
6b8a1b77a0 refactor(nodes): split media and invoke handlers 2026-03-30 00:57:27 +01:00
Vincent Koc
475defdf82 Anthropic: wire explicit service tier params (#45453)
* Anthropic: add explicit service tier wrapper

* Runner: wire explicit Anthropic service tiers

* Tests: cover explicit Anthropic service tiers

* Changelog: note Anthropic service tier follow-up

* fix(agents): make Anthropic service tiers override fast mode

* fix(config): drop duplicate healed sourceConfig

* docs(anthropic): update fast mode service tier guidance

* fix(agents): remove dead Anthropic Bedrock exports

* fix(agents): avoid cross-provider Anthropic tier warnings

* fix(agents): avoid cross-provider OpenAI tier warnings
2026-03-30 08:54:56 +09:00
Peter Steinberger
feed2c42dd test: stabilize subagent spawn harnesses 2026-03-30 00:54:09 +01:00
Vincent Koc
170a3a39d4 fix(test): restore subagent announce timeout mocks 2026-03-30 08:52:04 +09:00
Gustavo Madeira Santana
188fcbfa34 Docs: add zh-CN Diffs page 2026-03-29 19:48:08 -04:00
Gustavo Madeira Santana
c191dc9928 Control UI: preserve seq-gap reconnect state 2026-03-29 19:48:08 -04:00
Peter Steinberger
cf84a03ecf docs: clarify generic channel approval capabilities 2026-03-30 08:46:44 +09:00
Peter Steinberger
3b878e6b86 refactor: move approval auth and payload hooks to generic channel capabilities 2026-03-30 08:46:44 +09:00
Vincent Koc
7008379ff0 test(agents): restore cli runner test seams 2026-03-30 08:43:37 +09:00
Vincent Koc
408f6a5b0b test(matrix): stabilize file sync store persistence checks 2026-03-30 08:43:37 +09:00
Vincent Koc
d6a3580347 fix(lint): clear current main lint blockers 2026-03-30 08:43:37 +09:00
Peter Steinberger
193f781fad fix: stabilize ci and serial test gate 2026-03-30 00:43:01 +01:00
Vincent Koc
0da610a8ec fix(tasks): prefer ACP spawn metadata on merge 2026-03-30 08:42:31 +09:00
Peter Steinberger
c1137ef00d docs(nodes): remove nodes.run references 2026-03-30 00:41:06 +01:00
Peter Steinberger
2255e04b07 test(nodes): update coverage after exec consolidation 2026-03-30 00:41:06 +01:00
Peter Steinberger
5dae663ea4 refactor(nodes): remove nodes.run execution path 2026-03-30 00:41:06 +01:00
Peter Steinberger
dd8d66fc44 refactor: inject subagent announce test seams 2026-03-30 00:40:32 +01:00
Peter Steinberger
f914cd598a refactor(gateway): dedupe self-write config reloads 2026-03-30 00:39:39 +01:00
Peter Steinberger
a27ccee5d9 refactor(config): use source snapshots for config writes 2026-03-30 00:39:39 +01:00
Vincent Koc
c5baf63fa5 docs: deep audit of memory section -- fix icons, beef up engine pages, restructure config reference 2026-03-30 08:39:18 +09:00
Gustavo Madeira Santana
1600c1726e Control UI: reconnect on seq gaps 2026-03-29 19:31:01 -04:00
Peter Steinberger
15c3aa82bf refactor: unify approval forwarding and rendering 2026-03-30 08:28:33 +09:00
Peter Steinberger
8720070fe0 refactor: rename channel approval capabilities 2026-03-30 08:28:33 +09:00
Vincent Koc
53bcd5769e refactor(tasks): unify the shared task run registry (#57324)
* refactor(tasks): simplify shared task run registry

* refactor(tasks): remove legacy task registry aliases

* fix(cron): normalize timeout task status and harden ledger writes

* fix(cron): keep manual runs resilient to ledger failures
2026-03-30 08:28:17 +09:00
Peter Steinberger
e4466c72a2 test: stabilize runner and acp mocks
- reuse the shared cli-runner harness in claude runner tests
- make ACP session metadata and startup tests use stable static mocks
2026-03-30 00:27:52 +01:00
Vincent Koc
bf63264c62 docs: fix Honcho tool names and add CLI commands 2026-03-30 08:26:52 +09:00
Robin Waslander
bdd9bc93f1 fix(cron): deliver full announce output instead of last chunk only (#57322)
resolveCronPayloadOutcome() collapsed announce delivery to the last
deliverable payload. Replace with pickDeliverablePayloads() that
preserves all successful text payloads. Error-only runs fall back to
the last error payload only.

Extract shared isDeliverablePayload() helper. Keep
deliveryPayloadHasStructuredContent scoped to the last payload
to preserve downstream finalizeTextDelivery safeguards.

Fixes #13812
2026-03-30 01:24:45 +02:00
Peter Steinberger
0a4c11061d test: stabilize targeted harnesses
- reduce module-reset mock churn in auth/acp tests
- simplify runtime web mock cleanup
- make canvas reload test use in-memory websocket tracking
2026-03-30 00:23:38 +01:00
Radek Sienkiewicz
4680335b2a docs: fix English link audits (#57039)
Merged via squash.

Prepared head SHA: d20a3b620f
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-30 01:21:00 +02:00
Gustavo Madeira Santana
6c91b27756 Docs: remove internal CLI metadata note 2026-03-29 19:20:43 -04:00
Gustavo Madeira Santana
b0077904a7 Plugins: align CLI metadata loader behavior 2026-03-29 19:20:42 -04:00
Peter Steinberger
9a97c30fad style(test): sort mutate helper imports 2026-03-30 00:17:23 +01:00
Peter Steinberger
f928b81279 test(config): align snapshot fixtures and isolation 2026-03-30 00:17:23 +01:00
Peter Steinberger
89a4f2a34e refactor(config): centralize runtime config state 2026-03-30 00:17:23 +01:00
Armand du Plessis
b888741462 fix: QMD 1.1+ mcporter compatibility with legacy fallback [AI-assisted] (#54728)
* fix: QMD 2.0 mcporter compatibility with v1 fallback [AI-assisted]

QMD 2.0 unified all search modes under a single 'query' MCP tool
with typed sub-queries, replacing search/vector_search/deep_search.

- Default to QMD 2.0 'query' tool with {searches: [...]} format
- Auto-detect version on first call and cache for the session
- Fall back to v1 tool names if 'query' is not found
- Backwards compatible: v1 users get one retry then cached

AI-assisted: Built with Claude (Opus 4.6) via OpenClaw. Fully tested
against QMD 2.0 with mcporter 0.7.3 daemon. v1 fallback path not
live-tested (no v1 instance available). Code reviewed and understood.

* test: add QMD v2 tool format and v1 fallback tests

- Verify mcporter bridge uses 'query' tool with {searches: [...]} format (v2)
- Verify fallback to 'deep_search' with {query, limit} format when v2 not found
- Verify v1 fallback logs a warning for visibility

* fix: address review feedback — multi-collection v1 fallback + test cleanup

- Fix multi-collection v1 fallback: resolve effectiveTool at the top
  of runQmdSearchViaMcporter so stale 'query' tool names from the
  loop are corrected once qmdMcpToolVersion is set to 'v1'
- Assert callCount in v1 fallback test (one v2 attempt + one v1 retry)
- Remove spurious global state reset (qmdMcpToolVersion is per-instance)

* docs: correct version references — breaking change was QMD 1.5, not 2.0

The MCP tool removal (search/vector_search/deep_search → query) happened
in QMD 1.5, not 2.0. QMD 2.0 was the SDK/library refactor.
Updated all comments, test names, and documentation to reflect this.

* fix: respect searchMode when building v2 mcporter queries

When searchMode is 'search' (BM25), only send lex sub-query.
When 'vsearch', only send vec. Default 'query' sends all three
(lex + vec + hyde) for full hybrid search with reranking.

Previously all three sub-queries were always sent regardless of
the configured searchMode, which could trigger unnecessary vector
embedding and HyDE LLM work on setups explicitly requesting
lexical-only search.

Addresses Codex P2 review feedback.

* docs: correct to QMD 1.1.0 — that's the actual version that removed the tools

Per CHANGELOG.md, MCP tools search/vector_search/deep_search were removed
in QMD 1.1.0 (2026-02-20), not 1.5 (which doesn't exist). Versions go
1.0.7 → 1.1.0 → 1.1.1 → 1.1.2 → 1.1.5 → 1.1.6 → 2.0.0.

* fix: remove redundant v1 guard (race condition) + tighten error matching

1. Remove qmdMcpToolVersion !== 'v1' guard from catch block. It's
   redundant (effectiveTool === 'query' already prevents infinite retry)
   and introduces a race condition: concurrent searches that both probe
   with 'query' while version is null would fail after the first sets
   version to 'v1'.

2. Tighten isToolNotFoundError regex to require 'Tool' near 'not found'
   (within 40 chars, no sentence boundary). Prevents false-positives
   when user query text in the mcporter args contains both words.

Addresses Greptile P1 (concurrent-search race) and Codex P2
(overly broad error matching).

* fix: address claude code review — type safety, minScore docs, explicit switch

- Restore type union for tool param instead of bare string
- Add comment explaining minScore omission for v2 (QMD 1.1+ uses
  its own reranking pipeline, no minScore parameter)
- Make buildV2Searches switch exhaustive with explicit case 'query'

* fix: resolve CI failures — oxfmt formatting + TypeScript type errors

- Run oxfmt to fix formatting issues
- Add union return type to resolveQmdMcpTool() and
  runMcporterAcrossCollections() tool param to satisfy tsc
  (string was not assignable to the union type)

* fix(memory): align qmd query collection filters

* fix(memory): narrow qmd missing-tool fallback detection

* fix(memory): ignore qmd timeout text for v1 fallback

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 08:07:14 +09:00
Peter Steinberger
0fdf724125 test: trim stale extension boundary allowlist 2026-03-30 08:05:59 +09:00
Peter Steinberger
809833ef9d fix(config): recover clobbered config and isolate test paths 2026-03-30 00:05:36 +01:00
Peter Steinberger
d45b997ba9 docs: clarify shared approval delivery 2026-03-30 08:03:59 +09:00
Peter Steinberger
52fb4a149a refactor: share approval interactive renderers 2026-03-30 08:03:59 +09:00
Peter Steinberger
cfac0e8698 refactor: move plugin-owned test support into plugins 2026-03-30 08:03:04 +09:00
Vincent Koc
1ace91ee00 fix(bluebubbles): coalesce URL-only inbound shares 2026-03-30 08:00:25 +09:00
Cypher
924c264a74 fix: inject anthropic service_tier for OAuth auth (#55922)
* fix: inject anthropic service_tier for OAuth auth

Remove the OAuth-token exclusion from createAnthropicFastModeWrapper
so that sk-ant-oat-* requests receive service_tier injection, matching
Claude Code CLI behavior and reducing avoidable 529 overload cascades.

isAnthropicOAuthApiKey remains in use in createAnthropicBetaHeadersWrapper
for beta header selection — it is not dead code after this change.

Fixes #55758

* docs(changelog): note anthropic oauth service tier fix

* Update CHANGELOG.md

---------

Co-authored-by: Cypherm <28184436+Cypherm@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-30 07:54:24 +09:00
Gustavo Madeira Santana
e5dac0c39e CLI: keep root help plugin descriptors non-activating (#57294)
Merged via squash.

Prepared head SHA: c8da48f689
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 18:49:57 -04:00
Peter Steinberger
1efef8205c fix: stabilize extensions surface test gate 2026-03-30 07:47:58 +09:00
Peter Steinberger
07c6981c70 refactor: localize zalo test support 2026-03-30 07:47:28 +09:00
Vincent Koc
ed2528e6fb docs: nest memory engine pages under Memory sub-group 2026-03-30 07:45:20 +09:00
Vincent Koc
92d4c62d59 style: format Honcho docs tables 2026-03-30 07:44:35 +09:00
Vincent Koc
70b7f32c7e docs: add Honcho memory engine page 2026-03-30 07:44:35 +09:00
Peter Steinberger
8861cdbb6f refactor(plugin-sdk): untangle extension test seams 2026-03-29 23:43:53 +01:00
Peter Steinberger
c942bd798f docs: purge internal notes 2026-03-29 23:38:25 +01:00
Peter Steinberger
a9984e2bf9 fix(config): reuse in-memory gateway write reloads 2026-03-29 23:38:25 +01:00
Vincent Koc
0e47ce58bc fix(approvals): restore queue targeting and plugin id prefixes 2026-03-30 07:37:50 +09:00
Peter Steinberger
7043705ef3 refactor: split MCP runtime and transport seams 2026-03-29 23:36:37 +01:00
Peter Steinberger
69eea2cb80 refactor: split approval auth delivery and rendering 2026-03-30 07:36:18 +09:00
Vincent Koc
c9eb31382e docs: add dedicated memory engine pages, replace ASCII diagram with Mermaid 2026-03-30 07:34:14 +09:00
Vincent Koc
67b381b928 style: format docs tables 2026-03-30 07:32:20 +09:00
Vincent Koc
143b4c54ba docs: simplify sessions/memory concept pages and fix QMD experimental label 2026-03-30 07:32:20 +09:00
Onur Solmaz
57069f2b2f gitignore: ignore docs/internal 2026-03-30 00:26:13 +02:00
Peter Steinberger
147c2c7389 fix: port safer bundle MCP naming onto latest main (#49505) (thanks @ziomancer) 2026-03-30 07:22:36 +09:00
Peter Steinberger
004bffa1c3 fix: finalize safer MCP tool naming (#49505) (thanks @ziomancer) 2026-03-30 07:22:36 +09:00
ziomancer
97bf38099a docs(mcp): document connection timeout and URL scheme validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 07:22:36 +09:00
ziomancer
a74091eb98 docs(mcp): add CHANGELOG entries and MCP transport/namespacing docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 07:22:36 +09:00
ziomancer
14f78debd5 fix(mcp): allow colon in tool names and dispose transport on failed connect
The transcript validator regex rejected namespaced MCP tool names
(e.g., vigil-harbor:memory_status) because colon wasn't in the
allowed character set. Tool call blocks were silently dropped during
session repair, breaking tool-call/result pairing.

Also closes a resource leak: if client.connect() throws after the
transport is instantiated, the transport is now explicitly closed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 07:22:36 +09:00
ziomancer
414d7306d1 feat(mcp): add HTTP transport support and tool namespacing
MCP tools are now prefixed with their server name (e.g., vigil-harbor:memory_status)
to prevent collisions between tools from different MCP servers and built-in tools.

Adds SSE and StreamableHTTP transport support alongside existing stdio, enabling
connection to remote MCP servers via URL-based config with optional custom headers
and env var substitution. Includes config validation, session lifecycle management,
and 5 new tests for HTTP config edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 07:22:36 +09:00
Vincent Koc
102b7126c1 style: format session docs tables 2026-03-30 07:18:58 +09:00
Vincent Koc
2880b3d3ff docs: rewrite session management, session pruning, and session tools pages 2026-03-30 07:18:58 +09:00
Peter Steinberger
f8dc4305a5 refactor: share native approval delivery helpers 2026-03-30 07:16:33 +09:00
Mariano
5ef42fc856 Gateway: surface blocked ACP task outcomes (#57203) 2026-03-30 00:15:51 +02:00
Onur Solmaz
2da61e6553 [codex] Move internal development notes to maintainers (#57316)
* docs: move internal notes to maintainers

* docs: drop internal notes agent guidance
2026-03-30 00:15:08 +02:00
Peter Steinberger
d82d6ba0c4 fix: align openai fast mode with priority processing 2026-03-29 23:14:52 +01:00
Peter Steinberger
27519cf061 refactor: centralize node identity resolution 2026-03-29 23:14:22 +01:00
Vincent Koc
ace876b087 fix(gateway): keep startup model warmup static 2026-03-30 07:12:52 +09:00
Vincent Koc
bd89e07baa fix(agents): stop transient live-switch mismatches 2026-03-30 07:12:52 +09:00
Peter Steinberger
e01ca8cfc6 refactor(plugin-sdk): remove direct extension source leaks 2026-03-29 23:11:20 +01:00
Vincent Koc
40446ea27c docs: add cross-links to new memory-search page from reference docs 2026-03-30 07:10:33 +09:00
Vincent Koc
3584a893e8 docs: rewrite sessions/memory section -- compaction, memory, and new memory-search page 2026-03-30 07:10:01 +09:00
Peter Steinberger
6d9a7224aa refactor: unify approval command authorization 2026-03-30 07:06:29 +09:00
Peter Steinberger
6ca81f8ec7 test(gateway): decouple send coverage from telegram specifics 2026-03-29 22:59:32 +01:00
Peter Steinberger
168ab94eee refactor(config): pin runtime snapshot and drop ttl cache 2026-03-29 22:57:31 +01:00
Vincent Koc
22ffe7b1de docs: fix before_install finding field name, add MCP SSE transport docs, add acpx auto-install note 2026-03-30 06:53:35 +09:00
Vincent Koc
6d0abfa50c docs(exec): document denied approval output isolation behavior 2026-03-30 06:53:35 +09:00
Peter Steinberger
3ec000b995 refactor: align same-chat approval routing 2026-03-30 06:52:28 +09:00
Peter Steinberger
f16c176a4c fix: disambiguate legacy mac node identities 2026-03-29 22:47:15 +01:00
Peter Steinberger
2e0682d930 refactor: finish decoupling plugin sdk seams 2026-03-29 22:42:06 +01:00
Peter Steinberger
574d3c5213 fix: make same-chat approvals work across channels 2026-03-30 06:35:04 +09:00
Peter Steinberger
1ca01b738b fix: stabilize exec approval approver routing 2026-03-30 06:25:03 +09:00
Peter Steinberger
216afe275e test: harden packed manifest guardrails 2026-03-29 22:21:29 +01:00
Peter Steinberger
2f19b303c6 build: fix packaged plugin dependency ownership 2026-03-29 22:21:29 +01:00
Peter Steinberger
356adc98d5 test: align schema and redaction assertions 2026-03-29 22:21:29 +01:00
Peter Steinberger
b4badd7704 build: resolve plugin package contract path 2026-03-29 22:21:29 +01:00
Peter Steinberger
e45cc3890b refactor: unify sensitive URL config hints 2026-03-29 22:21:29 +01:00
Peter Steinberger
1318479a2c refactor: extract MCP transport helpers 2026-03-29 22:21:29 +01:00
Peter Steinberger
4635810385 fix: stabilize unit test planner scheduling 2026-03-30 06:17:06 +09:00
Peter Steinberger
9baa853797 test: isolate image read helper coverage 2026-03-29 22:10:37 +01:00
Peter Steinberger
421acd27e1 style: finalize exec formatter cleanup 2026-03-30 06:03:08 +09:00
Peter Steinberger
ddd2cbf03a style: resolve exec formatter follow-up 2026-03-30 06:03:08 +09:00
Peter Steinberger
bb10f60993 style: resolve exec formatter follow-up 2026-03-30 06:03:08 +09:00
Peter Steinberger
276ccd2583 fix(exec): default implicit target to auto 2026-03-30 06:03:08 +09:00
Peter Steinberger
d014f173f1 test: trim stale legacy coverage and repair mocks 2026-03-29 22:00:56 +01:00
Peter Steinberger
63e5c3349e refactor(config): drop obsolete legacy config aliases 2026-03-29 22:00:56 +01:00
Peter Steinberger
d9274444b7 fix: keep beta on newer prereleases 2026-03-30 05:37:01 +09:00
Peter Steinberger
aed87a608e fix: stabilize bundled plugin ci lanes 2026-03-30 05:35:53 +09:00
Peter Steinberger
694bc082a8 fix: resolve acpx MCP secret inputs 2026-03-30 05:30:32 +09:00
Peter Steinberger
35233bae96 refactor: decouple bundled plugin sdk surfaces 2026-03-29 21:20:46 +01:00
Peter Steinberger
5d4c4bb850 fix(exec): restore runtime-aware implicit host default 2026-03-29 21:18:41 +01:00
Onur Solmaz
96df794c12 [codex] Move internal experiment plans under docs-internal (#57262)
* docs: restore internal plan docs under docs-internal

* docs: move internal plans under docs/internal

* docs: restore deleted internal experiment notes

* docs: group internal notes by author

* docs: move Bob internal notes under osolmaz

* docs: move remaining internal notes under osolmaz

* Revert "docs: move remaining internal notes under osolmaz"

* docs: restore experiment architecture note

* docs: normalize osolmaz internal doc authorship

* docs: add identity metadata to internal docs

* docs: document internal development notes

* docs: use Peter in internal docs examples

* docs: move Mariano background task note to internal docs

* Revert "docs: use Peter in internal docs examples"

* docs: use real Peter note in internal docs readme

* docs: rename Mariano background task note

* docs: preserve others internal notes by default

* docs: allow BDFL override for internal notes
2026-03-29 22:18:26 +02:00
Peter Steinberger
f5f8ba6d35 fix: harden bundle MCP session runtime cache (#55090) (thanks @allan0509) 2026-03-30 05:10:32 +09:00
无忌
6477d783e8 Agents: cache bundle MCP runtime per session 2026-03-30 05:10:32 +09:00
Peter Steinberger
73477eee4c fix: harden ACP plugin tools bridge (#56867) (thanks @joe2643) 2026-03-30 05:09:59 +09:00
khhjoe
e24091413c fix: add curly braces for oxlint curly rule; copy postinstall script before pnpm install in Dockerfile 2026-03-30 05:09:59 +09:00
khhjoe
a8c189f463 fix(mcp): serialize result.content instead of wrapper object; warn on missing static assets 2026-03-30 05:09:59 +09:00
khhjoe
3151eb5b48 feat: auto-install acpx deps via npm postinstall on global install 2026-03-30 05:09:59 +09:00
khhjoe
5f628c0bf8 build: copy acpx mcp-proxy.mjs to dist in runtime-postbuild 2026-03-30 05:09:59 +09:00
khhjoe
415899984e feat(mcp): add plugin tools MCP server for ACP sessions
Standalone MCP server that exposes OpenClaw plugin-registered tools
(e.g. memory-lancedb's memory_recall, memory_store, memory_forget)
to ACP sessions running Claude Code via acpx's MCP proxy mechanism.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 05:09:59 +09:00
Peter Steinberger
3f5ed11266 fix: clear stalled model resolution lanes 2026-03-30 05:09:26 +09:00
Gustavo Madeira Santana
9b4f26e70a Plugins/CLI: add descriptor-backed lazy root command registration (#57165)
Merged via squash.

Prepared head SHA: ad1dee32eb
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 16:02:59 -04:00
Peter Steinberger
d330782ed1 fix(matrix): stop discovering runtime helper as plugin entry 2026-03-29 21:00:57 +01:00
Gustavo Madeira Santana
dc192d7b2f Build: mirror Matrix crypto WASM runtime deps (#57163)
Merged via squash.

Prepared head SHA: b3aeb9d08a
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-29 15:57:28 -04:00
Peter Steinberger
82f04ced27 refactor(plugin-sdk): drop legacy provider compat subpaths 2026-03-29 20:55:53 +01:00
Peter Steinberger
cce6d3bbb7 fix: resolve CI type and lint regressions 2026-03-30 04:51:33 +09:00
Peter Steinberger
855878b4f0 fix: stabilize serial test suite 2026-03-30 04:46:04 +09:00
George Zhang
b787669340 docs(plugins): add before_install payload example 2026-03-29 12:35:01 -07:00
George Zhang
2607191d04 refactor(plugins): centralize before_install context shaping 2026-03-29 12:35:01 -07:00
George Zhang
9a07fd83fb docs(plugins): describe before_install policy foundation 2026-03-29 12:35:01 -07:00
George Zhang
b5d48d311c test(plugins): cover before_install policy metadata 2026-03-29 12:35:01 -07:00
George Zhang
150faba8d1 plugins: enrich before_install policy context 2026-03-29 12:35:01 -07:00
George Zhang
9ea0b76f06 docs(plugins): document before_install hook 2026-03-29 12:35:01 -07:00
George Zhang
ac3951d731 test(plugins): cover before_install install flows 2026-03-29 12:35:01 -07:00
George Zhang
7cd9957f62 plugins: add before_install hook for install scanners 2026-03-29 12:35:01 -07:00
Robin Waslander
77555d6c85 fix(infra): classify SQLite transient errors as non-fatal in unhandled rejection handler (#57018)
Add isTransientSqliteError() covering SQLITE_CANTOPEN, SQLITE_BUSY,
SQLITE_LOCKED, and SQLITE_IOERR via named codes, numeric errcodes
(node:sqlite), and message-string fallback. Combine with existing
network transient check so both families are treated as non-fatal
in the global unhandled rejection handler.

Prevents crash loop under launchd on macOS when SQLite files are
temporarily unavailable.

Fixes #34678
2026-03-29 21:29:38 +02:00
Peter Steinberger
bfb0907777 fix: harden MCP SSE config redaction (#50396) (thanks @dhananjai1729) 2026-03-30 04:23:47 +09:00
dhananjai1729
2c6eb127d9 fix: redact sensitive query params in invalid URL error reasons
Extends the invalid-URL redaction to also scrub sensitive query parameters
(token, api_key, secret, access_token, etc.) using the same param list as
the valid-URL description path. Adds tests for both query param and
credential redaction in error reasons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 04:23:47 +09:00
dhananjai1729
4e03d899b3 fix: handle Headers instances in SSE fetch and redact invalid URLs
- Properly convert Headers instances to plain objects in eventSourceInit.fetch
  so SDK-generated headers (e.g. Accept: text/event-stream) are preserved
  while user-configured headers still take precedence.
- Redact potential credentials from invalid URLs in error reasons to prevent
  secret leakage in log output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 04:23:47 +09:00
dhananjai1729
62d0e12155 fix(mcp): user headers override SDK defaults & expand redaction list
Address Greptile P1/P2 review feedback:
- Fix header spread order so user-configured auth headers take precedence
  over SDK-internal headers in SSE eventSourceInit.fetch
- Add password, pass, auth, client_secret, refresh_token to the
  sensitive query-param redaction set in describeSseMcpServerLaunchConfig
- Add tests for redaction of all sensitive params and embedded credentials

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 04:23:47 +09:00
dhananjai1729
32b7c00f90 fix: apply SSE auth headers to initial GET, redact URL credentials, warn on malformed headers 2026-03-30 04:23:47 +09:00
dhananjai1729
6fda8b4e9a fix: use SDK Transport type to satisfy client.connect() signature 2026-03-30 04:23:47 +09:00
dhananjai1729
bf8303370e fix: address review feedback - fix env JSDoc, warn on dropped headers, await server close 2026-03-30 04:23:47 +09:00
dhananjai1729
d89bfed5cc feat(mcp): add SSE transport support for remote MCP servers 2026-03-30 04:23:47 +09:00
Peter Steinberger
fc5fdcb091 refactor(plugin-sdk): remove bundled provider setup shims 2026-03-29 20:23:20 +01:00
George Zhang
e133924047 [codex] harden clawhub plugin publishing and install (#56870)
* fix: harden clawhub plugin publishing and install

* fix(process): preserve windows shim exit success
2026-03-29 11:59:19 -07:00
Peter Steinberger
58dde4b016 test(contracts): hoist shared plugin mock ids 2026-03-29 19:54:27 +01:00
Marcus Castro
34648235a3 WhatsApp: use shared resolveReactionMessageId for context-aware reactions (#57226)
Wire the shared resolveReactionMessageId helper into the WhatsApp
channel adapter, matching the pattern already used by Telegram, Signal,
and Discord. The model can now react to the current inbound message
without explicitly providing a messageId.

Safety guards:
- Only falls back to context when the source is WhatsApp
- Suppresses fallback when targeting a different chat (normalized comparison)
- Throws ToolInputError (400) instead of plain Error (500) when messageId
  is missing, preserving gateway error mapping
2026-03-29 15:42:19 -03:00
Nimrod Gutman
f38b7291f9 fix(ios): mark activitykit import as preconcurrency (#57180)
* fix(ios): mark activitykit import as preconcurrency

* fix: note iOS ActivityKit preconcurrency build fix (#57180) (thanks @ngutman)
2026-03-29 21:24:25 +03:00
Peter Steinberger
5801506ce7 test(discord): fix hoisted configured-binding mocks 2026-03-29 19:17:08 +01:00
4347 changed files with 363423 additions and 108365 deletions

View File

@@ -1,380 +0,0 @@
---
description: Update OpenClaw from upstream when branch has diverged (ahead/behind)
---
# OpenClaw Upstream Sync Workflow
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
## Quick Reference
```bash
# Check divergence status
git fetch upstream && git rev-list --left-right --count main...upstream/main
# Full sync (rebase preferred)
git fetch upstream && git rebase upstream/main && pnpm install && pnpm build && ./scripts/restart-mac.sh
# Check for Swift 6.2 issues after sync
grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift"
```
---
## Step 1: Assess Divergence
```bash
git fetch upstream
git log --oneline --left-right main...upstream/main | head -20
```
This shows:
- `<` = your local commits (ahead)
- `>` = upstream commits you're missing (behind)
**Decision point:**
- Few local commits, many upstream → **Rebase** (cleaner history)
- Many local commits or shared branch → **Merge** (preserves history)
---
## Step 2A: Rebase Strategy (Preferred)
Replays your commits on top of upstream. Results in linear history.
```bash
# Ensure working tree is clean
git status
# Rebase onto upstream
git rebase upstream/main
```
### Handling Rebase Conflicts
```bash
# When conflicts occur:
# 1. Fix conflicts in the listed files
# 2. Stage resolved files
git add <resolved-files>
# 3. Continue rebase
git rebase --continue
# If a commit is no longer needed (already in upstream):
git rebase --skip
# To abort and return to original state:
git rebase --abort
```
### Common Conflict Patterns
| File | Resolution |
| ---------------- | ------------------------------------------------ |
| `package.json` | Take upstream deps, keep local scripts if needed |
| `pnpm-lock.yaml` | Accept upstream, regenerate with `pnpm install` |
| `*.patch` files | Usually take upstream version |
| Source files | Merge logic carefully, prefer upstream structure |
---
## Step 2B: Merge Strategy (Alternative)
Preserves all history with a merge commit.
```bash
git merge upstream/main --no-edit
```
Resolve conflicts same as rebase, then:
```bash
git add <resolved-files>
git commit
```
---
## Step 3: Rebuild Everything
After sync completes:
```bash
# Install dependencies (regenerates lock if needed)
pnpm install
# Build TypeScript
pnpm build
# Build UI assets
pnpm ui:build
# Run diagnostics
pnpm clawdbot doctor
```
---
## Step 4: Rebuild macOS App
```bash
# Full rebuild, sign, and launch
./scripts/restart-mac.sh
# Or just package without restart
pnpm mac:package
```
### Install to /Applications
```bash
# Kill running app
pkill -x "OpenClaw" || true
# Move old version
mv /Applications/OpenClaw.app /tmp/OpenClaw-backup.app
# Install new build
cp -R dist/OpenClaw.app /Applications/
# Launch
open /Applications/OpenClaw.app
```
---
## Step 4A: Verify macOS App & Agent
After rebuilding the macOS app, always verify it works correctly:
```bash
# Check gateway health
pnpm clawdbot health
# Verify no zombie processes
ps aux | grep -E "(clawdbot|gateway)" | grep -v grep
# Test agent functionality by sending a verification message
pnpm clawdbot agent --message "Verification: macOS app rebuild successful - agent is responding." --session-id YOUR_TELEGRAM_SESSION_ID
# Confirm the message was received on Telegram
# (Check your Telegram chat with the bot)
```
**Important:** Always wait for the Telegram verification message before proceeding. If the agent doesn't respond, troubleshoot the gateway or model configuration before pushing.
---
## Step 5: Handle Swift/macOS Build Issues (Common After Upstream Sync)
Upstream updates may introduce Swift 6.2 / macOS 26 SDK incompatibilities. Use analyze-mode for systematic debugging:
### Analyze-Mode Investigation
```bash
# Gather context with parallel agents
morph-mcp_warpgrep_codebase_search search_string="Find deprecated FileManager.default and Thread.isMainThread usages in Swift files" repo_path="/Volumes/Main SSD/Developer/clawdis"
morph-mcp_warpgrep_codebase_search search_string="Locate Peekaboo submodule and macOS app Swift files with concurrency issues" repo_path="/Volumes/Main SSD/Developer/clawdis"
```
### Common Swift 6.2 Fixes
**FileManager.default Deprecation:**
```bash
# Search for deprecated usage
grep -r "FileManager\.default" src/ apps/ --include="*.swift"
# Replace with proper initialization
# OLD: FileManager.default
# NEW: FileManager()
```
**Thread.isMainThread Deprecation:**
```bash
# Search for deprecated usage
grep -r "Thread\.isMainThread" src/ apps/ --include="*.swift"
# Replace with modern concurrency check
# OLD: Thread.isMainThread
# NEW: await MainActor.run { ... } or DispatchQueue.main.sync { ... }
```
### Peekaboo Submodule Fixes
```bash
# Check Peekaboo for concurrency issues
cd src/canvas-host/a2ui
grep -r "Thread\.isMainThread\|FileManager\.default" . --include="*.swift"
# Fix and rebuild submodule
cd /Volumes/Main SSD/Developer/clawdis
pnpm canvas:a2ui:bundle
```
### macOS App Concurrency Fixes
```bash
# Check macOS app for issues
grep -r "Thread\.isMainThread\|FileManager\.default" apps/macos/ --include="*.swift"
# Clean and rebuild after fixes
cd apps/macos && rm -rf .build .swiftpm
./scripts/restart-mac.sh
```
### Model Configuration Updates
If upstream introduced new model configurations:
```bash
# Check for OpenRouter API key requirements
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
# Update openclaw.json with fallback chains
# Add model fallback configurations as needed
```
---
## Step 6: Verify & Push
```bash
# Verify everything works
pnpm clawdbot health
pnpm test
# Push (force required after rebase)
git push origin main --force-with-lease
# Or regular push after merge
git push origin main
```
---
## Troubleshooting
### Build Fails After Sync
```bash
# Clean and rebuild
rm -rf node_modules dist
pnpm install
pnpm build
```
### Type Errors (Bun/Node Incompatibility)
Common issue: `fetch.preconnect` type mismatch. Fix by using `FetchLike` type instead of `typeof fetch`.
### macOS App Crashes on Launch
Usually resource bundle mismatch. Full rebuild required:
```bash
cd apps/macos && rm -rf .build .swiftpm
./scripts/restart-mac.sh
```
### Patch Failures
```bash
# Check patch status
pnpm install 2>&1 | grep -i patch
# If patches fail, they may need updating for new dep versions
# Check patches/ directory against package.json patchedDependencies
```
### Swift 6.2 / macOS 26 SDK Build Failures
**Symptoms:** Build fails with deprecation warnings about `FileManager.default` or `Thread.isMainThread`
**Search-Mode Investigation:**
```bash
# Exhaustive search for deprecated APIs
morph-mcp_warpgrep_codebase_search search_string="Find all Swift files using deprecated FileManager.default or Thread.isMainThread" repo_path="/Volumes/Main SSD/Developer/clawdis"
```
**Quick Fix Commands:**
```bash
# Find all affected files
find . -name "*.swift" -exec grep -l "FileManager\.default\|Thread\.isMainThread" {} \;
# Replace FileManager.default with FileManager()
find . -name "*.swift" -exec sed -i '' 's/FileManager\.default/FileManager()/g' {} \;
# For Thread.isMainThread, need manual review of each usage
grep -rn "Thread\.isMainThread" --include="*.swift" .
```
**Rebuild After Fixes:**
```bash
# Clean all build artifacts
rm -rf apps/macos/.build apps/macos/.swiftpm
rm -rf src/canvas-host/a2ui/.build
# Rebuild Peekaboo bundle
pnpm canvas:a2ui:bundle
# Full macOS rebuild
./scripts/restart-mac.sh
```
---
## Automation Script
Save as `scripts/sync-upstream.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
echo "==> Fetching upstream..."
git fetch upstream
echo "==> Current divergence:"
git rev-list --left-right --count main...upstream/main
echo "==> Rebasing onto upstream/main..."
git rebase upstream/main
echo "==> Installing dependencies..."
pnpm install
echo "==> Building..."
pnpm build
pnpm ui:build
echo "==> Running doctor..."
pnpm clawdbot doctor
echo "==> Rebuilding macOS app..."
./scripts/restart-mac.sh
echo "==> Verifying gateway health..."
pnpm clawdbot health
echo "==> Checking for Swift 6.2 compatibility issues..."
if grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" --quiet; then
echo "⚠️ Found potential Swift 6.2 deprecated API usage"
echo " Run manual fixes or use analyze-mode investigation"
else
echo "✅ No obvious Swift deprecation issues found"
fi
echo "==> Testing agent functionality..."
# Note: Update YOUR_TELEGRAM_SESSION_ID with actual session ID
pnpm clawdbot agent --message "Verification: Upstream sync and macOS rebuild completed successfully." --session-id YOUR_TELEGRAM_SESSION_ID || echo "Warning: Agent test failed - check Telegram for verification message"
echo "==> Done! Check Telegram for verification message, then run 'git push --force-with-lease' when ready."
```

View File

@@ -16,6 +16,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Pass `--json` for machine-readable summaries.
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
- If `main` is moving under active multi-agent work, prefer a detached worktree pinned to one commit for long Parallels suites. The smoke scripts now verify the packed tgz commit instead of live `git rev-parse HEAD`, but a pinned worktree still avoids noisy rebuild/version drift during reruns.
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.
- If the workflow installs OpenClaw from a repo checkout instead of the site installer/npm release, finish by installing a real guest CLI shim and verifying it in a fresh guest shell. `pnpm openclaw ...` inside the repo is not enough for handoff parity.
- On macOS guests, prefer a user-global install plus a stable PATH-visible shim:
@@ -28,10 +29,13 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Preferred entrypoint: `pnpm test:parallels:npm-update`
- Flow: fresh snapshot -> install npm package baseline -> smoke -> install current main tgz on the same guest -> smoke again.
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. On Peter's current host, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. Treat any Ubuntu guest with major version `>= 24` as acceptable when the exact default VM is missing, preferring the closest version match. On Peter's current host today, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
- For Windows same-guest update checks, prefer the done-file/log-drain PowerShell runner pattern over one long-lived `prlctl exec ... powershell -EncodedCommand ...` transport. The guest can finish successfully while the outer `prlctl exec` still hangs.
- The Windows same-guest update helper should write stage markers to its log before long steps like tgz download and `npm install -g` so the outer progress monitor does not sit on `waiting for first log line` during healthy but quiet installs.
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
- The npm-update wrapper now prints per-lane progress from the nested log files. If a lane still looks stuck, inspect the nested logs in `runDir` first (`macos-fresh.log`, `windows-fresh.log`, `linux-fresh.log`, `macos-update.log`, `windows-update.log`, `linux-update.log`) instead of assuming the outer wrapper hung.
- If the wrapper fails a lane, read the auto-dumped tail first, then the full nested lane log under `/tmp/openclaw-parallels-npm-update.*`.
## CLI invocation footgun
@@ -43,6 +47,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Default to the snapshot closest to `macOS 26.3.1 latest`.
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
- If a packaged install regresses with `500` on `/`, `/healthz`, or `__openclaw/control-ui-config.json` after `fresh.install-main` or `upgrade.install-main`, suspect bundled plugin runtime deps resolving from the package root `node_modules` rather than `dist/extensions/*/node_modules`. Repro quickly with a real `npm pack`/global install lane before blaming dashboard auth or Safari.
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.
- When ref-mode onboarding stores `OPENAI_API_KEY` as an env secret ref, the post-onboard agent verification should also export `OPENAI_API_KEY` for the guest command. The gateway can still reject with pairing-required and fall back to embedded execution, and that fallback needs the env-backed credential available in the shell.
@@ -62,7 +67,8 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Windows global `npm install -g` phases can stay quiet for a minute or more even when healthy; inspect the phase log before calling it hung, and only treat it as a regression once the retry wrapper or timeout trips.
- Fresh Windows ref-mode onboard should use the same background PowerShell runner plus done-file/log-drain pattern as the npm-update helper, including startup materialization checks, host-side timeouts on short poll `prlctl exec` calls, and retry-on-poll-failure behavior for transient transport flakes.
- Fresh Windows ref-mode agent verification should set `OPENAI_API_KEY` in the PowerShell environment before invoking `openclaw.cmd agent`, for the same pairing-required fallback reason as macOS.
- The Windows upgrade smoke lane should restart the managed gateway after `upgrade.install-main` and before `upgrade.onboard-ref`, or the old process can keep the previous gateway token and fail `gateway-health` with `unauthorized: gateway token mismatch`.
- The standalone Windows upgrade smoke lane should stop the managed gateway after `upgrade.install-main` and before `upgrade.onboard-ref`. Restarting before onboard can leave the old process alive on the pre-onboard token while onboard rewrites `~/.openclaw/openclaw.json`, which then fails `gateway-health` with `unauthorized: gateway token mismatch`.
- If standalone Windows upgrade fails with a gateway token mismatch but `pnpm test:parallels:npm-update` passes, trust the mismatch as a standalone ref-onboard ordering bug first; the npm-update helper does not re-run ref-mode onboard on the same guest.
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
- If you hit an older run with `rc=255` plus an empty `fresh.install-main.log` or `upgrade.install-main.log`, treat it as a likely `prlctl exec` transport drop after guest start-up, not immediate proof of an npm/package failure.
@@ -70,13 +76,13 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Preferred entrypoint: `pnpm test:parallels:linux`
- Use the snapshot closest to fresh `Ubuntu 24.04.3 ARM64`.
- If that exact VM is missing on the host, fall back to the closest Ubuntu guest with a fresh poweroff snapshot. On Peter's host today, that is `Ubuntu 25.10`.
- If that exact VM is missing on the host, any Ubuntu guest with major version `>= 24` is acceptable; prefer the closest versioned Ubuntu guest with a fresh poweroff snapshot. On Peter's host today, that is `Ubuntu 25.10`.
- Use plain `prlctl exec`; `--current-user` is not the right transport on this snapshot.
- Fresh snapshots may be missing `curl`, and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates`.
- Fresh `main` tgz smoke still needs the latest-release installer first because the snapshot has no Node or npm before bootstrap.
- This snapshot does not have a usable `systemd --user` session; managed daemon install is unsupported.
- `prlctl exec` reaps detached Linux child processes on this snapshot, so detached background gateway runs are not trustworthy smoke signals.
- Treat `gateway=skipped-no-detached-linux-gateway` plus `daemon=systemd-user-unavailable` as baseline on that Linux lane, not a regression.
- The Linux smoke now falls back to a manual `setsid openclaw gateway run --bind loopback --port 18789 --force` launch with `HOME=/root` and the provider secret exported, then verifies `gateway status --deep --require-rpc` when available.
- If Linux gateway bring-up fails, inspect `/tmp/openclaw-parallels-linux-gateway.log` in the guest phase logs first; the common failure mode is a missing provider secret in the launched gateway environment.
## Discord roundtrip

View File

@@ -17,7 +17,7 @@ Use this skill for release and publish-time workflow. Keep ordinary development
## Keep release channel naming aligned
- `stable`: tagged releases only, with npm dist-tag `latest`
- `stable`: tagged releases only, published to npm `beta` by default; operators may target npm `latest` explicitly or promote later
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
- `dev`: moving head on `main`
@@ -64,7 +64,8 @@ Use this skill for release and publish-time workflow. Keep ordinary development
Before tagging or publishing, run:
```bash
node --import tsx scripts/release-check.ts
pnpm build
pnpm ui:build
pnpm release:check
pnpm test:install:smoke
```
@@ -92,7 +93,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Default release checks:
- `pnpm check`
- `pnpm build`
- `node --import tsx scripts/release-check.ts`
- `pnpm ui:build`
- `pnpm release:check`
- `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`
- Check all release-related build surfaces touched by the release, not only the npm package.
@@ -115,10 +116,19 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
## Use the right auth flow
- OpenClaw publish uses GitHub trusted publishing.
- Stable npm promotion from `beta` to `latest` is an explicit mode on
`.github/workflows/openclaw-npm-release.yml`, but it still needs a valid
`NPM_TOKEN` because `npm dist-tag` management is separate from trusted
publishing.
- The publish run must be started manually with `workflow_dispatch`.
- The npm workflow and the private mac publish workflow accept
`preflight_only=true` to run validation/build/package steps without uploading
public release assets.
- Real npm publish requires a prior successful npm preflight run id so the
publish job promotes the prepared tarball instead of rebuilding it.
- Real private mac publish requires a prior successful private mac preflight
run id so the publish job promotes the prepared artifacts instead of
rebuilding or renotarizing them again.
- The private mac workflow also accepts `smoke_test_only=true` for branch-safe
workflow smoke tests that use ad-hoc signing, skip notarization, skip shared
appcast generation, and do not prove release readiness.
@@ -129,17 +139,23 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
workflow change before merge.
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
public validation-only handoff. It validates the tag/release state and points
operators to the private repo; it does not build or publish macOS artifacts.
operators to the private repo. It still rebuilds the JS outputs needed for
release validation, but it does not sign, notarize, or publish macOS
artifacts.
- `openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
is the required private mac validation lane for `swift test`; keep it green
before any real mac publish run starts.
- Real mac preflight and real mac publish both use
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`.
- The private mac workflow runs on GitHub's xlarge macOS runner and uses a
SwiftPM cache because the Swift build/test/package path is CPU-heavy.
- The private mac validation lane runs on GitHub's standard macOS runner.
- The private mac preflight path runs on GitHub's xlarge macOS runner and uses
a SwiftPM cache because the build/sign/notarize/package path is CPU-heavy.
- Private mac preflight uploads notarized build artifacts as workflow artifacts
instead of uploading public GitHub release assets.
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
workflow artifacts and intentionally skip stable `appcast.xml` generation.
- npm preflight, public mac validation, and private mac preflight must all pass
before any real publish run starts.
- npm preflight, public mac validation, private mac validation, and private mac
preflight must all pass before any real publish run starts.
- Real publish runs must be dispatched from `main`; branch-dispatched publish
attempts should fail before the protected environment is reached.
- The release workflows stay tag-based; rely on the documented release sequence
@@ -147,8 +163,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
- Mac publish uses
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for
build, signing, notarization, packaged mac artifact generation, and
stable-feed `appcast.xml` artifact generation.
private mac preflight artifact preparation and real publish artifact
promotion.
- Real private mac publish uploads the packaged `.zip`, `.dmg`, and
`.dSYM.zip` assets to the existing GitHub release in `openclaw/openclaw`
automatically when `OPENCLAW_PUBLIC_REPO_RELEASE_TOKEN` is present in the
@@ -206,31 +222,45 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
7. Create and push the git tag.
8. Create or refresh the matching GitHub release.
9. Start `.github/workflows/openclaw-npm-release.yml` with `preflight_only=true`
and wait for it to pass.
and choose the intended `npm_dist_tag` (`beta` default; `latest` only for
an intentional direct stable publish). Wait for it to pass. Save that run id
because the real publish requires it to reuse the prepared npm tarball.
10. Start `.github/workflows/macos-release.yml` in `openclaw/openclaw` and wait
for the public validation-only run to pass.
11. Start
`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
with the same tag and wait for the private mac validation lane to pass.
12. Start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
with `preflight_only=true` and wait for it to pass.
12. If any preflight or validation run fails, fix the issue on a new commit,
with `preflight_only=true` and wait for it to pass. Save that run id because
the real publish requires it to reuse the notarized mac artifacts.
13. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and matching GitHub release, recreate them from the fixed
commit, and rerun all relevant preflights from scratch before continuing.
Never reuse old preflight results after the commit changes.
13. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for
the real publish.
14. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
15. Start
14. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for
the real publish, choose `npm_dist_tag` (`beta` default, `latest` only when
you intentionally want direct stable publish), keep it the same as the
preflight run, and pass the successful npm `preflight_run_id`.
15. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
16. If the stable release was published to `beta`, start
`.github/workflows/openclaw-npm-release.yml` again after beta validation
passes with the same stable tag, `promote_beta_to_latest=true`,
`preflight_only=false`, empty `preflight_run_id`, and `npm_dist_tag=beta`,
then verify `latest` now points at that version.
17. Start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
for the real publish and wait for success.
16. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
for the real publish with the successful private mac `preflight_run_id` and
wait for success.
18. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
and `.dSYM.zip` artifacts to the existing GitHub release in
`openclaw/openclaw`.
17. For stable releases, download `macos-appcast-<tag>` from the successful
19. For stable releases, download `macos-appcast-<tag>` from the successful
private mac run, update `appcast.xml` on `main`, and verify the feed.
18. For beta releases, publish the mac assets but expect no shared production
20. For beta releases, publish the mac assets but expect no shared production
`appcast.xml` artifact and do not update the shared production feed unless a
separate beta feed exists.
19. After publish, verify npm and the attached release artifacts.
21. After publish, verify npm and the attached release artifacts.
## GHSA advisory work

View File

@@ -1,11 +1,11 @@
---
name: openclaw-test-heap-leaks
description: Investigate `pnpm test` memory growth, Vitest worker OOMs, and suspicious RSS increases in OpenClaw using the `scripts/test-parallel.mjs` heap snapshot tooling. Use when Codex needs to reproduce test-lane memory growth, collect repeated `.heapsnapshot` files, compare snapshots from the same worker PID, distinguish transformed-module retention from real data leaks, and fix or reduce the impact by patching cleanup logic or isolating hotspot tests.
description: Investigate `pnpm test` memory growth, Vitest worker OOMs, and suspicious RSS increases in OpenClaw using the `scripts/test-parallel.mjs` heap snapshot tooling. Use when Codex needs to reproduce test-lane memory growth, collect repeated `.heapsnapshot` files, compare snapshots from the same worker PID, triage likely transformed-module retention versus likely runtime leaks, and fix or reduce the impact by patching cleanup logic or isolating hotspot tests.
---
# OpenClaw Test Heap Leaks
Use this skill for test-memory investigations. Do not guess from RSS alone when heap snapshots are available.
Use this skill for test-memory investigations. Do not guess from RSS alone when heap snapshots are available. Treat snapshot-name deltas as triage evidence, not proof, until retainers or dominators support the call.
## Workflow
@@ -14,19 +14,23 @@ Use this skill for test-memory investigations. Do not guess from RSS alone when
- `pnpm canvas:a2ui:bundle && OPENCLAW_TEST_MEMORY_TRACE=1 OPENCLAW_TEST_HEAPSNAPSHOT_INTERVAL_MS=60000 OPENCLAW_TEST_HEAPSNAPSHOT_DIR=.tmp/heapsnap OPENCLAW_TEST_WORKERS=2 OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144 pnpm test`
- Keep `OPENCLAW_TEST_MEMORY_TRACE=1` enabled so the wrapper prints per-file RSS summaries alongside the snapshots.
- If the report is about a specific shard or worker budget, preserve that shape.
- Before you analyze snapshots, identify the real lane names from `[test-parallel] start ...` lines or `pnpm test --plan`. Do not assume a single `unit-fast` lane; local plans often split into `unit-fast-batch-*`.
2. Wait for repeated snapshots before concluding anything.
- Take at least two intervals from the same lane.
- Compare snapshots from the same PID inside one lane directory such as `.tmp/heapsnap/unit-fast/`.
- Use `scripts/heapsnapshot-delta.mjs` to compare either two files directly or the earliest/latest pair per PID in one lane directory.
- Compare snapshots from the same PID inside the real lane directory such as `.tmp/heapsnap/unit-fast-batch-2/`.
- Use `.agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs` to compare either two files directly or the earliest/latest pair per PID in one lane directory.
- If the helper suggests transformed-module retention, confirm the top entries in DevTools retainers/dominators before calling it solved.
3. Classify the growth before choosing a fix.
- If growth is dominated by Vite/Vitest transformed source strings, `Module`, `system / Context`, bytecode, descriptor arrays, or property maps, treat it as retained module graph growth in long-lived workers.
- If growth is dominated by Vite/Vitest transformed source strings, `Module`, `system / Context`, bytecode, descriptor arrays, or property maps, treat it as likely retained module graph growth in long-lived workers.
- If growth is dominated by app objects, caches, buffers, server handles, timers, mock state, sqlite state, or similar runtime objects, treat it as a likely cleanup or lifecycle leak.
- If the names are ambiguous, stop short of a confident label and inspect retainers/dominators in DevTools for the top deltas.
4. Fix the right layer.
- For retained transformed-module growth in shared workers:
- Move hotspot files out of `unit-fast` by updating `test/fixtures/test-parallel.behavior.json`.
- For likely retained transformed-module growth in shared workers:
- Prefer timing and hotspot-driven scheduling fixes first. Check whether the file is already represented in `test/fixtures/test-timings.unit.json` and whether `scripts/test-update-memory-hotspots.mjs` should refresh the measured hotspot manifest before hand-editing behavior overrides.
- Move hotspot files out of the real shared lane by updating `test/fixtures/test-parallel.behavior.json` only when timing-driven peeling is insufficient.
- Prefer `singletonIsolated` for files that are safe alone but inflate shared worker heaps.
- If the file should already have been peeled out by timings but is absent from `test/fixtures/test-timings.unit.json`, call that out explicitly. Missing timings are a scheduling blind spot.
- For real leaks:
@@ -40,24 +44,24 @@ Use this skill for test-memory investigations. Do not guess from RSS alone when
## Heuristics
- Do not call everything a leak. In this repo, large `unit-fast` growth can be a worker-lifetime problem rather than an application object leak.
- Do not call everything a leak. In this repo, large `unit-fast` or `unit-fast-batch-*` growth can be a worker-lifetime problem rather than an application object leak.
- `scripts/test-parallel.mjs` and `scripts/test-parallel-memory.mjs` are the primary control points for wrapper diagnostics.
- The lane names printed by `[test-parallel] start ...` and `[test-parallel][mem] summary ...` tell you where to focus.
- When one or two files account for most of the delta and they are missing from timings, reducing impact by isolating them is usually the first pragmatic fix.
- When the same retained object families grow across multiple intervals in the same worker PID, trust the snapshots over intuition.
- When the same retained object families grow across multiple intervals in the same worker PID, trust the snapshots over intuition, then confirm ambiguous calls with retainer evidence.
## Snapshot Comparison
- Direct comparison:
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs before.heapsnapshot after.heapsnapshot`
- Auto-select earliest/latest snapshots per PID within one lane:
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs --lane-dir .tmp/heapsnap/unit-fast`
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs --lane-dir .tmp/heapsnap/unit-fast-batch-2`
- Useful flags:
- `--top 40`
- `--min-kb 32`
- `--pid 16133`
Read the top positive deltas first. Large positive growth in module-transform artifacts suggests lane isolation; large positive growth in runtime objects suggests a real leak.
Read the top positive deltas first. Large positive growth in module-transform artifacts suggests lane isolation; large positive growth in runtime objects suggests a real leak. If the names alone do not settle it, open the same snapshot pair in DevTools and inspect retainers/dominators for the top rows before declaring root cause.
## Output Expectations
@@ -66,6 +70,6 @@ When using this skill, report:
- The exact reproduce command.
- Which lane and PID were compared.
- The dominant retained object families from the snapshot delta.
- Whether the issue is a real leak or shared-worker retained module growth.
- Whether the issue is a likely real leak or likely shared-worker retained module growth, plus whether retainers/dominators confirmed it.
- The concrete fix or impact-reduction patch.
- What you verified, and what snapshot overhead prevented you from verifying.

View File

@@ -64,6 +64,243 @@ function parseArgs(argv) {
return options;
}
class JsonStreamScanner {
constructor(filePath) {
this.stream = fs.createReadStream(filePath, {
encoding: "utf8",
highWaterMark: 1024 * 1024,
});
this.iterator = this.stream[Symbol.asyncIterator]();
this.buffer = "";
this.offset = 0;
this.done = false;
}
compactBuffer() {
if (this.offset > 65536) {
this.buffer = this.buffer.slice(this.offset);
this.offset = 0;
}
}
async ensureAvailable(count = 1) {
while (!this.done && this.buffer.length - this.offset < count) {
const next = await this.iterator.next();
if (next.done) {
this.done = true;
break;
}
this.buffer += next.value;
}
}
async peek() {
await this.ensureAvailable(1);
return this.buffer[this.offset] ?? null;
}
async next() {
await this.ensureAvailable(1);
if (this.offset >= this.buffer.length) {
return null;
}
const char = this.buffer[this.offset];
this.offset += 1;
this.compactBuffer();
return char;
}
async skipWhitespace() {
while (true) {
const char = await this.peek();
if (char === null || !/\s/u.test(char)) {
return;
}
await this.next();
}
}
async expectChar(expected) {
const char = await this.next();
if (char !== expected) {
fail(`Expected ${expected} but found ${char ?? "<eof>"}`);
}
}
async find(sequence) {
let matched = 0;
while (true) {
const char = await this.next();
if (char === null) {
fail(`Could not find ${sequence}`);
}
if (char === sequence[matched]) {
matched += 1;
if (matched === sequence.length) {
return;
}
continue;
}
matched = char === sequence[0] ? 1 : 0;
if (matched === sequence.length) {
return;
}
}
}
async readBalancedObject() {
const start = await this.next();
if (start !== "{") {
fail(`Expected { but found ${start ?? "<eof>"}`);
}
let text = "{";
let depth = 1;
let inString = false;
let escaped = false;
while (depth > 0) {
const char = await this.next();
if (char === null) {
fail("Unexpected EOF while reading JSON object");
}
text += char;
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
}
}
return text;
}
async parseNumberArray(onValue) {
await this.skipWhitespace();
await this.expectChar("[");
await this.skipWhitespace();
if ((await this.peek()) === "]") {
await this.next();
return;
}
let token = "";
let index = 0;
const flush = () => {
if (token.length === 0) {
fail("Unexpected empty number token");
}
const value = Number.parseInt(token, 10);
if (!Number.isFinite(value)) {
fail(`Invalid numeric token: ${token}`);
}
onValue(value, index);
index += 1;
token = "";
};
while (true) {
const char = await this.next();
if (char === null) {
fail("Unexpected EOF while reading number array");
}
if (char === "]") {
flush();
return;
}
if (char === ",") {
flush();
continue;
}
if (/\s/u.test(char)) {
continue;
}
token += char;
}
}
async readJsonString() {
await this.expectChar('"');
let value = "";
while (true) {
const char = await this.next();
if (char === null) {
fail("Unexpected EOF while reading JSON string");
}
if (char === '"') {
return value;
}
if (char !== "\\") {
value += char;
continue;
}
const escaped = await this.next();
if (escaped === null) {
fail("Unexpected EOF while reading JSON string escape");
}
if (escaped === "u") {
let hex = "";
for (let index = 0; index < 4; index += 1) {
const hexChar = await this.next();
if (hexChar === null) {
fail("Unexpected EOF while reading JSON unicode escape");
}
hex += hexChar;
}
value += String.fromCharCode(Number.parseInt(hex, 16));
continue;
}
value +=
escaped === "b"
? "\b"
: escaped === "f"
? "\f"
: escaped === "n"
? "\n"
: escaped === "r"
? "\r"
: escaped === "t"
? "\t"
: escaped;
}
}
async parseStringArray(onValue) {
await this.skipWhitespace();
await this.expectChar("[");
await this.skipWhitespace();
if ((await this.peek()) === "]") {
await this.next();
return;
}
let index = 0;
while (true) {
const value = await this.readJsonString();
onValue(value, index);
index += 1;
await this.skipWhitespace();
const separator = await this.next();
if (separator === "]") {
return;
}
if (separator !== ",") {
fail(`Expected , or ] but found ${separator ?? "<eof>"}`);
}
await this.skipWhitespace();
}
}
}
function parseHeapFilename(filePath) {
const base = path.basename(filePath);
const match = base.match(
@@ -151,38 +388,89 @@ function resolvePair(options) {
};
}
function loadSummary(filePath) {
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
const meta = data.snapshot?.meta;
async function parseSnapshotMeta(scanner) {
await scanner.find('"snapshot":');
await scanner.skipWhitespace();
const metaObjectText = await scanner.readBalancedObject();
const parsed = JSON.parse(metaObjectText);
return parsed?.meta ?? null;
}
async function buildSummary(filePath) {
const scanner = new JsonStreamScanner(filePath);
const meta = await parseSnapshotMeta(scanner);
if (!meta) {
fail(`Invalid heap snapshot: ${filePath}`);
}
const nodeFieldCount = meta.node_fields.length;
const typeNames = meta.node_types[0];
const strings = data.strings;
const typeIndex = meta.node_fields.indexOf("type");
const nameIndex = meta.node_fields.indexOf("name");
const selfSizeIndex = meta.node_fields.indexOf("self_size");
if (typeIndex === -1 || nameIndex === -1 || selfSizeIndex === -1) {
fail(`Unsupported heap snapshot schema: ${filePath}`);
}
const summary = new Map();
for (let offset = 0; offset < data.nodes.length; offset += nodeFieldCount) {
const type = typeNames[data.nodes[offset + typeIndex]];
const name = strings[data.nodes[offset + nameIndex]];
const selfSize = data.nodes[offset + selfSizeIndex];
const key = `${type}\t${name}`;
const current = summary.get(key) ?? {
type,
name,
const summaryByIndex = new Map();
let nodeCount = 0;
let currentTypeId = 0;
let currentNameId = 0;
let currentSelfSize = 0;
await scanner.find('"nodes":');
await scanner.parseNumberArray((value, index) => {
const fieldIndex = index % nodeFieldCount;
if (fieldIndex === typeIndex) {
currentTypeId = value;
return;
}
if (fieldIndex === nameIndex) {
currentNameId = value;
return;
}
if (fieldIndex === selfSizeIndex) {
currentSelfSize = value;
}
if (fieldIndex !== nodeFieldCount - 1) {
return;
}
const key = `${currentTypeId}\t${currentNameId}`;
const current = summaryByIndex.get(key) ?? {
typeId: currentTypeId,
nameId: currentNameId,
selfSize: 0,
count: 0,
};
current.selfSize += selfSize;
current.selfSize += currentSelfSize;
current.count += 1;
summary.set(key, current);
summaryByIndex.set(key, current);
nodeCount += 1;
});
const requiredNameIds = new Set(
Array.from(summaryByIndex.values(), (entry) => entry.nameId).filter((value) => value >= 0),
);
const nameStrings = new Map();
await scanner.find('"strings":');
await scanner.parseStringArray((value, index) => {
if (requiredNameIds.has(index)) {
nameStrings.set(index, value);
}
});
const summary = new Map();
for (const entry of summaryByIndex.values()) {
const key = `${typeNames[entry.typeId] ?? "unknown"}\t${nameStrings.get(entry.nameId) ?? ""}`;
summary.set(key, {
type: typeNames[entry.typeId] ?? "unknown",
name: nameStrings.get(entry.nameId) ?? "",
selfSize: entry.selfSize,
count: entry.count,
});
}
return {
nodeCount: data.snapshot.node_count,
nodeCount,
summary,
};
}
@@ -205,11 +493,11 @@ function truncate(text, maxLength) {
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}`;
}
function main() {
async function main() {
const options = parseArgs(process.argv.slice(2));
const pair = resolvePair(options);
const before = loadSummary(pair.before);
const after = loadSummary(pair.after);
const before = await buildSummary(pair.before);
const after = await buildSummary(pair.after);
const minBytes = options.minKb * 1024;
const rows = [];
@@ -262,4 +550,4 @@ function main() {
}
}
main();
await main();

0
.codex Normal file
View File

View File

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

9
.github/labeler.yml vendored
View File

@@ -59,6 +59,11 @@
- any-glob-to-any-file:
- "extensions/nostr/**"
- "docs/channels/nostr.md"
"channel: qqbot":
- changed-files:
- any-glob-to-any-file:
- "extensions/qqbot/**"
- "docs/channels/qqbot.md"
"channel: signal":
- changed-files:
- any-glob-to-any-file:
@@ -241,6 +246,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/deepseek/**"
"extensions: stepfun":
- changed-files:
- any-glob-to-any-file:
- "extensions/stepfun/**"
"extensions: anthropic":
- changed-files:
- any-glob-to-any-file:

View File

@@ -1,110 +0,0 @@
name: CI Bun
on:
push:
branches: [main]
concurrency:
group: ci-bun-push-${{ github.run_id }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
preflight:
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
outputs:
run_bun_checks: ${{ steps.manifest.outputs.run_bun_checks }}
bun_checks_matrix: ${{ steps.manifest.outputs.bun_checks_matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
use-sticky-disk: "false"
- name: Build Bun CI manifest
id: manifest
env:
OPENCLAW_CI_DOCS_ONLY: "false"
OPENCLAW_CI_DOCS_CHANGED: "false"
OPENCLAW_CI_RUN_NODE: "true"
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":[]}'
run: node scripts/ci-write-manifest-outputs.mjs --workflow ci-bun
build-bun-artifacts:
needs: [preflight]
if: needs.preflight.outputs.run_bun_checks == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Build A2UI bundle
run: pnpm canvas:a2ui:bundle
- name: Upload A2UI bundle artifact
uses: actions/upload-artifact@v7
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
include-hidden-files: true
retention-days: 1
bun-checks:
name: ${{ matrix.check_name }}
needs: [preflight, build-bun-artifacts]
if: needs.preflight.outputs.run_bun_checks == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.bun_checks_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "true"
use-sticky-disk: "false"
- name: Download A2UI bundle artifact
uses: actions/download-artifact@v8
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
- name: Run Bun test shard
env:
SHARD_COUNT: ${{ matrix.shard_count }}
SHARD_INDEX: ${{ matrix.shard_index }}
shell: bash
run: |
set -euo pipefail
OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard "$SHARD_INDEX/$SHARD_COUNT"

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
@@ -306,8 +450,8 @@ jobs:
run: |
set -euo pipefail
case "$TASK" in
extensions)
pnpm test:extensions
bundled)
pnpm test:bundled
;;
contracts|contracts-protocol)
pnpm build
@@ -320,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]
@@ -354,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'
@@ -388,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
@@ -459,6 +638,8 @@ jobs:
use-sticky-disk: "false"
- name: Check types and lint and oxfmt
env:
OPENCLAW_LOCAL_CHECK: "0"
run: pnpm check
- name: Strict TS build smoke
@@ -538,6 +719,11 @@ jobs:
continue-on-error: true
run: pnpm run lint:web-search-provider-boundaries
- name: Run web fetch provider boundary guard
id: web_fetch_provider_boundary
continue-on-error: true
run: pnpm run lint:web-fetch-provider-boundaries
- name: Run extension src boundary guard
id: extension_src_outside_plugin_sdk_boundary
continue-on-error: true
@@ -585,6 +771,7 @@ jobs:
NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME: ${{ steps.no_extension_test_core_imports.outcome }}
PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME: ${{ steps.plugin_sdk_subpaths_exported.outcome }}
WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
WEB_FETCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_fetch_provider_boundary.outcome }}
EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
@@ -604,6 +791,7 @@ jobs:
"lint:plugins:no-extension-test-core-imports|$NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME" \
"lint:plugins:plugin-sdk-subpaths-exported|$PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME" \
"web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
"web-fetch-provider-boundary|$WEB_FETCH_PROVIDER_BOUNDARY_OUTCOME" \
"extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
@@ -720,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
@@ -794,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
@@ -866,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

@@ -82,11 +82,12 @@ jobs:
{
echo "## Public macOS validation only"
echo
echo "This workflow no longer builds, signs, notarizes, or uploads macOS assets."
echo "This workflow validates the public release handoff and still builds JS artifacts needed for release checks."
echo "It does not sign, notarize, or upload macOS assets."
echo
echo "Next step:"
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\`."
echo "- Use \`preflight_only=true\` there for the full private mac preflight."
echo "- For the real publish path, the private run uploads the packaged \`.zip\`, \`.dmg\`, and \`.dSYM.zip\` files to the existing GitHub release in \`openclaw/openclaw\` automatically."
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml\` with tag \`${RELEASE_TAG}\` and wait for the private mac validation lane to pass."
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\` and \`preflight_only=true\` for the full private mac preflight."
echo "- For the real publish path, run the same private mac publish workflow from \`main\` with the successful private preflight \`preflight_run_id\` so it promotes the prepared artifacts instead of rebuilding them."
echo "- For stable releases, also download \`macos-appcast-${RELEASE_TAG}\` from the successful private run and commit \`appcast.xml\` back to \`main\` in \`openclaw/openclaw\`."
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -12,9 +12,26 @@ on:
required: true
default: false
type: boolean
preflight_run_id:
description: Existing successful preflight workflow run id to promote without rebuilding
required: false
type: string
npm_dist_tag:
description: npm dist-tag to publish to for stable releases
required: true
default: beta
type: choice
options:
- beta
- latest
promote_beta_to_latest:
description: Skip publish and promote the stable version already on npm beta to latest
required: true
default: false
type: boolean
concurrency:
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}-{2}', inputs.tag, inputs.npm_dist_tag, inputs.promote_beta_to_latest) || github.ref }}
cancel-in-progress: false
env:
@@ -24,6 +41,7 @@ env:
jobs:
preflight_openclaw_npm:
if: ${{ inputs.preflight_only && !inputs.promote_beta_to_latest }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -31,12 +49,23 @@ jobs:
- name: Validate tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
echo "Beta prerelease tags must publish to npm dist-tag beta."
exit 1
fi
- name: Forbid preflight artifact promotion on validation-only runs
if: ${{ inputs.preflight_only && inputs.preflight_run_id != '' }}
run: |
echo "preflight_run_id is only valid for real publish runs."
exit 1
- name: Checkout
uses: actions/checkout@v6
@@ -71,6 +100,8 @@ jobs:
echo "Publishing openclaw@${PACKAGE_VERSION}"
- name: Check
env:
OPENCLAW_LOCAL_CHECK: "0"
run: pnpm check
- name: Build
@@ -80,9 +111,12 @@ jobs:
run: pnpm ui:build
- name: Validate release tag and package metadata
if: ${{ inputs.preflight_run_id == '' }}
env:
OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "1"
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_MAIN_REF: origin/main
OPENCLAW_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
@@ -95,8 +129,40 @@ jobs:
- name: Verify release contents
run: pnpm release:check
validate_publish_dispatch_ref:
if: ${{ !inputs.preflight_only }}
- name: Pack prepared npm tarball
id: packed_tarball
env:
OPENCLAW_PREPACK_PREPARED: "1"
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
PACK_JSON="$(npm pack --json)"
echo "$PACK_JSON"
PACK_PATH="$(printf '%s\n' "$PACK_JSON" | node -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); const first = Array.isArray(parsed) ? parsed[0] : null; if (!first || typeof first.filename !== "string" || !first.filename) { process.exit(1); } process.stdout.write(first.filename); });')"
if [[ -z "$PACK_PATH" || ! -f "$PACK_PATH" ]]; then
echo "npm pack did not produce a tarball file." >&2
exit 1
fi
RELEASE_SHA="$(git rev-parse HEAD)"
ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
cp "$PACK_PATH" "$ARTIFACT_DIR/"
printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt"
printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt"
printf '%s\n' "$RELEASE_NPM_DIST_TAG" > "$ARTIFACT_DIR/release-npm-dist-tag.txt"
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
- name: Upload prepared npm publish bundle
uses: actions/upload-artifact@v7
with:
name: openclaw-npm-preflight-${{ inputs.tag }}
path: ${{ steps.packed_tarball.outputs.dir }}
if-no-files-found: error
validate_publish_request:
if: ${{ !inputs.preflight_only && !inputs.promote_beta_to_latest }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -111,25 +177,41 @@ jobs:
exit 1
fi
- name: Require preflight artifact promotion on real publish
env:
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
run: |
set -euo pipefail
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
echo "Real publish requires preflight_run_id from a successful npm preflight run." >&2
exit 1
fi
publish_openclaw_npm:
# npm trusted publishing + provenance requires a GitHub-hosted runner.
needs: [preflight_openclaw_npm, validate_publish_dispatch_ref]
if: ${{ !inputs.preflight_only }}
needs: [validate_publish_request]
if: ${{ !inputs.preflight_only && !inputs.promote_beta_to_latest }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
actions: read
contents: read
id-token: write
steps:
- name: Validate tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
echo "Beta prerelease tags must publish to npm dist-tag beta."
exit 1
fi
- name: Checkout
uses: actions/checkout@v6
@@ -157,14 +239,28 @@ jobs:
echo "Publishing openclaw@${PACKAGE_VERSION}"
- name: Build
run: pnpm build
- name: Verify preflight run metadata
env:
GH_TOKEN: ${{ github.token }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
run: |
set -euo pipefail
RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw NPM Release"], ["headBranch", "main"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`);'
- name: Build Control UI
run: pnpm ui:build
- name: Download prepared npm tarball
uses: actions/download-artifact@v8
with:
name: openclaw-npm-preflight-${{ inputs.tag }}
path: preflight-tarball
repository: ${{ github.repository }}
run-id: ${{ inputs.preflight_run_id }}
github-token: ${{ github.token }}
- name: Validate release tag and package metadata
if: ${{ inputs.preflight_run_id == '' }}
env:
OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "1"
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_MAIN_REF: origin/main
run: |
@@ -176,5 +272,153 @@ jobs:
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
pnpm release:openclaw:npm:check
- name: Verify prepared tarball provenance
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
TAG_FILE="preflight-tarball/release-tag.txt"
SHA_FILE="preflight-tarball/release-sha.txt"
NPM_DIST_TAG_FILE="preflight-tarball/release-npm-dist-tag.txt"
if [[ ! -f "$TAG_FILE" || ! -f "$SHA_FILE" || ! -f "$NPM_DIST_TAG_FILE" ]]; then
echo "Prepared preflight metadata is missing." >&2
ls -la preflight-tarball >&2 || true
exit 1
fi
ARTIFACT_RELEASE_TAG="$(tr -d '\r\n' < "$TAG_FILE")"
ARTIFACT_RELEASE_SHA="$(tr -d '\r\n' < "$SHA_FILE")"
ARTIFACT_RELEASE_NPM_DIST_TAG="$(tr -d '\r\n' < "$NPM_DIST_TAG_FILE")"
if [[ "$ARTIFACT_RELEASE_TAG" != "$RELEASE_TAG" ]]; then
echo "Prepared preflight tag mismatch: expected $RELEASE_TAG, got $ARTIFACT_RELEASE_TAG" >&2
exit 1
fi
if [[ "$ARTIFACT_RELEASE_SHA" != "$EXPECTED_RELEASE_SHA" ]]; then
echo "Prepared preflight SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $ARTIFACT_RELEASE_SHA" >&2
exit 1
fi
if [[ "$ARTIFACT_RELEASE_NPM_DIST_TAG" != "$RELEASE_NPM_DIST_TAG" ]]; then
echo "Prepared preflight npm dist-tag mismatch: expected $RELEASE_NPM_DIST_TAG, got $ARTIFACT_RELEASE_NPM_DIST_TAG" >&2
exit 1
fi
- name: Resolve publish tarball
id: publish_tarball
run: |
set -euo pipefail
TARBALL_PATH="$(find preflight-tarball -type f -name '*.tgz' -print | sort | tail -n 1)"
if [[ -z "$TARBALL_PATH" ]]; then
echo "Prepared preflight tarball not found." >&2
ls -la preflight-tarball >&2 || true
exit 1
fi
echo "path=$TARBALL_PATH" >> "$GITHUB_OUTPUT"
- name: Publish
run: bash scripts/openclaw-npm-publish.sh --publish
env:
OPENCLAW_PREPACK_PREPARED: "1"
OPENCLAW_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
publish_target="${{ steps.publish_tarball.outputs.path }}"
if [[ -n "${publish_target}" ]]; then
publish_target="./${publish_target}"
fi
bash scripts/openclaw-npm-publish.sh --publish "${publish_target}"
promote_beta_to_latest:
if: ${{ inputs.promote_beta_to_latest }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: read
steps:
- name: Require main workflow ref for promotion
env:
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
echo "Promotion runs must be dispatched from main."
exit 1
fi
- name: Validate promotion inputs
env:
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
echo "Promotion mode cannot run with preflight_only=true."
exit 1
fi
if [[ -n "${PREFLIGHT_RUN_ID}" ]]; then
echo "Promotion mode does not use preflight_run_id."
exit 1
fi
if [[ "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
echo "Promotion mode expects npm_dist_tag=beta because it moves beta to latest without publishing."
exit 1
fi
- name: Validate stable tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then
echo "Invalid stable release tag format: ${RELEASE_TAG}" >&2
exit 1
fi
echo "RELEASE_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v6
- 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"
install-deps: "false"
- name: Validate npm dist-tags
env:
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
run: |
set -euo pipefail
beta_version="$(npm view openclaw dist-tags.beta)"
latest_version="$(npm view openclaw dist-tags.latest)"
echo "Current beta dist-tag: ${beta_version}"
echo "Current latest dist-tag: ${latest_version}"
if [[ "${beta_version}" != "${RELEASE_VERSION}" ]]; then
echo "npm beta points at ${beta_version}, expected ${RELEASE_VERSION}." >&2
exit 1
fi
if ! npm view "openclaw@${RELEASE_VERSION}" version >/dev/null 2>&1; then
echo "openclaw@${RELEASE_VERSION} is not published on npm." >&2
exit 1
fi
- name: Promote beta to latest
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
run: |
set -euo pipefail
npm whoami >/dev/null
npm dist-tag add "openclaw@${RELEASE_VERSION}" latest
promoted_latest="$(npm view openclaw dist-tags.latest)"
if [[ "${promoted_latest}" != "${RELEASE_VERSION}" ]]; then
echo "npm latest points at ${promoted_latest}, expected ${RELEASE_VERSION} after promotion." >&2
exit 1
fi
echo "Promoted openclaw@${RELEASE_VERSION} from beta to latest."

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}"

View File

@@ -211,4 +211,7 @@ jobs:
fi
- name: Publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"

3
.gitignore vendored
View File

@@ -85,6 +85,7 @@ apps/ios/*.mobileprovision
# Local untracked files
.local/
docs/.local/
docs/internal/
tmp/
IDENTITY.md
USER.md
@@ -140,3 +141,5 @@ changelog/fragments/
# Local scratch workspace
.tmp/
test/fixtures/openclaw-vitest-unit-report.json
analysis/

View File

@@ -33,6 +33,9 @@
"img",
"a",
"br",
"table",
"tr",
"td",
"details",
"summary",
"p",

View File

@@ -1,2 +1,3 @@
**/node_modules/
**/.runtime-deps-*/
docs/.generated/

View File

@@ -112,6 +112,7 @@
- Type-check/build: `pnpm build`
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
- Local agent/dev shells default to lower-memory `OPENCLAW_LOCAL_CHECK=1` behavior for `pnpm tsgo` and `pnpm lint`; set `OPENCLAW_LOCAL_CHECK=0` in CI/shared runs.
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Terminology:
@@ -179,6 +180,16 @@
- When tests need example Anthropic/OpenAI model constants, prefer `sonnet-4.6` and `gpt-5.4`; update older Anthropic/GPT examples when you touch those tests.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Write tests to clean up timers, env, globals, mocks, sockets, temp dirs, and module state so `--isolate=false` stays green.
- Test performance guardrail: do not put `vi.resetModules()` plus `await import(...)` in `beforeEach`/per-test loops for heavy modules unless module state truly requires it. Prefer static imports or one-time `beforeAll` imports, then reset mocks/runtime state directly.
- Test performance guardrail: if a test file uses stable `vi.mock(...)` hoists or other static module mocks, do not pair them with `vi.resetModules()` and a fresh `await import(...)` in every `beforeEach`. Import the heavy module once in `beforeAll`, then reset/prime mocks in `beforeEach` so Browser/Matrix-style hotspot tests do not pay the module graph cost per case.
- 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.

File diff suppressed because it is too large Load Diff

View File

@@ -159,7 +159,10 @@ We are currently prioritizing:
- **Skills**: For skill contributions, head to [ClawHub](https://clawhub.ai/) — the community hub for OpenClaw skills.
- **Performance**: Optimizing token usage and compaction logic.
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels!
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for
["good first issue"](https://github.com/openclaw/openclaw/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
labels. If none are open, pick a small docs or bug issue and leave a quick comment saying
you'd like to work on it.
## Maintainers

View File

@@ -64,6 +64,7 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs ./scripts/
COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/

4
Makefile Normal file
View File

@@ -0,0 +1,4 @@
.PHONY: build
build:
pnpm build

View File

@@ -32,9 +32,50 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
## Sponsors
| OpenAI | Vercel | Blacksmith | Convex |
| ----------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| [![OpenAI](docs/assets/sponsors/openai.svg)](https://openai.com/) | [![Vercel](docs/assets/sponsors/vercel.svg)](https://vercel.com/) | [![Blacksmith](docs/assets/sponsors/blacksmith.svg)](https://blacksmith.sh/) | [![Convex](docs/assets/sponsors/convex.svg)](https://www.convex.dev/) |
<table>
<tr>
<td align="center" width="20%">
<a href="https://openai.com/">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/openai-light.svg">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/openai.svg" alt="OpenAI" height="28">
</picture>
</a>
</td>
<td align="center" width="20%">
<a href="https://www.nvidia.com/">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/nvidia.svg">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/nvidia-dark.svg" alt="NVIDIA" height="28">
</picture>
</a>
</td>
<td align="center" width="20%">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/vercel-light.svg">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/vercel.svg" alt="Vercel" height="24">
</picture>
</a>
</td>
<td align="center" width="20%">
<a href="https://blacksmith.sh/">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/blacksmith-light.svg">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/blacksmith.svg" alt="Blacksmith" height="28">
</picture>
</a>
</td>
<td align="center" width="20%">
<a href="https://www.convex.dev/">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/convex-light.svg">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/convex.svg" alt="Convex" height="24">
</picture>
</a>
</td>
</tr>
</table>
**Subscriptions (OAuth):**

View File

@@ -56,8 +56,12 @@ These are frequently reported but are typically closed with no code change:
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
- Reports that only show quoted/replied/thread/forwarded supplemental context from non-allowlisted senders being visible to the model, without demonstrating an auth, policy, approval, or sandbox boundary bypass.
- Reports that treat the Gateway HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) as if they implemented scoped operator auth (`operator.write` vs `operator.admin`). These endpoints authenticate the shared Gateway bearer secret/password and are documented full operator-access surfaces, not per-user/per-scope boundaries.
- Reports that assume `x-openclaw-scopes` can reduce or redefine shared-secret bearer auth on the OpenAI-compatible HTTP endpoints. For shared-secret auth (`gateway.auth.mode="token"` or `"password"`), those endpoints ignore narrower bearer-declared scopes and restore the full default operator scope set plus owner semantics.
- Reports that treat `POST /tools/invoke` under shared-secret bearer auth (`gateway.auth.mode="token"` or `"password"`) as a narrower per-request/per-scope authorization surface. That endpoint is designed as the same trusted-operator HTTP boundary: shared-secret bearer auth is full operator access there, narrower `x-openclaw-scopes` values do not reduce that path, and owner-only tool policy follows the shared-secret operator contract.
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
- Reports that only show an ACP tool can indirectly execute, mutate, orchestrate sessions, or reach another tool/runtime without demonstrating bypass of ACP prompt/approval, allowlist enforcement, sandboxing, or another documented trust boundary. ACP silent approval is intentionally limited to narrow readonly classes; parity-only indirect-command findings are hardening, not vulnerabilities.
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
@@ -93,7 +97,14 @@ When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (o
OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary.
- Authenticated Gateway callers are treated as trusted operators for that gateway instance.
- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split.
- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) and direct tool endpoint (`POST /tools/invoke`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split.
- Concretely, on the OpenAI-compatible HTTP surface:
- shared-secret bearer auth (`token` / `password`) authenticates possession of the gateway operator secret
- those requests receive the full default operator scope set (`operator.admin`, `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing`)
- chat-turn endpoints (`/v1/chat/completions`, `/v1/responses`) also treat those shared-secret callers as owner senders for owner-only tool policy
- `POST /tools/invoke` follows that same shared-secret rule and also treats those callers as owner senders for owner-only tool policy
- narrower `x-openclaw-scopes` headers are ignored for that shared-secret path
- only identity-bearing HTTP modes (for example trusted proxy auth or `gateway.auth.mode="none"` on private ingress) honor declared per-request operator scopes
- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries.
- If one operator can view data from another operator on the same gateway, that is expected in this trust model.
- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary.
@@ -101,7 +112,7 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun
- If multiple users need OpenClaw, use one VPS (or host/OS user boundary) per user.
- For advanced setups, multiple gateways on one machine are possible, but only with strict isolation and are not the recommended default.
- Exec behavior is host-first by default: `agents.defaults.sandbox.mode` defaults to `off`.
- `tools.exec.host` defaults to `sandbox` as a routing preference, but if sandbox runtime is not active for the session, exec runs on the gateway host.
- `tools.exec.host` defaults to `auto`: sandbox when sandbox runtime is active for the session, otherwise gateway.
- Implicit exec calls (no explicit host in the tool call) follow the same behavior.
- This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy.
@@ -129,6 +140,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These are hardening-only findings and are not vulnerabilities; triage may close them as `invalid`/`no-action` or track them separately as low/informational hardening.
- Reports whose only claim is that an ACP-exposed tool can indirectly execute commands, mutate host state, or reach another privileged tool/runtime without demonstrating a bypass of ACP prompt/approval, allowlist enforcement, sandboxing, or another documented trust boundary. These are hardening-only findings, not vulnerabilities.
- Reports whose only claim is that exec approvals do not semantically model every interpreter/runtime loader form, subcommand, flag combination, package script, or transitive module/config import. Exec approvals bind exact request context and best-effort direct local file operands; they are not a complete semantic model of everything a runtime may load.
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass.
@@ -156,6 +168,24 @@ OpenClaw's security model is "personal assistant" (one trusted operator, potenti
- For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime.
- If that host/browser profile is logged into personal accounts (for example Apple/Google/personal password manager), you have collapsed the boundary and increased personal-data exposure risk.
## Context Visibility and Allowlists
OpenClaw distinguishes:
- **Trigger authorization**: who can trigger the agent (`dmPolicy`, `groupPolicy`, allowlists, mention gates)
- **Context visibility**: what supplemental context is provided to the model (reply body, quoted text, thread history, forwarded metadata)
In current releases, allowlists primarily gate triggering and owner-style command access. They do not guarantee universal supplemental-context redaction across every channel/surface.
Current channel behavior is not fully uniform:
- some channels already filter parts of supplemental context by sender allowlist
- other channels still pass supplemental context as received
Reports that only show supplemental-context visibility differences are typically hardening/consistency findings unless they also demonstrate a documented boundary bypass (auth, policy, approvals, sandbox, or equivalent).
Hardening roadmap may add explicit visibility modes (for example `all`, `allowlist`, `allowlist_quote`) so operators can opt into stricter context filtering with predictable tradeoffs.
## Agent and Model Assumptions
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.

View File

@@ -3,238 +3,305 @@
<channel>
<title>OpenClaw</title>
<item>
<title>2026.3.28</title>
<pubDate>Sun, 29 Mar 2026 02:10:40 +0000</pubDate>
<title>2026.4.2</title>
<pubDate>Thu, 02 Apr 2026 18:57:54 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026032890</sparkle:version>
<sparkle:shortVersionString>2026.3.28</sparkle:shortVersionString>
<sparkle:version>2026040290</sparkle:version>
<sparkle:shortVersionString>2026.4.2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.28</h2>
<description><![CDATA[<h2>OpenClaw 2026.4.2</h2>
<h3>Breaking</h3>
<ul>
<li>Providers/Qwen: remove the deprecated <code>qwen-portal-auth</code> OAuth integration for <code>portal.qwen.ai</code>; migrate to Model Studio with <code>openclaw onboard --auth-choice modelstudio-api-key</code>. (#52709) Thanks @pomelo-nwu.</li>
<li>Config/Doctor: drop automatic config migrations older than two months; very old legacy keys now fail validation instead of being rewritten on load or by <code>openclaw doctor</code>.</li>
<li>Plugins/xAI: move <code>x_search</code> settings from the legacy core <code>tools.web.x_search.*</code> path to the plugin-owned <code>plugins.entries.xai.config.xSearch.*</code> path, standardize <code>x_search</code> auth on <code>plugins.entries.xai.config.webSearch.apiKey</code> / <code>XAI_API_KEY</code>, and migrate legacy config with <code>openclaw doctor --fix</code>. (#59674) Thanks @vincentkoc.</li>
<li>Plugins/web fetch: move Firecrawl <code>web_fetch</code> config from the legacy core <code>tools.web.fetch.firecrawl.*</code> path to the plugin-owned <code>plugins.entries.firecrawl.config.webFetch.*</code> path, route <code>web_fetch</code> fallback through the new fetch-provider boundary instead of a Firecrawl-only core branch, and migrate legacy config with <code>openclaw doctor --fix</code>. (#59465) Thanks @vincentkoc.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>xAI/tools: move the bundled xAI provider to the Responses API, add first-class <code>x_search</code>, and auto-enable the xAI plugin from owned web-search and tool config so bundled Grok auth/configured search flows work without manual plugin toggles. (#56048) Thanks @huntharo.</li>
<li>xAI/onboarding: let the bundled Grok web-search plugin offer optional <code>x_search</code> setup during <code>openclaw onboard</code> and <code>openclaw configure --section web</code>, including an x_search model picker with the shared xAI key.</li>
<li>MiniMax: add image generation provider for <code>image-01</code> model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97.</li>
<li>Plugins/hooks: add async <code>requireApproval</code> to <code>before_tool_call</code> hooks, letting plugins pause tool execution and prompt the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the <code>/approve</code> command on any channel. The <code>/approve</code> command now handles both exec and plugin approvals with automatic fallback. (#55339) Thanks @vaclavbelak and @joshavant.</li>
<li>ACP/channels: add current-conversation ACP binds for Discord, BlueBubbles, and iMessage so <code>/acp spawn codex --bind here</code> can turn the current chat into a Codex-backed workspace without creating a child thread, and document the distinction between chat surface, ACP session, and runtime workspace.</li>
<li>OpenAI/apply_patch: enable <code>apply_patch</code> by default for OpenAI and OpenAI Codex models, and align its sandbox policy access with <code>write</code> permissions.</li>
<li>Plugins/CLI backends: move bundled Claude CLI, Codex CLI, and Gemini CLI inference defaults onto the plugin surface, add bundled Gemini CLI backend support, and replace <code>gateway run --claude-cli-logs</code> with generic <code>--cli-backend-logs</code> while keeping the old flag as a compatibility alias.</li>
<li>Plugins/startup: auto-load bundled provider and CLI-backend plugins from explicit config refs, so bundled Claude CLI, Codex CLI, and Gemini CLI message-provider setups no longer need manual <code>plugins.allow</code> entries.</li>
<li>Podman: simplify the container setup around the current rootless user, install the launch helper under <code>~/.local/bin</code>, and document the host-CLI <code>openclaw --container <name> ...</code> workflow instead of a dedicated <code>openclaw</code> service user.</li>
<li>Slack/tool actions: add an explicit <code>upload-file</code> Slack action that routes file uploads through the existing Slack upload transport, with optional filename/title/comment overrides for channels and DMs.</li>
<li>Message actions/files: start unifying file-first sends on the canonical <code>upload-file</code> action by adding explicit support for Microsoft Teams and Google Chat, and by exposing BlueBubbles file sends through <code>upload-file</code> while keeping the legacy <code>sendAttachment</code> alias.</li>
<li>Plugins/Matrix TTS: send auto-TTS replies as native Matrix voice bubbles instead of generic audio attachments. (#37080) thanks @Matthew19990919.</li>
<li>CLI: add <code>openclaw config schema</code> to print the generated JSON schema for <code>openclaw.json</code>. (#54523) Thanks @kvokka.</li>
<li>Config/TTS: auto-migrate legacy speech config on normal reads and secret resolution, keep legacy diagnostics for Doctor, and remove regular-mode runtime fallback for old bundled <code>tts.<provider></code> API-key shapes.</li>
<li>Memory/plugins: move the pre-compaction memory flush plan behind the active memory plugin contract so <code>memory-core</code> owns flush prompts and target-path policy instead of hardcoded core logic.</li>
<li>MiniMax: trim model catalog to M2.7 only, removing legacy M2, M2.1, M2.5, and VL-01 models. (#54487) Thanks @liyuan97.</li>
<li>Plugins/runtime: expose <code>runHeartbeatOnce</code> in the plugin runtime <code>system</code> namespace so plugins can trigger a single heartbeat cycle with an explicit delivery target override (e.g. <code>heartbeat: { target: "last" }</code>). (#40299) Thanks @loveyana.</li>
<li>Agents/compaction: preserve the post-compaction AGENTS refresh on stale-usage preflight compaction for both immediate replies and queued followups. (#49479) Thanks @jared596.</li>
<li>Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual <code>/compact</code> no-op cases as skipped instead of failed. (#51072) Thanks @afurm.</li>
<li>Docs: add <code>pnpm docs:check-links:anchors</code> for Mintlify anchor validation while keeping <code>scripts/docs-link-audit.mjs</code> as the stable link-audit entrypoint. (#55912) Thanks @velvet-shark.</li>
<li>Tavily: mark outbound API requests with <code>X-Client-Source: openclaw</code> so Tavily can attribute OpenClaw-originated traffic. (#55335) Thanks @lakshyaag-tavily.</li>
<li>Tasks/Task Flow: restore the core Task Flow substrate with managed-vs-mirrored sync modes, durable flow state/revision tracking, and <code>openclaw flows</code> inspection/recovery primitives so background orchestration can persist and be operated separately from plugin authoring layers. (#58930) Thanks @mbelinky.</li>
<li>Tasks/Task Flow: add managed child task spawning plus sticky cancel intent, so external orchestrators can stop scheduling immediately and let parent Task Flows settle to <code>cancelled</code> once active child tasks finish. (#59610) Thanks @mbelinky.</li>
<li>Plugins/Task Flow: add a bound <code>api.runtime.taskFlow</code> seam so plugins and trusted authoring layers can create and drive managed Task Flows from host-resolved OpenClaw context without passing owner identifiers on each call. (#59622) Thanks @mbelinky.</li>
<li>Android/assistant: add assistant-role entrypoints plus Google Assistant App Actions metadata so Android can launch OpenClaw from the assistant trigger and hand prompts into the chat composer. (#59596) Thanks @obviyus.</li>
<li>Exec defaults: make gateway/node host exec default to YOLO mode by requesting <code>security=full</code> with <code>ask=off</code>, and align host approval-file fallbacks plus docs/doctor reporting with that no-prompt default.</li>
<li>Providers/runtime: add provider-owned replay hook surfaces for transcript policy, replay cleanup, and reasoning-mode dispatch. (#59143) Thanks @jalehman.</li>
<li>Plugins/hooks: add <code>before_agent_reply</code> so plugins can short-circuit the LLM with synthetic replies after inline actions. (#20067) Thanks @JoshuaLelon.</li>
<li>Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths.</li>
<li>Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and <code>feishu_drive</code> comment actions for document collaboration workflows. (#58497) Thanks @wittam-01.</li>
<li>Matrix/plugin: emit spec-compliant <code>m.mentions</code> metadata across text sends, media captions, edits, poll fallback text, and action-driven edits so Matrix mentions notify reliably in clients like Element. (#59323) Thanks @gumadeiras.</li>
<li>Diffs: add plugin-owned <code>viewerBaseUrl</code> so viewer links can use a stable proxy/public origin without passing <code>baseUrl</code> on every tool call. (#59341) Related #59227. Thanks @gumadeiras.</li>
<li>Agents/compaction: resolve <code>agents.defaults.compaction.model</code> consistently for manual <code>/compact</code> and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg.</li>
<li>Agents/compaction: add <code>agents.defaults.compaction.notifyUser</code> so the <code>🧹 Compacting context...</code> start notice is opt-in instead of always being shown. (#54251) Thanks @oguricap0327.</li>
<li>WhatsApp/reactions: add <code>reactionLevel</code> guidance for agent reactions. Thanks @mcaxtr.</li>
<li>Exec approvals/channels: auto-enable DM-first native chat approvals when supported channels can infer approvers from existing owner config, while keeping channel fanout explicit and clarifying forwarding versus native approval client config.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Agents/Anthropic: recover unhandled provider stop reasons (e.g. <code>sensitive</code>) as structured assistant errors instead of crashing the agent run. (#56639)</li>
<li>Google/models: resolve Gemini 3.1 pro, flash, and flash-lite for all Google provider aliases by passing the actual runtime provider ID and adding a template-provider fallback; fix flash-lite prefix ordering. (#56567)</li>
<li>OpenAI Codex/image tools: register Codex for media understanding and route image prompts through Codex instructions so image analysis no longer fails on missing provider registration or missing <code>instructions</code>. (#54829) Thanks @neeravmakwana.</li>
<li>Agents/image tool: restore the generic image-runtime fallback when no provider-specific media-understanding provider is registered, so image analysis works again for providers like <code>openrouter</code> and <code>minimax-portal</code>. (#54858) Thanks @MonkeyLeeT.</li>
<li>WhatsApp: fix infinite echo loop in self-chat DM mode where the bot's own outbound replies were re-processed as new inbound user messages. (#54570) Thanks @joelnishanth</li>
<li>Telegram/splitting: replace proportional text estimate with verified HTML-length search so long messages split at word boundaries instead of mid-word; gracefully degrade when tag overhead exceeds the limit. (#56595)</li>
<li>Telegram/delivery: skip whitespace-only and hook-blanked text replies in bot delivery to prevent GrammyError 400 empty-text crashes. (#56620)</li>
<li>Telegram/send: validate <code>replyToMessageId</code> at all four API sinks with a shared normalizer that rejects non-numeric, NaN, and mixed-content strings. (#56587)</li>
<li>Mistral: normalize OpenAI-compatible request flags so official Mistral API runs no longer fail with remaining <code>422 status code (no body)</code> chat errors.</li>
<li>Control UI/config: keep sensitive raw config hidden by default, replace the blank blocked editor with an explicit reveal-to-edit state, and restore raw JSON editing without auto-exposing secrets. Fixes #55322.</li>
<li>CLI/zsh: defer <code>compdef</code> registration until <code>compinit</code> is available so zsh completion loads cleanly with plugin managers and manual setups. (#56555)</li>
<li>BlueBubbles/debounce: guard debounce flush against null message text by sanitizing at the enqueue boundary and adding an independent combiner guard. (#56573)</li>
<li>Auto-reply: suppress JSON-wrapped <code>{"action":"NO_REPLY"}</code> control envelopes before channel delivery with a strict single-key detector; preserves media when text is only a silent envelope. (#56612)</li>
<li>ACP/ACPX agent registry: align OpenClaw's ACPX built-in agent mirror with the latest <code>openclaw/acpx</code> command defaults and built-in aliases, pin versioned <code>npx</code> built-ins to exact versions, and stop unknown ACP agent ids from falling through to raw <code>--agent</code> command execution on the MCP-proxy path. (#28321) Thanks @m0nkmaster and @vincentkoc.</li>
<li>Security/audit: extend web search key audit to recognize Gemini, Grok/xAI, Kimi, Moonshot, and OpenRouter credentials via a boundary-safe bundled-web-search registry shim. (#56540)</li>
<li>Docs/FAQ: remove broken Xfinity SSL troubleshooting cross-links from English and zh-CN FAQ entries — both sections already contain the full workaround inline. (#56500)</li>
<li>Telegram: deliver verbose tool summaries inside forum topic sessions again, so threaded topic chats now match DM verbose behavior. (#43236) Thanks @frankbuild.</li>
<li>BlueBubbles/CLI agents: restore inbound prompt image refs for CLI routed turns, reapply embedded runner image size guardrails, and cover both CLI image transport paths with regression tests. (#51373)</li>
<li>BlueBubbles/groups: optionally enrich unnamed participant lists with local macOS Contacts names after group gating passes, so group member context can show names instead of only raw phone numbers.</li>
<li>Discord/reconnect: drain stale gateway sockets, clear cached resume state before forced fresh reconnects, and fail closed when old sockets refuse to die so Discord recovery stops looping on poisoned resume state. (#54697) Thanks @ngutman.</li>
<li>iMessage: stop leaking inline <code>[[reply_to:...]]</code> tags into delivered text by sending <code>reply_to</code> as RPC metadata and stripping stray directive tags from outbound messages. (#39512) Thanks @mvanhorn.</li>
<li>CLI/plugins: make routed commands use the same auto-enabled bundled-channel snapshot as gateway startup, so configured bundled channels like Slack load without requiring a prior config rewrite. (#54809) Thanks @neeravmakwana.</li>
<li>CLI/message send: write manual <code>openclaw message send</code> deliveries into the resolved agent session transcript again by always threading the default CLI agent through outbound mirroring. (#54187) Thanks @KevInTheCloud5617.</li>
<li>CLI/onboarding: show the Kimi Code API key option again in the Moonshot setup menu so the interactive picker includes all Kimi setup paths together. Fixes #54412 Thanks @sparkyrider</li>
<li>Agents/status: use provider-aware context window lookup for fresh Anthropic 4.6 model overrides so <code>/status</code> shows the correct 1.0m window instead of an underreported shared-cache minimum. (#54796) Thanks @neeravmakwana.</li>
<li>OpenAI/WebSocket: preserve reasoning replay metadata and tool-call item ids on WebSocket tool turns, and start a fresh response chain when full-context resend is required. (#53856) Thanks @xujingchen1996.</li>
<li>OpenAI/WS: restore reasoning blocks for Responses WebSocket runs and keep reasoning/tool-call replay metadata intact so resumed sessions do not lose or break follow-up reasoning-capable turns. (#53856) Thanks @xujingchen1996.</li>
<li>Agents/errors: surface provider quota/reset details when available, but keep HTML/Cloudflare rate-limit pages on the generic fallback so raw error pages are not shown to users. (#54512) Thanks @bugkill3r.</li>
<li>Claude CLI: switch the bundled Claude CLI backend to <code>stream-json</code> output so watchdogs see progress on long runs, and keep session/usage metadata even when Claude finishes with an empty result line. (#49698) Thanks @felear2022.</li>
<li>Claude CLI/MCP: always pass a strict generated <code>--mcp-config</code> overlay for background Claude CLI runs, including the empty-server case, so Claude does not inherit ambient user/global MCP servers. (#54961) Thanks @markojak.</li>
<li>Agents/embedded replies: surface mid-turn 429 and overload failures when embedded runs end without a user-visible reply, while preserving successful media-only replies that still use legacy <code>mediaUrl</code>. (#50930) Thanks @infichen.</li>
<li>Chat/UI: move the chat send button onto the shared ghost-button theme styling, while keeping the stop button icon readable on the danger state. (#55075) Thanks @bottenbenny.</li>
<li>WhatsApp/allowFrom: show a specific allowFrom policy error for valid blocked targets instead of the misleading <code><E.164|group JID></code> format hint. Thanks @mcaxtr.</li>
<li>Agents/cooldowns: scope rate-limit cooldowns per model so one 429 no longer blocks every model on the same auth profile, replace the exponential 1 min -> 1 h escalation with a stepped 30 s / 1 min / 5 min ladder, and surface a user-facing countdown message when all models are rate-limited. (#49834) Thanks @kiranvk-2011.</li>
<li>Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob.</li>
<li>Telegram/pairing: ignore self-authored DM <code>message</code> updates so bot-pinned status cards and similar service updates do not trigger bogus pairing requests or re-enter inbound dispatch. (#54530) thanks @huntharo</li>
<li>Mattermost/replies: keep pairing replies, slash-command fallback replies, and model-picker messages on the resolved config path so <code>exec:</code> SecretRef bot tokens work across all outbound reply branches. (#48347) thanks @mathiasnagler.</li>
<li>Microsoft Teams/config: accept the existing <code>welcomeCard</code>, <code>groupWelcomeCard</code>, <code>promptStarters</code>, and feedback/reflection keys in strict config validation so already-supported Teams runtime settings stop failing schema checks. (#54679) Thanks @gumclaw.</li>
<li>MCP/channels: add a Gateway-backed channel MCP bridge with Codex/Claude-facing conversation tools, Claude channel notifications, and safer stdio bridge lifecycle handling for reconnects and routed session discovery.</li>
<li>Plugins/SDK: thread <code>moduleUrl</code> through plugin-sdk alias resolution so user-installed plugins outside the openclaw directory correctly resolve <code>openclaw/plugin-sdk/*</code> subpath imports, and gate <code>plugin-sdk:check-exports</code> in <code>release:check</code>. (#54283) Thanks @xieyongliang.</li>
<li>Config/web fetch: allow the documented <code>tools.web.fetch.maxResponseBytes</code> setting in runtime schema validation so valid configs no longer fail with unrecognized-key errors. (#53401) Thanks @erhhung.</li>
<li>Message tool/buttons: keep the shared <code>buttons</code> schema optional in merged tool definitions so plain <code>action=send</code> calls stop failing validation when no buttons are provided. (#54418) Thanks @adzendo.</li>
<li>Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate <code>tool_call_id</code> values with HTTP 400. (#40996) Thanks @xaeon2026.</li>
<li>Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition <code>strict</code> fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.</li>
<li>Plugins/context engines: retry strict legacy <code>assemble()</code> calls without the new <code>prompt</code> field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.</li>
<li>CLI/update status: explicitly say <code>up to date</code> when the local version already matches npm latest, while keeping the availability logic unchanged. (#51409) Thanks @dongzhenye.</li>
<li>Daemon/Linux: stop flagging non-gateway systemd services as duplicate gateways just because their unit files mention OpenClaw, reducing false-positive doctor/log noise. (#45328) Thanks @gregretkowski.</li>
<li>Feishu: close WebSocket connections on monitor stop/abort so ghost connections no longer persist, preventing duplicate event processing and resource leaks across restart cycles. (#52844) Thanks @schumilin.</li>
<li>Feishu: use the original message <code>create_time</code> instead of <code>Date.now()</code> for inbound timestamps so offline-retried messages carry the correct authoring time, preventing mis-targeted agent actions on stale instructions. (#52809) Thanks @schumilin.</li>
<li>Control UI/Skills: open skill detail dialogs with the browser modal lifecycle so clicking a skill row keeps the panel centered instead of rendering it off-screen at the bottom of the page.</li>
<li>Matrix/replies: include quoted poll question/options in inbound reply context so the agent sees the original poll content when users reply to Matrix poll messages. (#55056) Thanks @alberthild.</li>
<li>Matrix/plugins: keep plugin bootstrap from crashing when built runtime mixes bare and deep <code>matrix-js-sdk</code> entrypoints, so unrelated channels do not get taken down during plugin load. (#56273) Thanks @aquaright1.</li>
<li>Agents/sandbox: honor <code>tools.sandbox.tools.alsoAllow</code>, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman.</li>
<li>Agents/sandbox: make blocked-tool guidance glob-aware again, redact/sanitize session-specific explain hints for safer copy-paste, and avoid leaking control-character session keys in those hints. (#54684) Thanks @ngutman.</li>
<li>Agents/compaction: trigger timeout recovery compaction before retrying high-context LLM timeouts so embedded runs stop repeating oversized requests. (#46417) thanks @joeykrug.</li>
<li>Agents/compaction: reconcile <code>sessions.json.compactionCount</code> after a late embedded auto-compaction success so persisted session counts catch up once the handler reports completion. (#45493) Thanks @jackal092927.</li>
<li>Agents/failover: classify Codex accountId token extraction failures as auth errors so model fallback continues to the next configured candidate. (#55206) Thanks @cosmicnet.</li>
<li>Plugins/runtime: reuse only compatible active plugin registries across tools, providers, web search, and channel bootstrap, align <code>/tools/invoke</code> plugin loading with the session workspace, and retry outbound channel recovery when the pinned channel surface changes so plugin tools and channels stop disappearing or re-registering from mismatched runtime loads. Thanks @gumadeiras.</li>
<li>Talk/macOS: stop direct system-voice failures from replaying system speech, use app-locale fallback for shared watchdog timing, and add regression coverage for the macOS fallback route and language-aware timeout policy. (#53511) thanks @hongsw.</li>
<li>Discord/gateway cleanup: keep late Carbon reconnect-exhausted errors suppressed through startup/dispose cleanup so Discord monitor shutdown no longer crashes on late gateway close events. (#55373) Thanks @Takhoffman.</li>
<li>Discord/gateway shutdown: treat expected reconnect-exhausted events during intentional lifecycle stop as clean shutdowns so startup-abort cleanup no longer surfaces false gateway failures. (#55324) Thanks @joelnishanth.</li>
<li>Discord/gateway shutdown: suppress reconnect-exhausted events that were already buffered before teardown flips <code>lifecycleStopping</code>, so stale-socket Discord restarts no longer crash the whole gateway. Fixes #55403 and #55421. Thanks @lml2468 and @vincentkoc.</li>
<li>GitHub Copilot/auth refresh: treat large <code>expires_at</code> values as seconds epochs and clamp far-future runtime auth refresh timers so Copilot token refresh cannot fall into a <code>setTimeout</code> overflow hot loop. (#55360) Thanks @michael-abdo.</li>
<li>Agents/status: use the persisted runtime session model in <code>session_status</code> when no explicit override exists, and honor per-agent <code>thinkingDefault</code> in both <code>session_status</code> and <code>/status</code>. (#55425) Thanks @scoootscooob, @xaeon2026, and @ysfbsf.</li>
<li>Heartbeat/runner: guarantee the interval timer is re-armed after heartbeat runs and unexpected runner errors so scheduled heartbeats do not silently stop after an interrupted cycle. (#52270) Thanks @MiloStack.</li>
<li>Config/Doctor: rewrite stale bundled plugin load paths from legacy bundled-plugin locations to the packaged bundled path, including directory-name mismatches and slash-suffixed config entries. (#55054) Thanks @SnowSky1.</li>
<li>WhatsApp/mentions: stop treating mentions embedded in quoted messages as direct mentions so replying to a message that @mentioned the bot no longer falsely triggers mention gating. (#52711) Thanks @lurebat.</li>
<li>Matrix: keep separate 2-person rooms out of DM routing after <code>m.direct</code> seeds successfully, while still honoring explicit <code>is_direct</code> state and startup fallback recovery. (#54890) thanks @private-peter</li>
<li>Agents/ollama fallback: surface non-2xx Ollama HTTP errors with a leading status code so HTTP 503 responses trigger model fallback again. (#55214) Thanks @bugkill3r.</li>
<li>Feishu/tools: stop synthetic agent ids like <code>agent-spawner</code> from being treated as Feishu account ids during tool execution, so tools fall back to the configured/default Feishu account unless the contextual id is a real enabled Feishu account. (#55627) Thanks @MonkeyLeeT.</li>
<li>Google/tools: strip empty <code>required: []</code> arrays from Gemini tool schemas so optional-only tool parameters no longer trigger Google validator 400s. (#52106) Thanks @oliviareid-svg.</li>
<li>Onboarding/TUI/local gateways: show the resolved gateway port in setup output, clarify no-daemon local health/dashboard messaging, and preserve loopback Control UI auth on reruns and explicit local gateway URLs so local quickstart flows recover cleanly. (#55730) Thanks @shakkernerd.</li>
<li>TUI/chat log: keep system messages as single logical entries and prune overflow at whole-message boundaries so wrapped system spacing stays intact. (#55732) Thanks @shakkernerd.</li>
<li>TUI/activation: validate <code>/activation</code> arguments in the TUI and reject invalid values instead of silently coercing them to <code>mention</code>. (#55733) Thanks @shakkernerd.</li>
<li>Agents/model switching: apply <code>/model</code> changes to active embedded runs at the next safe retry boundary, so overloaded or retrying turns switch to the newly selected model instead of staying pinned to the old provider.</li>
<li>Agents/Codex fallback: classify Codex <code>server_error</code> payloads as failoverable, sanitize <code>Codex error:</code> payloads before they reach chat, preserve context-overflow guidance for prefixed <code>invalid_request_error</code> payloads, and omit provider <code>request_id</code> values from user-facing UI copy. (#42892) Thanks @xaeon2026.</li>
<li>Memory/search: share memory embedding provider registrations across split plugin runtimes so memory search no longer fails with unknown provider errors after memory-core registers built-in adapters. (#55945) Thanks @glitch418x.</li>
<li>Discord/Carbon beta: update <code>@buape/carbon</code> to the latest beta and pass the new <code>RateLimitError</code> request argument so Discord stays compatible with the upstream beta constructor change. (#55980) Thanks @ngutman.</li>
<li>Plugins/inbound claims: pass full inbound attachment arrays through <code>inbound_claim</code> hook metadata while keeping the legacy singular media attachment fields for compatibility. (#55452) Thanks @huntharo.</li>
<li>Plugins/Matrix: preserve sender filenames for inbound media by forwarding <code>originalFilename</code> to <code>saveMediaBuffer</code>. (#55692) thanks @esrehmki.</li>
<li>Matrix/mentions: recognize <code>matrix.to</code> mentions whose visible label uses the bot's room display name, so <code>requireMention: true</code> rooms respond correctly in modern Matrix clients. (#55393) thanks @nickludlam.</li>
<li>Ollama/thinking off: route <code>thinkingLevel=off</code> through the live Ollama extension request path so thinking-capable Ollama models now receive top-level <code>think: false</code> instead of silently generating hidden reasoning tokens. (#53200) Thanks @BruceMacD.</li>
<li>Plugins/diffs: stage bundled <code>@pierre/diffs</code> runtime dependencies during packaged updates so the bundled diff viewer keeps loading after global installs and updates. (#56077) Thanks @gumadeiras.</li>
<li>Plugins/diffs: load bundled Pierre themes without JSON module imports so diff rendering keeps working on newer Node builds. (#45869) thanks @NickHood1984.</li>
<li>Plugins/uninstall: remove owned <code>channels.<id></code> config when uninstalling channel plugins, and keep the uninstall preview aligned with explicit channel ownership so built-in channels and shared keys stay intact. (#35915) Thanks @wbxl2000.</li>
<li>Plugins/Matrix: prefer explicit DM signals when choosing outbound direct rooms and routing unmapped verification summaries, so strict 2-person fallback rooms do not outrank the real DM. (#56076) thanks @gumadeiras</li>
<li>Plugins/Matrix: resolve env-backed <code>accessToken</code> and <code>password</code> SecretRefs against the active Matrix config env path during startup, and officially accept SecretRef <code>accessToken</code> config values. (#54980) thanks @kakahu2015.</li>
<li>Microsoft Teams/proactive DMs: prefer the freshest personal conversation reference for <code>user:<aadObjectId></code> sends when multiple stored references exist, so replies stop targeting stale DM threads. (#54702) Thanks @gumclaw.</li>
<li>Gateway/plugins: reuse the session workspace when building HTTP <code>/tools/invoke</code> tool lists and harden tool construction to infer the session agent workspace by default, so workspace plugins do not re-register on repeated HTTP tool calls. (#56101) thanks @neeravmakwana</li>
<li>Brave/web search: normalize unsupported Brave <code>country</code> filters to <code>ALL</code> before request and cache-key generation so locale-derived values like <code>VN</code> stop failing with upstream 422 validation errors. (#55695) Thanks @chen-zhang-cs-code.</li>
<li>Discord/replies: preserve leading indentation when stripping inline reply tags so reply-tagged plain text and fenced code blocks keep their formatting. (#55960) Thanks @Nanako0129.</li>
<li>Daemon/status: surface immediate gateway close reasons from lightweight probes and prefer those concrete auth or pairing failures over generic timeouts in <code>openclaw daemon status</code>. (#56282) Thanks @mbelinky.</li>
<li>Agents/failover: classify HTTP 410 errors as retryable timeouts by default while still preserving explicit session-expired, billing, and auth signals from the payload. (#55201) thanks @nikus-pan.</li>
<li>Agents/subagents: restore completion announce delivery for extension channels like BlueBubbles. (#56348)</li>
<li>Plugins/Matrix: load bundled <code>@matrix-org/matrix-sdk-crypto-nodejs</code> through <code>createRequire(...)</code> so E2EE media send and receive keep the package-local native binding lookup working in packaged ESM builds. (#54566) thanks @joelnishanth.</li>
<li>Plugins/Matrix: encrypt E2EE image thumbnails with <code>thumbnail_file</code> while keeping unencrypted-room previews on <code>thumbnail_url</code>, so encrypted Matrix image events keep thumbnail metadata without leaking plaintext previews. (#54711) thanks @frischeDaten.</li>
<li>Telegram/forum topics: keep native <code>/new</code> and <code>/reset</code> routed to the active topic by preserving the topic target on forum-thread command context. (#35963)</li>
<li>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.</li>
<li>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.</li>
<li>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.</li>
<li>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.</li>
<li>Providers/OpenAI-compatible routing: centralize native-vs-proxy request policy so hidden attribution and related OpenAI-family defaults only apply on verified native endpoints across stream, websocket, and shared audio HTTP paths. (#59433) Thanks @vincentkoc.</li>
<li>Providers/Anthropic routing: centralize native-vs-proxy endpoint classification for direct Anthropic <code>service_tier</code> handling so spoofed or proxied hosts do not inherit native Anthropic defaults. (#59608) Thanks @vincentkoc.</li>
<li>Gateway/exec loopback: restore legacy-role fallback for empty paired-device token maps and allow silent local role upgrades so local exec and node clients stop failing with pairing-required errors after <code>2026.3.31</code>. (#59092) Thanks @openperf.</li>
<li>Agents/subagents: pin admin-only subagent gateway calls to <code>operator.admin</code> while keeping <code>agent</code> at least privilege, so <code>sessions_spawn</code> no longer dies on loopback scope-upgrade pairing with <code>close(1008) "pairing required"</code>. (#59555) Thanks @openperf.</li>
<li>Exec approvals/config: strip invalid <code>security</code>, <code>ask</code>, and <code>askFallback</code> values from <code>~/.openclaw/exec-approvals.json</code> during normalization so malformed policy enums fall back cleanly to the documented defaults instead of corrupting runtime policy resolution. (#59112) Thanks @openperf.</li>
<li>Exec approvals/doctor: report host policy sources from the real approvals file path and ignore malformed host override values when attributing effective policy conflicts. (#59367) Thanks @gumadeiras.</li>
<li>Exec/runtime: treat <code>tools.exec.host=auto</code> as routing-only, keep implicit no-config exec on sandbox when available or gateway otherwise, and reject per-call host overrides that would bypass the configured sandbox or host target. (#58897) Thanks @vincentkoc.</li>
<li>Slack/mrkdwn formatting: add built-in Slack mrkdwn guidance in inbound context so Slack replies stop falling back to generic Markdown patterns that render poorly in Slack. (#59100) Thanks @jadewon.</li>
<li>WhatsApp/presence: send <code>unavailable</code> presence on connect in self-chat mode so personal-phone users stop losing all push notifications while the gateway is running. (#59410) Thanks @mcaxtr.</li>
<li>WhatsApp/media: add HTML, XML, and CSS to the MIME map and fall back gracefully for unknown media types instead of dropping the attachment. (#51562) Thanks @bobbyt74.</li>
<li>Matrix/onboarding: restore guided setup in <code>openclaw channels add</code> and <code>openclaw configure --section channels</code>, while keeping custom plugin wizards on the shared <code>setupWizard</code> seam. (#59462) Thanks @gumadeiras.</li>
<li>Matrix/streaming: keep live partial previews for the current assistant block while preserving completed block updates as separate messages when <code>channels.matrix.blockStreaming</code> is enabled. (#59384) Thanks @gumadeiras.</li>
<li>Feishu/comment threads: harden document comment-thread delivery so whole-document comments fall back to <code>add_comment</code>, delayed reply lookups retry more reliably, and user-visible replies avoid reasoning/planning spillover. (#59129) Thanks @wittam-01.</li>
<li>MS Teams/streaming: strip already-streamed text from fallback block delivery when replies exceed the 4000-character streaming limit so long responses stop duplicating content. (#59297) Thanks @bradgroux.</li>
<li>Slack/thread context: filter thread starter and history by the effective conversation allowlist without dropping valid open-room, DM, or group DM context. (#58380) Thanks @jacobtomlinson.</li>
<li>Mattermost/probes: route status probes through the SSRF guard and honor <code>allowPrivateNetwork</code> so connectivity checks stay safe for self-hosted Mattermost deployments. (#58529) Thanks @mappel-nv.</li>
<li>Zalo/webhook replay: scope replay dedupe key by chat and sender so reused message IDs across different chats or senders no longer collide, and harden metadata reads for partially missing payloads. (#58444)</li>
<li>QQBot/structured payloads: restrict local file paths to QQ Bot-owned media storage, block traversal outside that root, reduce path leakage in logs, and keep inline image data URLs working. (#58453) Thanks @jacobtomlinson.</li>
<li>Image generation/providers: route OpenAI, MiniMax, and fal image requests through the shared provider HTTP transport path so custom base URLs, guarded private-network routing, and provider request defaults stay aligned with the rest of provider HTTP. Thanks @vincentkoc.</li>
<li>Image generation/providers: stop inferring private-network access from configured OpenAI, MiniMax, and fal image base URLs, and cap shared HTTP error-body reads so hostile or misconfigured endpoints fail closed without relaxing SSRF policy or buffering unbounded error payloads. Thanks @vincentkoc.</li>
<li>Browser/host inspection: keep static Chrome inspection helpers out of the activated browser runtime so <code>openclaw doctor browser</code> and related checks do not eagerly load the bundled browser plugin. (#59471) Thanks @vincentkoc.</li>
<li>Browser/CDP: normalize trailing-dot localhost absolute-form hosts before loopback checks so remote CDP websocket URLs like <code>ws://localhost.:...</code> rewrite back to the configured remote host. (#59236) Thanks @mappel-nv.</li>
<li>Agents/output sanitization: strip namespaced <code>antml:thinking</code> blocks from user-visible text so Anthropic-style internal monologue tags do not leak into replies. (#59550) Thanks @obviyus.</li>
<li>Kimi Coding/tools: normalize Anthropic tool payloads into the OpenAI-compatible function shape Kimi Coding expects so tool calls stop losing required arguments. (#59440) Thanks @obviyus.</li>
<li>Image tool/paths: resolve relative local media paths against the agent <code>workspaceDir</code> instead of <code>process.cwd()</code> so inputs like <code>inbox/receipt.png</code> pass the local-path allowlist reliably. (#57222) Thanks Priyansh Gupta.</li>
<li>Podman/launch: remove noisy container output from <code>scripts/run-openclaw-podman.sh</code> and align the Podman install guidance with the quieter startup flow. (#59368) Thanks @sallyom.</li>
<li>Plugins/runtime: keep LINE reply directives and browser-backed cleanup/reset flows working even when those plugins are disabled while tightening bundled plugin activation guards. (#59412) Thanks @vincentkoc.</li>
<li>ACP/gateway reconnects: keep ACP prompts alive across transient websocket drops while still failing boundedly when reconnect recovery does not complete. (#59473) Thanks @obviyus.</li>
<li>ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run.</li>
<li>Gateway/session kill: enforce HTTP operator scopes on session kill requests and gate authorization before session lookup so unauthenticated callers cannot probe session existence. (#59128) Thanks @jacobtomlinson.</li>
<li>MS Teams/logging: format non-<code>Error</code> failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into <code>[object Object]</code>. (#59321) Thanks @bradgroux.</li>
<li>Channels/setup: ignore untrusted workspace channel plugins during setup resolution so a shadowing workspace plugin cannot override built-in channel setup/login flows unless explicitly trusted in config. (#59158) Thanks @mappel-nv.</li>
<li>Exec/Windows: restore allowlist enforcement with quote-aware <code>argPattern</code> matching across gateway and node exec, and surface accurate dynamic pre-approved executable hints in the exec tool description. (#56285) Thanks @kpngr.</li>
<li>Gateway: prune empty <code>node-pending-work</code> state entries after explicit acknowledgments and natural expiry so the per-node state map no longer grows indefinitely. (#58179) Thanks @gavyngong.</li>
<li>Webhooks/secret comparison: replace ad-hoc timing-safe secret comparisons across BlueBubbles, Feishu, Mattermost, Telegram, Twilio, and Zalo webhook handlers with the shared <code>safeEqualSecret</code> helper and reject empty auth tokens in BlueBubbles. (#58432) Thanks @eleqtrizit.</li>
<li>OpenShell/mirror: constrain <code>remoteWorkspaceDir</code> and <code>remoteAgentWorkspaceDir</code> to the managed <code>/sandbox</code> and <code>/agent</code> roots, and keep mirror sync from overwriting or removing user-added shell roots during config synchronization. (#58515) Thanks @eleqtrizit.</li>
<li>Plugins/activation: preserve explicit, auto-enabled, and default activation provenance plus reason metadata across CLI, gateway bootstrap, and status surfaces so plugin enablement state stays accurate after auto-enable resolution. (#59641) Thanks @vincentkoc.</li>
<li>Exec/env: block additional host environment override pivots for package roots, language runtimes, compiler include paths, and credential/config locations so request-scoped exec cannot redirect trusted toolchains or config lookups. (#59233) Thanks @drobison00.</li>
<li>Dotenv/workspace overrides: block workspace <code>.env</code> files from overriding <code>OPENCLAW_PINNED_PYTHON</code> and <code>OPENCLAW_PINNED_WRITE_PYTHON</code> so trusted helper interpreters cannot be redirected by repo-local env injection. (#58473) Thanks @eleqtrizit.</li>
<li>Plugins/install: accept JSON5 syntax in <code>openclaw.plugin.json</code> and bundle <code>plugin.json</code> manifests during install/validation, so third-party plugins with trailing commas, comments, or unquoted keys no longer fail to install. (#59084) Thanks @singleGanghood.</li>
<li>Telegram/exec approvals: rewrite shared <code>/approve … allow-always</code> callback payloads to <code>/approve … always</code> before Telegram button rendering so plugin approval IDs still fit Telegram's <code>callback_data</code> limit and keep the Allow Always action visible. (#59217) Thanks @jameslcowan.</li>
<li>Cron/exec timeouts: surface timed-out <code>exec</code> and <code>bash</code> failures in isolated cron runs even when <code>verbose: off</code>, including custom session-target cron jobs, so scheduled runs stop failing silently. (#58247) Thanks @skainguyen1412.</li>
<li>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.</li>
<li>Node-host/exec approvals: bind <code>pnpm dlx</code> invocations through the approval planner's mutable-script path so the effective runtime command is resolved for approval instead of being left unbound. (#58374)</li>
<li>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 <code>SYSTEM_RUN_DENIED</code>. (#58977) Thanks @Starhappysh.</li>
<li>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.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>macOS/Voice Wake: add the Voice Wake option to trigger Talk Mode. (#58490) Thanks @SmoothExec.</li>
<li>Tasks/chat: add <code>/tasks</code> as a chat-native background task board for the current session, with recent task details and agent-local fallback counts when no linked tasks are visible. Related #54226. Thanks @vincentkoc.</li>
<li>Web search/SearXNG: add the bundled SearXNG provider plugin for <code>web_search</code> with configurable host support. (#57317) Thanks @cgdusek.</li>
<li>Telegram/errors: add configurable <code>errorPolicy</code> and <code>errorCooldownMs</code> controls so Telegram can suppress repeated delivery errors per account, chat, and topic without muting distinct failures. (#51914) Thanks @chinar-amrutkar</li>
<li>Gateway/webchat: make <code>chat.history</code> text truncation configurable with <code>gateway.webchat.chatHistoryMaxChars</code> and per-request <code>maxChars</code>, while preserving silent-reply filtering and existing default payload limits. (#58900)</li>
<li>Amazon Bedrock/Guardrails: add Bedrock Guardrails support to the bundled provider. (#58588) Thanks @MikeORed.</li>
<li>ZAI/models: add <code>glm-5.1</code> and <code>glm-5v-turbo</code> to the bundled Z.AI provider catalog. (#58793) Thanks @tomsun28</li>
<li>Agents/default params: add <code>agents.defaults.params</code> for global default provider parameters. (#58548) Thanks @lpender.</li>
<li>Agents/failover: cap prompt-side and assistant-side same-provider auth-profile retries for rate-limit failures before cross-provider model fallback, add the <code>auth.cooldowns.rateLimitedProfileRotations</code> knob, and document the new fallback behavior. (#58707) Thanks @Forgely3D</li>
<li>Agents/compaction: resolve <code>agents.defaults.compaction.model</code> consistently for manual <code>/compact</code> and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg</li>
<li>Cron/tools allowlist: add <code>openclaw cron --tools</code> for per-job tool allowlists. (#58504) Thanks @andyk-ms.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Chat/error replies: stop leaking raw provider/runtime failures into external chat channels, return a friendly retry message instead, and add a specific <code>/new</code> hint for Bedrock toolResult/toolUse session mismatches. (#58831) Thanks @ImLukeF.</li>
<li>Sessions/model switching: keep <code>/model</code> changes queued behind busy runs instead of interrupting the active turn, and retarget queued followups so later work picks up the new model as soon as the current turn finishes.</li>
<li>Web UI/OpenResponses: preserve rewritten stream snapshots in webchat and keep OpenResponses final streamed text aligned when models rewind earlier output. (#58641) Thanks @neeravmakwana</li>
<li>Discord/inbound media: pass Discord attachment and sticker downloads through the shared idle-timeout and worker-abort path so slow or stuck inbound media fetches stop hanging message processing. (#58593) Thanks @aquaright1</li>
<li>Telegram/retries: keep non-idempotent sends on the strict safe-send path, retry wrapped pre-connect failures, and preserve <code>429</code> / <code>retry_after</code> backoff for safe delivery retries. (#51895) Thanks @chinar-amrutkar</li>
<li>Telegram/exec approvals: route topic-aware exec approval followups through Telegram-owned threading and approval-target parsing, so forum-topic approvals stay in the originating topic instead of falling back to the root chat. (#58783)</li>
<li>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</li>
<li>Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae</li>
<li>QQBot/voice: lazy-load <code>silk-wasm</code> in <code>audio-convert.ts</code> 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.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.28/OpenClaw-2026.3.28.zip" length="25811288" type="application/octet-stream" sparkle:edSignature="SJp4ptVaGlOIXRPevS89DbfN2WKP0bKMXQoaT0fmLhy7pataDfHN0kxC3zu6P0Q/HtsxaESEhJUw48SCUNNKDA=="/>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.2/OpenClaw-2026.4.2.zip" length="25843797" type="application/octet-stream" sparkle:edSignature="bNNXr4BJEU8W7ghXOujLJTYHZL2PL/r/p4llGBw0BFL+46mJ2Bir+IK8XQaCj5zp+O5JSuh5mY+Y/Nrq6TR7Cg=="/>
</item>
<item>
<title>2026.3.24</title>
<pubDate>Wed, 25 Mar 2026 17:06:31 +0000</pubDate>
<title>2026.4.1</title>
<pubDate>Wed, 01 Apr 2026 17:14:12 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026032490</sparkle:version>
<sparkle:shortVersionString>2026.3.24</sparkle:shortVersionString>
<sparkle:version>2026040190</sparkle:version>
<sparkle:shortVersionString>2026.4.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.24</h2>
<h3>Breaking</h3>
<description><![CDATA[<h2>OpenClaw 2026.4.1</h2>
<h3>Changes</h3>
<ul>
<li>Gateway/OpenAI compatibility: add <code>/v1/models</code> and <code>/v1/embeddings</code>, and forward explicit model overrides through <code>/v1/chat/completions</code> and <code>/v1/responses</code> for broader client and RAG compatibility. Thanks @vincentkoc.</li>
<li>Agents/tools: make <code>/tools</code> show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live "Available Right Now" section in the Control UI so it is easier to see what will work before you ask.</li>
<li>Microsoft Teams: migrate to the official Teams SDK and add AI-agent UX best practices including streaming 1:1 replies, welcome cards with prompt starters, feedback/reflection, informative status updates, typing indicators, and native AI labeling. (#51808)</li>
<li>Microsoft Teams: add message edit and delete support for sent messages, including in-thread fallbacks when no explicit target is provided. (#49925)</li>
<li>Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.</li>
<li>Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.</li>
<li>Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing <code>Options:</code> lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.</li>
<li>CLI/containers: add <code>--container</code> and <code>OPENCLAW_CONTAINER</code> to run <code>openclaw</code> commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.</li>
<li>Discord/auto threads: add optional <code>autoThreadName: "generated"</code> naming so new auto-created threads can be renamed asynchronously with concise LLM-generated titles while keeping the existing message-based naming as the default. (#43366) Thanks @davidguttman.</li>
<li>Plugins/hooks: add <code>before_dispatch</code> with canonical inbound metadata and route handled replies through the normal final-delivery path, preserving TTS and routed delivery semantics. (#50444) Thanks @gfzhx.</li>
<li>Control UI/agents: convert agent workspace file rows to expandable <code><details></code> with lazy-loaded inline markdown preview, and add comprehensive <code>.sidebar-markdown</code> styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.</li>
<li>Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate <code>@create-markdown/preview</code> v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev.</li>
<li>macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.</li>
<li>CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in <code>openclaw skills info</code> output. (#53411) Thanks @BunsDev.</li>
<li>macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.</li>
<li>Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.</li>
<li>Runtime/install: lower the supported Node 22 floor to <code>22.14+</code> while continuing to recommend Node 24, so npm installs and self-updates do not strand Node 22.14 users on older releases.</li>
<li>CLI/update: preflight the target npm package <code>engines.node</code> before <code>openclaw update</code> runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release.</li>
<li>Tasks/chat: add <code>/tasks</code> as a chat-native background task board for the current session, with recent task details and agent-local fallback counts when no linked tasks are visible. Related #54226. Thanks @vincentkoc.</li>
<li>Web search/SearXNG: add the bundled SearXNG provider plugin for <code>web_search</code> with configurable host support. (#57317) Thanks @cgdusek.</li>
<li>Amazon Bedrock/Guardrails: add Bedrock Guardrails support to the bundled provider. (#58588) Thanks @MikeORed.</li>
<li>macOS/Voice Wake: add the Voice Wake option to trigger Talk Mode. (#58490) Thanks @SmoothExec.</li>
<li>Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and <code>feishu_drive</code> comment actions for document collaboration workflows. (#58497) Thanks @wittam-01.</li>
<li>Gateway/webchat: make <code>chat.history</code> text truncation configurable with <code>gateway.webchat.chatHistoryMaxChars</code> and per-request <code>maxChars</code>, while preserving silent-reply filtering and existing default payload limits. (#58900)</li>
<li>Agents/default params: add <code>agents.defaults.params</code> for global default provider parameters. (#58548) Thanks @lpender.</li>
<li>Agents/failover: cap prompt-side and assistant-side same-provider auth-profile retries for rate-limit failures before cross-provider model fallback, add the <code>auth.cooldowns.rateLimitedProfileRotations</code> knob, and document the new fallback behavior. (#58707) Thanks @Forgely3D</li>
<li>Cron/tools allowlist: add <code>openclaw cron --tools</code> for per-job tool allowlists. (#58504) Thanks @andyk-ms.</li>
<li>Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths.</li>
<li>WhatsApp/reactions: add <code>reactionLevel</code> guidance for agent reactions. Thanks @mcaxtr.</li>
<li>Telegram/errors: add configurable <code>errorPolicy</code> and <code>errorCooldownMs</code> controls so Telegram can suppress repeated delivery errors per account, chat, and topic without muting distinct failures. (#51914) Thanks @chinar-amrutkar</li>
<li>ZAI/models: add <code>glm-5.1</code> and <code>glm-5v-turbo</code> to the bundled Z.AI provider catalog. (#58793) Thanks @tomsun28</li>
<li>Agents/compaction: resolve <code>agents.defaults.compaction.model</code> consistently for manual <code>/compact</code> and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Outbound media/local files: align outbound media access with the configured fs policy so host-local files and inbound-media paths keep sending when <code>workspaceOnly</code> is off, while strict workspace-only agents remain sandboxed.</li>
<li>Security/sandbox media dispatch: close the <code>mediaUrl</code>/<code>fileUrl</code> alias bypass so outbound tool and message actions cannot escape media-root restrictions. (#54034)</li>
<li>Gateway/restart sentinel: wake the interrupted agent session via heartbeat after restart instead of only sending a best-effort restart note, retry outbound delivery once on transient failure, and preserve explicit thread/topic routing through the wake path so replies land in the correct Telegram topic or Slack thread. (#53940) Thanks @VACInc.</li>
<li>Docker/setup: avoid the pre-start <code>openclaw-cli</code> shared-network namespace loop by routing setup-time onboard/config writes through <code>openclaw-gateway</code>, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn.</li>
<li>Gateway/channels: keep channel startup sequential while isolating per-channel boot failures, so one broken channel no longer blocks later channels from starting. (#54215) Thanks @JonathanJing.</li>
<li>Embedded runs/secrets: stop unresolved <code>SecretRef</code> config from crashing embedded agent runs by falling back to the resolved runtime snapshot when needed. Fixes #45838.</li>
<li>WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner <code>/status</code>, <code>/new</code>, and <code>/activation</code> commands from linked-account <code>fromMe</code> traffic. (#53624) Thanks @w-sss.</li>
<li>WhatsApp/reply-to-bot detection: restore implicit group reply detection by unwrapping <code>botInvokeMessage</code> payloads and reading <code>selfLid</code> from <code>creds.json</code>, so reply-based mentions reach the bot again in linked-account group chats.</li>
<li>Telegram/forum topics: recover <code>#General</code> topic <code>1</code> routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo</li>
<li>Discord/gateway supervision: centralize gateway error handling behind a lifetime-owned supervisor so early, active, and late-teardown Carbon gateway errors stay classified consistently and stop surfacing as process-killing teardown crashes.</li>
<li>Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870.</li>
<li>ACP/direct chats: always deliver a terminal ACP result when final TTS does not yield audio, even if block text already streamed earlier, and skip redundant empty-text final synthesis. (#53692) Thanks @w-sss.</li>
<li>Telegram/outbound errors: preserve actionable 403 membership/block/kick details and treat <code>bot not a member</code> as a permanent delivery failure so Telegram sends stop retrying doomed chats. (#53635) Thanks @w-sss.</li>
<li>Telegram/photos: preflight Telegram photo dimension and aspect-ratio rules, and fall back to document sends when image metadata is invalid or unavailable so photo uploads stop failing with <code>PHOTO_INVALID_DIMENSIONS</code>. (#52545) Thanks @hnshah.</li>
<li>Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc.</li>
<li>Chat/error replies: stop leaking raw provider/runtime failures into external chat channels, return a friendly retry message instead, and add a specific <code>/new</code> hint for Bedrock toolResult/toolUse session mismatches. (#58831) Thanks @ImLukeF.</li>
<li>Gateway/reload: ignore startup config writes by persisted hash in the config reloader so generated auth tokens and seeded Control UI origins do not trigger a restart loop, while real <code>gateway.auth.*</code> edits still require restart. (#58678) Thanks @yelog</li>
<li>Tasks/gateway: keep the task registry maintenance sweep from stalling the gateway event loop under synchronous SQLite pressure, so upgraded gateways stop hanging about a minute after startup. (#58670) Thanks @openperf</li>
<li>Tasks/status: hide stale completed background tasks from <code>/status</code> and <code>session_status</code>, prefer live task context, and show recent failures only when no active work remains. (#58661) Thanks @vincentkoc</li>
<li>Tasks/gateway: re-check the current task record before maintenance marks runs lost or prunes them, so a task heartbeat or cleanup update that lands during a sweep no longer gets overwritten by stale snapshot state.</li>
<li>Exec/approvals: honor <code>exec-approvals.json</code> security defaults when inline or configured tool policy is unset, and keep Slack and Discord native approval handling aligned with inferred approvers and real channel enablement so remote exec stops falling into false approval timeouts and disabled states. Thanks @scoootscooob and @vincentkoc.</li>
<li>Exec/approvals: make <code>allow-always</code> persist as durable user-approved trust instead of behaving like <code>allow-once</code>, reuse exact-command trust on shell-wrapper paths that cannot safely persist an executable allowlist entry, keep static allowlist entries from silently bypassing <code>ask:"always"</code>, and require explicit approval when Windows cannot build an allowlist execution plan instead of hard-dead-ending remote exec. Thanks @scoootscooob and @vincentkoc.</li>
<li>Exec/cron: resolve isolated cron no-route approval dead-ends from the effective host fallback policy when trusted automation is allowed, and make <code>openclaw doctor</code> warn when <code>tools.exec</code> is broader than <code>~/.openclaw/exec-approvals.json</code> so stricter host-policy conflicts are explicit. Thanks @scoootscooob and @vincentkoc.</li>
<li>Sessions/model switching: keep <code>/model</code> changes queued behind busy runs instead of interrupting the active turn, and retarget queued followups so later work picks up the new model as soon as the current turn finishes.</li>
<li>Gateway/HTTP: skip failing HTTP request stages so one broken facade no longer forces every HTTP endpoint to return 500. (#58746) Thanks @yelog</li>
<li>Gateway/nodes: stop pinning live node commands to the approved node-pair record. Node pairing remains a trust/token flow, while per-node <code>system.run</code> policy stays in that node's exec approvals config. Fixes #58824.</li>
<li>WebChat/exec approvals: use native approval UI guidance in agent system prompts instead of telling agents to paste manual <code>/approve</code> commands in webchat sessions. Thanks @vincentkoc.</li>
<li>Web UI/OpenResponses: preserve rewritten stream snapshots in webchat and keep OpenResponses final streamed text aligned when models rewind earlier output. (#58641) Thanks @neeravmakwana</li>
<li>Discord/inbound media: pass Discord attachment and sticker downloads through the shared idle-timeout and worker-abort path so slow or stuck inbound media fetches stop hanging message processing. (#58593) Thanks @aquaright1</li>
<li>Telegram/retries: keep non-idempotent sends on the strict safe-send path, retry wrapped pre-connect failures, and preserve <code>429</code> / <code>retry_after</code> backoff for safe delivery retries. (#51895) Thanks @chinar-amrutkar</li>
<li>Telegram/exec approvals: route topic-aware exec approval followups through Telegram-owned threading and approval-target parsing, so forum-topic approvals stay in the originating topic instead of falling back to the root chat. (#58783)</li>
<li>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</li>
<li>Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae</li>
<li>Channels/QQ Bot: keep <code>/bot-logs</code> export gated behind a truly explicit QQBot allowlist, rejecting wildcard and mixed wildcard entries while preserving the real framework command path. Thanks @vincentkoc.</li>
<li>Channels/plugins: keep bundled channel plugins loadable from legacy <code>channels.<id></code> config even under restrictive plugin allowlists, and make <code>openclaw doctor</code> warn only on real plugin blockers instead of misleading setup guidance. (#58873) Thanks @obviyus</li>
<li>Plugins/bundled runtimes: restore externalized bundled plugin runtime dependency staging across packed installs, Docker builds, and local runtime staging so bundled plugins keep their declared runtime deps after the 2026.3.31 externalization change. (#58782)</li>
<li>LINE/runtime: resolve the packaged runtime contract from the built <code>dist/plugins/runtime</code> layout so LINE channels start correctly again after global npm installs on <code>2026.3.31</code>. (#58799) Thanks @vincentkoc.</li>
<li>MiniMax/plugins: auto-enable the bundled MiniMax plugin for API-key auth/config so MiniMax image generation and other plugin-owned capabilities load without manual plugin allowlisting. (#57127) Thanks @tars90percent.</li>
<li>Ollama/model picker: show only Ollama models after provider selection in the CLI picker. (#55290) Thanks @Luckymingxuan.</li>
<li>CDP/profiles: prefer <code>cdpPort</code> over stale WebSocket URLs so browser automation reconnects cleanly. (#58499) Thanks @Mlightsnow.</li>
<li>Media/paths: resolve relative <code>MEDIA</code> paths against the agent workspace so local attachment references keep working. (#58624) Thanks @aquaright1.</li>
<li>Memory/session indexing: keep full reindexes from skipping session transcripts when sync is triggered by <code>session-start</code> or <code>watch</code>, so restart-driven reindexes preserve session memory. (#39732) Thanks @upupc</li>
<li>Memory/QMD: prefer <code>--mask</code> over <code>--glob</code> when creating QMD collections so default memory collections keep their intended patterns and stop colliding on restart. (#58643) Thanks @GitZhangChi.</li>
<li>Subagents/tasks: keep subagent completion and cleanup from crashing when task-registry writes fail, so a corrupt or missing task row no longer takes down the gateway during lifecycle finalization. Thanks @vincentkoc.</li>
<li>Sandbox/browser: compare browser runtime inspection against <code>agents.defaults.sandbox.browser.image</code> so <code>openclaw sandbox list --browser</code> stops reporting healthy browser containers as image mismatches. (#58759) Thanks @sandpile.</li>
<li>Plugins/install: forward <code>--dangerously-force-unsafe-install</code> through archive and npm-spec plugin installs so the documented override reaches the security scanner on those install paths. (#58879) Thanks @ryanlee-gemini.</li>
<li>Auto-reply/commands: strip inbound metadata before slash command detection so wrapped <code>/model</code>, <code>/new</code>, and <code>/status</code> commands are recognized. (#58725) Thanks @Mlightsnow.</li>
<li>Agents/Anthropic: preserve thinking blocks and signatures across replay, cache-control patching, and context pruning so compacted Anthropic sessions continue working instead of failing on later turns. (#58916) Thanks @obviyus</li>
<li>Agents/failover: unify structured and raw provider error classification so provider-specific <code>400</code>/<code>422</code> payloads no longer get forced into generic format failures before retry, billing, or compaction logic can inspect them. (#58856) Thanks @aaron-he-zhu.</li>
<li>Auth profiles/store: coerce misplaced SecretRef objects out of plaintext <code>key</code> and <code>token</code> fields during store load so agents without ACP runtime stop crashing on <code>.trim()</code> after upgrade. (#58923) Thanks @openperf.</li>
<li>ACPX/runtime: repair <code>queue owner unavailable</code> session recovery by replacing dead named sessions and resuming the backend session when ACPX exposes a stable session id, so the first ACP prompt no longer inherits a dead handle. (#58669) Thanks @neeravmakwana</li>
<li>ACPX/runtime: retry dead-session queue-owner repair without <code>--resume-session</code> when the reported ACPX session id is stale, so recovery still creates a fresh named session instead of failing session init. Thanks @obviyus.</li>
<li>Auth/OpenAI Codex: persist plugin-refreshed OAuth credentials to <code>auth-profiles.json</code> before returning them, so rotated Codex refresh tokens survive restart and stop falling into <code>refresh_token_reused</code> loops. (#53082)</li>
<li>Discord/gateway: hand reconnect ownership back to Carbon, keep runtime status aligned with close/reconnect state, and force-stop sockets that open without reaching READY so Discord monitors recover promptly instead of waiting on stale health timeouts. (#59019) Thanks @obviyus</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.24/OpenClaw-2026.3.24.zip" length="24749233" type="application/octet-stream" sparkle:edSignature="gLm2VvI+PPEnNy4klYSs9WmZLkJTF5BcfFparrtPdnmeE4xgc8kFfICg445I039ev9/A6xGav7pm08reUHDcAg=="/>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.1/OpenClaw-2026.4.1.zip" length="25841903" type="application/octet-stream" sparkle:edSignature="0TPiyshScmwDbgs626JU08NOUUFJmIsVFa5g0xmizfl64Fr+IoT4l/dkXarFqbZAJidtj5WN7Bff7fG8ye/7AA=="/>
</item>
<item>
<title>2026.3.23</title>
<pubDate>Mon, 23 Mar 2026 16:59:51 -0700</pubDate>
<title>2026.3.31</title>
<pubDate>Tue, 31 Mar 2026 21:47:15 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026032390</sparkle:version>
<sparkle:shortVersionString>2026.3.23</sparkle:shortVersionString>
<sparkle:version>2026033190</sparkle:version>
<sparkle:shortVersionString>2026.3.31</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.23</h2>
<description><![CDATA[<h2>OpenClaw 2026.3.31</h2>
<h3>Breaking</h3>
<ul>
<li>Nodes/exec: remove the duplicated <code>nodes.run</code> shell wrapper from the CLI and agent <code>nodes</code> tool so node shell execution always goes through <code>exec host=node</code>, keeping node-specific capabilities on <code>nodes invoke</code> and the dedicated media/location/notify actions.</li>
<li>Plugin SDK: deprecate the legacy provider compat subpaths plus the older bundled provider setup and channel-runtime compatibility shims, emit migration warnings, and keep the current documented <code>openclaw/plugin-sdk/*</code> entrypoints plus local <code>api.ts</code> / <code>runtime-api.ts</code> barrels as the forward path ahead of a future major-release removal.</li>
<li>Skills/install and Plugins/install: built-in dangerous-code <code>critical</code> findings and install-time scan failures now fail closed by default, so plugin installs and gateway-backed skill dependency installs that previously succeeded may now require an explicit dangerous override such as <code>--dangerously-force-unsafe-install</code> to proceed.</li>
<li>Gateway/auth: <code>trusted-proxy</code> now rejects mixed shared-token configs, and local-direct fallback requires the configured token instead of implicitly authenticating same-host callers. Thanks @zhangning-agent, @jacobtomlinson, and @vincentkoc.</li>
<li>Gateway/node commands: node commands now stay disabled until node pairing is approved, so device pairing alone is no longer enough to expose declared node commands. (#57777) Thanks @jacobtomlinson.</li>
<li>Gateway/node events: node-originated runs now stay on a reduced trusted surface, so notification-driven or node-triggered flows that previously relied on broader host/session tool access may need adjustment. (#57691) Thanks @jacobtomlinson.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>ACP/plugins: add an explicit default-off ACPX plugin-tools MCP bridge config, document the trust boundary, and harden the built-in bridge packaging/logging path so global installs and stdio MCP sessions work reliably. (#56867) Thanks @joe2643.</li>
<li>Agents/LLM: add a configurable idle-stream timeout for embedded runner requests so stalled model streams abort cleanly instead of hanging until the broader run timeout fires. (#55072) Thanks @liuy.</li>
<li>Agents/MCP: materialize bundle MCP tools with provider-safe names (<code>serverName__toolName</code>), support optional <code>streamable-http</code> transport selection plus per-server connection timeouts, and preserve real tool results from aborted/error turns unless truncation explicitly drops them. (#49505) Thanks @ziomancer.</li>
<li>Android/notifications: add notification-forwarding controls with package filtering, quiet hours, rate limiting, and safer picker behavior for forwarded notification events. (#40175) Thanks @nimbleenigma.</li>
<li>Background tasks: turn tasks into a real shared background-run control plane instead of ACP-only bookkeeping by unifying ACP, subagent, cron, and background CLI execution under one SQLite-backed ledger, routing detached lifecycle updates through the executor seam, adding audit/maintenance/status visibility, tightening auto-cleanup and lost-run recovery, improving task awareness in internal status/tool surfaces, and clarifying the split between heartbeat/main-session automation and detached scheduled runs. Thanks @mbelinky and @vincentkoc.</li>
<li>Background tasks: add the first linear task flow control surface with <code>openclaw flows list|show|cancel</code>, keep manual multi-task flows separate from one-task auto-sync flows, and surface doctor recovery hints for obviously orphaned or broken flow/task linkage. Thanks @mbelinky and @vincentkoc.</li>
<li>Channels/QQ Bot: add QQ Bot as a bundled channel plugin with multi-account setup, SecretRef-aware credentials, slash commands, reminders, and media send/receive support. (#52986) Thanks @sliverp.</li>
<li>Diffs: skip unused viewer-versus-file SSR preload work so <code>diffs</code> view-only and file-only runs do less render work while keeping mode outputs aligned. (#57909) thanks @gumadeiras.</li>
<li>Tasks: add a minimal SQLite-backed task flow registry plus task-to-flow linkage scaffolding, so orchestrated work can start gaining a first-class parent record without changing current task delivery behavior. Thanks @mbelinky and @vincentkoc.</li>
<li>Tasks: persist blocked state on one-task task flows and let the same flow reopen cleanly on retry, so blocked detached work can carry a parent-level reason and continue without fragmenting into a new job. Thanks @mbelinky and @vincentkoc.</li>
<li>Tasks: route one-task ACP and subagent updates through a parent task-flow owner context, so detached work can emerge back through the intended parent thread/session instead of speaking only as a raw child task. Thanks @mbelinky and @vincentkoc.</li>
<li>LINE/outbound media: add LINE image, video, and audio outbound sends on the LINE-specific delivery path, including explicit preview/tracking handling for videos while keeping generic media sends on the existing image-only route. (#45826) Thanks @masatohoshino.</li>
<li>Matrix/history: add optional room history context for Matrix group triggers via <code>channels.matrix.historyLimit</code>, with per-agent watermarks and retry-safe snapshots so failed trigger retries do not drift into newer room messages. (#57022) thanks @chain710.</li>
<li>Matrix/network: add explicit <code>channels.matrix.proxy</code> config for routing Matrix traffic through an HTTP(S) proxy, including account-level overrides and matching probe/runtime behavior. (#56931) thanks @patrick-yingxi-pan.</li>
<li>Matrix/streaming: add draft streaming so partial Matrix replies update the same message in place instead of sending a new message for each chunk. (#56387) Thanks @jrusz.</li>
<li>Matrix/threads: add per-DM <code>threadReplies</code> overrides and keep thread session isolation aligned with the effective room or DM thread policy from the triggering message onward. (#57995) thanks @teconomix.</li>
<li>MCP: add remote HTTP/SSE server support for <code>mcp.servers</code> URL configs, including auth headers and safer config redaction for MCP credentials. (#50396) Thanks @dhananjai1729.</li>
<li>Memory/QMD: add per-agent <code>memorySearch.qmd.extraCollections</code> so agents can opt into cross-agent session search without flattening every transcript collection into one shared QMD namespace. Thanks @vincentkoc.</li>
<li>Microsoft Teams/member info: add a Graph-backed member info action so Teams automations and tools can resolve channel member details directly from Microsoft Graph. (#57528) Thanks @sudie-codes.</li>
<li>Nostr/inbound DMs: verify inbound event signatures before pairing or sender-authorization side effects, so forged DM events no longer create pairing requests or trigger reply attempts. Thanks @smaeljaish771 and @vincentkoc.</li>
<li>OpenAI/Responses: forward configured <code>text.verbosity</code> across Responses HTTP and WebSocket transports, surface it in <code>/status</code>, and keep per-agent verbosity precedence aligned with runtime behavior. (#47106) Thanks @merc1305 and @vincentkoc.</li>
<li>Pi/Codex: add native Codex web search support for embedded Pi runs, including config/docs/wizard coverage and managed-tool suppression when native Codex search is active. (#46579) Thanks @Evizero.</li>
<li>Slack/exec approvals: add native Slack approval routing and approver authorization so exec approval prompts can stay in Slack instead of falling back to the Web UI or terminal. Thanks @vincentkoc.</li>
<li>TTS: Add structured provider diagnostics and fallback attempt analytics. (#57954) Thanks @joshavant.</li>
<li>WhatsApp/reactions: agents can now react with emoji on incoming WhatsApp messages, enabling more natural conversational interactions like acknowledging a photo with ❤️ instead of typing a reply. Thanks @mcaxtr.</li>
<li>Agents/BTW: force <code>/btw</code> side questions to disable provider reasoning so Anthropic adaptive-thinking sessions stop failing with <code>No BTW response generated</code>. Fixes #55376. Thanks @Catteres and @vincentkoc.</li>
<li>CLI/onboarding: reset the remote gateway URL prompt to the safe loopback default after declining a discovered endpoint, so onboarding does not keep a previously rejected remote URL. (#57828)</li>
<li>Agents/exec defaults: honor per-agent <code>tools.exec</code> defaults when no inline directive or session override is present, so configured exec host, security, ask, and node settings actually apply. (#57689)</li>
<li>Sandbox/networking: sanitize SSH subprocess env vars through the shared sandbox policy and route marketplace archive downloads plus Ollama discovery, auth, and pull requests through the guarded fetch path so sandboxed execution and remote fetches follow the repo's trust boundaries. (#57848, #57850)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc.</li>
<li>Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc.</li>
<li>ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so <code>openclaw skills ...</code> and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034.</li>
<li>ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.</li>
<li>ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.</li>
<li>Plugins/message tool: make Discord <code>components</code> and Slack <code>blocks</code> optional again, and route Feishu <code>message(..., media=...)</code> sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.</li>
<li>Gateway/model pricing: stop <code>openrouter/auto</code> pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and <code>usage.cost</code> again. Fixes #53035. Thanks @vincentkoc.</li>
<li>Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach <code>openclaw doctor --fix</code> to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.</li>
<li>Agents/web_search: use the active runtime <code>web_search</code> provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.</li>
<li>Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry.</li>
<li>Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so <code>plugins.slots.memory="memory-lancedb"</code> works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.</li>
<li>Config/plugins: treat stale unknown <code>plugins.allow</code> ids as warnings instead of fatal config errors, so recovery commands like <code>plugins install</code>, <code>doctor --fix</code>, and <code>status</code> still run when a plugin is missing locally. Fixes #52992. Thanks @vincentkoc.</li>
<li>Doctor/WhatsApp: stop auto-enable from appending built-in channel ids like <code>whatsapp</code> to <code>plugins.allow</code>, so <code>openclaw doctor --fix</code> no longer writes schema-invalid plugin allowlist entries when repairing built-in channels. Fixes #52931. Thanks @vincentkoc.</li>
<li>Telegram/auto-reply: preserve same-chat inbound debounce order without stranding stale busy-session followups, and keep same-key overflow turns ordered when tracked debounce keys are saturated. (#52998) Thanks @osolmaz.</li>
<li>Discord/commands: return an explicit unauthorized reply for privileged native slash commands instead of falling through to Discord's misleading generic completion when auth gates reject the sender. Fixes #53041. Thanks @scoootscooob.</li>
<li>Channels/catalog: let external channel catalogs override shipped fallback metadata and honor overridden npm specs during channel setup, so custom channel catalogs no longer fall back to bundled packages when a channel id matches. (#52988)</li>
<li>Voice-call/Plivo: stabilize Plivo v2 replay keys so webhook retries and replay protection stop colliding on valid follow-up deliveries.</li>
<li>Agents/skills: prefer the active resolved runtime snapshot for embedded skill config and env injection, so <code>skills.entries.<skill>.apiKey</code> SecretRefs resolve correctly during embedded startup instead of failing on raw source config. Fixes #53098. Thanks @vincentkoc.</li>
<li>Agents/subagents: recheck timed-out worker waits against the latest runtime snapshot before sending completion events, so fast-finishing workers stop being reported as timed out when they actually succeeded. Fixes #53106. Thanks @vincentkoc.</li>
<li>Agents/Anthropic: preserve latest assistant thinking and redacted-thinking block ordering during transcript image sanitization so follow-up turns do not trip Anthropic's unmodified-thinking validation. (#52961) Thanks @vincentkoc.</li>
<li>Gateway/probe: stop successful gateway handshakes from timing out as unreachable while post-connect detail RPCs are still loading, so slow devices report a reachable RPC failure instead of a false negative dead gateway. Fixes #52927. Thanks @vincentkoc.</li>
<li>Gateway/supervision: stop lock conflicts from crash-looping under launchd and systemd by keeping the duplicate process in a retry wait instead of exiting as a failure while another healthy gateway still owns the lock. Fixes #52922. Thanks @vincentkoc.</li>
<li>Gateway/auth: require auth for canvas routes and admin scope for agent session reset, so anonymous canvas access and non-admin reset requests fail closed.</li>
<li>Release/install: keep previously released bundled plugins and Control UI assets in published openclaw npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.</li>
<li>Slack: stop retry-driven duplicate replies when draft-finalization edits fail ambiguously, and log configured allowlisted users/channels by readable name instead of raw IDs.</li>
<li>Agents/OpenAI Responses: normalize raw bundled MCP tool schemas on the WebSocket/Responses path so bare-object, object-ish, and top-level union MCP tools no longer get rejected by OpenAI during tool registration. (#58299) Thanks @yelog.</li>
<li>ACP/security: replace ACP's dangerous-tool name override with semantic approval classes, so only narrow readonly reads/searches can auto-approve while indirect exec-capable and control-plane tools always require explicit prompt approval. Thanks @vincentkoc.</li>
<li>ACP/sessions_spawn: register ACP child runs for completion tracking and lifecycle cleanup, and make registration-failure cleanup explicitly best-effort so callers do not assume an already-started ACP turn was fully aborted. (#40885) Thanks @xaeon2026 and @vincentkoc.</li>
<li>ACP/tasks: mark cleanly exited ACP runs as blocked when they end on deterministic write or authorization blockers, and wake the parent session with a follow-up instead of falsely reporting success.</li>
<li>ACPX/runtime: derive the bundled ACPX expected version from the extension package metadata instead of hardcoding a separate literal, so plugin-local ACPX installs stop drifting out of health-check parity after version bumps. (#49089) Thanks @jiejiesks and @vincentkoc.</li>
<li>Agents/Anthropic failover: treat Anthropic <code>api_error</code> payloads with <code>An unexpected error occurred while processing the response</code> as transient so retry/fallback can engage instead of surfacing a terminal failure. (#57441) Thanks @zijiess and @vincentkoc.</li>
<li>Agents/compaction: keep late compaction-retry completions from double-resolving finished compaction futures, so interrupted or timed-out compactions stop surfacing spurious second-completion races. (#57796) Thanks @joshavant.</li>
<li>Agents/disabled providers: make disabled providers disappear from default model selection and embedded provider fallback, while letting explicitly pinned disabled providers fail with a clear config error instead of silently taking traffic. (#57735) Thanks @rileybrown-dev and @vincentkoc.</li>
<li>Agents/OAuth output: force exec-host OAuth output readers through the gateway fs policy so embedded gateway runs stop crashing when provider auth writes land outside the current sandbox workspace. (#58249) Thanks @joshavant.</li>
<li>Agents/system prompt: fix <code>agent.name</code> interpolation in the embedded runtime system prompt and make provider/model fallback text reflect the effective runtime selection after start. (#57625) Thanks @StllrSvr and @vincentkoc.</li>
<li>Android/device info: read the app's version metadata from the package manager instead of hidden APIs so Android 15+ onboarding and device info no longer fail to compile or report placeholder values. (#58126) Thanks @L3ER0Y.</li>
<li>Android/pairing: stop appending duplicate push receiver entries to <code>gateway-service.conf</code> on repeated QR pairing and keep push registration bounded to the current successful pairing, so Android push delivery stays healthy across re-pair and token rotation. (#58256) Thanks @surrealroad.</li>
<li>App install smoke: pin the latest-release lookup to <code>latest</code>, cache the first stable install version across the rerun, and relax prerelease package assertions so the Parallels smoke lane can validate stable-to-main upgrades even when <code>beta</code> moves ahead or the guest starts from an older stable. (#58177) Thanks @vincentkoc.</li>
<li>Auth/profiles: keep the last successful config load in memory for the running process and refresh that snapshot on successful writes/reloads, so hot paths stop reparsing <code>openclaw.json</code> between watcher-driven swaps.</li>
<li>Config/SecretRef + Control UI: harden SecretRef redaction round-trip restore, block unsafe raw fallback (force Form mode when raw is unavailable), and preflight submitted-config SecretRefs before config write RPC persistence. (#58044) Thanks @joshavant.</li>
<li>Config/Telegram: migrate removed <code>channels.telegram.groupMentionsOnly</code> into <code>channels.telegram.groups[\"*\"].requireMention</code> on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan.</li>
<li>Config/update: stop <code>openclaw doctor</code> write-backs from persisting plugin-injected channel defaults, so <code>openclaw update</code> no longer seeds config keys that later break service refresh validation. (#56834) Thanks @openperf.</li>
<li>Control UI/agents: auto-load agent workspace files on initial Files panel open, and populate overview model/workspace/fallbacks from effective runtime agent metadata so defaulted models no longer show as <code>Not set</code>. (#56637) Thanks @dxsx84.</li>
<li>Control UI/slash commands: make <code>/steer</code> and <code>/redirect</code> work from the chat command palette with visible pending state for active-run <code>/steer</code>, correct redirected-run tracking, and a single canonical <code>/steer</code> entry in the command menu. (#54625) Thanks @fuller-stack-dev.</li>
<li>Cron/announce: preserve all deliverable text payloads for announce mode instead of collapsing to the last chunk, so multi-line cron reports deliver in full to Telegram forum topics.</li>
<li>Cron/isolated sessions: carry the full live-session provider, model, and auth-profile selection across retry restarts so cron jobs with model overrides no longer fail or loop on mid-run model-switch requests. (#57972) Thanks @issaba1.</li>
<li>Diffs/config: preserve schema-shaped plugin config parsing from <code>diffsPluginConfigSchema.safeParse()</code>, so direct callers keep <code>defaults</code> and <code>security</code> sections instead of receiving flattened tool defaults. (#57904) Thanks @gumadeiras.</li>
<li>Diffs: fall back to plain text when <code>lang</code> hints are invalid during diff render and viewer hydration, so bad or stale language values no longer break the diff viewer. (#57902) Thanks @gumadeiras.</li>
<li>Discord/voice: enforce the same guild channel and member allowlist checks on spoken voice ingress before transcription, so joined voice channels no longer accept speech from users outside the configured Discord access policy. Thanks @cyjhhh and @vincentkoc.</li>
<li>Docker/setup: force BuildKit for local image builds (including sandbox image builds) so <code>./docker-setup.sh</code> no longer fails on <code>RUN --mount=...</code> when hosts default to Docker's legacy builder. (#56681) Thanks @zhanghui-china.</li>
<li>Docs/anchors: fix broken English docs links and make Mint anchor audits run against the English-source docs tree. (#57039) thanks @velvet-shark.</li>
<li>Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled <code>enabledByDefault</code> plugins in the gateway startup set. (#57931) Thanks @dinakars777.</li>
<li>Exec approvals/macOS: unwrap <code>arch</code> and <code>xcrun</code> before deriving shell payloads and allow-always patterns, so wrapper approvals stay bound to the carried command instead of the outer carrier. Thanks @tdjackey and @vincentkoc.</li>
<li>Exec approvals: unwrap <code>caffeinate</code> and <code>sandbox-exec</code> before persisting allow-always trust so later shell payload changes still require a fresh approval. Thanks @tdjackey and @vincentkoc.</li>
<li>Exec/approvals: infer Discord and Telegram exec approvers from existing owner config when <code>execApprovals.approvers</code> is unset, extend the default approval window to 30 minutes, and clarify approval-unavailable guidance so approvals do not appear to silently disappear.</li>
<li>Pi/TUI: flush message-boundary replies at <code>message_end</code> so turns stop looking stuck until the next nudge when the final reply was already ready. Thanks @vincentkoc.</li>
<li>Exec/approvals: keep <code>awk</code> and <code>sed</code> family binaries out of the low-risk <code>safeBins</code> fast path, and stop doctor profile scaffolding from treating them like ordinary custom filters. Thanks @vincentkoc.</li>
<li>Exec/env: block proxy, TLS, and Docker endpoint env overrides in host execution so request-scoped commands cannot silently reroute outbound traffic or trust attacker-supplied certificate settings. Thanks @AntAISecurityLab.</li>
<li>Exec/env: block Python package index override variables from request-scoped host exec environment sanitization so package fetches cannot be redirected through a caller-supplied index. Thanks @nexrin and @vincentkoc.</li>
<li>Exec/node: stop gateway-side workdir fallback from rewriting explicit <code>host=node</code> cwd values to the gateway filesystem, so remote node exec approval and runs keep using the intended node-local directory. (#50961) Thanks @openperf.</li>
<li>Exec/runtime: default implicit exec to <code>host=auto</code>, resolve that target to sandbox only when a sandbox runtime exists, keep explicit <code>host=sandbox</code> fail-closed without sandbox, and show <code>/exec</code> effective host state in runtime status/docs.</li>
<li>Exec: fail closed when the implicit sandbox host has no sandbox runtime, and stop denied async approval followups from reusing prior command output from the same session. (#56800) Thanks @scoootscooob.</li>
<li>Feishu/groups: keep quoted replies and topic bootstrap context aligned with group sender allowlists so only allowlisted thread messages seed agent context. Thanks @AntAISecurityLab and @vincentkoc.</li>
<li>Gateway/attachments: offload large inbound images without leaking <code>media://</code> markers into text-only runs, preserve mixed attachment order for model input/transcripts, and fail closed when model image capability cannot be resolved. (#55513) Thanks @Syysean.</li>
<li>Gateway/auth: keep shared-auth rate limiting active during WebSocket handshake attempts even when callers also send device-token candidates, so bogus device-token fields no longer suppress shared-secret brute-force tracking. Thanks @kexinoh and @vincentkoc.</li>
<li>Gateway/auth: reject mismatched browser <code>Origin</code> headers on trusted-proxy HTTP operator requests while keeping origin-less headless proxy clients working. Thanks @AntAISecurityLab and @vincentkoc.</li>
<li>Gateway/device tokens: disconnect active device sessions after token rotation so newly rotated credentials revoke existing live connections immediately instead of waiting for those sockets to close naturally. Thanks @zsxsoft and @vincentkoc.</li>
<li>Gateway/health: carry webhook-vs-polling account mode from channel descriptors into runtime snapshots so passive channels like LINE and BlueBubbles skip false stale-socket health failures. (#47488) Thanks @karesansui-u.</li>
<li>Gateway/pairing: restore QR bootstrap onboarding handoff so fresh <code>/pair qr</code> iPhone setup can auto-approve the initial node pairing, receive a reusable node device token, and stop retrying with spent bootstrap auth. (#58382) Thanks @ngutman.</li>
<li>Gateway/OpenAI compatibility: accept flat Responses API function tool definitions on <code>/v1/responses</code> and preserve <code>strict</code> when normalizing hosted tools into the embedded runner, so spec-compliant clients like Codex no longer fail validation or silently lose strict tool enforcement. Thanks @malaiwah and @vincentkoc.</li>
<li>Gateway/OpenAI HTTP: restore default operator scopes for bearer-authenticated requests that omit <code>x-openclaw-scopes</code>, so headless <code>/v1/chat/completions</code> and session-history callers work again after the recent method-scope hardening. (#57596) Thanks @openperf.</li>
<li>Gateway/plugins: scope plugin-auth HTTP route runtime clients to read-only access and keep gateway-authenticated plugin routes on write scope, so plugin-owned webhook handlers do not inherit write-capable runtime access by default. Thanks @davidluzsilva and @vincentkoc.</li>
<li>Gateway/SecretRef: resolve restart token drift checks with merged service/runtime env sources and hard-fail unsupported mutable SecretRef plus OAuth-profile combinations so restart warnings and policy enforcement match runtime behavior. (#58141) Thanks @joshavant.</li>
<li>Gateway/tools HTTP: tighten HTTP tool-invoke authorization so owner-only tools stay off HTTP invoke paths. (#57773) Thanks @jacobtomlinson.</li>
<li>Harden async approval followup delivery in webchat-only sessions (#57359) Thanks @joshavant.</li>
<li>Heartbeat/auth: prevent exec-event heartbeat runs from inheriting owner-only tool access from the session delivery target, so node exec output stays on the non-owner tool surface even when the target session belongs to the owner. Thanks @AntAISecurityLab and @vincentkoc.</li>
<li>Hooks/config: accept runtime channel plugin ids in <code>hooks.mappings[].channel</code> (for example <code>feishu</code>) instead of rejecting non-core channels during config validation. (#56226) Thanks @AiKrai001.</li>
<li>Hooks/session routing: rebind hook-triggered <code>agent:</code> session keys to the actual target agent before isolated dispatch so dedicated hook agents keep their own session-scoped tool and plugin identity. Thanks @kexinoh and @vincentkoc.</li>
<li>Host exec/env: block additional request-scoped env overrides that can redirect Docker endpoints, trust roots, compiler include paths, package resolution, or Python environment roots during approved host runs. Thanks @tdjackey and @vincentkoc.</li>
<li>Image generation/build: write stable runtime alias files into <code>dist/</code> and route provider-auth runtime lookups through those aliases so image-generation providers keep resolving auth/runtime modules after rebuilds instead of crashing on missing hashed chunk files.</li>
<li>iOS/Live Activities: mark the <code>ActivityKit</code> import in <code>LiveActivityManager.swift</code> as <code>@preconcurrency</code> so Xcode 26.4 / Swift 6 builds stop failing on strict concurrency checks. (#57180) Thanks @ngutman.</li>
<li>LINE/ACP: add current-conversation binding and inbound binding-routing parity so <code>/acp spawn ... --thread here</code>, configured ACP bindings, and active conversation-bound ACP sessions work on LINE like the other conversation channels.</li>
<li>LINE/markdown: preserve underscores inside Latin, Cyrillic, and CJK words when stripping markdown, while still removing standalone <code>_italic_</code> markers on the shared text-runtime path used by LINE and TTS. (#47465) Thanks @jackjin1997.</li>
<li>Agents/failover: make overloaded same-provider retry count and retry delay configurable via <code>auth.cooldowns</code>, default to one retry with no delay, and document the model-fallback behavior.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.23/OpenClaw-2026.3.23.zip" length="24522883" type="application/octet-stream" sparkle:edSignature="ptBgHYLBqq/TSdONYCfIB5d6aP/ij/9G0gYQ5mJI9jf8Y31sbQIh5CqpJVxEEWLTMIGQKsHQir/kXZjtRvvZAg=="/>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.31/OpenClaw-2026.3.31.zip" length="25820093" type="application/octet-stream" sparkle:edSignature="NjpuH/j7OaNASEatBTpQ4uQy6+oUNq/lIwjrY69rJfkgGSk3/kU8vgxo9osjSgx034m7TpuZvWyulu57OBsQCg=="/>
</item>
</channel>
</rss>
</rss>

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026032900
versionName = "2026.3.29"
versionCode = 2026040301
versionName = "2026.4.3"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -31,6 +31,13 @@
android:name="android.hardware.telephony"
android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:name=".NodeApp"
android:allowBackup="true"
@@ -69,10 +76,17 @@
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,43 @@
package ai.openclaw.app
import android.content.Intent
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
const val extraAssistantPrompt = "prompt"
enum class HomeDestination {
Connect,
Chat,
Voice,
Screen,
Settings,
}
data class AssistantLaunchRequest(
val source: String,
val prompt: String?,
val autoSend: Boolean,
)
fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
val action = intent?.action ?: return null
return when (action) {
Intent.ACTION_ASSIST ->
AssistantLaunchRequest(
source = "assist",
prompt = null,
autoSend = false,
)
actionAskOpenClaw -> {
val prompt = intent.getStringExtra(extraAssistantPrompt)?.trim()?.ifEmpty { null }
AssistantLaunchRequest(
source = "app_action",
prompt = prompt,
autoSend = prompt != null,
)
}
else -> null
}
}

View File

@@ -23,6 +23,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleAssistantIntent(intent)
WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester = PermissionRequester(this)
@@ -70,4 +71,15 @@ class MainActivity : ComponentActivity() {
viewModel.setForeground(false)
super.onStop()
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleAssistantIntent(intent)
}
private fun handleAssistantIntent(intent: android.content.Intent?) {
val request = parseAssistantLaunchIntent(intent) ?: return
viewModel.handleAssistantLaunch(request)
}
}

View File

@@ -27,6 +27,12 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
private val prefs = nodeApp.prefs
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
private var foreground = true
private val _requestedHomeDestination = MutableStateFlow<HomeDestination?>(null)
val requestedHomeDestination: StateFlow<HomeDestination?> = _requestedHomeDestination
private val _chatDraft = MutableStateFlow<String?>(null)
val chatDraft: StateFlow<String?> = _chatDraft
private val _pendingAssistantAutoSend = MutableStateFlow<String?>(null)
val pendingAssistantAutoSend: StateFlow<String?> = _pendingAssistantAutoSend
private fun ensureRuntime(): NodeRuntime {
runtimeRef.value?.let { return it }
@@ -56,6 +62,17 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val gateways: StateFlow<List<GatewayEndpoint>> = runtimeState(initial = emptyList()) { it.gateways }
val discoveryStatusText: StateFlow<String> = runtimeState(initial = "Searching…") { it.discoveryStatusText }
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
prefs.notificationForwardingMode
val notificationForwardingPackages: StateFlow<Set<String>> = prefs.notificationForwardingPackages
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> =
prefs.notificationForwardingQuietHoursEnabled
val notificationForwardingQuietStart: StateFlow<String> = prefs.notificationForwardingQuietStart
val notificationForwardingQuietEnd: StateFlow<String> = prefs.notificationForwardingQuietEnd
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> =
prefs.notificationForwardingMaxEventsPerMinute
val notificationForwardingSessionKey: StateFlow<String?> = prefs.notificationForwardingSessionKey
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
@@ -80,6 +97,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls
val gatewayToken: StateFlow<String> = prefs.gatewayToken
val gatewayBootstrapToken: StateFlow<String> = prefs.gatewayBootstrapToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
@@ -197,10 +215,66 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
prefs.setCanvasDebugStatusEnabled(value)
}
fun setNotificationForwardingEnabled(value: Boolean) {
ensureRuntime().setNotificationForwardingEnabled(value)
}
fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
ensureRuntime().setNotificationForwardingMode(mode)
}
fun setNotificationForwardingPackagesCsv(csv: String) {
val packages =
csv
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
ensureRuntime().setNotificationForwardingPackages(packages)
}
fun setNotificationForwardingQuietHours(
enabled: Boolean,
start: String,
end: String,
): Boolean {
return ensureRuntime().setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
}
fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
ensureRuntime().setNotificationForwardingMaxEventsPerMinute(value)
}
fun setNotificationForwardingSessionKey(value: String?) {
ensureRuntime().setNotificationForwardingSessionKey(value)
}
fun setVoiceScreenActive(active: Boolean) {
ensureRuntime().setVoiceScreenActive(active)
}
fun handleAssistantLaunch(request: AssistantLaunchRequest) {
_requestedHomeDestination.value = HomeDestination.Chat
if (request.autoSend) {
_pendingAssistantAutoSend.value = request.prompt
_chatDraft.value = null
return
}
_pendingAssistantAutoSend.value = null
_chatDraft.value = request.prompt
}
fun clearRequestedHomeDestination() {
_requestedHomeDestination.value = null
}
fun clearChatDraft() {
_chatDraft.value = null
}
fun clearPendingAssistantAutoSend() {
_pendingAssistantAutoSend.value = null
}
fun setMicEnabled(enabled: Boolean) {
ensureRuntime().setMicEnabled(enabled)
}
@@ -217,6 +291,22 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
ensureRuntime().connect(endpoint)
}
fun connect(
endpoint: GatewayEndpoint,
token: String?,
bootstrapToken: String?,
password: String?,
) {
ensureRuntime().connect(
endpoint,
NodeRuntime.GatewayConnectAuth(
token = token,
bootstrapToken = bootstrapToken,
password = password,
),
)
}
fun connectManual() {
ensureRuntime().connectManual()
}
@@ -276,4 +366,16 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments)
}
suspend fun sendChatAwaitAcceptance(
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
): Boolean {
return ensureRuntime().sendChatAwaitAcceptance(
message = message,
thinking = thinking,
attachments = attachments,
)
}
}

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,14 @@ import java.util.concurrent.atomic.AtomicLong
class NodeRuntime(
context: Context,
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint,
) {
data class GatewayConnectAuth(
val token: String?,
val bootstrapToken: String?,
val password: String?,
)
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val deviceAuthStore = DeviceAuthStore(prefs)
@@ -139,6 +148,7 @@ class NodeRuntime(
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsSearchPossible = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
@@ -164,6 +174,8 @@ class NodeRuntime(
locationEnabled = { locationMode.value != LocationMode.Off },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsFeatureEnabled = { BuildConfig.OPENCLAW_ENABLE_SMS },
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
@@ -180,6 +192,7 @@ class NodeRuntime(
data class GatewayTrustPrompt(
val endpoint: GatewayEndpoint,
val fingerprintSha256: String,
val auth: GatewayConnectAuth,
)
private val _isConnected = MutableStateFlow(false)
@@ -534,6 +547,17 @@ class NodeRuntime(
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
prefs.notificationForwardingMode
val notificationForwardingPackages: StateFlow<Set<String>> = prefs.notificationForwardingPackages
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> =
prefs.notificationForwardingQuietHoursEnabled
val notificationForwardingQuietStart: StateFlow<String> = prefs.notificationForwardingQuietStart
val notificationForwardingQuietEnd: StateFlow<String> = prefs.notificationForwardingQuietEnd
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> =
prefs.notificationForwardingMaxEventsPerMinute
val notificationForwardingSessionKey: StateFlow<String?> = prefs.notificationForwardingSessionKey
private var didAutoConnect = false
@@ -686,6 +710,34 @@ class NodeRuntime(
prefs.setCanvasDebugStatusEnabled(value)
}
fun setNotificationForwardingEnabled(value: Boolean) {
prefs.setNotificationForwardingEnabled(value)
}
fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
prefs.setNotificationForwardingMode(mode)
}
fun setNotificationForwardingPackages(packages: List<String>) {
prefs.setNotificationForwardingPackages(packages)
}
fun setNotificationForwardingQuietHours(
enabled: Boolean,
start: String,
end: String,
): Boolean {
return prefs.setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
}
fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
prefs.setNotificationForwardingMaxEventsPerMinute(value)
}
fun setNotificationForwardingSessionKey(value: String?) {
prefs.setNotificationForwardingSessionKey(value)
}
fun setVoiceScreenActive(active: Boolean) {
if (!active) {
stopActiveVoiceSession()
@@ -733,41 +785,69 @@ class NodeRuntime(
}
operatorStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
val bootstrapToken = prefs.loadGatewayBootstrapToken()
val password = prefs.loadGatewayPassword()
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
}
private fun connectWithAuth(
endpoint: GatewayEndpoint,
auth: GatewayConnectAuth,
reconnect: Boolean = false,
) {
val tls = connectionManager.resolveTlsParams(endpoint)
operatorSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildOperatorConnectOptions(),
tls,
)
val connectOperator =
shouldConnectOperatorSession(
auth.token,
auth.bootstrapToken,
auth.password,
loadStoredRoleDeviceToken("operator"),
)
if (!connectOperator) {
operatorConnected = false
operatorStatusText = "Offline"
operatorSession.disconnect()
updateStatus()
} else {
operatorSession.connect(
endpoint,
auth.token,
auth.bootstrapToken,
auth.password,
connectionManager.buildOperatorConnectOptions(),
tls,
)
}
nodeSession.connect(
endpoint,
token,
bootstrapToken,
password,
auth.token,
auth.bootstrapToken,
auth.password,
connectionManager.buildNodeConnectOptions(),
tls,
)
operatorSession.reconnect()
nodeSession.reconnect()
if (reconnect && connectOperator) {
operatorSession.reconnect()
}
if (reconnect) {
nodeSession.reconnect()
}
}
fun connect(endpoint: GatewayEndpoint) {
private fun beginConnect(
endpoint: GatewayEndpoint,
auth: GatewayConnectAuth,
) {
val tls = connectionManager.resolveTlsParams(endpoint)
if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) {
// 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 = probeGatewayTlsFingerprint(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 = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp)
_pendingGatewayTrust.value =
GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp, auth = auth)
}
return
}
@@ -776,32 +856,34 @@ class NodeRuntime(
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
val bootstrapToken = prefs.loadGatewayBootstrapToken()
val password = prefs.loadGatewayPassword()
operatorSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildOperatorConnectOptions(),
tls,
)
nodeSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildNodeConnectOptions(),
tls,
)
connectWithAuth(endpoint = endpoint, auth = auth)
}
fun connect(endpoint: GatewayEndpoint) {
beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth())
}
fun connect(
endpoint: GatewayEndpoint,
auth: GatewayConnectAuth,
) {
beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth(auth))
}
internal fun resolveGatewayConnectAuth(explicitAuth: GatewayConnectAuth? = null): GatewayConnectAuth {
return explicitAuth
?: GatewayConnectAuth(
token = prefs.loadGatewayToken(),
bootstrapToken = prefs.loadGatewayBootstrapToken(),
password = prefs.loadGatewayPassword(),
)
}
fun acceptGatewayTrustPrompt() {
val prompt = _pendingGatewayTrust.value ?: return
_pendingGatewayTrust.value = null
prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, prompt.fingerprintSha256)
connect(prompt.endpoint)
beginConnect(endpoint = prompt.endpoint, auth = prompt.auth)
}
fun declineGatewayTrustPrompt() {
@@ -809,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) ==
@@ -826,6 +917,11 @@ class NodeRuntime(
connect(GatewayEndpoint.manual(host = host, port = port))
}
private fun loadStoredRoleDeviceToken(role: String): String? {
val deviceId = identityStore.loadOrCreate().deviceId
return deviceAuthStore.loadToken(deviceId, role)
}
fun disconnect() {
connectedEndpoint = null
_pendingGatewayTrust.value = null
@@ -938,6 +1034,14 @@ class NodeRuntime(
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
}
suspend fun sendChatAwaitAcceptance(
message: String,
thinking: String,
attachments: List<OutgoingAttachment>,
): Boolean {
return chat.sendMessageAwaitAcceptance(message = message, thinkingLevel = thinking, attachments = attachments)
}
private fun handleGatewayEvent(event: String, payloadJson: String?) {
micCapture.handleGatewayEvent(event, payloadJson)
talkMode.handleGatewayEvent(event, payloadJson)
@@ -1155,6 +1259,20 @@ class NodeRuntime(
}
internal fun shouldConnectOperatorSession(
token: String?,
bootstrapToken: String?,
password: String?,
storedOperatorToken: String?,
): Boolean {
return (
!token.isNullOrBlank() ||
!bootstrapToken.isNullOrBlank() ||
!password.isNullOrBlank() ||
!storedOperatorToken.isNullOrBlank()
)
}
private enum class HomeCanvasGatewayState {
Connected,
Connecting,

View File

@@ -0,0 +1,102 @@
package ai.openclaw.app
import java.time.Instant
import java.time.ZoneId
enum class NotificationPackageFilterMode(val rawValue: String) {
Allowlist("allowlist"),
Blocklist("blocklist"),
;
companion object {
fun fromRawValue(raw: String?): NotificationPackageFilterMode {
return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
}
}
}
internal data class NotificationForwardingPolicy(
val enabled: Boolean,
val mode: NotificationPackageFilterMode,
val packages: Set<String>,
val quietHoursEnabled: Boolean,
val quietStart: String,
val quietEnd: String,
val maxEventsPerMinute: Int,
val sessionKey: String?,
)
internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Boolean {
val normalized = packageName.trim()
if (normalized.isEmpty()) {
return false
}
return when (mode) {
NotificationPackageFilterMode.Allowlist -> packages.contains(normalized)
NotificationPackageFilterMode.Blocklist -> !packages.contains(normalized)
}
}
internal fun NotificationForwardingPolicy.isWithinQuietHours(
nowEpochMs: Long,
zoneId: ZoneId = ZoneId.systemDefault(),
): Boolean {
if (!quietHoursEnabled) {
return false
}
val startMinutes = parseLocalHourMinute(quietStart) ?: return false
val endMinutes = parseLocalHourMinute(quietEnd) ?: return false
if (startMinutes == endMinutes) {
return true
}
val now =
Instant.ofEpochMilli(nowEpochMs)
.atZone(zoneId)
.toLocalTime()
val nowMinutes = now.hour * 60 + now.minute
return if (startMinutes < endMinutes) {
nowMinutes in startMinutes until endMinutes
} else {
nowMinutes >= startMinutes || nowMinutes < endMinutes
}
}
private val localHourMinuteRegex = Regex("""^([01]\d|2[0-3]):([0-5]\d)$""")
internal fun normalizeLocalHourMinute(raw: String): String? {
val trimmed = raw.trim()
val match = localHourMinuteRegex.matchEntire(trimmed) ?: return null
return "${match.groupValues[1]}:${match.groupValues[2]}"
}
internal fun parseLocalHourMinute(raw: String): Int? {
val normalized = normalizeLocalHourMinute(raw) ?: return null
val parts = normalized.split(':')
val hour = parts[0].toInt()
val minute = parts[1].toInt()
return hour * 60 + minute
}
internal class NotificationBurstLimiter {
private val lock = Any()
private var windowStartMs: Long = -1L
private var eventsInWindow: Int = 0
fun allow(nowEpochMs: Long, maxEventsPerMinute: Int): Boolean {
if (maxEventsPerMinute <= 0) {
return false
}
val currentWindow = nowEpochMs - (nowEpochMs % 60_000L)
synchronized(lock) {
if (currentWindow != windowStartMs) {
windowStartMs = currentWindow
eventsInWindow = 0
}
if (eventsInWindow >= maxEventsPerMinute) {
return false
}
eventsInWindow += 1
return true
}
}
}

View File

@@ -185,7 +185,16 @@ class PermissionRequester(private val activity: ComponentActivity) {
when (permission) {
Manifest.permission.CAMERA -> "Camera"
Manifest.permission.RECORD_AUDIO -> "Microphone"
Manifest.permission.SEND_SMS -> "SMS"
Manifest.permission.SEND_SMS -> "Send SMS"
Manifest.permission.READ_SMS -> "Read SMS"
Manifest.permission.READ_CONTACTS -> "Read Contacts"
Manifest.permission.WRITE_CONTACTS -> "Write Contacts"
Manifest.permission.READ_CALENDAR -> "Read Calendar"
Manifest.permission.WRITE_CALENDAR -> "Write Calendar"
Manifest.permission.READ_CALL_LOG -> "Read Call Log"
Manifest.permission.ACTIVITY_RECOGNITION -> "Motion Activity"
Manifest.permission.READ_MEDIA_IMAGES -> "Photos"
Manifest.permission.READ_EXTERNAL_STORAGE -> "Photos"
else -> permission
}
}

View File

@@ -26,6 +26,17 @@ class SecurePrefs(
private const val voiceWakeModeKey = "voiceWake.mode"
private const val plainPrefsName = "openclaw.node"
private const val securePrefsName = "openclaw.node.secure"
private const val notificationsForwardingEnabledKey = "notifications.forwarding.enabled"
private const val defaultNotificationForwardingEnabled = false
private const val notificationsForwardingModeKey = "notifications.forwarding.mode"
private const val notificationsForwardingPackagesKey = "notifications.forwarding.packages"
private const val notificationsForwardingQuietHoursEnabledKey =
"notifications.forwarding.quietHoursEnabled"
private const val notificationsForwardingQuietStartKey = "notifications.forwarding.quietStart"
private const val notificationsForwardingQuietEndKey = "notifications.forwarding.quietEnd"
private const val notificationsForwardingMaxEventsPerMinuteKey =
"notifications.forwarding.maxEventsPerMinute"
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
}
private val appContext = context.applicationContext
@@ -96,6 +107,55 @@ class SecurePrefs(
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _notificationForwardingEnabled =
MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
val notificationForwardingEnabled: StateFlow<Boolean> = _notificationForwardingEnabled
private val _notificationForwardingMode =
MutableStateFlow(
NotificationPackageFilterMode.fromRawValue(
plainPrefs.getString(notificationsForwardingModeKey, null),
),
)
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> = _notificationForwardingMode
private val _notificationForwardingPackages = MutableStateFlow(loadNotificationForwardingPackages())
val notificationForwardingPackages: StateFlow<Set<String>> = _notificationForwardingPackages
private val storedQuietStart =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
?: "22:00"
private val storedQuietEnd =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
?: "07:00"
private val storedQuietHoursEnabled =
plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
private val _notificationForwardingQuietHoursEnabled =
MutableStateFlow(storedQuietHoursEnabled)
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> = _notificationForwardingQuietHoursEnabled
private val _notificationForwardingQuietStart = MutableStateFlow(storedQuietStart)
val notificationForwardingQuietStart: StateFlow<String> = _notificationForwardingQuietStart
private val _notificationForwardingQuietEnd = MutableStateFlow(storedQuietEnd)
val notificationForwardingQuietEnd: StateFlow<String> = _notificationForwardingQuietEnd
private val _notificationForwardingMaxEventsPerMinute =
MutableStateFlow(plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20).coerceAtLeast(1))
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> = _notificationForwardingMaxEventsPerMinute
private val _notificationForwardingSessionKey =
MutableStateFlow(
plainPrefs
.getString(notificationsForwardingSessionKeyKey, "")
?.trim()
?.takeIf { it.isNotEmpty() },
)
val notificationForwardingSessionKey: StateFlow<String?> = _notificationForwardingSessionKey
private val _wakeWords = MutableStateFlow(loadWakeWords())
val wakeWords: StateFlow<List<String>> = _wakeWords
@@ -185,6 +245,114 @@ class SecurePrefs(
_canvasDebugStatusEnabled.value = value
}
internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy {
val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null)
val mode = NotificationPackageFilterMode.fromRawValue(modeRaw)
val configuredPackages = loadNotificationForwardingPackages()
val normalizedAppPackage = appPackageName.trim()
val defaultBlockedPackages =
if (normalizedAppPackage.isNotEmpty()) setOf(normalizedAppPackage) else emptySet()
val packages =
when (mode) {
NotificationPackageFilterMode.Allowlist -> configuredPackages
NotificationPackageFilterMode.Blocklist -> configuredPackages + defaultBlockedPackages
}
val maxEvents = plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20)
val quietStart =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
?: "22:00"
val quietEnd =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
?: "07:00"
val sessionKey =
plainPrefs
.getString(notificationsForwardingSessionKeyKey, "")
?.trim()
?.takeIf { it.isNotEmpty() }
val quietHoursEnabled =
plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
return NotificationForwardingPolicy(
enabled = plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled),
mode = mode,
packages = packages,
quietHoursEnabled = quietHoursEnabled,
quietStart = quietStart,
quietEnd = quietEnd,
maxEventsPerMinute = maxEvents.coerceAtLeast(1),
sessionKey = sessionKey,
)
}
internal fun setNotificationForwardingEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(notificationsForwardingEnabledKey, value) }
_notificationForwardingEnabled.value = value
}
internal fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
plainPrefs.edit { putString(notificationsForwardingModeKey, mode.rawValue) }
_notificationForwardingMode.value = mode
}
internal fun setNotificationForwardingPackages(packages: List<String>) {
val sanitized =
packages
.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
.toList()
.sorted()
val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
plainPrefs.edit { putString(notificationsForwardingPackagesKey, encoded) }
_notificationForwardingPackages.value = sanitized.toSet()
}
internal fun setNotificationForwardingQuietHours(
enabled: Boolean,
start: String,
end: String,
): Boolean {
if (!enabled) {
plainPrefs.edit { putBoolean(notificationsForwardingQuietHoursEnabledKey, false) }
_notificationForwardingQuietHoursEnabled.value = false
return true
}
val normalizedStart = normalizeLocalHourMinute(start) ?: return false
val normalizedEnd = normalizeLocalHourMinute(end) ?: return false
plainPrefs.edit {
putBoolean(notificationsForwardingQuietHoursEnabledKey, enabled)
putString(notificationsForwardingQuietStartKey, normalizedStart)
putString(notificationsForwardingQuietEndKey, normalizedEnd)
}
_notificationForwardingQuietHoursEnabled.value = enabled
_notificationForwardingQuietStart.value = normalizedStart
_notificationForwardingQuietEnd.value = normalizedEnd
return true
}
internal fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
val normalized = value.coerceAtLeast(1)
plainPrefs.edit {
putInt(notificationsForwardingMaxEventsPerMinuteKey, normalized)
}
_notificationForwardingMaxEventsPerMinute.value = normalized
}
internal fun setNotificationForwardingSessionKey(value: String?) {
val normalized = value?.trim()?.takeIf { it.isNotEmpty() }
plainPrefs.edit {
putString(notificationsForwardingSessionKeyKey, normalized.orEmpty())
}
_notificationForwardingSessionKey.value = normalized
}
fun loadGatewayToken(): String? {
val manual =
_gatewayToken.value.trim().ifEmpty {
@@ -308,6 +476,28 @@ class SecurePrefs(
_speakerEnabled.value = value
}
private fun loadNotificationForwardingPackages(): Set<String> {
val raw = plainPrefs.getString(notificationsForwardingPackagesKey, null)?.trim()
if (raw.isNullOrEmpty()) {
return emptySet()
}
return try {
val element = json.parseToJsonElement(raw)
val array = element as? JsonArray ?: return emptySet()
array
.mapNotNull { item ->
when (item) {
is JsonNull -> null
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
else -> null
}
}
.toSet()
} catch (_: Throwable) {
emptySet()
}
}
private fun loadVoiceWakeMode(): VoiceWakeMode {
val raw = plainPrefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw)

View File

@@ -130,11 +130,25 @@ class ChatController(
thinkingLevel: String,
attachments: List<OutgoingAttachment>,
) {
scope.launch {
sendMessageAwaitAcceptance(
message = message,
thinkingLevel = thinkingLevel,
attachments = attachments,
)
}
}
suspend fun sendMessageAwaitAcceptance(
message: String,
thinkingLevel: String,
attachments: List<OutgoingAttachment>,
): Boolean {
val trimmed = message.trim()
if (trimmed.isEmpty() && attachments.isEmpty()) return
if (trimmed.isEmpty() && attachments.isEmpty()) return false
if (!_healthOk.value) {
_errorText.value = "Gateway health not OK; cannot send"
return
return false
}
val runId = UUID.randomUUID().toString()
@@ -177,45 +191,45 @@ class ChatController(
pendingToolCallsById.clear()
publishPendingToolCalls()
scope.launch {
try {
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(sessionKey))
put("message", JsonPrimitive(text))
put("thinking", JsonPrimitive(thinking))
put("timeoutMs", JsonPrimitive(30_000))
put("idempotencyKey", JsonPrimitive(runId))
if (attachments.isNotEmpty()) {
put(
"attachments",
JsonArray(
attachments.map { att ->
buildJsonObject {
put("type", JsonPrimitive(att.type))
put("mimeType", JsonPrimitive(att.mimeType))
put("fileName", JsonPrimitive(att.fileName))
put("content", JsonPrimitive(att.base64))
}
},
),
)
}
}
val res = session.request("chat.send", params.toString())
val actualRunId = parseRunId(res) ?: runId
if (actualRunId != runId) {
clearPendingRun(runId)
armPendingRunTimeout(actualRunId)
synchronized(pendingRuns) {
pendingRuns.add(actualRunId)
_pendingRunCount.value = pendingRuns.size
return try {
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(sessionKey))
put("message", JsonPrimitive(text))
put("thinking", JsonPrimitive(thinking))
put("timeoutMs", JsonPrimitive(30_000))
put("idempotencyKey", JsonPrimitive(runId))
if (attachments.isNotEmpty()) {
put(
"attachments",
JsonArray(
attachments.map { att ->
buildJsonObject {
put("type", JsonPrimitive(att.type))
put("mimeType", JsonPrimitive(att.mimeType))
put("fileName", JsonPrimitive(att.fileName))
put("content", JsonPrimitive(att.base64))
}
},
),
)
}
}
} catch (err: Throwable) {
val res = session.request("chat.send", params.toString())
val actualRunId = parseRunId(res) ?: runId
if (actualRunId != runId) {
clearPendingRun(runId)
_errorText.value = err.message
armPendingRunTimeout(actualRunId)
synchronized(pendingRuns) {
pendingRuns.add(actualRunId)
_pendingRunCount.value = pendingRuns.size
}
}
true
} catch (err: Throwable) {
clearPendingRun(runId)
_errorText.value = err.message
false
}
}

View File

@@ -0,0 +1,124 @@
package ai.openclaw.app.gateway
import android.os.Build
import java.net.InetAddress
import java.util.Locale
internal fun isLoopbackGatewayHost(
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) return false
if (host.isEmpty()) return false
if (host == "localhost") return true
if (allowEmulatorBridgeAlias && host == "10.0.2.2") return true
parseIpv4Address(host)?.let { ipv4 ->
return ipv4.first() == 127.toByte()
}
if (!host.contains(':') || !host.all(::isIpv6LiteralChar)) return false
val address = runCatching { InetAddress.getByName(host) }.getOrNull()?.address ?: return false
if (address.size == 4) {
return address[0] == 127.toByte()
}
if (address.size != 16) return false
// `::1` is 15 zero bytes followed by `0x01`.
val isIpv6Loopback = address.copyOfRange(0, 15).all { it == 0.toByte() } && address[15] == 1.toByte()
if (isIpv6Loopback) return true
val isMappedIpv4 =
address.copyOfRange(0, 10).all { it == 0.toByte() } &&
address[10] == 0xFF.toByte() &&
address[11] == 0xFF.toByte()
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()
val manufacturer = Build.MANUFACTURER?.lowercase(Locale.US).orEmpty()
val brand = Build.BRAND?.lowercase(Locale.US).orEmpty()
val device = Build.DEVICE?.lowercase(Locale.US).orEmpty()
val product = Build.PRODUCT?.lowercase(Locale.US).orEmpty()
return fingerprint.contains("generic") ||
fingerprint.contains("robolectric") ||
model.contains("emulator") ||
model.contains("sdk_gphone") ||
manufacturer.contains("genymotion") ||
(brand.contains("generic") && device.contains("generic")) ||
product.contains("sdk_gphone") ||
product.contains("emulator") ||
product.contains("simulator")
}
private fun parseIpv4Address(host: String): ByteArray? {
val parts = host.split('.')
if (parts.size != 4) return null
val bytes = ByteArray(4)
for ((index, part) in parts.withIndex()) {
val value = part.toIntOrNull() ?: return null
if (value !in 0..255) return null
bytes[index] = value.toByte()
}
return bytes
}
private fun isIpv6LiteralChar(char: Char): Boolean = char in '0'..'9' || char in 'a'..'f' || char == ':' || char == '.'

View File

@@ -181,17 +181,10 @@ class GatewaySession(
suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean {
val conn = currentConnection ?: return false
val parsedPayload = payloadJson?.let { parseJsonOrNull(it) }
val params =
buildJsonObject {
put("event", JsonPrimitive(event))
if (parsedPayload != null) {
put("payload", parsedPayload)
} else if (payloadJson != null) {
put("payloadJSON", JsonPrimitive(payloadJson))
} else {
put("payloadJSON", JsonNull)
}
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
try {
conn.request("node.event", params, timeoutMs = 8_000)
@@ -275,16 +268,10 @@ class GatewaySession(
private var socket: WebSocket? = null
private val loggerTag = "OpenClawGateway"
val remoteAddress: String =
if (endpoint.host.contains(":")) {
"[${endpoint.host}]:${endpoint.port}"
} else {
"${endpoint.host}:${endpoint.port}"
}
val remoteAddress: String = formatGatewayAuthority(endpoint.host, endpoint.port)
suspend fun connect() {
val scheme = if (tls != null) "wss" else "ws"
val url = "$scheme://${endpoint.host}:${endpoint.port}"
val url = buildGatewayWebSocketUrl(endpoint.host, endpoint.port, tls != null)
val request = Request.Builder().url(url).build()
socket = client.newWebSocket(request, Listener())
try {
@@ -759,7 +746,7 @@ class GatewaySession(
// If raw URL is a non-loopback address and this connection uses TLS,
// normalize scheme/port to the endpoint we actually connected to.
if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackHost(host)) {
if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackGatewayHost(host)) {
val needsTlsRewrite =
isTlsConnection &&
(
@@ -788,7 +775,7 @@ class GatewaySession(
private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String {
val loweredScheme = scheme.lowercase()
val formattedHost = if (host.contains(":")) "[${host}]" else host
val formattedHost = formatGatewayAuthorityHost(host)
val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port"
return "$loweredScheme://$formattedHost$portSuffix$suffix"
}
@@ -801,15 +788,6 @@ class GatewaySession(
return "$path$query$fragment"
}
private fun isLoopbackHost(raw: String?): Boolean {
val host = raw?.trim()?.lowercase().orEmpty()
if (host.isEmpty()) return false
if (host == "localhost") return true
if (host == "::1") return true
if (host == "0.0.0.0" || host == "::") return true
return host.startsWith("127.")
}
private fun selectConnectAuth(
endpoint: GatewayEndpoint,
tls: GatewayTlsParams?,
@@ -898,13 +876,27 @@ class GatewaySession(
endpoint: GatewayEndpoint,
tls: GatewayTlsParams?,
): Boolean {
if (isLoopbackHost(endpoint.host)) {
if (isLoopbackGatewayHost(endpoint.host)) {
return true
}
return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true
}
}
internal fun buildGatewayWebSocketUrl(host: String, port: Int, useTls: Boolean): String {
val scheme = if (useTls) "wss" else "ws"
return "$scheme://${formatGatewayAuthority(host, port)}"
}
internal fun formatGatewayAuthority(host: String, port: Int): String {
return "${formatGatewayAuthorityHost(host)}:$port"
}
private fun formatGatewayAuthorityHost(host: String): String {
val normalizedHost = host.trim().trim('[', ']')
return if (normalizedHost.contains(":")) "[${normalizedHost}]" else normalizedHost
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =

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

@@ -7,6 +7,7 @@ import ai.openclaw.app.gateway.GatewayClientInfo
import ai.openclaw.app.gateway.GatewayConnectOptions
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayTlsParams
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.LocationMode
import ai.openclaw.app.VoiceWakeMode
@@ -19,6 +20,7 @@ class ConnectionManager(
private val motionPedometerAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val smsSearchPossible: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
@@ -32,9 +34,10 @@ class ConnectionManager(
val stableId = endpoint.stableId
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
val isManual = stableId.startsWith("manual|")
val cleartextAllowedHost = isLoopbackGatewayHost(endpoint.host)
if (isManual) {
if (!manualTlsEnabled) return null
if (!manualTlsEnabled && cleartextAllowedHost) return null
if (!stored.isNullOrBlank()) {
return GatewayTlsParams(
required = true,
@@ -72,6 +75,15 @@ class ConnectionManager(
)
}
if (!cleartextAllowedHost) {
return GatewayTlsParams(
required = true,
expectedFingerprint = null,
allowTOFU = false,
stableId = stableId,
)
}
return null
}
}
@@ -82,6 +94,7 @@ class ConnectionManager(
locationEnabled = locationMode() != LocationMode.Off,
sendSmsAvailable = sendSmsAvailable(),
readSmsAvailable = readSmsAvailable(),
smsSearchPossible = smsSearchPossible(),
callLogAvailable = callLogAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import android.Manifest
import android.app.ActivityManager
import android.content.Context
@@ -15,9 +16,9 @@ import android.os.PowerManager
import android.os.StatFs
import android.os.SystemClock
import androidx.core.content.ContextCompat
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.gateway.GatewaySession
import java.util.Locale
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
@@ -28,6 +29,25 @@ class DeviceHandler(
private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS,
private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
) {
companion object {
internal fun hasAnySmsCapability(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
smsSendGranted: Boolean,
smsReadGranted: Boolean,
): Boolean {
return smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted)
}
internal fun isSmsPromptable(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
smsSendGranted: Boolean,
smsReadGranted: Boolean,
): Boolean {
return smsEnabled && telephonyAvailable && (!smsSendGranted || !smsReadGranted)
}
}
private data class BatterySnapshot(
val status: Int,
val plugged: Int,
@@ -131,6 +151,8 @@ class DeviceHandler(
private fun permissionsPayloadJson(): String {
val canSendSms = appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
val smsSendGranted = hasPermission(Manifest.permission.SEND_SMS)
val smsReadGranted = hasPermission(Manifest.permission.READ_SMS)
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
val photosGranted =
if (Build.VERSION.SDK_INT >= 33) {
@@ -174,10 +196,34 @@ class DeviceHandler(
)
put(
"sms",
permissionStateJson(
granted = smsEnabled && hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
promptableWhenDenied = smsEnabled && canSendSms,
),
buildJsonObject {
put(
"status",
JsonPrimitive(
if (hasAnySmsCapability(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)) "granted" else "denied",
),
)
put("promptable", JsonPrimitive(isSmsPromptable(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)))
put(
"capabilities",
buildJsonObject {
put(
"send",
permissionStateJson(
granted = smsEnabled && smsSendGranted && canSendSms,
promptableWhenDenied = smsEnabled && canSendSms,
),
)
put(
"read",
permissionStateJson(
granted = smsEnabled && smsReadGranted && canSendSms,
promptableWhenDenied = smsEnabled && canSendSms,
),
)
},
)
},
)
put(
"notificationListener",

View File

@@ -8,6 +8,10 @@ import android.content.Context
import android.content.Intent
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import ai.openclaw.app.NotificationBurstLimiter
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.allowsPackage
import ai.openclaw.app.isWithinQuietHours
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
@@ -126,6 +130,9 @@ private object DeviceNotificationStore {
}
class DeviceNotificationListenerService : NotificationListenerService() {
private val securePrefs by lazy { SecurePrefs(applicationContext) }
private val forwardingLimiter = NotificationBurstLimiter()
override fun onListenerConnected() {
super.onListenerConnected()
activeService = this
@@ -152,24 +159,12 @@ class DeviceNotificationListenerService : NotificationListenerService() {
super.onNotificationPosted(sbn)
val entry = sbn?.toEntry() ?: return
DeviceNotificationStore.upsert(entry)
rememberRecentPackage(entry.packageName)
if (entry.packageName == packageName) {
return
}
emitNotificationsChanged(
buildJsonObject {
put("change", JsonPrimitive("posted"))
put("key", JsonPrimitive(entry.key))
put("packageName", JsonPrimitive(entry.packageName))
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
put("isOngoing", JsonPrimitive(entry.isOngoing))
put("isClearable", JsonPrimitive(entry.isClearable))
entry.title?.let { put("title", JsonPrimitive(it)) }
entry.text?.let { put("text", JsonPrimitive(it)) }
entry.subText?.let { put("subText", JsonPrimitive(it)) }
entry.category?.let { put("category", JsonPrimitive(it)) }
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
}.toString(),
)
val payload = notificationChangedPayload(entry) ?: return
emitNotificationsChanged(payload)
}
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
@@ -180,21 +175,79 @@ class DeviceNotificationListenerService : NotificationListenerService() {
return
}
DeviceNotificationStore.remove(key)
rememberRecentPackage(removed.packageName)
if (removed.packageName == packageName) {
return
}
emitNotificationsChanged(
buildJsonObject {
put("change", JsonPrimitive("removed"))
put("key", JsonPrimitive(key))
val packageName = removed.packageName.trim()
if (packageName.isNotEmpty()) {
put("packageName", JsonPrimitive(packageName))
}
}.toString(),
val packageName = removed.packageName.trim()
val payload =
notificationChangedPayload(
entry = null,
change = "removed",
key = key,
packageName = packageName,
postTimeMs = removed.postTime,
isOngoing = removed.isOngoing,
isClearable = removed.isClearable,
) ?: return
emitNotificationsChanged(payload)
}
private fun notificationChangedPayload(entry: DeviceNotificationEntry): String? {
return notificationChangedPayload(
entry = entry,
change = "posted",
key = entry.key,
packageName = entry.packageName,
postTimeMs = entry.postTimeMs,
isOngoing = entry.isOngoing,
isClearable = entry.isClearable,
)
}
private fun notificationChangedPayload(
entry: DeviceNotificationEntry?,
change: String,
key: String,
packageName: String,
postTimeMs: Long,
isOngoing: Boolean,
isClearable: Boolean,
): String? {
val normalizedPackage = packageName.trim()
if (normalizedPackage.isEmpty()) {
return null
}
val policy = securePrefs.getNotificationForwardingPolicy(appPackageName = this.packageName)
if (!policy.enabled) {
return null
}
if (!policy.allowsPackage(normalizedPackage)) {
return null
}
val nowEpochMs = System.currentTimeMillis()
if (policy.isWithinQuietHours(nowEpochMs = nowEpochMs)) {
return null
}
if (!forwardingLimiter.allow(nowEpochMs, policy.maxEventsPerMinute)) {
return null
}
return buildJsonObject {
put("change", JsonPrimitive(change))
put("key", JsonPrimitive(key))
put("packageName", JsonPrimitive(normalizedPackage))
put("postTimeMs", JsonPrimitive(postTimeMs))
put("isOngoing", JsonPrimitive(isOngoing))
put("isClearable", JsonPrimitive(isClearable))
policy.sessionKey?.let { put("sessionKey", JsonPrimitive(it)) }
entry?.title?.let { put("title", JsonPrimitive(it)) }
entry?.text?.let { put("text", JsonPrimitive(it)) }
entry?.subText?.let { put("subText", JsonPrimitive(it)) }
entry?.category?.let { put("category", JsonPrimitive(it)) }
entry?.channelId?.let { put("channelId", JsonPrimitive(it)) }
}.toString()
}
private fun refreshActiveNotifications() {
val entries =
runCatching {
@@ -228,6 +281,9 @@ class DeviceNotificationListenerService : NotificationListenerService() {
}
companion object {
private const val recentPackagesPref = "notifications.forwarding.recentPackages"
private const val legacyRecentPackagesPref = "notifications.recentPackages"
private const val recentPackagesLimit = 64
@Volatile private var activeService: DeviceNotificationListenerService? = null
@Volatile private var nodeEventSink: ((event: String, payloadJson: String?) -> Unit)? = null
@@ -239,6 +295,31 @@ class DeviceNotificationListenerService : NotificationListenerService() {
nodeEventSink = sink
}
private fun recentPackagesPrefs(context: Context) =
context.applicationContext.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
private fun migrateLegacyRecentPackagesIfNeeded(context: Context) {
val prefs = recentPackagesPrefs(context)
val hasNew = prefs.contains(recentPackagesPref)
val legacy = prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
if (!hasNew && legacy.isNotEmpty()) {
prefs.edit().putString(recentPackagesPref, legacy).remove(legacyRecentPackagesPref).apply()
} else if (hasNew && prefs.contains(legacyRecentPackagesPref)) {
prefs.edit().remove(legacyRecentPackagesPref).apply()
}
}
fun recentPackages(context: Context): List<String> {
migrateLegacyRecentPackagesIfNeeded(context)
val prefs = recentPackagesPrefs(context)
val stored = prefs.getString(recentPackagesPref, null).orEmpty()
return stored
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
}
fun isAccessEnabled(context: Context): Boolean {
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
@@ -276,6 +357,21 @@ class DeviceNotificationListenerService : NotificationListenerService() {
nodeEventSink?.invoke(NOTIFICATIONS_CHANGED_EVENT, payloadJson)
}
}
private fun rememberRecentPackage(packageName: String?) {
val service = activeService ?: return
val normalized = packageName?.trim().orEmpty()
if (normalized.isEmpty() || normalized == service.packageName) return
migrateLegacyRecentPackagesIfNeeded(service.applicationContext)
val prefs = recentPackagesPrefs(service.applicationContext)
val existing = prefs.getString(recentPackagesPref, null).orEmpty()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() && it != normalized }
.take(recentPackagesLimit - 1)
val updated = listOf(normalized) + existing
prefs.edit().putString(recentPackagesPref, updated.joinToString(",")).apply()
}
}
private fun executeActionInternal(request: NotificationActionRequest): NotificationActionResult {

View File

@@ -20,6 +20,7 @@ data class NodeRuntimeFlags(
val locationEnabled: Boolean,
val sendSmsAvailable: Boolean,
val readSmsAvailable: Boolean,
val smsSearchPossible: Boolean,
val callLogAvailable: Boolean,
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
@@ -33,6 +34,7 @@ enum class InvokeCommandAvailability {
LocationEnabled,
SendSmsAvailable,
ReadSmsAvailable,
RequestableSmsSearchAvailable,
CallLogAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
@@ -199,7 +201,7 @@ object InvokeCommandRegistry {
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Search.rawValue,
availability = InvokeCommandAvailability.ReadSmsAvailable,
availability = InvokeCommandAvailability.RequestableSmsSearchAvailable,
),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
@@ -244,6 +246,7 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
InvokeCommandAvailability.RequestableSmsSearchAvailable -> flags.smsSearchPossible
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable

View File

@@ -14,6 +14,44 @@ import ai.openclaw.app.protocol.OpenClawNotificationsCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
internal enum class SmsSearchAvailabilityReason {
Available,
PermissionRequired,
Unavailable,
}
internal fun classifySmsSearchAvailability(
readSmsAvailable: Boolean,
smsFeatureEnabled: Boolean,
smsTelephonyAvailable: Boolean,
): SmsSearchAvailabilityReason {
if (readSmsAvailable) return SmsSearchAvailabilityReason.Available
if (!smsFeatureEnabled || !smsTelephonyAvailable) return SmsSearchAvailabilityReason.Unavailable
return SmsSearchAvailabilityReason.PermissionRequired
}
internal fun smsSearchAvailabilityError(
readSmsAvailable: Boolean,
smsFeatureEnabled: Boolean,
smsTelephonyAvailable: Boolean,
): GatewaySession.InvokeResult? {
return when (
classifySmsSearchAvailability(
readSmsAvailable = readSmsAvailable,
smsFeatureEnabled = smsFeatureEnabled,
smsTelephonyAvailable = smsTelephonyAvailable,
)
) {
SmsSearchAvailabilityReason.Available,
SmsSearchAvailabilityReason.PermissionRequired -> null
SmsSearchAvailabilityReason.Unavailable ->
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
}
class InvokeDispatcher(
private val canvas: CanvasController,
private val cameraHandler: CameraHandler,
@@ -34,6 +72,8 @@ class InvokeDispatcher(
private val locationEnabled: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val smsFeatureEnabled: () -> Boolean,
private val smsTelephonyAvailable: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val refreshNodeCanvasCapability: suspend () -> Boolean,
@@ -268,15 +308,13 @@ class InvokeDispatcher(
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.ReadSmsAvailable ->
if (readSmsAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.ReadSmsAvailable,
InvokeCommandAvailability.RequestableSmsSearchAvailable ->
smsSearchAvailabilityError(
readSmsAvailable = readSmsAvailable(),
smsFeatureEnabled = smsFeatureEnabled(),
smsTelephonyAvailable = smsTelephonyAvailable(),
)
InvokeCommandAvailability.CallLogAvailable ->
if (callLogAvailable()) {
null

View File

@@ -9,23 +9,28 @@ class SmsHandler(
val res = sms.send(paramsJson)
if (res.ok) {
return GatewaySession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEND_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
return GatewaySession.InvokeResult.error(code = code, message = error)
}
return errorResult(res.error, defaultCode = "SMS_SEND_FAILED")
}
suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult {
val res = sms.search(paramsJson)
if (res.ok) {
return GatewaySession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEARCH_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEARCH_FAILED"
return GatewaySession.InvokeResult.error(code = code, message = error)
}
return errorResult(res.error, defaultCode = "SMS_SEARCH_FAILED")
}
private fun errorResult(error: String?, defaultCode: String): GatewaySession.InvokeResult {
val rawMessage = error ?: defaultCode
val idx = rawMessage.indexOf(':')
val code = if (idx > 0) rawMessage.substring(0, idx).trim() else defaultCode
val message =
if (idx > 0 && code == rawMessage.substring(0, idx).trim()) {
rawMessage.substring(idx + 1).trim().ifEmpty { rawMessage }
} else {
rawMessage
}
return GatewaySession.InvokeResult.error(code = code, message = message)
}
}

View File

@@ -3,21 +3,21 @@ package ai.openclaw.app.node
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.provider.ContactsContract
import android.provider.Telephony
import android.telephony.SmsManager as AndroidSmsManager
import androidx.core.content.ContextCompat
import ai.openclaw.app.PermissionRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.Serializable
import ai.openclaw.app.PermissionRequester
/**
* Sends SMS messages via the Android SMS API.
@@ -39,7 +39,7 @@ class SmsManager(private val context: Context) {
)
/**
* Represents a single SMS message
* Represents a single SMS message.
*/
@Serializable
data class SmsMessage(
@@ -53,6 +53,7 @@ class SmsManager(private val context: Context) {
val type: Int,
val body: String?,
val status: Int,
val transportType: String? = null,
)
data class SearchResult(
@@ -62,6 +63,13 @@ class SmsManager(private val context: Context) {
val payloadJson: String,
)
internal data class QueryMetadata(
val mmsRequested: Boolean,
val mmsEligible: Boolean,
val mmsAttempted: Boolean,
val mmsIncluded: Boolean,
)
internal data class ParsedParams(
val to: String,
val message: String,
@@ -84,6 +92,8 @@ class SmsManager(private val context: Context) {
val keyword: String? = null,
val type: Int? = null,
val isRead: Boolean? = null,
val includeMms: Boolean = false,
val conversationReview: Boolean = false,
val limit: Int = DEFAULT_SMS_LIMIT,
val offset: Int = 0,
)
@@ -100,6 +110,11 @@ class SmsManager(private val context: Context) {
companion object {
private const val DEFAULT_SMS_LIMIT = 25
internal const val MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW = 500
private const val MMS_SMS_BY_PHONE_BASE = "content://mms-sms/messages/byphone"
private const val MMS_CONTENT_BASE = "content://mms"
private const val MMS_PART_URI = "content://mms/part"
private val PHONE_FORMATTING_REGEX = Regex("""[\s\-()]""")
internal val JsonConfig = Json { ignoreUnknownKeys = true }
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
@@ -157,31 +172,333 @@ class SmsManager(private val context: Context) {
val keyword = (obj["keyword"] as? JsonPrimitive)?.content?.trim()
val type = (obj["type"] as? JsonPrimitive)?.content?.toIntOrNull()
val isRead = (obj["isRead"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull()
val includeMms = (obj["includeMms"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false
val conversationReview = (obj["conversationReview"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false
val limit = ((obj["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_SMS_LIMIT)
.coerceIn(1, 200)
val offset = ((obj["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
.coerceAtLeast(0)
// Validate time range
if (startTime != null && endTime != null && startTime > endTime) {
return QueryParseResult.Error("INVALID_REQUEST: startTime must be less than or equal to endTime")
}
return QueryParseResult.Ok(QueryParams(
startTime = startTime,
endTime = endTime,
contactName = contactName,
phoneNumber = phoneNumber,
keyword = keyword,
type = type,
isRead = isRead,
limit = limit,
offset = offset,
))
return QueryParseResult.Ok(
QueryParams(
startTime = startTime,
endTime = endTime,
contactName = contactName,
phoneNumber = phoneNumber,
keyword = keyword,
type = type,
isRead = isRead,
includeMms = includeMms,
conversationReview = conversationReview,
limit = limit,
offset = offset,
)
)
}
private fun normalizePhoneNumber(phone: String): String {
return phone.replace(Regex("""[\s\-()]"""), "")
return phone.replace(PHONE_FORMATTING_REGEX, "")
}
internal fun normalizePhoneNumberOrNull(phone: String?): String? {
val normalized = phone?.let(::normalizePhoneNumber)?.trim().orEmpty()
if (normalized.isEmpty()) {
return null
}
val digits = toByPhoneLookupNumber(normalized)
return normalized.takeIf { digits.isNotEmpty() }
}
internal fun sanitizeContactPhoneNumberOrNull(phone: String?): String? {
val normalized = normalizePhoneNumberOrNull(phone) ?: return null
return normalized.takeUnless(::hasSqlLikeWildcard)
}
internal fun shouldPromptForContactNameSearchPermission(
contactName: String?,
phoneNumber: String?,
hasReadContactsPermission: Boolean,
): Boolean {
return !contactName.isNullOrEmpty() && phoneNumber.isNullOrEmpty() && !hasReadContactsPermission
}
internal fun mapMmsMsgBoxToSearchType(msgBox: Int?): Int? {
return when (msgBox) {
1 -> 1 // inbox
2 -> 2 // sent
3 -> 3 // draft
4 -> 4 // outbox
5 -> 5 // failed
6 -> 6 // queued
else -> null
}
}
internal fun escapeSqlLikeLiteral(value: String): String {
return buildString(value.length) {
for (ch in value) {
when (ch) {
'\\', '%', '_' -> {
append('\\')
append(ch)
}
else -> append(ch)
}
}
}
}
internal fun buildContactNameLikeSelection(): String {
return "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ? ESCAPE '\\'"
}
internal fun buildContactNameLikeArg(contactName: String): String {
return "%${escapeSqlLikeLiteral(contactName)}%"
}
internal fun buildKeywordLikeSelection(): String {
return "${Telephony.Sms.BODY} LIKE ? ESCAPE '\\'"
}
internal fun buildKeywordLikeArg(keyword: String): String {
return "%${escapeSqlLikeLiteral(keyword)}%"
}
internal fun buildMixedByPhoneProjection(): Array<String> {
return arrayOf(
"_id",
"thread_id",
"transport_type",
"address",
"date",
"date_sent",
"read",
"type",
"body",
"status",
)
}
internal fun hasSqlLikeWildcard(value: String): Boolean {
return value.contains('%') || value.contains('_')
}
internal fun isExplicitPhoneInputInvalid(rawPhone: String?, normalizedPhone: String?): Boolean {
if (rawPhone.isNullOrBlank()) {
return false
}
if (normalizedPhone == null) {
return true
}
return hasSqlLikeWildcard(normalizedPhone)
}
internal fun resolveMixedByPhoneRowStatus(transportType: String?, smsStatus: Int?): Int {
return if (transportType.equals("mms", ignoreCase = true)) -1 else (smsStatus ?: 0)
}
internal fun resolveMixedByPhoneRowAddress(
providerAddress: String?,
phoneNumber: String,
mmsAddress: String? = null,
): String? {
val resolvedMmsAddress = normalizePhoneNumberOrNull(mmsAddress)
if (resolvedMmsAddress != null) {
return resolvedMmsAddress
}
val resolvedProviderAddress = normalizePhoneNumberOrNull(providerAddress)
return resolvedProviderAddress ?: phoneNumber
}
internal fun selectPreferredMmsAddress(
addressRows: List<Pair<String?, Int?>>,
lookupNumber: String,
): String? {
val lookupDigits = toByPhoneLookupNumber(lookupNumber)
val normalizedRows = addressRows.mapNotNull { (address, type) ->
val normalized = normalizePhoneNumberOrNull(address) ?: return@mapNotNull null
val digits = toByPhoneLookupNumber(normalized)
if (digits.isBlank()) return@mapNotNull null
Triple(normalized, digits, type)
}
fun firstPreferred(vararg types: Int): String? {
return normalizedRows.firstOrNull { row ->
(types.isEmpty() || types.contains(row.third ?: -1)) && row.second != lookupDigits
}?.first
}
return firstPreferred(137)
?: firstPreferred(151, 130, 129)
?: firstPreferred()
?: normalizedRows.firstOrNull()?.first
}
internal fun shouldUseConversationReviewByPhoneMode(
params: QueryParams,
resolvedPhoneNumbers: List<String> = emptyList(),
): Boolean {
val hasExplicitPhoneNumber = !params.phoneNumber.isNullOrEmpty()
val hasSingleResolvedPhoneNumber = resolvedPhoneNumbers.size == 1
return params.conversationReview && params.includeMms && (hasExplicitPhoneNumber || hasSingleResolvedPhoneNumber)
}
internal fun effectiveSearchParams(
params: QueryParams,
resolvedPhoneNumbers: List<String> = emptyList(),
): QueryParams {
if (!shouldUseConversationReviewByPhoneMode(params, resolvedPhoneNumbers)) return params
val reviewLimit = maxOf(params.limit, 25)
return params.copy(limit = reviewLimit)
}
internal fun resolveSearchParams(
params: QueryParams,
normalizedPhoneNumber: String?,
resolvedPhoneNumbers: List<String> = emptyList(),
): QueryParams {
val effectivePhoneNumber = normalizedPhoneNumber ?: resolvedPhoneNumbers.singleOrNull()
val normalizedParams = params.copy(phoneNumber = effectivePhoneNumber)
return effectiveSearchParams(normalizedParams, resolvedPhoneNumbers)
}
internal fun toByPhoneLookupNumber(phone: String): String {
return phone.filter { it.isDigit() }
}
internal fun normalizeProviderDateMillis(rawDate: Long): Long {
return if (rawDate in 1..99_999_999_999L) rawDate * 1000L else rawDate
}
internal fun canonicalizeMixedPathPhoneFilters(phoneNumbers: List<String>): List<String> {
return phoneNumbers
.map(::toByPhoneLookupNumber)
.filter { it.isNotBlank() }
.distinct()
}
internal fun requestedMixedByPhoneCandidateWindow(params: QueryParams): Long {
return params.offset.toLong() + params.limit.toLong()
}
internal fun exceedsMixedByPhoneCandidateWindow(
params: QueryParams,
allPhoneNumbers: List<String>,
): Boolean {
return params.includeMms &&
allPhoneNumbers.size == 1 &&
requestedMixedByPhoneCandidateWindow(params) > MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW
}
internal fun mixedByPhoneWindowError(): String {
return "INVALID_REQUEST: includeMms offset+limit exceeds supported window ($MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW)"
}
internal fun isMmsTransportRow(message: SmsMessage): Boolean {
return message.transportType.equals("mms", ignoreCase = true)
}
internal fun shouldHydrateMmsByPhoneRow(transportType: String?, body: String?, type: Int): Boolean {
return transportType.equals("mms", ignoreCase = true) && (body.isNullOrBlank() || type == 0)
}
internal fun buildQueryMetadata(
params: QueryParams,
allPhoneNumbers: List<String>,
messages: List<SmsMessage>,
): QueryMetadata {
val mmsRequested = params.includeMms
val mmsEligible = mmsRequested && allPhoneNumbers.size == 1
val mmsAttempted = mmsEligible
val mmsIncluded = mmsAttempted && messages.any(::isMmsTransportRow)
return QueryMetadata(
mmsRequested = mmsRequested,
mmsEligible = mmsEligible,
mmsAttempted = mmsAttempted,
mmsIncluded = mmsIncluded,
)
}
internal fun compareByPhoneCandidateOrder(left: SmsMessage, right: SmsMessage): Int {
return when {
left.date != right.date -> right.date.compareTo(left.date)
left.id != right.id -> right.id.compareTo(left.id)
else -> 0
}
}
internal fun buildMixedRowIdentity(rowId: Long, transportType: String?): String {
return "${transportType?.ifBlank { "unknown" } ?: "unknown"}:$rowId"
}
internal fun upsertTopDateCandidates(
candidates: MutableList<Pair<String, SmsMessage>>,
identityKey: String,
message: SmsMessage,
maxCandidates: Int,
) {
if (maxCandidates <= 0) {
return
}
candidates.removeAll { existing -> existing.first == identityKey }
candidates.add(identityKey to message)
candidates.sortWith { left, right -> compareByPhoneCandidateOrder(left.second, right.second) }
while (candidates.size > maxCandidates) {
candidates.removeAt(candidates.lastIndex)
}
}
internal fun materializeByPhoneCandidate(
candidates: MutableMap<String, SmsMessage>,
identityKey: String,
message: SmsMessage,
) {
candidates[identityKey] = message
}
internal fun collectMixedByPhoneCandidate(
topCandidates: MutableList<Pair<String, SmsMessage>>,
materializedCandidates: MutableMap<String, SmsMessage>,
identityKey: String,
message: SmsMessage,
maxCandidates: Int,
reviewMode: Boolean,
) {
if (reviewMode) {
materializeByPhoneCandidate(materializedCandidates, identityKey, message)
} else {
upsertTopDateCandidates(topCandidates, identityKey, message, maxCandidates)
}
}
internal fun pageMixedByPhoneCandidates(
topCandidates: Collection<Pair<String, SmsMessage>>,
materializedCandidates: Map<String, SmsMessage>,
params: QueryParams,
reviewMode: Boolean,
): List<SmsMessage> {
return if (reviewMode) {
pageByPhoneCandidates(materializedCandidates.values, params)
} else {
pageByPhoneCandidates(topCandidates.map { it.second }, params)
}
}
internal fun pageByPhoneCandidates(
candidates: Collection<SmsMessage>,
params: QueryParams,
): List<SmsMessage> {
return candidates
.sortedWith(::compareByPhoneCandidateOrder)
.drop(params.offset)
.take(params.limit)
}
internal fun buildSendPlan(
@@ -214,14 +531,21 @@ class SmsManager(private val context: Context) {
ok: Boolean,
messages: List<SmsMessage>,
error: String? = null,
queryMetadata: QueryMetadata? = null,
): String {
val messagesArray = json.encodeToString(messages)
val messagesElement = json.parseToJsonElement(messagesArray)
val payload = mutableMapOf<String, JsonElement>(
"ok" to JsonPrimitive(ok),
"count" to JsonPrimitive(messages.size),
"messages" to messagesElement
"messages" to messagesElement,
)
queryMetadata?.let {
payload["mmsRequested"] = JsonPrimitive(it.mmsRequested)
payload["mmsEligible"] = JsonPrimitive(it.mmsEligible)
payload["mmsAttempted"] = JsonPrimitive(it.mmsAttempted)
payload["mmsIncluded"] = JsonPrimitive(it.mmsIncluded)
}
if (!ok && error != null) {
payload["error"] = JsonPrimitive(error)
}
@@ -254,10 +578,14 @@ class SmsManager(private val context: Context) {
return hasSmsPermission() && hasTelephonyFeature()
}
fun canReadSms(): Boolean {
fun canSearchSms(): Boolean {
return hasReadSmsPermission() && hasTelephonyFeature()
}
fun canReadSms(): Boolean {
return canSearchSms()
}
fun hasTelephonyFeature(): Boolean {
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
@@ -302,19 +630,19 @@ class SmsManager(private val context: Context) {
val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) }
if (plan.useMultipart) {
smsManager.sendMultipartTextMessage(
params.to, // destination
null, // service center (null = default)
ArrayList(plan.parts), // message parts
null, // sent intents
null, // delivery intents
params.to,
null,
ArrayList(plan.parts),
null,
null,
)
} else {
smsManager.sendTextMessage(
params.to, // destination
null, // service center (null = default)
params.message,// message
null, // sent intent
null, // delivery intent
params.to,
null,
params.message,
null,
null,
)
}
@@ -334,6 +662,82 @@ class SmsManager(private val context: Context) {
}
}
/**
* Search SMS messages with the specified parameters.
*/
suspend fun search(paramsJson: String?): SearchResult = withContext(Dispatchers.IO) {
if (!hasTelephonyFeature()) {
return@withContext queryError("SMS_UNAVAILABLE: telephony not available")
}
if (!ensureReadSmsPermission()) {
return@withContext queryError("SMS_PERMISSION_REQUIRED: grant READ_SMS permission")
}
val parseResult = parseQueryParams(paramsJson, json)
if (parseResult is QueryParseResult.Error) {
return@withContext queryError(parseResult.error)
}
val parsedParams = (parseResult as QueryParseResult.Ok).params
val normalizedPhoneNumber = normalizePhoneNumberOrNull(parsedParams.phoneNumber)
if (isExplicitPhoneInputInvalid(parsedParams.phoneNumber, normalizedPhoneNumber)) {
val error =
if (!parsedParams.phoneNumber.isNullOrBlank() && normalizedPhoneNumber != null && hasSqlLikeWildcard(normalizedPhoneNumber)) {
"INVALID_REQUEST: phoneNumber must not contain SQL LIKE wildcard characters"
} else {
"INVALID_REQUEST: phoneNumber must contain at least one digit"
}
return@withContext queryError(error)
}
val normalizedParams = resolveSearchParams(parsedParams, normalizedPhoneNumber)
return@withContext try {
val contactsPermissionGranted = hasReadContactsPermission()
val shouldPromptForContactsPermission =
shouldPromptForContactNameSearchPermission(
contactName = normalizedParams.contactName,
phoneNumber = normalizedParams.phoneNumber,
hasReadContactsPermission = contactsPermissionGranted,
)
val phoneNumbers = if (!normalizedParams.contactName.isNullOrEmpty()) {
if (contactsPermissionGranted || (shouldPromptForContactsPermission && ensureReadContactsPermission())) {
getPhoneNumbersFromContactName(normalizedParams.contactName)
} else if (shouldPromptForContactsPermission) {
return@withContext queryError("CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission")
} else {
emptyList()
}
} else {
emptyList()
}
val params = resolveSearchParams(parsedParams, normalizedPhoneNumber, phoneNumbers)
val mixedPathPhoneFilters = if (!params.phoneNumber.isNullOrEmpty()) {
canonicalizeMixedPathPhoneFilters(phoneNumbers + params.phoneNumber)
} else {
canonicalizeMixedPathPhoneFilters(phoneNumbers)
}
if (exceedsMixedByPhoneCandidateWindow(params, mixedPathPhoneFilters)) {
val error = mixedByPhoneWindowError()
return@withContext queryError(error)
}
if (!params.contactName.isNullOrEmpty() && phoneNumbers.isEmpty() && params.phoneNumber.isNullOrEmpty()) {
val queryMetadata = buildQueryMetadata(params, mixedPathPhoneFilters, emptyList())
return@withContext queryOk(emptyList(), queryMetadata)
}
val messages = querySmsMessages(params, phoneNumbers)
val queryMetadata = buildQueryMetadata(params, mixedPathPhoneFilters, messages)
queryOk(messages, queryMetadata)
} catch (e: SecurityException) {
queryError("SMS_PERMISSION_REQUIRED: ${e.message}")
} catch (e: Throwable) {
queryError("SMS_QUERY_FAILED: ${e.message ?: "unknown error"}")
}
}
private suspend fun ensureSmsPermission(): Boolean {
if (hasSmsPermission()) return true
val requester = permissionRequester ?: return false
@@ -375,98 +779,31 @@ class SmsManager(private val context: Context) {
)
}
/**
* search SMS messages with the specified parameters.
*
* @param paramsJson JSON with optional fields:
* - startTime (Long): Start time in milliseconds
* - endTime (Long): End time in milliseconds
* - contactName (String): Contact name to search
* - phoneNumber (String): Phone number to search (supports partial matching)
* - keyword (String): Keyword to search in message body
* - type (Int): SMS type (1=Inbox, 2=Sent, 3=Draft, etc.)
* - isRead (Boolean): Read status
* - limit (Int): Number of records to return (default: 25, range: 1-200)
* - offset (Int): Number of records to skip (default: 0)
* @return SearchResult containing the list of SMS messages or an error
*/
suspend fun search(paramsJson: String?): SearchResult = withContext(Dispatchers.IO) {
if (!hasTelephonyFeature()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_UNAVAILABLE: telephony not available",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_UNAVAILABLE: telephony not available")
)
}
if (!ensureReadSmsPermission()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission")
)
}
val parseResult = parseQueryParams(paramsJson, json)
if (parseResult is QueryParseResult.Error) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = parseResult.error,
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = parseResult.error)
)
}
val params = (parseResult as QueryParseResult.Ok).params
return@withContext try {
// Get phone numbers from contact name if provided
val phoneNumbers = if (!params.contactName.isNullOrEmpty()) {
if (!ensureReadContactsPermission()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission")
)
}
getPhoneNumbersFromContactName(params.contactName)
} else {
emptyList()
}
val messages = querySmsMessages(params, phoneNumbers)
SearchResult(
ok = true,
messages = messages,
error = null,
payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages)
)
} catch (e: SecurityException) {
SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: ${e.message}")
)
} catch (e: Throwable) {
SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}")
)
}
private fun queryOk(
messages: List<SmsMessage>,
queryMetadata: QueryMetadata? = null,
): SearchResult {
return SearchResult(
ok = true,
messages = messages,
error = null,
payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages, queryMetadata = queryMetadata),
)
}
private fun queryError(error: String): SearchResult {
return SearchResult(
ok = false,
messages = emptyList(),
error = error,
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = error),
)
}
/**
* Get all phone numbers associated with a contact name
*/
private fun getPhoneNumbersFromContactName(contactName: String): List<String> {
val phoneNumbers = mutableListOf<String>()
val selection = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ?"
val selectionArgs = arrayOf("%$contactName%")
val selection = buildContactNameLikeSelection()
val selectionArgs = arrayOf(buildContactNameLikeArg(contactName))
val cursor = context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
@@ -480,26 +817,19 @@ class SmsManager(private val context: Context) {
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (it.moveToNext()) {
val number = it.getString(numberIndex)
if (!number.isNullOrBlank()) {
phoneNumbers.add(normalizePhoneNumber(number))
}
sanitizeContactPhoneNumberOrNull(number)?.let(phoneNumbers::add)
}
}
return phoneNumbers
}
/**
* Query SMS messages based on the provided parameters
*/
private fun querySmsMessages(params: QueryParams, phoneNumbers: List<String>): List<SmsMessage> {
val messages = mutableListOf<SmsMessage>()
// Build selection and selectionArgs
val selections = mutableListOf<String>()
val selectionArgs = mutableListOf<String>()
// Time range
if (params.startTime != null) {
selections.add("${Telephony.Sms.DATE} >= ?")
selectionArgs.add(params.startTime.toString())
@@ -509,11 +839,17 @@ class SmsManager(private val context: Context) {
selectionArgs.add(params.endTime.toString())
}
// Phone numbers (from contact name or direct phone number)
val allPhoneNumbers = if (!params.phoneNumber.isNullOrEmpty()) {
phoneNumbers + normalizePhoneNumber(params.phoneNumber)
(phoneNumbers + normalizePhoneNumber(params.phoneNumber)).distinct()
} else {
phoneNumbers
phoneNumbers.distinct()
}
val mixedPathPhoneFilters = canonicalizeMixedPathPhoneFilters(allPhoneNumbers)
// Unified SMS+MMS query path is opt-in to keep sms.search semantics
// stable by default. Use includeMms=true for by-phone provider behavior.
if (params.includeMms && mixedPathPhoneFilters.size == 1) {
return querySmsMmsMessagesByPhone(mixedPathPhoneFilters.first(), params)
}
if (allPhoneNumbers.isNotEmpty()) {
@@ -526,19 +862,16 @@ class SmsManager(private val context: Context) {
}
}
// Keyword in body
if (!params.keyword.isNullOrEmpty()) {
selections.add("${Telephony.Sms.BODY} LIKE ?")
selectionArgs.add("%${params.keyword}%")
selections.add(buildKeywordLikeSelection())
selectionArgs.add(buildKeywordLikeArg(params.keyword))
}
// Type
if (params.type != null) {
selections.add("${Telephony.Sms.TYPE} = ?")
selectionArgs.add(params.type.toString())
}
// Read status
if (params.isRead != null) {
selections.add("${Telephony.Sms.READ} = ?")
selectionArgs.add(if (params.isRead) "1" else "0")
@@ -556,7 +889,8 @@ class SmsManager(private val context: Context) {
null
}
// Query SMS with SQL-level LIMIT and OFFSET to avoid loading all matching rows
// Android SMS providers still honor LIMIT/OFFSET through sortOrder on this path.
// Keep the bounded interpolation here because parseQueryParams already clamps both values.
val sortOrder = "${Telephony.Sms.DATE} DESC LIMIT ${params.limit} OFFSET ${params.offset}"
val cursor = context.contentResolver.query(
Telephony.Sms.CONTENT_URI,
@@ -570,7 +904,7 @@ class SmsManager(private val context: Context) {
Telephony.Sms.READ,
Telephony.Sms.TYPE,
Telephony.Sms.BODY,
Telephony.Sms.STATUS
Telephony.Sms.STATUS,
),
selection,
selectionArgsArray,
@@ -601,7 +935,7 @@ class SmsManager(private val context: Context) {
read = it.getInt(readIndex) == 1,
type = it.getInt(typeIndex),
body = it.getString(bodyIndex),
status = it.getInt(statusIndex)
status = it.getInt(statusIndex),
)
messages.add(message)
count++
@@ -610,4 +944,184 @@ class SmsManager(private val context: Context) {
return messages
}
private fun querySmsMmsMessagesByPhone(phoneNumber: String, params: QueryParams): List<SmsMessage> {
val lookupNumber = toByPhoneLookupNumber(phoneNumber)
if (lookupNumber.isBlank()) {
return emptyList()
}
val uri = Uri.parse("$MMS_SMS_BY_PHONE_BASE/${Uri.encode(lookupNumber)}")
val projection = buildMixedByPhoneProjection()
val maxCandidates = params.offset + params.limit
if (maxCandidates <= 0) {
return emptyList()
}
val reviewMode = shouldUseConversationReviewByPhoneMode(params)
val topCandidates = mutableListOf<Pair<String, SmsMessage>>()
val materializedCandidates = linkedMapOf<String, SmsMessage>()
val cursor = context.contentResolver.query(uri, projection, null, null, "date DESC")
cursor?.use {
val idIndex = it.getColumnIndex("_id")
val threadIdIndex = it.getColumnIndex("thread_id")
val transportTypeIndex = it.getColumnIndex("transport_type")
val addressIndex = it.getColumnIndex("address")
val dateIndex = it.getColumnIndex("date")
val dateSentIndex = it.getColumnIndex("date_sent")
val readIndex = it.getColumnIndex("read")
val typeIndex = it.getColumnIndex("type")
val bodyIndex = it.getColumnIndex("body")
val statusIndex = it.getColumnIndex("status")
while (it.moveToNext()) {
val id = if (idIndex >= 0 && !it.isNull(idIndex)) it.getLong(idIndex) else continue
val rawDate = if (dateIndex >= 0 && !it.isNull(dateIndex)) it.getLong(dateIndex) else 0L
val dateMs = normalizeProviderDateMillis(rawDate)
if (params.startTime != null && dateMs < params.startTime) continue
if (params.endTime != null && dateMs > params.endTime) continue
val threadId = if (threadIdIndex >= 0 && !it.isNull(threadIdIndex)) it.getLong(threadIdIndex) else 0L
val transportType = if (transportTypeIndex >= 0 && !it.isNull(transportTypeIndex)) it.getString(transportTypeIndex) else null
val providerAddress = if (addressIndex >= 0 && !it.isNull(addressIndex)) it.getString(addressIndex) else null
val mmsAddress = if (transportType.equals("mms", ignoreCase = true)) getMmsAddress(id, phoneNumber) else null
val address = resolveMixedByPhoneRowAddress(providerAddress, phoneNumber, mmsAddress)
var read = if (readIndex >= 0 && !it.isNull(readIndex)) it.getInt(readIndex) == 1 else true
var type = if (typeIndex >= 0 && !it.isNull(typeIndex)) it.getInt(typeIndex) else 0
var body = if (bodyIndex >= 0 && !it.isNull(bodyIndex)) it.getString(bodyIndex) else null
val smsStatus = if (statusIndex >= 0 && !it.isNull(statusIndex)) it.getInt(statusIndex) else null
// Only MMS transport rows are allowed to hydrate from MMS storage.
if (shouldHydrateMmsByPhoneRow(transportType, body, type)) {
body = body?.takeIf { msg -> msg.isNotBlank() } ?: getMmsTextBody(id)
val mmsMeta = getMmsMeta(id)
if (type == 0) {
type = mmsMeta.first ?: type
}
if (readIndex < 0 || it.isNull(readIndex)) {
read = mmsMeta.second ?: read
}
}
val dateSentRaw = if (dateSentIndex >= 0 && !it.isNull(dateSentIndex)) it.getLong(dateSentIndex) else 0L
val dateSentMs = normalizeProviderDateMillis(dateSentRaw)
if (!params.keyword.isNullOrEmpty()) {
val keyword = params.keyword
if (body.isNullOrEmpty() || !body.contains(keyword, ignoreCase = true)) {
continue
}
}
if (params.type != null && type != params.type) continue
if (params.isRead != null && read != params.isRead) continue
val message = SmsMessage(
id = id,
threadId = threadId,
address = address,
person = null,
date = dateMs,
dateSent = dateSentMs,
read = read,
type = type,
body = body,
status = resolveMixedByPhoneRowStatus(transportType, smsStatus),
transportType = transportType,
)
val identityKey = buildMixedRowIdentity(id, transportType)
collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = identityKey,
message = message,
maxCandidates = maxCandidates,
reviewMode = reviewMode,
)
}
}
return pageMixedByPhoneCandidates(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
params = params,
reviewMode = reviewMode,
)
}
private fun getMmsTextBody(messageId: Long): String? {
val cursor = context.contentResolver.query(
Uri.parse(MMS_PART_URI),
arrayOf("text", "ct"),
"mid=?",
arrayOf(messageId.toString()),
null,
)
cursor?.use {
val textIndex = it.getColumnIndex("text")
val ctIndex = it.getColumnIndex("ct")
while (it.moveToNext()) {
val contentType = if (ctIndex >= 0 && !it.isNull(ctIndex)) it.getString(ctIndex) else null
if (contentType != null && contentType != "text/plain") continue
val text = if (textIndex >= 0 && !it.isNull(textIndex)) it.getString(textIndex) else null
if (!text.isNullOrBlank()) return text
}
}
return null
}
private fun getMmsMeta(messageId: Long): Pair<Int?, Boolean?> {
val cursor = context.contentResolver.query(
Uri.parse("$MMS_CONTENT_BASE/$messageId"),
arrayOf("msg_box", "read"),
null,
null,
null,
)
cursor?.use {
if (it.moveToFirst()) {
val msgBoxIndex = it.getColumnIndex("msg_box")
val readIndex = it.getColumnIndex("read")
val msgBox = if (msgBoxIndex >= 0 && !it.isNull(msgBoxIndex)) it.getInt(msgBoxIndex) else null
val mappedType = mapMmsMsgBoxToSearchType(msgBox)
val read = if (readIndex >= 0 && !it.isNull(readIndex)) it.getInt(readIndex) == 1 else null
return mappedType to read
}
}
return null to null
}
private fun getMmsAddress(messageId: Long, phoneNumber: String): String? {
val lookupNumber = toByPhoneLookupNumber(phoneNumber)
if (lookupNumber.isBlank()) {
return null
}
val cursor = context.contentResolver.query(
Uri.parse("$MMS_CONTENT_BASE/$messageId/addr"),
arrayOf("address", "type"),
null,
null,
null,
)
cursor?.use {
val addressIndex = it.getColumnIndex("address")
val typeIndex = it.getColumnIndex("type")
val addressRows = mutableListOf<Pair<String?, Int?>>()
while (it.moveToNext()) {
val address = if (addressIndex >= 0 && !it.isNull(addressIndex)) it.getString(addressIndex) else null
val type = if (typeIndex >= 0 && !it.isNull(typeIndex)) it.getInt(typeIndex) else null
addressRows.add(address to type)
}
return selectPreferredMmsAddress(addressRows, lookupNumber)
}
return null
}
}

View File

@@ -53,6 +53,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.ui.mobileCardSurface
private enum class ConnectInputMode {
@@ -71,6 +72,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
val manualTls by viewModel.manualTls.collectAsState()
val manualEnabled by viewModel.manualEnabled.collectAsState()
val gatewayToken by viewModel.gatewayToken.collectAsState()
val gatewayBootstrapToken by viewModel.gatewayBootstrapToken.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
var advancedOpen by rememberSaveable { mutableStateOf(false) }
@@ -240,9 +242,13 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
resolveGatewayConnectConfig(
useSetupCode = inputMode == ConnectInputMode.SetupCode,
setupCode = setupCode,
manualHost = manualHostInput,
manualPort = manualPortInput,
manualTls = manualTlsInput,
savedManualHost = manualHost,
savedManualPort = manualPort.toString(),
savedManualTls = manualTls,
manualHostInput = manualHostInput,
manualPortInput = manualPortInput,
manualTlsInput = manualTlsInput,
fallbackBootstrapToken = gatewayBootstrapToken,
fallbackToken = gatewayToken,
fallbackPassword = passwordInput,
)
@@ -250,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
}
@@ -269,7 +289,12 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
viewModel.setGatewayToken("")
}
viewModel.setGatewayPassword(config.password)
viewModel.connectManual()
viewModel.connect(
GatewayEndpoint.manual(host = config.host, port = config.port),
token = config.token.ifEmpty { null },
bootstrapToken = config.bootstrapToken.ifEmpty { null },
password = config.password.ifEmpty { null },
)
},
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(14.dp),
@@ -375,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)
@@ -457,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

@@ -1,5 +1,6 @@
package ai.openclaw.app.ui
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import java.util.Base64
import java.util.Locale
import java.net.URI
@@ -32,20 +33,49 @@ 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,
setupCode: String,
manualHost: String,
manualPort: String,
manualTls: Boolean,
savedManualHost: String,
savedManualPort: String,
savedManualTls: Boolean,
manualHostInput: String,
manualPortInput: String,
manualTlsInput: Boolean,
fallbackBootstrapToken: String,
fallbackToken: String,
fallbackPassword: String,
): 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 {
@@ -69,26 +99,42 @@ internal fun resolveGatewayConnectConfig(
)
}
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) ?: return null
val parsed = parseGatewayEndpoint(manualUrl) ?: return null
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput) ?: return null
val parsed = parseGatewayEndpointResult(manualUrl).config ?: return null
val savedManualEndpoint =
composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls)
?.let { parseGatewayEndpointResult(it).config }
val preserveBootstrapToken =
savedManualEndpoint != null &&
savedManualEndpoint.host == parsed.host &&
savedManualEndpoint.port == parsed.port &&
savedManualEndpoint.tls == parsed.tls &&
fallbackToken.isBlank() &&
fallbackPassword.isBlank()
return GatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
bootstrapToken = "",
bootstrapToken = if (preserveBootstrapToken) fallbackBootstrapToken.trim() else "",
token = fallbackToken.trim(),
password = fallbackPassword.trim(),
)
}
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 host = uri.host?.trim().orEmpty()
if (host.isEmpty()) 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 GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty()
val tls =
@@ -97,6 +143,9 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
"wss", "https" -> true
else -> true
}
if (!tls && !isLoopbackGatewayHost(host)) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
}
val defaultPort =
when (scheme) {
"wss", "https" -> 443
@@ -110,14 +159,17 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
else -> 443
}
val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort
val displayHost = if (host.contains(":")) "[$host]" else host
val displayUrl =
if (port == displayPort && defaultPort == displayPort) {
"${if (tls) "https" else "http"}://$host"
"${if (tls) "https" else "http"}://$displayHost"
} else {
"${if (tls) "https" else "http"}://$host:$port"
"${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? {
@@ -148,8 +200,44 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
internal fun resolveScannedSetupCode(rawInput: String): String? {
val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null
return setupCode.takeIf { decodeGatewaySetupCode(it) != 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

@@ -96,6 +96,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.node.DeviceNotificationListenerService
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
@@ -211,6 +212,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val context = androidx.compose.ui.platform.LocalContext.current
val statusText by viewModel.statusText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val persistedGatewayToken by viewModel.gatewayToken.collectAsState()
@@ -227,6 +229,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
var manualTls by rememberSaveable { mutableStateOf(false) }
var gatewayError by rememberSaveable { mutableStateOf<String?>(null) }
var attemptedConnect by rememberSaveable { mutableStateOf(false) }
val canFinishOnboarding = canFinishOnboarding(isConnected = isConnected, isNodeConnected = isNodeConnected)
val lifecycleOwner = LocalLifecycleOwner.current
val qrScannerOptions =
@@ -563,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
@@ -732,7 +739,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
FinalStep(
parsedGateway = parseGatewayEndpoint(gatewayUrl),
statusText = statusText,
isConnected = isConnected,
isConnected = canFinishOnboarding,
serverName = serverName,
remoteAddress = remoteAddress,
attemptedConnect = attemptedConnect,
@@ -796,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
@@ -816,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
@@ -848,7 +863,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
}
OnboardingStep.FinalCheck -> {
if (isConnected) {
if (canFinishOnboarding) {
Button(
onClick = { viewModel.setOnboardingCompleted(true) },
modifier = Modifier.weight(1f).height(52.dp),
@@ -860,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("")
}
@@ -882,7 +901,17 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
viewModel.setGatewayToken("")
}
viewModel.setGatewayPassword(password)
viewModel.connectManual()
viewModel.connect(
GatewayEndpoint.manual(host = parsed.config.host, port = parsed.config.port),
token = token.ifEmpty { null },
bootstrapToken =
if (gatewayInputMode == GatewayInputMode.SetupCode) {
decodeGatewaySetupCode(setupCode)?.bootstrapToken?.trim()?.ifEmpty { null }
} else {
null
},
password = password.ifEmpty { null },
)
},
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
@@ -898,6 +927,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
}
internal fun canFinishOnboarding(isConnected: Boolean, isNodeConnected: Boolean): Boolean {
return isConnected && isNodeConnected
}
@Composable
private fun onboardingPrimaryButtonColors() =
ButtonDefaults.buttonColors(
@@ -1023,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,
)
@@ -1055,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,
@@ -1136,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,
@@ -1459,8 +1496,8 @@ private fun PermissionsStep(
subtitle = "Send and search text messages via the gateway",
checked = enableSms,
granted =
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS),
isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
isPermissionGranted(context, Manifest.permission.READ_SMS),
onCheckedChange = onSmsChange,
)
}
@@ -1677,21 +1714,22 @@ private fun FinalStep(
)
}
}
Text("Status", style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), color = onboardingTextSecondary)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = onboardingCommandBg,
border = BorderStroke(1.dp, onboardingCommandBorder),
) {
Text(
statusLabel,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace),
color = onboardingCommandText,
)
}
if (showDiagnostics) {
Text("Error", style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), color = onboardingTextSecondary)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = onboardingCommandBg,
border = BorderStroke(1.dp, onboardingCommandBorder),
) {
Text(
statusLabel,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace),
color = onboardingCommandText,
)
}
Text(
"OpenClaw Android ${openClawAndroidVersionLabel()}",
style = onboardingCaption1Style,

View File

@@ -46,6 +46,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ai.openclaw.app.HomeDestination
import ai.openclaw.app.MainViewModel
private enum class HomeTab(
@@ -72,6 +73,20 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
var chatTabStarted by rememberSaveable { mutableStateOf(false) }
var screenTabStarted by rememberSaveable { mutableStateOf(false) }
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
LaunchedEffect(requestedHomeDestination) {
val destination = requestedHomeDestination ?: return@LaunchedEffect
activeTab =
when (destination) {
HomeDestination.Connect -> HomeTab.Connect
HomeDestination.Chat -> HomeTab.Chat
HomeDestination.Voice -> HomeTab.Voice
HomeDestination.Screen -> HomeTab.Screen
HomeDestination.Settings -> HomeTab.Settings
}
viewModel.clearRequestedHomeDestination()
}
// Stop TTS when user navigates away from voice tab, and lazily keep the Chat/Screen tabs
// alive after the first visit so repeated tab switches do not rebuild their UI trees.

View File

@@ -9,6 +9,7 @@ import android.hardware.SensorManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.app.role.RoleManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -34,7 +35,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
@@ -54,20 +54,23 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.normalizeLocalHourMinute
import ai.openclaw.app.NotificationPackageFilterMode
import ai.openclaw.app.node.DeviceNotificationListenerService
@Composable
@@ -81,6 +84,55 @@ fun SettingsSheet(viewModel: MainViewModel) {
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
val preventSleep by viewModel.preventSleep.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val notificationForwardingEnabled by viewModel.notificationForwardingEnabled.collectAsState()
val notificationForwardingMode by viewModel.notificationForwardingMode.collectAsState()
val notificationForwardingPackages by viewModel.notificationForwardingPackages.collectAsState()
val notificationForwardingQuietHoursEnabled by viewModel.notificationForwardingQuietHoursEnabled.collectAsState()
val notificationForwardingQuietStart by viewModel.notificationForwardingQuietStart.collectAsState()
val notificationForwardingQuietEnd by viewModel.notificationForwardingQuietEnd.collectAsState()
val notificationForwardingMaxEventsPerMinute by viewModel.notificationForwardingMaxEventsPerMinute.collectAsState()
val notificationForwardingSessionKey by viewModel.notificationForwardingSessionKey.collectAsState()
var notificationQuietStartDraft by remember(notificationForwardingQuietStart) {
mutableStateOf(notificationForwardingQuietStart)
}
var notificationQuietEndDraft by remember(notificationForwardingQuietEnd) {
mutableStateOf(notificationForwardingQuietEnd)
}
var notificationRateDraft by remember(notificationForwardingMaxEventsPerMinute) {
mutableStateOf(notificationForwardingMaxEventsPerMinute.toString())
}
var notificationSessionKeyDraft by remember(notificationForwardingSessionKey) {
mutableStateOf(notificationForwardingSessionKey.orEmpty())
}
val normalizedQuietStartDraft = remember(notificationQuietStartDraft) {
normalizeLocalHourMinute(notificationQuietStartDraft)
}
val normalizedQuietEndDraft = remember(notificationQuietEndDraft) {
normalizeLocalHourMinute(notificationQuietEndDraft)
}
val quietHoursDraftValid = normalizedQuietStartDraft != null && normalizedQuietEndDraft != null
val selectedPackagesSummary = remember(notificationForwardingMode, notificationForwardingPackages) {
when (notificationForwardingMode) {
NotificationPackageFilterMode.Allowlist ->
if (notificationForwardingPackages.isEmpty()) {
"Selected: none — allowlist mode forwards nothing until you add apps."
} else {
"Selected: ${notificationForwardingPackages.size} app(s) allowed."
}
NotificationPackageFilterMode.Blocklist ->
if (notificationForwardingPackages.isEmpty()) {
"Selected: none — blocklist mode forwards all apps except OpenClaw."
} else {
"Selected: ${notificationForwardingPackages.size} app(s) blocked."
}
}
}
val quietHoursCanEnable = notificationForwardingEnabled && quietHoursDraftValid
val quietHoursDraftDirty =
notificationForwardingQuietStart != (normalizedQuietStartDraft ?: notificationQuietStartDraft.trim()) ||
notificationForwardingQuietEnd != (normalizedQuietEndDraft ?: notificationQuietEndDraft.trim())
val quietHoursSaveEnabled = notificationForwardingEnabled && quietHoursDraftValid && quietHoursDraftDirty
val listState = rememberLazyListState()
val deviceModel =
@@ -99,6 +151,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
versionName
}
}
var assistantRoleAvailable by remember(context) { mutableStateOf(isAssistantRoleAvailable(context)) }
var assistantRoleHeld by remember(context) { mutableStateOf(isAssistantRoleHeld(context)) }
val listItemColors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
@@ -175,6 +229,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
remember {
mutableStateOf(isNotificationListenerEnabled(context))
}
val notificationForwardingAvailable = notificationForwardingEnabled && notificationListenerEnabled
val notificationForwardingControlsAlpha = if (notificationForwardingAvailable) 1f else 0.6f
var notificationPickerExpanded by remember { mutableStateOf(false) }
var notificationAppSearch by remember { mutableStateOf("") }
var notificationShowSystemApps by remember { mutableStateOf(false) }
var installedNotificationApps by
remember(context, notificationForwardingPackages) {
mutableStateOf(queryInstalledApps(context, notificationForwardingPackages))
}
var photosPermissionGranted by
remember {
@@ -249,19 +313,28 @@ fun SettingsSheet(viewModel: MainViewModel) {
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED &&
PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED,
)
}
val smsPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val sendOk = perms[Manifest.permission.SEND_SMS] == true
val readOk = perms[Manifest.permission.READ_SMS] == true
smsPermissionGranted = sendOk && readOk
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED
||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED
viewModel.refreshGatewayConnection()
}
val assistantRoleLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
assistantRoleAvailable = isAssistantRoleAvailable(context)
assistantRoleHeld = isAssistantRoleHeld(context)
}
DisposableEffect(lifecycleOwner, context) {
val observer =
LifecycleEventObserver { _, event ->
@@ -271,6 +344,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED
notificationsPermissionGranted = hasNotificationsPermission(context)
notificationListenerEnabled = isNotificationListenerEnabled(context)
installedNotificationApps = queryInstalledApps(context, notificationForwardingPackages)
photosPermissionGranted =
ContextCompat.checkSelfPermission(context, photosPermission) ==
PackageManager.PERMISSION_GRANTED
@@ -293,9 +367,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED &&
PackageManager.PERMISSION_GRANTED
||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED
assistantRoleAvailable = isAssistantRoleAvailable(context)
assistantRoleHeld = isAssistantRoleHeld(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
@@ -351,6 +428,20 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
val normalizedAppSearch = notificationAppSearch.trim().lowercase()
val filteredNotificationApps =
remember(installedNotificationApps, normalizedAppSearch, notificationShowSystemApps) {
installedNotificationApps
.asSequence()
.filter { app -> notificationShowSystemApps || !app.isSystemApp }
.filter { app ->
normalizedAppSearch.isEmpty() ||
app.label.lowercase().contains(normalizedAppSearch) ||
app.packageName.lowercase().contains(normalizedAppSearch)
}
.toList()
}
Box(
modifier =
Modifier
@@ -398,6 +489,42 @@ fun SettingsSheet(viewModel: MainViewModel) {
color = mobileTextTertiary,
)
}
if (assistantRoleAvailable) {
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Default Assistant", style = mobileHeadline) },
supportingContent = {
Text(
if (assistantRoleHeld) {
"OpenClaw is registered as the device assistant."
} else {
"Let Android launch OpenClaw from the assistant gesture. Google Assistant App Actions still work separately."
},
style = mobileCallout,
)
},
trailingContent = {
Button(
onClick = {
assistantRoleLauncher.launch(
context
.getSystemService(RoleManager::class.java)
.createRequestRoleIntent(RoleManager.ROLE_ASSISTANT),
)
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (assistantRoleHeld) "Manage" else "Enable",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
}
}
}
@@ -491,9 +618,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Notification Listener", style = mobileHeadline) },
headlineContent = { Text("Notification Listener Access", style = mobileHeadline) },
supportingContent = {
Text("Read and interact with notifications.", style = mobileCallout)
Text(
"Required for `notifications.list`, `notifications.actions`, and forwarded notification events.",
style = mobileCallout,
)
},
trailingContent = {
Button(
@@ -530,7 +660,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
shape = RoundedCornerShape(14.dp),
) {
Text(
if (smsPermissionGranted) "Manage" else "Grant",
if (smsPermissionGranted) {
"Manage"
} else {
"Grant"
},
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
@@ -539,6 +673,297 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
}
item {
ListItem(
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Forward Notification Events", style = mobileHeadline) },
supportingContent = {
Text(
if (notificationListenerEnabled) {
"Forward listener events into gateway node events. Off by default until you enable it."
} else {
"Notification listener access is off, so no notification events can be forwarded yet."
},
style = mobileCallout,
)
},
trailingContent = {
Switch(
checked = notificationForwardingEnabled,
onCheckedChange = viewModel::setNotificationForwardingEnabled,
enabled = notificationListenerEnabled,
)
},
)
}
item {
Text(
if (notificationListenerEnabled) {
"Forwarding is available when enabled below."
} else {
"Forwarding controls stay disabled until Notification Listener Access is enabled in system Settings."
},
style = mobileCallout,
color = mobileTextSecondary,
)
}
item {
Column(
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Package Filter: Allowlist", style = mobileHeadline) },
supportingContent = {
Text("Only listed package IDs are forwarded.", style = mobileCallout)
},
trailingContent = {
RadioButton(
selected = notificationForwardingMode == NotificationPackageFilterMode.Allowlist,
onClick = {
viewModel.setNotificationForwardingMode(NotificationPackageFilterMode.Allowlist)
},
enabled = notificationForwardingAvailable,
)
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Package Filter: Blocklist", style = mobileHeadline) },
supportingContent = {
Text("All packages except listed IDs are forwarded.", style = mobileCallout)
},
trailingContent = {
RadioButton(
selected = notificationForwardingMode == NotificationPackageFilterMode.Blocklist,
onClick = {
viewModel.setNotificationForwardingMode(NotificationPackageFilterMode.Blocklist)
},
enabled = notificationForwardingAvailable,
)
},
)
}
}
item {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = { notificationPickerExpanded = !notificationPickerExpanded },
enabled = notificationForwardingAvailable,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (notificationPickerExpanded) "Close App Picker" else "Open App Picker",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
}
}
item {
Text(
selectedPackagesSummary,
style = mobileCallout,
color = mobileTextSecondary,
)
}
if (notificationPickerExpanded) {
item {
OutlinedTextField(
value = notificationAppSearch,
onValueChange = { notificationAppSearch = it },
label = {
Text("Search apps", style = mobileCaption1, color = mobileTextSecondary)
},
modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
enabled = notificationForwardingAvailable,
)
}
item {
ListItem(
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
colors = listItemColors,
headlineContent = { Text("Show System Apps", style = mobileHeadline) },
supportingContent = {
Text("Include Android/system packages in results.", style = mobileCallout)
},
trailingContent = {
Switch(
checked = notificationShowSystemApps,
onCheckedChange = { notificationShowSystemApps = it },
enabled = notificationForwardingAvailable,
)
},
)
}
items(filteredNotificationApps, key = { it.packageName }) { app ->
ListItem(
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
colors = listItemColors,
headlineContent = { Text(app.label, style = mobileHeadline) },
supportingContent = { Text(app.packageName, style = mobileCallout) },
trailingContent = {
Switch(
checked = notificationForwardingPackages.contains(app.packageName),
onCheckedChange = { checked ->
val next = notificationForwardingPackages.toMutableSet()
if (checked) {
next.add(app.packageName)
} else {
next.remove(app.packageName)
}
viewModel.setNotificationForwardingPackagesCsv(next.sorted().joinToString(","))
},
enabled = notificationForwardingAvailable,
)
},
)
}
}
item {
ListItem(
modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
colors = listItemColors,
headlineContent = { Text("Quiet Hours", style = mobileHeadline) },
supportingContent = {
Text("Suppress forwarding during a local time window.", style = mobileCallout)
},
trailingContent = {
Switch(
checked = notificationForwardingQuietHoursEnabled,
onCheckedChange = {
if (!quietHoursCanEnable && it) return@Switch
viewModel.setNotificationForwardingQuietHours(
enabled = it,
start = notificationQuietStartDraft,
end = notificationQuietEndDraft,
)
},
enabled = if (notificationForwardingQuietHoursEnabled) notificationForwardingAvailable else quietHoursCanEnable,
)
},
)
}
item {
OutlinedTextField(
value = notificationQuietStartDraft,
onValueChange = { notificationQuietStartDraft = it },
label = { Text("Quiet Start (HH:mm)", style = mobileCaption1, color = mobileTextSecondary) },
modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
enabled = notificationForwardingAvailable,
isError = notificationForwardingAvailable && normalizedQuietStartDraft == null,
supportingText = {
if (notificationForwardingAvailable && normalizedQuietStartDraft == null) {
Text("Use 24-hour HH:mm format, for example 22:00.", style = mobileCaption1, color = mobileDanger)
}
},
)
}
item {
OutlinedTextField(
value = notificationQuietEndDraft,
onValueChange = { notificationQuietEndDraft = it },
label = { Text("Quiet End (HH:mm)", style = mobileCaption1, color = mobileTextSecondary) },
modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
enabled = notificationForwardingAvailable,
isError = notificationForwardingAvailable && normalizedQuietEndDraft == null,
supportingText = {
if (notificationForwardingAvailable && normalizedQuietEndDraft == null) {
Text("Use 24-hour HH:mm format, for example 07:00.", style = mobileCaption1, color = mobileDanger)
}
},
)
}
item {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = {
viewModel.setNotificationForwardingQuietHours(
enabled = notificationForwardingQuietHoursEnabled,
start = notificationQuietStartDraft,
end = notificationQuietEndDraft,
)
},
enabled = quietHoursSaveEnabled,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text("Save Quiet Hours", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
}
}
}
item {
OutlinedTextField(
value = notificationRateDraft,
onValueChange = { notificationRateDraft = it.filter { c -> c.isDigit() } },
label = { Text("Max Events / Minute", style = mobileCaption1, color = mobileTextSecondary) },
modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
enabled = notificationForwardingAvailable,
)
}
item {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = {
val parsed = notificationRateDraft.toIntOrNull() ?: notificationForwardingMaxEventsPerMinute
viewModel.setNotificationForwardingMaxEventsPerMinute(parsed)
},
enabled = notificationForwardingAvailable,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text("Save Rate", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
}
}
}
item {
OutlinedTextField(
value = notificationSessionKeyDraft,
onValueChange = { notificationSessionKeyDraft = it },
label = {
Text(
"Route Session Key (optional)",
style = mobileCaption1,
color = mobileTextSecondary,
)
},
placeholder = {
Text("Blank keeps notification events on this device's default notification route. Set a key only to pin forwarding into a different session.", style = mobileCaption1, color = mobileTextSecondary)
},
modifier = Modifier.fillMaxWidth(),
textStyle = mobileBody.copy(color = mobileText),
colors = settingsTextFieldColors(),
enabled = notificationForwardingAvailable,
)
}
item {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = {
viewModel.setNotificationForwardingSessionKey(notificationSessionKeyDraft.trim().ifEmpty { null })
},
enabled = notificationForwardingAvailable,
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text("Save Session Route", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
}
}
}
item { HorizontalDivider(color = mobileBorder) }
// ── Data Access ──
item {
@@ -774,6 +1199,78 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
data class InstalledApp(
val label: String,
val packageName: String,
val isSystemApp: Boolean,
)
private fun queryInstalledApps(
context: Context,
configuredPackages: Set<String>,
): List<InstalledApp> {
val packageManager = context.packageManager
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
val launcherPackages =
packageManager
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
.asSequence()
.mapNotNull { it.activityInfo?.packageName?.trim()?.takeIf(String::isNotEmpty) }
.toMutableSet()
val recentNotificationPackages =
DeviceNotificationListenerService
.recentPackages(context)
.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.toList()
val candidatePackages =
resolveNotificationCandidatePackages(
launcherPackages = launcherPackages,
recentPackages = recentNotificationPackages,
configuredPackages = configuredPackages,
appPackageName = context.packageName,
)
return candidatePackages
.asSequence()
.mapNotNull { packageName ->
runCatching {
val appInfo = packageManager.getApplicationInfo(packageName, 0)
val label = packageManager.getApplicationLabel(appInfo)?.toString()?.trim().orEmpty()
InstalledApp(
label = if (label.isEmpty()) packageName else label,
packageName = packageName,
isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0,
)
}.getOrNull()
}
.sortedWith(compareBy<InstalledApp> { it.label.lowercase() }.thenBy { it.packageName })
.toList()
}
internal fun resolveNotificationCandidatePackages(
launcherPackages: Set<String>,
recentPackages: List<String>,
configuredPackages: Set<String>,
appPackageName: String,
): Set<String> {
val blockedPackage = appPackageName.trim()
return sequenceOf(
configuredPackages.asSequence(),
launcherPackages.asSequence(),
recentPackages.asSequence(),
)
.flatten()
.map { it.trim() }
.filter { it.isNotEmpty() && it != blockedPackage }
.toSet()
}
@Composable
private fun settingsTextFieldColors() =
OutlinedTextFieldDefaults.colors(
@@ -842,5 +1339,13 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
}
private fun isAssistantRoleAvailable(context: Context): Boolean {
return context.getSystemService(RoleManager::class.java).isRoleAvailable(RoleManager.ROLE_ASSISTANT)
}
private fun isAssistantRoleHeld(context: Context): Boolean {
return context.getSystemService(RoleManager::class.java).isRoleHeld(RoleManager.ROLE_ASSISTANT)
}

View File

@@ -33,6 +33,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,12 +60,45 @@ import ai.openclaw.app.ui.mobileText
import ai.openclaw.app.ui.mobileTextSecondary
import ai.openclaw.app.ui.mobileTextTertiary
internal data class DraftApplication(
val input: String,
val lastAppliedDraft: String?,
val consumed: Boolean,
)
internal fun applyDraftText(
draftText: String?,
currentInput: String,
lastAppliedDraft: String?,
): DraftApplication {
val draft =
draftText?.trim()?.ifEmpty { null } ?: return DraftApplication(
input = currentInput,
lastAppliedDraft = null,
consumed = false,
)
if (draft == lastAppliedDraft) {
return DraftApplication(
input = currentInput,
lastAppliedDraft = lastAppliedDraft,
consumed = false,
)
}
return DraftApplication(
input = draft,
lastAppliedDraft = draft,
consumed = true,
)
}
@Composable
fun ChatComposer(
draftText: String?,
healthOk: Boolean,
thinkingLevel: String,
pendingRunCount: Int,
attachments: List<PendingImageAttachment>,
onDraftApplied: () -> Unit,
onPickImages: () -> Unit,
onRemoveAttachment: (id: String) -> Unit,
onSetThinkingLevel: (level: String) -> Unit,
@@ -73,8 +107,18 @@ fun ChatComposer(
onSend: (text: String) -> Unit,
) {
var input by rememberSaveable { mutableStateOf("") }
var lastAppliedDraft by rememberSaveable { mutableStateOf<String?>(null) }
var showThinkingMenu by remember { mutableStateOf(false) }
LaunchedEffect(draftText) {
val next = applyDraftText(draftText = draftText, currentInput = input, lastAppliedDraft = lastAppliedDraft)
input = next.input
lastAppliedDraft = next.lastAppliedDraft
if (next.consumed) {
onDraftApplied()
}
}
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
val sendBusy = pendingRunCount > 0

View File

@@ -48,6 +48,31 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
internal fun resolvePendingAssistantAutoSend(
pendingPrompt: String?,
healthOk: Boolean,
pendingRunCount: Int,
): String? {
val prompt = pendingPrompt?.trim()?.ifEmpty { null } ?: return null
if (!healthOk || pendingRunCount > 0) return null
return prompt
}
internal suspend fun dispatchPendingAssistantAutoSend(
pendingPrompt: String?,
healthOk: Boolean,
pendingRunCount: Int,
dispatch: suspend (String) -> Boolean,
): Boolean {
val prompt =
resolvePendingAssistantAutoSend(
pendingPrompt = pendingPrompt,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
) ?: return false
return dispatch(prompt)
}
@Composable
fun ChatSheetContent(viewModel: MainViewModel) {
val messages by viewModel.chatMessages.collectAsState()
@@ -60,11 +85,30 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
val chatDraft by viewModel.chatDraft.collectAsState()
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadChat(mainSessionKey)
}
LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) {
val accepted =
dispatchPendingAssistantAutoSend(
pendingPrompt = pendingAssistantAutoSend,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
) { prompt ->
viewModel.sendChatAwaitAcceptance(
message = prompt,
thinking = thinkingLevel,
attachments = emptyList(),
)
}
if (!accepted) return@LaunchedEffect
viewModel.clearPendingAssistantAutoSend()
}
val context = LocalContext.current
val resolver = context.contentResolver
val scope = rememberCoroutineScope()
@@ -118,10 +162,12 @@ fun ChatSheetContent(viewModel: MainViewModel) {
Row(modifier = Modifier.fillMaxWidth().imePadding()) {
ChatComposer(
draftText = chatDraft,
healthOk = healthOk,
thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount,
attachments = attachments,
onDraftApplied = viewModel::clearChatDraft,
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },

View File

@@ -0,0 +1,7 @@
<resources>
<string-array name="ask_openclaw_query_patterns">
<item>ask OpenClaw $prompt</item>
<item>tell OpenClaw to $prompt</item>
<item>open OpenClaw and ask $prompt</item>
</string-array>
</resources>

View File

@@ -0,0 +1,17 @@
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<capability
android:name="custom.actions.intent.ASK_OPENCLAW"
app:queryPatterns="@array/ask_openclaw_query_patterns">
<intent
android:action="ai.openclaw.app.action.ASK_OPENCLAW"
android:targetPackage="ai.openclaw.app"
android:targetClass="ai.openclaw.app.MainActivity">
<parameter
android:name="prompt"
android:key="prompt"
android:mimeType="text/*"
android:required="true" />
</intent>
</capability>
</shortcuts>

View File

@@ -0,0 +1,43 @@
package ai.openclaw.app
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AssistantLaunchTest {
@Test
fun parsesAssistGestureIntent() {
val parsed = parseAssistantLaunchIntent(Intent(Intent.ACTION_ASSIST))
requireNotNull(parsed)
assertEquals("assist", parsed.source)
assertNull(parsed.prompt)
assertFalse(parsed.autoSend)
}
@Test
fun parsesAppActionPrompt() {
val parsed =
parseAssistantLaunchIntent(
Intent(actionAskOpenClaw).putExtra(extraAssistantPrompt, " summarize my unread texts "),
)
requireNotNull(parsed)
assertEquals("app_action", parsed.source)
assertEquals("summarize my unread texts", parsed.prompt)
assertTrue(parsed.autoSend)
}
@Test
fun ignoresUnrelatedIntents() {
assertNull(parseAssistantLaunchIntent(Intent(Intent.ACTION_VIEW)))
}
}

View File

@@ -0,0 +1,165 @@
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
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.lang.reflect.Field
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewayBootstrapAuthTest {
@Test
fun connectsOperatorSessionWhenBootstrapAuthExists() {
assertTrue(shouldConnectOperatorSession(token = "", bootstrapToken = "bootstrap-1", password = "", storedOperatorToken = ""))
assertTrue(shouldConnectOperatorSession(token = null, bootstrapToken = "bootstrap-1", password = null, storedOperatorToken = null))
}
@Test
fun skipsOperatorSessionOnlyWhenNoSharedBootstrapOrStoredAuthExists() {
assertTrue(shouldConnectOperatorSession(token = "shared-token", bootstrapToken = "bootstrap-1", password = null, storedOperatorToken = null))
assertTrue(shouldConnectOperatorSession(token = null, bootstrapToken = "bootstrap-1", password = "shared-password", storedOperatorToken = null))
assertTrue(shouldConnectOperatorSession(token = null, bootstrapToken = null, password = null, storedOperatorToken = "stored-token"))
assertFalse(shouldConnectOperatorSession(token = null, bootstrapToken = "", password = null, storedOperatorToken = null))
}
@Test
fun resolveGatewayConnectAuth_prefersExplicitSetupAuthOverStoredPrefs() {
val app = RuntimeEnvironment.getApplication()
val securePrefs =
app.getSharedPreferences(
"openclaw.node.secure.test.${UUID.randomUUID()}",
android.content.Context.MODE_PRIVATE,
)
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
prefs.setGatewayToken("stale-shared-token")
prefs.setGatewayBootstrapToken("")
prefs.setGatewayPassword("stale-password")
val runtime = NodeRuntime(app, prefs)
val auth =
runtime.resolveGatewayConnectAuth(
NodeRuntime.GatewayConnectAuth(
token = null,
bootstrapToken = "setup-bootstrap-token",
password = null,
),
)
assertNull(auth.token)
assertEquals("setup-bootstrap-token", auth.bootstrapToken)
assertNull(auth.password)
}
@Test
fun acceptGatewayTrustPrompt_preservesExplicitSetupAuth() =
runBlocking {
val app = RuntimeEnvironment.getApplication()
val securePrefs =
app.getSharedPreferences(
"openclaw.node.secure.test.${UUID.randomUUID()}",
android.content.Context.MODE_PRIVATE,
)
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
prefs.setGatewayToken("stale-shared-token")
prefs.setGatewayBootstrapToken("")
prefs.setGatewayPassword("stale-password")
val runtime =
NodeRuntime(
app,
prefs,
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp-1") },
)
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
val explicitAuth =
NodeRuntime.GatewayConnectAuth(
token = null,
bootstrapToken = "setup-bootstrap-token",
password = null,
)
runtime.connect(endpoint, explicitAuth)
val prompt = waitForGatewayTrustPrompt(runtime)
assertEquals("setup-bootstrap-token", prompt.auth.bootstrapToken)
runtime.acceptGatewayTrustPrompt()
assertEquals("fp-1", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "nodeSession"))
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 }
Thread.sleep(10)
}
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
return readField(desired, "bootstrapToken")
}
private fun <T> readField(target: Any, name: String): T {
var type: Class<*>? = target.javaClass
while (type != null) {
try {
val field: Field = type.getDeclaredField(name)
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
return field.get(target) as T
} catch (_: NoSuchFieldException) {
type = type.superclass
}
}
error("Field $name not found on ${target.javaClass.name}")
}
}

View File

@@ -0,0 +1,189 @@
package ai.openclaw.app
import java.time.LocalDateTime
import java.time.ZoneId
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NotificationForwardingPolicyTest {
@Test
fun parseLocalHourMinute_parsesValidValues() {
assertEquals(0, parseLocalHourMinute("00:00"))
assertEquals(23 * 60 + 59, parseLocalHourMinute("23:59"))
assertEquals(7 * 60 + 5, parseLocalHourMinute("07:05"))
}
@Test
fun normalizeLocalHourMinute_acceptsStrict24HourDrafts() {
assertEquals("00:00", normalizeLocalHourMinute("00:00"))
assertEquals("23:59", normalizeLocalHourMinute("23:59"))
assertEquals("07:05", normalizeLocalHourMinute("07:05"))
}
@Test
fun parseLocalHourMinute_rejectsInvalidValues() {
assertEquals(null, parseLocalHourMinute(""))
assertEquals(null, parseLocalHourMinute("24:00"))
assertEquals(null, parseLocalHourMinute("12:60"))
assertEquals(null, parseLocalHourMinute("abc"))
assertEquals(null, parseLocalHourMinute("7:05"))
assertEquals(null, parseLocalHourMinute("07:5"))
}
@Test
fun normalizeLocalHourMinute_rejectsNonCanonicalDrafts() {
assertEquals(null, normalizeLocalHourMinute(""))
assertEquals(null, normalizeLocalHourMinute("7:05"))
assertEquals(null, normalizeLocalHourMinute("07:5"))
assertEquals(null, normalizeLocalHourMinute("24:00"))
assertEquals(null, normalizeLocalHourMinute("12:60"))
}
@Test
fun allowsPackage_blocklistBlocksConfiguredPackages() {
val policy =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Blocklist,
packages = setOf("com.blocked.app"),
quietHoursEnabled = false,
quietStart = "22:00",
quietEnd = "07:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
assertFalse(policy.allowsPackage("com.blocked.app"))
assertTrue(policy.allowsPackage("com.allowed.app"))
}
@Test
fun allowsPackage_allowlistOnlyAllowsConfiguredPackages() {
val policy =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Allowlist,
packages = setOf("com.allowed.app"),
quietHoursEnabled = false,
quietStart = "22:00",
quietEnd = "07:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
assertTrue(policy.allowsPackage("com.allowed.app"))
assertFalse(policy.allowsPackage("com.other.app"))
}
@Test
fun isWithinQuietHours_handlesWindowCrossingMidnight() {
val policy =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Blocklist,
packages = emptySet(),
quietHoursEnabled = true,
quietStart = "22:00",
quietEnd = "07:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
val zone = ZoneId.of("UTC")
val at2330 =
LocalDateTime
.of(2024, 1, 6, 23, 30)
.atZone(zone)
.toInstant()
.toEpochMilli()
val at1200 =
LocalDateTime
.of(2024, 1, 6, 12, 0)
.atZone(zone)
.toInstant()
.toEpochMilli()
assertTrue(policy.isWithinQuietHours(nowEpochMs = at2330, zoneId = zone))
assertFalse(policy.isWithinQuietHours(nowEpochMs = at1200, zoneId = zone))
}
@Test
fun isWithinQuietHours_sameStartEndMeansAlwaysQuiet() {
val policy =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Blocklist,
packages = emptySet(),
quietHoursEnabled = true,
quietStart = "00:00",
quietEnd = "00:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
assertTrue(policy.isWithinQuietHours(nowEpochMs = 1_704_098_400_000L, zoneId = ZoneId.of("UTC")))
}
@Test
fun blocksEventsWhenDisabledOrQuietHoursOrRateLimited() {
val disabled =
NotificationForwardingPolicy(
enabled = false,
mode = NotificationPackageFilterMode.Blocklist,
packages = emptySet(),
quietHoursEnabled = false,
quietStart = "22:00",
quietEnd = "07:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
assertFalse(disabled.enabled && disabled.allowsPackage("com.allowed.app"))
val quiet =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Blocklist,
packages = emptySet(),
quietHoursEnabled = true,
quietStart = "22:00",
quietEnd = "07:00",
maxEventsPerMinute = 20,
sessionKey = null,
)
val zone = ZoneId.of("UTC")
val at2330 =
LocalDateTime
.of(2024, 1, 6, 23, 30)
.atZone(zone)
.toInstant()
.toEpochMilli()
assertTrue(quiet.isWithinQuietHours(nowEpochMs = at2330, zoneId = zone))
val limiter = NotificationBurstLimiter()
val minute = 1_704_098_400_000L
assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 1))
assertFalse(limiter.allow(nowEpochMs = minute + 500L, maxEventsPerMinute = 1))
}
@Test
fun burstLimiter_blocksEventsAboveLimitInSameMinute() {
val limiter = NotificationBurstLimiter()
val minute = 1_704_098_400_000L
assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 2))
assertTrue(limiter.allow(nowEpochMs = minute + 1_000L, maxEventsPerMinute = 2))
assertFalse(limiter.allow(nowEpochMs = minute + 2_000L, maxEventsPerMinute = 2))
}
@Test
fun burstLimiter_resetsOnNextMinuteWindow() {
val limiter = NotificationBurstLimiter()
val minute = 1_704_098_400_000L
assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 1))
assertFalse(limiter.allow(nowEpochMs = minute + 1_000L, maxEventsPerMinute = 1))
assertTrue(limiter.allow(nowEpochMs = minute + 60_000L, maxEventsPerMinute = 1))
}
}

View File

@@ -0,0 +1,133 @@
package ai.openclaw.app
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class SecurePrefsNotificationForwardingTest {
@Test
fun setNotificationForwardingQuietHours_rejectsInvalidDraftsWithoutMutatingStoredValues() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
assertTrue(
prefs.setNotificationForwardingQuietHours(
enabled = false,
start = "22:00",
end = "07:00",
),
)
val originalStart = prefs.notificationForwardingQuietStart.value
val originalEnd = prefs.notificationForwardingQuietEnd.value
val originalEnabled = prefs.notificationForwardingQuietHoursEnabled.value
assertFalse(
prefs.setNotificationForwardingQuietHours(
enabled = true,
start = "7:00",
end = "07:00",
),
)
assertEquals(originalStart, prefs.notificationForwardingQuietStart.value)
assertEquals(originalEnd, prefs.notificationForwardingQuietEnd.value)
assertEquals(originalEnabled, prefs.notificationForwardingQuietHoursEnabled.value)
}
@Test
fun setNotificationForwardingQuietHours_persistsValidDraftsAndEnabledState() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
assertTrue(
prefs.setNotificationForwardingQuietHours(
enabled = true,
start = "22:30",
end = "06:45",
),
)
assertTrue(prefs.notificationForwardingQuietHoursEnabled.value)
assertEquals("22:30", prefs.notificationForwardingQuietStart.value)
assertEquals("06:45", prefs.notificationForwardingQuietEnd.value)
}
@Test
fun setNotificationForwardingQuietHours_disablesWithoutRevalidatingDrafts() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
assertTrue(
prefs.setNotificationForwardingQuietHours(
enabled = true,
start = "22:30",
end = "06:45",
),
)
assertTrue(
prefs.setNotificationForwardingQuietHours(
enabled = false,
start = "7:00",
end = "06:45",
),
)
assertFalse(prefs.notificationForwardingQuietHoursEnabled.value)
assertEquals("22:30", prefs.notificationForwardingQuietStart.value)
assertEquals("06:45", prefs.notificationForwardingQuietEnd.value)
}
@Test
fun getNotificationForwardingPolicy_readsLatestQuietHoursImmediately() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
assertTrue(
prefs.setNotificationForwardingQuietHours(
enabled = true,
start = "21:15",
end = "06:10",
),
)
val policy = prefs.getNotificationForwardingPolicy(appPackageName = "ai.openclaw.app")
assertTrue(policy.quietHoursEnabled)
assertEquals("21:15", policy.quietStart)
assertEquals("06:10", policy.quietEnd)
}
@Test
fun notificationForwarding_defaultsDisabledForSaferPosture() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
val policy = prefs.getNotificationForwardingPolicy(appPackageName = "ai.openclaw.app")
assertFalse(prefs.notificationForwardingEnabled.value)
assertFalse(policy.enabled)
assertEquals(NotificationPackageFilterMode.Blocklist, policy.mode)
}
}

View File

@@ -4,6 +4,23 @@ import org.junit.Assert.assertEquals
import org.junit.Test
class GatewaySessionInvokeTimeoutTest {
@Test
fun formatGatewayAuthority_bracketsIpv6Hosts() {
assertEquals("[::1]:18789", formatGatewayAuthority("::1", 18_789))
}
@Test
fun buildGatewayWebSocketUrl_bracketsIpv6Hosts() {
assertEquals("ws://[::1]:18789", buildGatewayWebSocketUrl("::1", 18_789, useTls = false))
assertEquals("wss://[::1]:443", buildGatewayWebSocketUrl("::1", 443, useTls = true))
}
@Test
fun buildGatewayWebSocketUrl_normalizesPersistedBracketedIpv6Hosts() {
assertEquals("ws://[::1]:18789", buildGatewayWebSocketUrl("[::1]", 18_789, useTls = false))
assertEquals("wss://[::1]:443", buildGatewayWebSocketUrl("[::1]", 443, useTls = true))
}
@Test
fun resolveInvokeResultAckTimeoutMs_usesFloorWhenMissingOrTooSmall() {
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(null))

View File

@@ -173,15 +173,50 @@ class CallLogHandlerTest : NodeHandlerRobolectricTest() {
assertTrue(callLogObj.containsKey("number"))
assertTrue(callLogObj.containsKey("cachedName"))
}
@Test
fun handleCallLogSearch_clampsLimitAndOffsetBeforeSearch() {
val source = FakeCallLogDataSource(canRead = true)
val handler = CallLogHandler.forTesting(appContext(), source)
val result = handler.handleCallLogSearch("""{"limit":999,"offset":-5}""")
assertTrue(result.ok)
assertEquals(200, source.lastRequest?.limit)
assertEquals(0, source.lastRequest?.offset)
}
@Test
fun handleCallLogSearch_mapsSearchFailuresToUnavailable() {
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(
canRead = true,
failure = IllegalStateException("provider down"),
),
)
val result = handler.handleCallLogSearch(null)
assertFalse(result.ok)
assertEquals("CALL_LOG_UNAVAILABLE", result.error?.code)
assertEquals("CALL_LOG_UNAVAILABLE: provider down", result.error?.message)
}
}
private class FakeCallLogDataSource(
private val canRead: Boolean,
private val searchResults: List<CallLogRecord> = emptyList(),
private val failure: Throwable? = null,
) : CallLogDataSource {
var lastRequest: CallLogSearchRequest? = null
override fun hasReadPermission(context: Context): Boolean = canRead
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
lastRequest = request
failure?.let { throw it }
val startIndex = request.offset.coerceAtLeast(0)
val endIndex = (startIndex + request.limit).coerceAtMost(searchResults.size)
return if (startIndex < searchResults.size) {

View File

@@ -1,10 +1,27 @@
package ai.openclaw.app.node
import ai.openclaw.app.LocationMode
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.VoiceWakeMode
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawLocationCommand
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
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class ConnectionManagerTest {
@Test
fun resolveTlsParamsForEndpoint_prefersStoredPinOverAdvertisedFingerprint() {
@@ -54,7 +71,7 @@ class ConnectionManagerTest {
@Test
fun resolveTlsParamsForEndpoint_manualRespectsManualTlsToggle() {
val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443)
val endpoint = GatewayEndpoint.manual(host = "127.0.0.1", port = 443)
val off =
ConnectionManager.resolveTlsParamsForEndpoint(
@@ -73,4 +90,449 @@ class ConnectionManagerTest {
assertNull(on?.expectedFingerprint)
assertEquals(false, on?.allowTOFU)
}
@Test
fun resolveTlsParamsForEndpoint_manualNonLoopbackForcesTlsWhenToggleIsOff() {
val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertEquals(true, params?.required)
assertNull(params?.expectedFingerprint)
assertEquals(false, params?.allowTOFU)
}
@Test
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 = "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,
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertEquals(true, params?.required)
assertNull(params?.expectedFingerprint)
assertEquals(false, params?.allowTOFU)
}
@Test
fun resolveTlsParamsForEndpoint_discoveryLoopbackWithoutHintsCanStayCleartext() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "127.0.0.1",
port = 18789,
tlsEnabled = false,
tlsFingerprintSha256 = null,
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertNull(params)
}
@Test
fun resolveTlsParamsForEndpoint_discoveryLocalhostWithoutHintsCanStayCleartext() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "localhost",
port = 18789,
tlsEnabled = false,
tlsFingerprintSha256 = null,
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertNull(params)
}
@Test
fun resolveTlsParamsForEndpoint_discoveryAndroidEmulatorWithoutHintsCanStayCleartext() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "10.0.2.2",
port = 18789,
tlsEnabled = false,
tlsFingerprintSha256 = null,
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertNull(params)
}
@Test
fun isLoopbackGatewayHost_onlyTreatsEmulatorBridgeAsLocalWhenAllowed() {
assertTrue(isLoopbackGatewayHost("10.0.2.2", allowEmulatorBridgeAlias = true))
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 =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "::1",
port = 18789,
tlsEnabled = false,
tlsFingerprintSha256 = null,
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertNull(params)
}
@Test
fun resolveTlsParamsForEndpoint_discoveryMappedIpv4LoopbackWithoutHintsCanStayCleartext() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "::ffff:127.0.0.1",
port = 18789,
tlsEnabled = false,
tlsFingerprintSha256 = null,
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertNull(params)
}
@Test
fun resolveTlsParamsForEndpoint_discoveryNonLoopbackIpv6WithoutHintsRequiresTls() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "2001:db8::1",
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_discoveryUnspecifiedIpv4WithoutHintsRequiresTls() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "0.0.0.0",
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_discoveryUnspecifiedIpv6WithoutHintsRequiresTls() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "::",
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 buildNodeConnectOptions_advertisesRequestableSmsSearchWithoutSmsCapability() {
val options =
newManager(
sendSmsAvailable = false,
readSmsAvailable = false,
smsSearchPossible = true,
).buildNodeConnectOptions()
assertTrue(options.commands.contains(OpenClawSmsCommand.Search.rawValue))
assertFalse(options.commands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.Sms.rawValue))
}
@Test
fun buildNodeConnectOptions_doesNotAdvertiseSmsWhenSearchIsImpossible() {
val options =
newManager(
sendSmsAvailable = false,
readSmsAvailable = false,
smsSearchPossible = false,
).buildNodeConnectOptions()
assertFalse(options.commands.contains(OpenClawSmsCommand.Search.rawValue))
assertFalse(options.commands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.Sms.rawValue))
}
@Test
fun buildNodeConnectOptions_advertisesSmsCapabilityWhenReadSmsIsAvailable() {
val options =
newManager(
sendSmsAvailable = false,
readSmsAvailable = true,
smsSearchPossible = true,
).buildNodeConnectOptions()
assertTrue(options.commands.contains(OpenClawSmsCommand.Search.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Sms.rawValue))
}
@Test
fun buildNodeConnectOptions_advertisesSmsSendWithoutSearchWhenOnlySendIsAvailable() {
val options =
newManager(
sendSmsAvailable = true,
readSmsAvailable = false,
smsSearchPossible = false,
).buildNodeConnectOptions()
assertTrue(options.commands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(options.commands.contains(OpenClawSmsCommand.Search.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Sms.rawValue))
}
@Test
fun buildNodeConnectOptions_advertisesAvailableNonSmsCommandsAndCapabilities() {
val options =
newManager(
cameraEnabled = true,
locationMode = LocationMode.WhileUsing,
voiceWakeMode = VoiceWakeMode.Always,
motionActivityAvailable = true,
callLogAvailable = true,
hasRecordAudioPermission = true,
).buildNodeConnectOptions()
assertTrue(options.commands.contains(OpenClawCameraCommand.List.rawValue))
assertTrue(options.commands.contains(OpenClawLocationCommand.Get.rawValue))
assertTrue(options.commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertTrue(options.commands.contains(OpenClawCallLogCommand.Search.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Camera.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Location.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Motion.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.CallLog.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.VoiceWake.rawValue))
}
@Test
fun buildNodeConnectOptions_omitsVoiceWakeWithoutMicrophonePermission() {
val options =
newManager(
voiceWakeMode = VoiceWakeMode.Always,
hasRecordAudioPermission = false,
).buildNodeConnectOptions()
assertFalse(options.caps.contains(OpenClawCapability.VoiceWake.rawValue))
}
@Test
fun buildNodeConnectOptions_omitsUnavailableCameraLocationAndCallLogSurfaces() {
val options =
newManager(
cameraEnabled = false,
locationMode = LocationMode.Off,
callLogAvailable = false,
).buildNodeConnectOptions()
assertFalse(options.commands.contains(OpenClawCameraCommand.List.rawValue))
assertFalse(options.commands.contains(OpenClawCameraCommand.Snap.rawValue))
assertFalse(options.commands.contains(OpenClawCameraCommand.Clip.rawValue))
assertFalse(options.commands.contains(OpenClawLocationCommand.Get.rawValue))
assertFalse(options.commands.contains(OpenClawCallLogCommand.Search.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.Camera.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.Location.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.CallLog.rawValue))
}
@Test
fun buildNodeConnectOptions_advertisesOnlyAvailableMotionCommand() {
val options =
newManager(
motionActivityAvailable = false,
motionPedometerAvailable = true,
).buildNodeConnectOptions()
assertFalse(options.commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertTrue(options.commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
assertTrue(options.caps.contains(OpenClawCapability.Motion.rawValue))
}
@Test
fun buildNodeConnectOptions_omitsMotionSurfaceWhenMotionApisUnavailable() {
val options =
newManager(
motionActivityAvailable = false,
motionPedometerAvailable = false,
).buildNodeConnectOptions()
assertFalse(options.commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertFalse(options.commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
assertFalse(options.caps.contains(OpenClawCapability.Motion.rawValue))
}
private fun newManager(
cameraEnabled: Boolean = false,
locationMode: LocationMode = LocationMode.Off,
voiceWakeMode: VoiceWakeMode = VoiceWakeMode.Off,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
smsSearchPossible: Boolean = false,
callLogAvailable: Boolean = false,
hasRecordAudioPermission: Boolean = false,
): ConnectionManager {
val context = RuntimeEnvironment.getApplication()
val prefs =
SecurePrefs(
context,
securePrefsOverride = context.getSharedPreferences("connection-manager-test", android.content.Context.MODE_PRIVATE),
)
return ConnectionManager(
prefs = prefs,
cameraEnabled = { cameraEnabled },
locationMode = { locationMode },
voiceWakeMode = { voiceWakeMode },
motionActivityAvailable = { motionActivityAvailable },
motionPedometerAvailable = { motionPedometerAvailable },
sendSmsAvailable = { sendSmsAvailable },
readSmsAvailable = { readSmsAvailable },
smsSearchPossible = { smsSearchPossible },
callLogAvailable = { callLogAvailable },
hasRecordAudioPermission = { hasRecordAudioPermission },
manualTls = { false },
)
}
}

View File

@@ -101,9 +101,131 @@ class DeviceHandlerTest {
val status = state.getValue("status").jsonPrimitive.content
assertTrue(status == "granted" || status == "denied")
state.getValue("promptable").jsonPrimitive.boolean
if (key == "sms") {
val capabilities = state.getValue("capabilities").jsonObject
for (capabilityKey in listOf("send", "read")) {
val capability = capabilities.getValue(capabilityKey).jsonObject
val capabilityStatus = capability.getValue("status").jsonPrimitive.content
assertTrue(capabilityStatus == "granted" || capabilityStatus == "denied")
capability.getValue("promptable").jsonPrimitive.boolean
}
}
}
}
@Test
fun smsTopLevelStatusTreatsSendOnlyPartialGrantAsGranted() {
assertTrue(
DeviceHandler.hasAnySmsCapability(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = true,
smsReadGranted = false,
),
)
}
@Test
fun smsTopLevelStatusTreatsReadOnlyPartialGrantAsGranted() {
assertTrue(
DeviceHandler.hasAnySmsCapability(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = false,
smsReadGranted = true,
),
)
}
@Test
fun smsTopLevelStatusTreatsNoSmsGrantAsDenied() {
assertTrue(
!DeviceHandler.hasAnySmsCapability(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = false,
smsReadGranted = false,
),
)
}
@Test
fun smsTopLevelStatusTreatsDisabledSmsAsDenied() {
assertTrue(
!DeviceHandler.hasAnySmsCapability(
smsEnabled = false,
telephonyAvailable = true,
smsSendGranted = true,
smsReadGranted = true,
),
)
}
@Test
fun smsTopLevelStatusTreatsMissingTelephonyAsDenied() {
assertTrue(
!DeviceHandler.hasAnySmsCapability(
smsEnabled = true,
telephonyAvailable = false,
smsSendGranted = true,
smsReadGranted = true,
),
)
}
@Test
fun smsTopLevelPromptableStaysTrueUntilBothSmsPermissionsAreGranted() {
assertTrue(
DeviceHandler.isSmsPromptable(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = true,
smsReadGranted = false,
),
)
assertTrue(
!DeviceHandler.isSmsPromptable(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = true,
smsReadGranted = true,
),
)
}
@Test
fun smsTopLevelPromptableIsFalseWhenSmsCannotExist() {
assertTrue(
!DeviceHandler.isSmsPromptable(
smsEnabled = false,
telephonyAvailable = true,
smsSendGranted = false,
smsReadGranted = false,
),
)
assertTrue(
!DeviceHandler.isSmsPromptable(
smsEnabled = true,
telephonyAvailable = false,
smsSendGranted = false,
smsReadGranted = false,
),
)
}
@Test
fun handleDevicePermissions_marksCallLogUnpromptableWhenFeatureDisabled() {
val handler = DeviceHandler(appContext(), callLogEnabled = false)
val result = handler.handleDevicePermissions(null)
assertTrue(result.ok)
val payload = parsePayload(result.payloadJson)
val callLog = payload.getValue("permissions").jsonObject.getValue("callLog").jsonObject
assertEquals("denied", callLog.getValue("status").jsonPrimitive.content)
assertTrue(!callLog.getValue("promptable").jsonPrimitive.boolean)
}
@Test
fun handleDeviceHealth_returnsExpectedShape() {
val handler = DeviceHandler(appContext())

View File

@@ -0,0 +1,119 @@
package ai.openclaw.app.node
import android.content.Context
import ai.openclaw.app.NotificationBurstLimiter
import ai.openclaw.app.NotificationForwardingPolicy
import ai.openclaw.app.NotificationPackageFilterMode
import ai.openclaw.app.isWithinQuietHours
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class DeviceNotificationListenerServiceTest {
@Test
fun recentPackages_migratesLegacyPreferenceKey() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
prefs.edit()
.clear()
.putString("notifications.recentPackages", "com.example.one, com.example.two")
.commit()
val packages = DeviceNotificationListenerService.recentPackages(context)
assertEquals(listOf("com.example.one", "com.example.two"), packages)
assertEquals(
"com.example.one, com.example.two",
prefs.getString("notifications.forwarding.recentPackages", null),
)
assertFalse(prefs.contains("notifications.recentPackages"))
}
@Test
fun recentPackages_cleansUpLegacyKeyWhenNewKeyAlreadyExists() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
prefs.edit()
.clear()
.putString("notifications.forwarding.recentPackages", "com.example.new")
.putString("notifications.recentPackages", "com.example.legacy")
.commit()
val packages = DeviceNotificationListenerService.recentPackages(context)
assertEquals(listOf("com.example.new"), packages)
assertNull(prefs.getString("notifications.recentPackages", null))
}
@Test
fun recentPackages_trimsDedupesAndPreservesRecencyOrder() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
prefs.edit()
.clear()
.putString(
"notifications.forwarding.recentPackages",
" com.example.recent , ,com.example.other,com.example.recent, com.example.third ",
)
.commit()
val packages = DeviceNotificationListenerService.recentPackages(context)
assertEquals(
listOf("com.example.recent", "com.example.other", "com.example.third"),
packages,
)
}
@Test
fun quietHoursAndRateLimitingUseWallClockTimeNotNotificationPostTime() {
val zone = java.time.ZoneId.systemDefault()
val now = java.time.ZonedDateTime.now(zone)
val quietStart = now.minusMinutes(5).toLocalTime().withSecond(0).withNano(0)
val quietEnd = now.plusMinutes(5).toLocalTime().withSecond(0).withNano(0)
val stalePostTime =
now
.minusHours(2)
.withMinute(0)
.withSecond(0)
.withNano(0)
.toInstant()
.toEpochMilli()
val policy =
NotificationForwardingPolicy(
enabled = true,
mode = NotificationPackageFilterMode.Blocklist,
packages = emptySet(),
quietHoursEnabled = true,
quietStart = "%02d:%02d".format(quietStart.hour, quietStart.minute),
quietEnd = "%02d:%02d".format(quietEnd.hour, quietEnd.minute),
maxEventsPerMinute = 1,
sessionKey = null,
)
assertFalse(policy.isWithinQuietHours(nowEpochMs = stalePostTime, zoneId = zone))
assertTrue(policy.isWithinQuietHours(nowEpochMs = System.currentTimeMillis(), zoneId = zone))
val limiter = NotificationBurstLimiter()
assertTrue(limiter.allow(nowEpochMs = stalePostTime, maxEventsPerMinute = 1))
assertTrue(limiter.allow(nowEpochMs = System.currentTimeMillis(), maxEventsPerMinute = 1))
assertFalse(limiter.allow(nowEpochMs = System.currentTimeMillis(), maxEventsPerMinute = 1))
}
@Test
fun burstLimiter_capsAnyForwardedNotificationEvent() {
val limiter = NotificationBurstLimiter()
val nowEpochMs = System.currentTimeMillis()
assertTrue(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2))
assertTrue(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2))
assertFalse(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2))
}
}

View File

@@ -12,6 +12,9 @@ import ai.openclaw.app.protocol.OpenClawNotificationsCommand
import ai.openclaw.app.protocol.OpenClawPhotosCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -86,6 +89,7 @@ class InvokeCommandRegistryTest {
locationEnabled = true,
sendSmsAvailable = true,
readSmsAvailable = true,
smsSearchPossible = true,
callLogAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
@@ -113,6 +117,7 @@ class InvokeCommandRegistryTest {
locationEnabled = true,
sendSmsAvailable = true,
readSmsAvailable = true,
smsSearchPossible = true,
callLogAvailable = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
@@ -132,6 +137,7 @@ class InvokeCommandRegistryTest {
locationEnabled = false,
sendSmsAvailable = false,
readSmsAvailable = false,
smsSearchPossible = false,
callLogAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = true,
@@ -148,17 +154,22 @@ class InvokeCommandRegistryTest {
fun advertisedCommands_splitsSmsSendAndSearchAvailability() {
val readOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(readSmsAvailable = true),
defaultFlags(readSmsAvailable = true, smsSearchPossible = true),
)
val sendOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(sendSmsAvailable = true),
)
val requestableSearchCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(smsSearchPossible = true),
)
assertTrue(readOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
assertFalse(readOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
assertTrue(sendOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(sendOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
assertTrue(requestableSearchCommands.contains(OpenClawSmsCommand.Search.rawValue))
}
@Test
@@ -171,9 +182,14 @@ class InvokeCommandRegistryTest {
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(sendSmsAvailable = true),
)
val requestableSearchCapabilities =
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(smsSearchPossible = true),
)
assertTrue(readOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
assertFalse(requestableSearchCapabilities.contains(OpenClawCapability.Sms.rawValue))
}
@Test
@@ -190,11 +206,37 @@ class InvokeCommandRegistryTest {
assertFalse(capabilities.contains(OpenClawCapability.CallLog.rawValue))
}
@Test
fun advertisedCapabilities_includesVoiceWakeWithoutAdvertisingCommands() {
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags(voiceWakeEnabled = true))
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags(voiceWakeEnabled = true))
assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
assertFalse(commands.any { it.contains("voice", ignoreCase = true) })
}
@Test
fun find_returnsForegroundMetadataForCameraCommands() {
val list = InvokeCommandRegistry.find(OpenClawCameraCommand.List.rawValue)
val location = InvokeCommandRegistry.find(OpenClawLocationCommand.Get.rawValue)
assertNotNull(list)
assertEquals(true, list?.requiresForeground)
assertNotNull(location)
assertEquals(false, location?.requiresForeground)
}
@Test
fun find_returnsNullForUnknownCommand() {
assertNull(InvokeCommandRegistry.find("not.real"))
}
private fun defaultFlags(
cameraEnabled: Boolean = false,
locationEnabled: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
smsSearchPossible: Boolean = false,
callLogAvailable: Boolean = false,
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
@@ -206,6 +248,7 @@ class InvokeCommandRegistryTest {
locationEnabled = locationEnabled,
sendSmsAvailable = sendSmsAvailable,
readSmsAvailable = readSmsAvailable,
smsSearchPossible = smsSearchPossible,
callLogAvailable = callLogAvailable,
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,

View File

@@ -0,0 +1,368 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import android.content.Context
import android.content.pm.PackageManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class InvokeDispatcherTest {
@Test
fun classifySmsSearchAvailability_returnsAvailable_whenReadSmsIsAvailable() {
assertEquals(
SmsSearchAvailabilityReason.Available,
classifySmsSearchAvailability(
readSmsAvailable = true,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
),
)
}
@Test
fun classifySmsSearchAvailability_returnsUnavailable_whenSmsFeatureDisabled() {
assertEquals(
SmsSearchAvailabilityReason.Unavailable,
classifySmsSearchAvailability(
readSmsAvailable = false,
smsFeatureEnabled = false,
smsTelephonyAvailable = true,
),
)
}
@Test
fun classifySmsSearchAvailability_returnsUnavailable_whenTelephonyUnavailable() {
assertEquals(
SmsSearchAvailabilityReason.Unavailable,
classifySmsSearchAvailability(
readSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = false,
),
)
}
@Test
fun classifySmsSearchAvailability_returnsPermissionRequired_whenOnlyReadSmsPermissionIsMissing() {
assertEquals(
SmsSearchAvailabilityReason.PermissionRequired,
classifySmsSearchAvailability(
readSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
),
)
}
@Test
fun smsSearchAvailabilityError_returnsNull_whenReadSmsPermissionIsRequestable() {
assertNull(
smsSearchAvailabilityError(
readSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
),
)
}
@Test
fun smsSearchAvailabilityError_returnsUnavailable_whenSmsSearchIsImpossible() {
val result =
smsSearchAvailabilityError(
readSmsAvailable = false,
smsFeatureEnabled = false,
smsTelephonyAvailable = true,
)
assertEquals("SMS_UNAVAILABLE", result?.error?.code)
assertEquals("SMS_UNAVAILABLE: SMS not available on this device", result?.error?.message)
}
@Test
fun handleInvoke_allowsRequestableSmsSearchToReachHandler() =
runTest {
val result =
newDispatcher(
readSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
).handleInvoke(OpenClawSmsCommand.Search.rawValue, "not-json")
assertEquals("SMS_PERMISSION_REQUIRED", result.error?.code)
assertEquals("grant READ_SMS permission", result.error?.message)
}
@Test
fun handleInvoke_blocksSmsSearchWhenFeatureIsUnavailable() =
runTest {
val result =
newDispatcher(
readSmsAvailable = false,
smsFeatureEnabled = false,
smsTelephonyAvailable = true,
).handleInvoke(OpenClawSmsCommand.Search.rawValue, "not-json")
assertEquals("SMS_UNAVAILABLE", result.error?.code)
assertEquals("SMS_UNAVAILABLE: SMS not available on this device", result.error?.message)
}
@Test
fun handleInvoke_allowsAvailableSmsSendToReachHandler() =
runTest {
val result =
newDispatcher(
sendSmsAvailable = true,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
).handleInvoke(OpenClawSmsCommand.Send.rawValue, """{"to":"+15551234567","message":"hi"}""")
assertEquals("SMS_PERMISSION_REQUIRED", result.error?.code)
assertEquals("grant SMS permission", result.error?.message)
}
@Test
fun handleInvoke_blocksSmsSendWhenUnavailable() =
runTest {
val result =
newDispatcher(
sendSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
).handleInvoke(OpenClawSmsCommand.Send.rawValue, """{"to":"+15551234567","message":"hi"}""")
assertEquals("SMS_UNAVAILABLE", result.error?.code)
assertEquals("SMS_UNAVAILABLE: SMS not available on this device", result.error?.message)
}
@Test
fun handleInvoke_blocksCameraCommandsWhenCameraDisabled() =
runTest {
val result = newDispatcher(cameraEnabled = false).handleInvoke(OpenClawCameraCommand.List.rawValue, null)
assertEquals("CAMERA_DISABLED", result.error?.code)
assertEquals("CAMERA_DISABLED: enable Camera in Settings", result.error?.message)
}
@Test
fun handleInvoke_blocksLocationCommandWhenLocationDisabled() =
runTest {
val result = newDispatcher(locationEnabled = false).handleInvoke(OpenClawLocationCommand.Get.rawValue, null)
assertEquals("LOCATION_DISABLED", result.error?.code)
assertEquals("LOCATION_DISABLED: enable Location in Settings", result.error?.message)
}
@Test
fun handleInvoke_blocksMotionActivityWhenUnavailable() =
runTest {
val result =
newDispatcher(motionActivityAvailable = false)
.handleInvoke(OpenClawMotionCommand.Activity.rawValue, null)
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
assertEquals("MOTION_UNAVAILABLE: accelerometer not available", result.error?.message)
}
@Test
fun handleInvoke_blocksMotionPedometerWhenUnavailable() =
runTest {
val result =
newDispatcher(motionPedometerAvailable = false)
.handleInvoke(OpenClawMotionCommand.Pedometer.rawValue, null)
assertEquals("PEDOMETER_UNAVAILABLE", result.error?.code)
assertEquals("PEDOMETER_UNAVAILABLE: step counter not available", result.error?.message)
}
@Test
fun handleInvoke_blocksCallLogWhenUnavailable() =
runTest {
val result =
newDispatcher(callLogAvailable = false).handleInvoke(OpenClawCallLogCommand.Search.rawValue, null)
assertEquals("CALL_LOG_UNAVAILABLE", result.error?.code)
assertEquals("CALL_LOG_UNAVAILABLE: call log not available on this build", result.error?.message)
}
@Test
fun handleInvoke_treatsDebugCommandsAsUnknownOutsideDebugBuilds() =
runTest {
val result = newDispatcher(debugBuild = false).handleInvoke("debug.logs", null)
assertEquals("INVALID_REQUEST", result.error?.code)
assertEquals("INVALID_REQUEST: unknown command", result.error?.message)
}
private fun newDispatcher(
cameraEnabled: Boolean = false,
locationEnabled: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
smsFeatureEnabled: Boolean = true,
smsTelephonyAvailable: Boolean = true,
callLogAvailable: Boolean = false,
debugBuild: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
): InvokeDispatcher {
val appContext = RuntimeEnvironment.getApplication()
shadowOf(appContext.packageManager).setSystemFeature(PackageManager.FEATURE_TELEPHONY, smsTelephonyAvailable)
val canvas = CanvasController()
return InvokeDispatcher(
canvas = canvas,
cameraHandler = newCameraHandler(appContext),
locationHandler =
LocationHandler.forTesting(
appContext = appContext,
dataSource = InvokeDispatcherFakeLocationDataSource(),
),
deviceHandler = DeviceHandler(appContext),
notificationsHandler =
NotificationsHandler.forTesting(
appContext = appContext,
stateProvider = InvokeDispatcherFakeNotificationsStateProvider(),
),
systemHandler = SystemHandler.forTesting(InvokeDispatcherFakeSystemNotificationPoster()),
photosHandler = PhotosHandler.forTesting(appContext, InvokeDispatcherFakePhotosDataSource()),
contactsHandler = ContactsHandler.forTesting(appContext, InvokeDispatcherFakeContactsDataSource()),
calendarHandler = CalendarHandler.forTesting(appContext, InvokeDispatcherFakeCalendarDataSource()),
motionHandler = MotionHandler.forTesting(appContext, InvokeDispatcherFakeMotionDataSource()),
smsHandler = SmsHandler(SmsManager(appContext)),
a2uiHandler =
A2UIHandler(
canvas = canvas,
json = Json { ignoreUnknownKeys = true },
getNodeCanvasHostUrl = { null },
getOperatorCanvasHostUrl = { null },
),
debugHandler = DebugHandler(appContext, DeviceIdentityStore(appContext)),
callLogHandler = CallLogHandler.forTesting(appContext, InvokeDispatcherFakeCallLogDataSource()),
isForeground = { true },
cameraEnabled = { cameraEnabled },
locationEnabled = { locationEnabled },
sendSmsAvailable = { sendSmsAvailable },
readSmsAvailable = { readSmsAvailable },
smsFeatureEnabled = { smsFeatureEnabled },
smsTelephonyAvailable = { smsTelephonyAvailable },
callLogAvailable = { callLogAvailable },
debugBuild = { debugBuild },
refreshNodeCanvasCapability = { false },
onCanvasA2uiPush = {},
onCanvasA2uiReset = {},
motionActivityAvailable = { motionActivityAvailable },
motionPedometerAvailable = { motionPedometerAvailable },
)
}
private fun newCameraHandler(appContext: Context): CameraHandler {
return CameraHandler(
appContext = appContext,
camera = CameraCaptureManager(appContext),
externalAudioCaptureActive = MutableStateFlow(false),
showCameraHud = { _, _, _ -> },
triggerCameraFlash = {},
invokeErrorFromThrowable = { err -> "UNAVAILABLE" to (err.message ?: "camera failed") },
)
}
}
private class InvokeDispatcherFakeLocationDataSource : LocationDataSource {
override fun hasFinePermission(context: Context): Boolean = false
override fun hasCoarsePermission(context: Context): Boolean = false
override suspend fun fetchLocation(
desiredProviders: List<String>,
maxAgeMs: Long?,
timeoutMs: Long,
isPrecise: Boolean,
): LocationCaptureManager.Payload {
error("unused in InvokeDispatcherTest")
}
}
private class InvokeDispatcherFakeNotificationsStateProvider : NotificationsStateProvider {
override fun readSnapshot(context: Context): DeviceNotificationSnapshot {
return DeviceNotificationSnapshot(enabled = false, connected = false, notifications = emptyList())
}
override fun requestServiceRebind(context: Context) = Unit
override fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
return NotificationActionResult(ok = true, code = null, message = null)
}
}
private class InvokeDispatcherFakeSystemNotificationPoster : SystemNotificationPoster {
override fun isAuthorized(): Boolean = true
override fun post(request: SystemNotifyRequest) = Unit
}
private class InvokeDispatcherFakePhotosDataSource : PhotosDataSource {
override fun hasPermission(context: Context): Boolean = true
override fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload> = emptyList()
}
private class InvokeDispatcherFakeContactsDataSource : ContactsDataSource {
override fun hasReadPermission(context: Context): Boolean = true
override fun hasWritePermission(context: Context): Boolean = true
override fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord> = emptyList()
override fun add(context: Context, request: ContactsAddRequest): ContactRecord {
error("unused in InvokeDispatcherTest")
}
}
private class InvokeDispatcherFakeCalendarDataSource : CalendarDataSource {
override fun hasReadPermission(context: Context): Boolean = true
override fun hasWritePermission(context: Context): Boolean = true
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> = emptyList()
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
error("unused in InvokeDispatcherTest")
}
}
private class InvokeDispatcherFakeMotionDataSource : MotionDataSource {
override fun isActivityAvailable(context: Context): Boolean = false
override fun isPedometerAvailable(context: Context): Boolean = false
override fun hasPermission(context: Context): Boolean = true
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
error("unused in InvokeDispatcherTest")
}
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
error("unused in InvokeDispatcherTest")
}
}
private class InvokeDispatcherFakeCallLogDataSource : CallLogDataSource {
override fun hasReadPermission(context: Context): Boolean = true
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> = emptyList()
}

View File

@@ -1,7 +1,9 @@
package ai.openclaw.app.node
import android.content.Context
import android.location.LocationManager
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -65,12 +67,110 @@ class LocationHandlerTest : NodeHandlerRobolectricTest() {
assertTrue(granted.hasFineLocationPermission())
assertFalse(granted.hasCoarseLocationPermission())
}
@Test
fun handleLocationGet_usesPreciseGpsFirstWhenFinePermissionAndPreciseEnabled() =
runTest {
val source =
FakeLocationDataSource(
fineGranted = true,
coarseGranted = true,
payload = LocationCaptureManager.Payload("""{"ok":true}"""),
)
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource = source,
locationPreciseEnabled = { true },
)
val result = handler.handleLocationGet("""{"desiredAccuracy":"precise","maxAgeMs":1234,"timeoutMs":2000}""")
assertTrue(result.ok)
assertEquals(listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER), source.lastDesiredProviders)
assertEquals(1234L, source.lastMaxAgeMs)
assertEquals(2000L, source.lastTimeoutMs)
assertTrue(source.lastIsPrecise)
}
@Test
fun handleLocationGet_fallsBackToBalancedWhenPreciseUnavailable() =
runTest {
val source =
FakeLocationDataSource(
fineGranted = false,
coarseGranted = true,
payload = LocationCaptureManager.Payload("""{"ok":true}"""),
)
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource = source,
locationPreciseEnabled = { true },
)
val result = handler.handleLocationGet("""{"desiredAccuracy":"precise"}""")
assertTrue(result.ok)
assertEquals(listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER), source.lastDesiredProviders)
assertFalse(source.lastIsPrecise)
}
@Test
fun handleLocationGet_mapsTimeoutToLocationTimeout() =
runTest {
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource =
FakeLocationDataSource(
fineGranted = true,
coarseGranted = true,
timeout = true,
),
)
val result = handler.handleLocationGet(null)
assertFalse(result.ok)
assertEquals("LOCATION_TIMEOUT", result.error?.code)
assertEquals("LOCATION_TIMEOUT: no fix in time", result.error?.message)
}
@Test
fun handleLocationGet_mapsOtherFailuresToLocationUnavailable() =
runTest {
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource =
FakeLocationDataSource(
fineGranted = true,
coarseGranted = true,
failure = IllegalStateException("gps offline"),
),
)
val result = handler.handleLocationGet(null)
assertFalse(result.ok)
assertEquals("LOCATION_UNAVAILABLE", result.error?.code)
assertEquals("gps offline", result.error?.message)
}
}
private class FakeLocationDataSource(
private val fineGranted: Boolean,
private val coarseGranted: Boolean,
private val payload: LocationCaptureManager.Payload? = null,
private val failure: Throwable? = null,
private val timeout: Boolean = false,
) : LocationDataSource {
var lastDesiredProviders: List<String> = emptyList()
var lastMaxAgeMs: Long? = null
var lastTimeoutMs: Long? = null
var lastIsPrecise: Boolean = false
override fun hasFinePermission(context: Context): Boolean = fineGranted
override fun hasCoarsePermission(context: Context): Boolean = coarseGranted
@@ -81,8 +181,16 @@ private class FakeLocationDataSource(
timeoutMs: Long,
isPrecise: Boolean,
): LocationCaptureManager.Payload {
throw IllegalStateException(
"LocationHandlerTest: fetchLocation must not run in this scenario",
)
lastDesiredProviders = desiredProviders
lastMaxAgeMs = maxAgeMs
lastTimeoutMs = timeoutMs
lastIsPrecise = isPrecise
if (timeout) {
kotlinx.coroutines.withTimeout(1) {
kotlinx.coroutines.delay(5)
}
}
failure?.let { throw it }
return payload ?: LocationCaptureManager.Payload(Json.encodeToString(mapOf("ok" to true)))
}
}

View File

@@ -140,6 +140,46 @@ class NotificationsHandlerTest {
assertEquals(0, provider.actionRequests)
}
@Test
fun notificationsActions_rejectsMissingKey() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = true,
connected = true,
notifications = listOf(sampleEntry("n3")),
),
)
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsActions("""{"action":"open"}""")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
assertEquals(0, provider.actionRequests)
}
@Test
fun notificationsActions_rejectsInvalidAction() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = true,
connected = true,
notifications = listOf(sampleEntry("n3")),
),
)
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsActions("""{"key":"n3","action":"archive"}""")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
assertEquals(0, provider.actionRequests)
}
@Test
fun notificationsActions_propagatesProviderError() =
runTest {
@@ -167,6 +207,29 @@ class NotificationsHandlerTest {
assertEquals(1, provider.actionRequests)
}
@Test
fun notificationsActions_fallsBackWhenProviderOmitsErrorDetails() =
runTest {
val provider =
FakeNotificationsStateProvider(
DeviceNotificationSnapshot(
enabled = true,
connected = true,
notifications = listOf(sampleEntry("n4")),
),
).also {
it.actionResult = NotificationActionResult(ok = false)
}
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
val result = handler.handleNotificationsActions("""{"key":"n4","action":"open"}""")
assertFalse(result.ok)
assertEquals("UNAVAILABLE", result.error?.code)
assertEquals("notification action failed", result.error?.message)
assertEquals(1, provider.actionRequests)
}
@Test
fun notificationsActions_requestsRebindWhenEnabledButDisconnected() =
runTest {

View File

@@ -1,15 +1,39 @@
package ai.openclaw.app.node
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SmsManagerTest {
private val json = SmsManager.JsonConfig
private fun smsMessage(
id: Long,
date: Long,
status: Int = 0,
body: String? = "msg-$id",
transportType: String? = null,
): SmsManager.SmsMessage =
SmsManager.SmsMessage(
id = id,
threadId = 1L,
address = "+15551234567",
person = null,
date = date,
dateSent = date,
read = true,
type = 1,
body = body,
status = status,
transportType = transportType,
)
@Test
fun parseParamsRejectsEmptyPayload() {
val result = SmsManager.parseParams("", json)
@@ -61,6 +85,73 @@ class SmsManagerTest {
assertEquals("Hello", ok.params.message)
}
@Test
fun parseQueryParamsDefaultsWhenPayloadEmpty() {
val result = SmsManager.parseQueryParams(null, json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(25, ok.params.limit)
assertEquals(0, ok.params.offset)
assertEquals(null, ok.params.startTime)
assertEquals(null, ok.params.endTime)
}
@Test
fun parseQueryParamsRejectsInvalidJson() {
val result = SmsManager.parseQueryParams("not-json", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseQueryParamsRejectsInvertedTimeRange() {
val result = SmsManager.parseQueryParams("{\"startTime\":200,\"endTime\":100}", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: startTime must be less than or equal to endTime", error.error)
}
@Test
fun parseQueryParamsClampsLimitAndOffset() {
val result = SmsManager.parseQueryParams("{\"limit\":999,\"offset\":-5}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(200, ok.params.limit)
assertEquals(0, ok.params.offset)
}
@Test
fun parseQueryParamsParsesAllSupportedFields() {
val result = SmsManager.parseQueryParams(
"""
{
"startTime": 100,
"endTime": 200,
"contactName": " Leah ",
"phoneNumber": " +1555 ",
"keyword": " ping ",
"type": 1,
"isRead": true,
"limit": 10,
"offset": 2
}
""".trimIndent(),
json,
)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(100L, ok.params.startTime)
assertEquals(200L, ok.params.endTime)
assertEquals("Leah", ok.params.contactName)
assertEquals("+1555", ok.params.phoneNumber)
assertEquals("ping", ok.params.keyword)
assertEquals(1, ok.params.type)
assertEquals(true, ok.params.isRead)
assertEquals(10, ok.params.limit)
assertEquals(2, ok.params.offset)
}
@Test
fun buildPayloadJsonEscapesFields() {
val payload = SmsManager.buildPayloadJson(
@@ -75,6 +166,69 @@ class SmsManagerTest {
assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content)
}
@Test
fun buildQueryPayloadJsonIncludesCountAndMessages() {
val payload = SmsManager.buildQueryPayloadJson(
json = json,
ok = true,
messages = listOf(
SmsManager.SmsMessage(
id = 1L,
threadId = 2L,
address = "+1555",
person = null,
date = 123L,
dateSent = 124L,
read = true,
type = 1,
body = "hello",
status = 0,
)
),
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("true", parsed["ok"]?.jsonPrimitive?.content)
assertEquals(1, parsed["count"]?.jsonPrimitive?.content?.toInt())
val messages = parsed["messages"]?.jsonArray
assertEquals(1, messages?.size)
assertEquals("hello", messages?.get(0)?.jsonObject?.get("body")?.jsonPrimitive?.content)
}
@Test
fun buildQueryPayloadJsonIncludesErrorOnFailure() {
val payload = SmsManager.buildQueryPayloadJson(
json = json,
ok = false,
messages = emptyList(),
error = "SMS_QUERY_FAILED: nope",
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("false", parsed["ok"]?.jsonPrimitive?.content)
assertEquals(0, parsed["count"]?.jsonPrimitive?.content?.toInt())
assertEquals("SMS_QUERY_FAILED: nope", parsed["error"]?.jsonPrimitive?.content)
}
@Test
fun buildQueryPayloadJsonIncludesMmsMetadataWhenProvided() {
val payload = SmsManager.buildQueryPayloadJson(
json = json,
ok = true,
messages = listOf(smsMessage(id = 1L, date = 1000L)),
queryMetadata =
SmsManager.QueryMetadata(
mmsRequested = true,
mmsEligible = true,
mmsAttempted = true,
mmsIncluded = false,
),
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("true", parsed["mmsRequested"]?.jsonPrimitive?.content)
assertEquals("true", parsed["mmsEligible"]?.jsonPrimitive?.content)
assertEquals("true", parsed["mmsAttempted"]?.jsonPrimitive?.content)
assertEquals("false", parsed["mmsIncluded"]?.jsonPrimitive?.content)
}
@Test
fun buildSendPlanUsesMultipartWhenMultipleParts() {
val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") }
@@ -98,14 +252,6 @@ class SmsManagerTest {
assertEquals(0, ok.params.offset)
}
@Test
fun parseQueryParamsRejectsInvalidJson() {
val result = SmsManager.parseQueryParams("not-json", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseQueryParamsRejectsNonObjectJson() {
val result = SmsManager.parseQueryParams("[]", json)
@@ -179,4 +325,749 @@ class SmsManagerTest {
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(true, ok.params.isRead)
}
@Test
fun parseQueryParamsIncludeMmsDefaultsFalse() {
val result = SmsManager.parseQueryParams("{}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertFalse(ok.params.includeMms)
}
@Test
fun parseQueryParamsParsesIncludeMmsTrue() {
val result = SmsManager.parseQueryParams("{\"includeMms\":true}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertTrue(ok.params.includeMms)
}
@Test
fun parseQueryParamsParsesConversationReviewTrue() {
val result = SmsManager.parseQueryParams("{\"conversationReview\":true}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertTrue(ok.params.conversationReview)
}
@Test
fun toByPhoneLookupNumberStripsFormattingToDigits() {
assertEquals("12107588120", SmsManager.toByPhoneLookupNumber("+1 (210) 758-8120"))
}
@Test
fun normalizePhoneNumberOrNullReturnsNullForFormattingOnlyInput() {
assertNull(SmsManager.normalizePhoneNumberOrNull("() - "))
}
@Test
fun normalizePhoneNumberOrNullReturnsNullForPlusOnlyInput() {
assertNull(SmsManager.normalizePhoneNumberOrNull(" + "))
}
@Test
fun normalizePhoneNumberOrNullKeepsUsableNormalizedNumber() {
assertEquals("+15551234567", SmsManager.normalizePhoneNumberOrNull(" +1 (555) 123-4567 "))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsFormattingOnlyInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull(" () - "))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsPlusOnlyInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull(" + "))
}
@Test
fun sanitizeContactPhoneNumberOrNullKeepsUsableNormalizedNumber() {
assertEquals("+15551234567", SmsManager.sanitizeContactPhoneNumberOrNull(" +1 (555) 123-4567 "))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsPercentWildcardInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull("1%2"))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsUnderscoreWildcardInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull("1_2"))
}
@Test
fun shouldPromptForContactNameSearchPermissionTrueForContactNameOnlyWithoutContactsAccess() {
assertTrue(
SmsManager.shouldPromptForContactNameSearchPermission(
contactName = "Alice",
phoneNumber = null,
hasReadContactsPermission = false,
),
)
}
@Test
fun shouldPromptForContactNameSearchPermissionFalseWhenExplicitPhoneFallbackExists() {
assertFalse(
SmsManager.shouldPromptForContactNameSearchPermission(
contactName = "Alice",
phoneNumber = "+15551234567",
hasReadContactsPermission = false,
),
)
}
@Test
fun shouldPromptForContactNameSearchPermissionFalseWhenContactsAlreadyGranted() {
assertFalse(
SmsManager.shouldPromptForContactNameSearchPermission(
contactName = "Alice",
phoneNumber = null,
hasReadContactsPermission = true,
),
)
}
@Test
fun escapeSqlLikeLiteralEscapesPercentUnderscoreAndBackslash() {
assertEquals("\\%a\\_b\\\\c", SmsManager.escapeSqlLikeLiteral("%a_b\\c"))
}
@Test
fun escapeSqlLikeLiteralLeavesOrdinaryTextUnchanged() {
assertEquals("Leah", SmsManager.escapeSqlLikeLiteral("Leah"))
}
@Test
fun buildContactNameLikeSelectionUsesSingleBackslashEscapeLiteral() {
assertEquals(
"display_name LIKE ? ESCAPE '\\'",
SmsManager.buildContactNameLikeSelection(),
)
}
@Test
fun buildContactNameLikeArgEscapesWildcardsAndBackslash() {
assertEquals("%\\%a\\_b\\\\c%", SmsManager.buildContactNameLikeArg("%a_b\\c"))
}
@Test
fun buildKeywordLikeSelectionUsesSingleBackslashEscapeLiteral() {
assertEquals(
"body LIKE ? ESCAPE '\\'",
SmsManager.buildKeywordLikeSelection(),
)
}
@Test
fun buildKeywordLikeArgEscapesWildcardsAndBackslash() {
assertEquals("%\\%a\\_b\\\\c%", SmsManager.buildKeywordLikeArg("%a_b\\c"))
}
@Test
fun buildMixedByPhoneProjectionMatchesExpectedStatusAwareShape() {
assertArrayEquals(
arrayOf(
"_id",
"thread_id",
"transport_type",
"address",
"date",
"date_sent",
"read",
"type",
"body",
"status",
),
SmsManager.buildMixedByPhoneProjection(),
)
}
@Test
fun compareByPhoneCandidateOrderUsesDateThenIdDescending() {
val newer = smsMessage(id = 1L, date = 2000L)
val older = smsMessage(id = 2L, date = 1000L)
val sameDateHigherId = smsMessage(id = 9L, date = 1500L)
val sameDateLowerId = smsMessage(id = 3L, date = 1500L)
assertTrue(SmsManager.compareByPhoneCandidateOrder(newer, older) < 0)
assertTrue(SmsManager.compareByPhoneCandidateOrder(sameDateHigherId, sameDateLowerId) < 0)
assertTrue(SmsManager.compareByPhoneCandidateOrder(sameDateLowerId, sameDateHigherId) > 0)
}
@Test
fun upsertTopDateCandidatesKeepsDescendingOrderAndBounds() {
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val max = 2
SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 1700L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:2", smsMessage(id = 2L, date = 2000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:3", smsMessage(id = 3L, date = 1500L), max)
assertEquals(listOf(2L, 1L), candidates.map { it.second.id })
assertEquals(listOf(2000L, 1700L), candidates.map { it.second.date })
}
@Test
fun upsertTopDateCandidatesSupportsDefaultMixedPathBoundedWindow() {
val params = SmsManager.QueryParams(limit = 3, offset = 2, includeMms = true, phoneNumber = "+15551234567")
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val max = params.offset + params.limit
SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 1000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:2", smsMessage(id = 2L, date = 2000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:3", smsMessage(id = 3L, date = 3000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:4", smsMessage(id = 4L, date = 4000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:5", smsMessage(id = 5L, date = 5000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:6", smsMessage(id = 6L, date = 6000L), max)
assertEquals(5, candidates.size)
assertEquals(listOf(6L, 5L, 4L, 3L, 2L), candidates.map { it.second.id })
assertEquals(listOf(4000L, 3000L, 2000L), SmsManager.pageByPhoneCandidates(candidates.map { it.second }, params).map { it.date })
}
@Test
fun upsertTopDateCandidatesDedupesBySourceAwareIdentityAndKeepsBestOrdering() {
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val max = 5
SmsManager.upsertTopDateCandidates(candidates, "sms:1987", smsMessage(id = 1987L, date = 1773950752506L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1986", smsMessage(id = 1986L, date = 1773899354039L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1985", smsMessage(id = 1985L, date = 1773872989602L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1981", smsMessage(id = 1981L, date = 1773790733566L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1976", smsMessage(id = 1976L, date = 1773784153770L), max)
// same source-aware identity should replace, not duplicate
SmsManager.upsertTopDateCandidates(candidates, "sms:1986", smsMessage(id = 1986L, date = 1773899354039L), max)
// different source-aware identity with same raw id must be preserved
SmsManager.upsertTopDateCandidates(candidates, "mms:1986", smsMessage(id = 1986L, date = 1773899354038L), max)
assertEquals(5, candidates.size)
assertEquals(2, candidates.count { it.second.id == 1986L })
assertEquals(listOf("sms:1987", "sms:1986", "mms:1986", "sms:1985", "sms:1981"), candidates.map { it.first })
}
@Test
fun materializeByPhoneCandidateDedupesBySourceAwareIdentity() {
val candidates = linkedMapOf<String, SmsManager.SmsMessage>()
SmsManager.materializeByPhoneCandidate(candidates, "sms:1", smsMessage(id = 1L, date = 1000L))
SmsManager.materializeByPhoneCandidate(candidates, "sms:1", smsMessage(id = 1L, date = 2000L))
SmsManager.materializeByPhoneCandidate(candidates, "mms:1", smsMessage(id = 1L, date = 1500L))
assertEquals(2, candidates.size)
assertEquals(2000L, candidates["sms:1"]?.date)
assertEquals(1500L, candidates["mms:1"]?.date)
}
@Test
fun collectMixedByPhoneCandidateUsesBoundedCollectorWhenReviewModeDisabled() {
val topCandidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val materializedCandidates = linkedMapOf<String, SmsManager.SmsMessage>()
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "sms:1",
message = smsMessage(id = 1L, date = 1000L),
maxCandidates = 1,
reviewMode = false,
)
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "mms:2",
message = smsMessage(id = 2L, date = 2000L, transportType = "mms"),
maxCandidates = 1,
reviewMode = false,
)
assertEquals(listOf(2L), topCandidates.map { it.second.id })
assertTrue(materializedCandidates.isEmpty())
}
@Test
fun collectMixedByPhoneCandidateMaterializesFullSetWhenReviewModeEnabled() {
val topCandidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val materializedCandidates = linkedMapOf<String, SmsManager.SmsMessage>()
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "sms:1",
message = smsMessage(id = 1L, date = 1000L),
maxCandidates = 1,
reviewMode = true,
)
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "mms:2",
message = smsMessage(id = 2L, date = 2000L, transportType = "mms"),
maxCandidates = 1,
reviewMode = true,
)
assertTrue(topCandidates.isEmpty())
assertEquals(listOf(1L, 2L), materializedCandidates.values.map { it.id })
}
@Test
fun pageMixedByPhoneCandidatesLetsReviewModeSurfaceOlderRowsBeyondBoundedDefaultWindow() {
val params =
SmsManager.QueryParams(
limit = 2,
offset = 2,
includeMms = true,
phoneNumber = "+15551234567",
conversationReview = true,
)
val topCandidates = listOf(
"sms:9" to smsMessage(id = 9L, date = 9000L),
"sms:8" to smsMessage(id = 8L, date = 8000L),
"sms:7" to smsMessage(id = 7L, date = 7000L),
)
val materializedCandidates =
linkedMapOf(
"sms:9" to smsMessage(id = 9L, date = 9000L),
"sms:8" to smsMessage(id = 8L, date = 8000L),
"sms:7" to smsMessage(id = 7L, date = 7000L),
"mms:6" to smsMessage(id = 6L, date = 6000L, transportType = "mms"),
)
val defaultPage =
SmsManager.pageMixedByPhoneCandidates(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
params = params.copy(conversationReview = false),
reviewMode = false,
)
val reviewPage =
SmsManager.pageMixedByPhoneCandidates(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
params = params,
reviewMode = true,
)
assertEquals(listOf(7L), defaultPage.map { it.id })
assertEquals(listOf(7L, 6L), reviewPage.map { it.id })
assertEquals(4, materializedCandidates.size)
}
@Test
fun pageByPhoneCandidatesHonorsDeepOffsetAfterStableSort() {
val params = SmsManager.QueryParams(limit = 5, offset = 5, includeMms = true)
val candidates = listOf(
smsMessage(id = 1399L, date = 1741112335720L),
smsMessage(id = 1976L, date = 1773784153770L),
smsMessage(id = 1981L, date = 1773790733566L),
smsMessage(id = 1985L, date = 1773872989602L),
smsMessage(id = 1986L, date = 1773899354039L),
smsMessage(id = 1987L, date = 1773950752506L),
)
assertEquals(listOf(1399L), SmsManager.pageByPhoneCandidates(candidates, params).map { it.id })
assertTrue(SmsManager.pageByPhoneCandidates(candidates, params.copy(offset = 10)).isEmpty())
}
@Test
fun upsertTopDateCandidatesNoOpWhenMaxIsZero() {
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 2000L), 0)
assertTrue(candidates.isEmpty())
}
@Test
fun buildMixedRowIdentityUsesTransportTypeAndRowId() {
assertEquals("sms:7", SmsManager.buildMixedRowIdentity(7L, "sms"))
assertEquals("mms:7", SmsManager.buildMixedRowIdentity(7L, "mms"))
assertEquals("unknown:7", SmsManager.buildMixedRowIdentity(7L, null))
assertEquals("unknown:7", SmsManager.buildMixedRowIdentity(7L, ""))
}
@Test
fun normalizeProviderDateMillisConvertsSecondsToMillis() {
assertEquals(1773944910000L, SmsManager.normalizeProviderDateMillis(1773944910L))
}
@Test
fun normalizeProviderDateMillisKeepsMillisUnchanged() {
assertEquals(1773944910123L, SmsManager.normalizeProviderDateMillis(1773944910123L))
}
@Test
fun normalizeProviderDateMillisKeepsHistoricMillisUnchanged() {
assertEquals(946684800000L, SmsManager.normalizeProviderDateMillis(946684800000L))
}
@Test
fun resolveMixedByPhoneRowStatusPreservesRealSmsStatus() {
assertEquals(64, SmsManager.resolveMixedByPhoneRowStatus("sms", 64))
assertEquals(32, SmsManager.resolveMixedByPhoneRowStatus(null, 32))
}
@Test
fun resolveMixedByPhoneRowStatusKeepsMmsOnSentinelValue() {
assertEquals(-1, SmsManager.resolveMixedByPhoneRowStatus("mms", 64))
assertEquals(-1, SmsManager.resolveMixedByPhoneRowStatus("MMS", null))
}
@Test
fun resolveMixedByPhoneRowStatusFallsBackToZeroWhenSmsStatusMissing() {
assertEquals(0, SmsManager.resolveMixedByPhoneRowStatus("sms", null))
}
@Test
fun resolveMixedByPhoneRowAddressPreservesProviderAddressWhenPresent() {
assertEquals(
"+12107588120",
SmsManager.resolveMixedByPhoneRowAddress("+12107588120", "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressFallsBackToLookupNumberWhenProviderAddressMissing() {
assertEquals(
"12107588120",
SmsManager.resolveMixedByPhoneRowAddress(null, "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressCanPreserveLookupNumberWhenProviderAlreadyReturnsIt() {
assertEquals(
"12107588120",
SmsManager.resolveMixedByPhoneRowAddress("12107588120", "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressPreservesNonMatchingProviderAddress() {
assertEquals(
"+13105550123",
SmsManager.resolveMixedByPhoneRowAddress("+13105550123", "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressPrefersResolvedMmsParticipantAddress() {
assertEquals(
"+13105550123",
SmsManager.resolveMixedByPhoneRowAddress("insert-address-token", "12107588120", "+13105550123"),
)
}
@Test
fun selectPreferredMmsAddressPrefersType137AddressThatDoesNotMatchLookup() {
assertEquals(
"+13105550123",
SmsManager.selectPreferredMmsAddress(
listOf(
"+12107588120" to 151,
"+13105550123" to 137,
"+12107588120" to 130,
),
"12107588120",
),
)
}
@Test
fun selectPreferredMmsAddressFallsBackToFirstNormalizedAddressWhenOnlyLookupMatchesExist() {
assertEquals(
"+12107588120",
SmsManager.selectPreferredMmsAddress(
listOf(
"insert-address-token" to 137,
"+12107588120" to 151,
),
"12107588120",
),
)
}
@Test
fun isExplicitPhoneInputInvalidTrueWhenCallerSuppliesOnlyFormatting() {
val normalized = SmsManager.normalizePhoneNumberOrNull(" + ")
assertTrue(SmsManager.isExplicitPhoneInputInvalid(" + ", normalized))
}
@Test
fun hasSqlLikeWildcardDetectsPercentAndUnderscore() {
assertTrue(SmsManager.hasSqlLikeWildcard("+1555%1234"))
assertTrue(SmsManager.hasSqlLikeWildcard("+1555_1234"))
assertFalse(SmsManager.hasSqlLikeWildcard("+15551234"))
}
@Test
fun isExplicitPhoneInputInvalidRejectsLikeWildcardPhoneFilter() {
assertTrue(SmsManager.isExplicitPhoneInputInvalid("+1555%1234", "+1555%1234"))
assertTrue(SmsManager.isExplicitPhoneInputInvalid("+1555_1234", "+1555_1234"))
}
@Test
fun isExplicitPhoneInputInvalidFalseWhenPhoneWasOmitted() {
assertFalse(SmsManager.isExplicitPhoneInputInvalid(null, null))
assertFalse(SmsManager.isExplicitPhoneInputInvalid(" ", null))
}
@Test
fun mapMmsMsgBoxToSearchTypeCoversSearchRelevantMmsBoxes() {
assertEquals(1, SmsManager.mapMmsMsgBoxToSearchType(1))
assertEquals(2, SmsManager.mapMmsMsgBoxToSearchType(2))
assertEquals(3, SmsManager.mapMmsMsgBoxToSearchType(3))
assertEquals(4, SmsManager.mapMmsMsgBoxToSearchType(4))
assertEquals(5, SmsManager.mapMmsMsgBoxToSearchType(5))
assertEquals(6, SmsManager.mapMmsMsgBoxToSearchType(6))
}
@Test
fun mapMmsMsgBoxToSearchTypeLeavesUnsupportedBoxesUnmapped() {
assertNull(SmsManager.mapMmsMsgBoxToSearchType(0))
assertNull(SmsManager.mapMmsMsgBoxToSearchType(99))
assertNull(SmsManager.mapMmsMsgBoxToSearchType(null))
}
@Test
fun shouldUseConversationReviewByPhoneModeOnlyForMixedByPhoneReviewPulls() {
val active =
SmsManager.QueryParams(
limit = 5,
offset = 0,
isRead = null,
contactName = null,
phoneNumber = "+12107588120",
keyword = null,
startTime = null,
endTime = null,
includeMms = true,
conversationReview = true,
)
val disabledByMode = active.copy(conversationReview = false)
val disabledByMms = active.copy(includeMms = false)
val disabledByPhone = active.copy(phoneNumber = null)
assertTrue(SmsManager.shouldUseConversationReviewByPhoneMode(active))
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByMode))
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByMms))
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByPhone))
}
@Test
fun effectiveSearchParamsRaisesConversationReviewLimitFloor() {
val params =
SmsManager.QueryParams(
limit = 5,
offset = 0,
isRead = null,
contactName = null,
phoneNumber = "+12107588120",
keyword = null,
startTime = null,
endTime = null,
includeMms = true,
conversationReview = true,
)
assertEquals(25, SmsManager.effectiveSearchParams(params).limit)
assertEquals(40, SmsManager.effectiveSearchParams(params.copy(limit = 40)).limit)
assertEquals(5, SmsManager.effectiveSearchParams(params.copy(conversationReview = false)).limit)
val singleResolvedContact = params.copy(phoneNumber = null, contactName = "Leah")
assertEquals(25, SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567")).limit)
assertEquals(5, SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567", "15557654321")).limit)
assertEquals(
SmsManager.effectiveSearchParams(params).limit,
SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567")).limit,
)
}
@Test
fun resolveSearchParamsCarriesSingleResolvedContactIntoReviewMode() {
val params =
SmsManager.QueryParams(
limit = 5,
offset = 0,
isRead = null,
contactName = "Leah",
phoneNumber = null,
keyword = null,
startTime = null,
endTime = null,
includeMms = true,
conversationReview = true,
)
val beforeResolution = SmsManager.resolveSearchParams(params, normalizedPhoneNumber = null)
val singleResolved =
SmsManager.resolveSearchParams(
params,
normalizedPhoneNumber = null,
resolvedPhoneNumbers = listOf("15551234567"),
)
val multiResolved =
SmsManager.resolveSearchParams(
params,
normalizedPhoneNumber = null,
resolvedPhoneNumbers = listOf("15551234567", "15557654321"),
)
val explicit =
SmsManager.resolveSearchParams(
params.copy(contactName = null, phoneNumber = "+12107588120"),
normalizedPhoneNumber = "12107588120",
)
val nonReview =
SmsManager.resolveSearchParams(
params.copy(conversationReview = false),
normalizedPhoneNumber = null,
resolvedPhoneNumbers = listOf("15551234567"),
)
assertEquals(5, beforeResolution.limit)
assertEquals(25, singleResolved.limit)
assertEquals("15551234567", singleResolved.phoneNumber)
assertTrue(SmsManager.shouldUseConversationReviewByPhoneMode(singleResolved))
assertEquals(5, multiResolved.limit)
assertNull(multiResolved.phoneNumber)
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(multiResolved))
assertEquals(25, explicit.limit)
assertEquals("12107588120", explicit.phoneNumber)
assertEquals(5, nonReview.limit)
assertEquals("15551234567", nonReview.phoneNumber)
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(nonReview))
}
@Test
fun canonicalizeMixedPathPhoneFiltersDedupesEquivalentExplicitAndContactNumbers() {
assertEquals(
listOf("15551234567"),
SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "15551234567")),
)
}
@Test
fun canonicalizeMixedPathPhoneFiltersDropsBlankByPhoneValues() {
assertEquals(
listOf("15551234567"),
SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "+", " ")),
)
}
@Test
fun buildQueryMetadataUsesCanonicalizedSingleMixedFilterAsEligible() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val canonical = SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "15551234567"))
val metadata = SmsManager.buildQueryMetadata(params, canonical, emptyList())
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
}
@Test
fun requestedMixedByPhoneCandidateWindowAddsOffsetAndLimitSafely() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 300)
assertEquals(500L, SmsManager.requestedMixedByPhoneCandidateWindow(params))
}
@Test
fun exceedsMixedByPhoneCandidateWindowFalseAtSupportedBoundary() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 300)
assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567")))
}
@Test
fun exceedsMixedByPhoneCandidateWindowTrueWhenSingleNumberMixedWindowTooLarge() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 301)
assertTrue(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567")))
}
@Test
fun exceedsMixedByPhoneCandidateWindowFalseForSmsOnlyQueries() {
val params = SmsManager.QueryParams(includeMms = false, phoneNumber = "+15551234567", limit = 200, offset = 50000)
assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567")))
}
@Test
fun exceedsMixedByPhoneCandidateWindowFalseWhenMultiplePhoneNumbersDisableMixedByPhonePath() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = null, limit = 200, offset = 50000)
assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567", "+15557654321")))
}
@Test
fun mixedByPhoneWindowErrorMentionsSupportedWindow() {
assertEquals(
"INVALID_REQUEST: includeMms offset+limit exceeds supported window (500)",
SmsManager.mixedByPhoneWindowError(),
)
}
@Test
fun buildQueryMetadataMarksIneligibleWhenIncludeMmsNotRequested() {
val params = SmsManager.QueryParams(includeMms = false)
val metadata = SmsManager.buildQueryMetadata(params, emptyList(), emptyList())
assertFalse(metadata.mmsRequested)
assertFalse(metadata.mmsEligible)
assertFalse(metadata.mmsAttempted)
assertFalse(metadata.mmsIncluded)
}
@Test
fun buildQueryMetadataMarksEligibleAttemptedButNotIncludedForSingleNumberFallback() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val messages = listOf(smsMessage(id = 1L, date = 1000L))
val metadata = SmsManager.buildQueryMetadata(params, listOf("+15551234567"), messages)
assertTrue(metadata.mmsRequested)
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
assertFalse(metadata.mmsIncluded)
}
@Test
fun isMmsTransportRowTrueOnlyForMmsTransport() {
assertTrue(SmsManager.isMmsTransportRow(smsMessage(id = 1L, date = 1000L, transportType = "mms")))
assertFalse(SmsManager.isMmsTransportRow(smsMessage(id = 2L, date = 1000L, transportType = "sms")))
assertFalse(SmsManager.isMmsTransportRow(smsMessage(id = 3L, date = 1000L, transportType = null)))
}
@Test
fun shouldHydrateMmsByPhoneRowTrueOnlyForMmsTransportWithBlankBodyOrZeroType() {
assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", null, 1))
assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", "", 1))
assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", "body", 0))
assertFalse(SmsManager.shouldHydrateMmsByPhoneRow("sms", null, 0))
assertFalse(SmsManager.shouldHydrateMmsByPhoneRow(null, null, 0))
assertFalse(SmsManager.shouldHydrateMmsByPhoneRow("mms", "body", 1))
}
@Test
fun buildQueryMetadataDoesNotTreatSmsStatusSentinelAsMmsInclusion() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val smsLikeMessage = smsMessage(id = 7L, date = 1000L, status = -1, transportType = "sms")
val metadata = SmsManager.buildQueryMetadata(params, listOf("15551234567"), listOf(smsLikeMessage))
assertTrue(metadata.mmsRequested)
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
assertFalse(metadata.mmsIncluded)
}
@Test
fun buildQueryMetadataMarksIncludedWhenMixedQueryYieldsMmsTransportRow() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val mmsTransportMessage = smsMessage(id = 7L, date = 1000L, status = 0, body = null, transportType = "mms")
val metadata = SmsManager.buildQueryMetadata(params, listOf("15551234567"), listOf(mmsTransportMessage))
assertTrue(metadata.mmsRequested)
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
assertTrue(metadata.mmsIncluded)
}
}

View File

@@ -26,6 +26,16 @@ class SystemHandlerTest {
assertEquals("INVALID_REQUEST", result.error?.code)
}
@Test
fun handleSystemNotify_rejectsInvalidRequestObject() {
val handler = SystemHandler.forTesting(poster = FakePoster(authorized = true))
val result = handler.handleSystemNotify("""{"title":"OpenClaw"}""")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
}
@Test
fun handleSystemNotify_postsNotification() {
val poster = FakePoster(authorized = true)
@@ -37,6 +47,23 @@ class SystemHandlerTest {
assertEquals(1, poster.posts)
}
@Test
fun handleSystemNotify_trimsAndPassesOptionalFields() {
val poster = FakePoster(authorized = true)
val handler = SystemHandler.forTesting(poster = poster)
val result =
handler.handleSystemNotify(
"""{"title":" OpenClaw ","body":" done ","priority":" passive ","sound":" silent "}""",
)
assertTrue(result.ok)
assertEquals("OpenClaw", poster.lastRequest?.title)
assertEquals("done", poster.lastRequest?.body)
assertEquals("passive", poster.lastRequest?.priority)
assertEquals("silent", poster.lastRequest?.sound)
}
@Test
fun handleSystemNotify_returnsUnauthorizedWhenPostFailsPermission() {
val handler = SystemHandler.forTesting(poster = ThrowingPoster(authorized = true, error = SecurityException("denied")))
@@ -55,6 +82,7 @@ class SystemHandlerTest {
assertFalse(result.ok)
assertEquals("UNAVAILABLE", result.error?.code)
assertEquals("NOTIFICATION_FAILED: boom", result.error?.message)
}
}
@@ -63,11 +91,14 @@ private class FakePoster(
) : SystemNotificationPoster {
var posts: Int = 0
private set
var lastRequest: SystemNotifyRequest? = null
private set
override fun isAuthorized(): Boolean = authorized
override fun post(request: SystemNotifyRequest) {
posts += 1
lastRequest = request
}
}

View File

@@ -86,13 +86,15 @@ class OpenClawProtocolConstantsTest {
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
}
@Test
fun smsCommandsUseStableStrings() {
assertEquals("sms.send", OpenClawSmsCommand.Send.rawValue)
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
}
@Test
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
@Test
fun smsCommandsUseStableStrings() {
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
}
}

View File

@@ -25,18 +25,17 @@ class GatewayConfigResolverTest {
}
@Test
fun parseGatewayEndpointUsesDefaultCleartextPortForBareWsUrls() {
fun parseGatewayEndpointRejectsNonLoopbackCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://gateway.example")
assertEquals(
GatewayEndpointConfig(
host = "gateway.example",
port = 18789,
tls = false,
displayUrl = "http://gateway.example:18789",
),
parsed,
)
assertNull(parsed)
}
@Test
fun parseGatewayEndpointRejectsTailnetCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://100.64.0.9:18789")
assertNull(parsed)
}
@Test
@@ -55,30 +54,158 @@ class GatewayConfigResolverTest {
}
@Test
fun parseGatewayEndpointKeepsExplicitNonDefaultPortInDisplayUrl() {
val parsed = parseGatewayEndpoint("http://gateway.example:8080")
fun parseGatewayEndpointAllowsLoopbackCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://127.0.0.1")
assertEquals(
GatewayEndpointConfig(
host = "gateway.example",
port = 8080,
host = "127.0.0.1",
port = 18789,
tls = false,
displayUrl = "http://gateway.example:8080",
displayUrl = "http://127.0.0.1:18789",
),
parsed,
)
}
@Test
fun parseGatewayEndpointKeepsExplicitCleartextPort80InDisplayUrl() {
val parsed = parseGatewayEndpoint("http://gateway.example:80")
fun parseGatewayEndpointAllowsLocalhostCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://localhost:18789")
assertEquals(
GatewayEndpointConfig(
host = "gateway.example",
host = "localhost",
port = 18789,
tls = false,
displayUrl = "http://localhost:18789",
),
parsed,
)
}
@Test
fun parseGatewayEndpointAllowsAndroidEmulatorCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://10.0.2.2:18789")
assertEquals(
GatewayEndpointConfig(
host = "10.0.2.2",
port = 18789,
tls = false,
displayUrl = "http://10.0.2.2:18789",
),
parsed,
)
}
@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]")
assertEquals("::1", parsed?.host)
assertEquals(18789, parsed?.port)
assertEquals(false, parsed?.tls)
assertEquals("http://[::1]:18789", parsed?.displayUrl)
}
@Test
fun parseGatewayEndpointAllowsIpv4MappedIpv6LoopbackCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://[::ffff:127.0.0.1]")
assertEquals("::ffff:127.0.0.1", parsed?.host)
assertEquals(18789, parsed?.port)
assertEquals(false, parsed?.tls)
assertEquals("http://[::ffff:127.0.0.1]:18789", parsed?.displayUrl)
}
@Test
fun parseGatewayEndpointRejectsCleartextLoopbackPrefixBypassHost() {
val parsed = parseGatewayEndpoint("http://127.attacker.example:80")
assertNull(parsed)
}
@Test
fun parseGatewayEndpointRejectsNonLoopbackIpv6CleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://[2001:db8::1]")
assertNull(parsed)
}
@Test
fun parseGatewayEndpointAllowsLinkLocalIpv6ZoneCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://[fe80::1%25eth0]")
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
fun parseGatewayEndpointRejectsUnspecifiedIpv4CleartextHttpUrls() {
val parsed = parseGatewayEndpoint("http://0.0.0.0:80")
assertNull(parsed)
}
@Test
fun parseGatewayEndpointRejectsUnspecifiedIpv6CleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://[::]")
assertNull(parsed)
}
@Test
fun parseGatewayEndpointAllowsLoopbackCleartextHttpUrls() {
val parsed = parseGatewayEndpoint("http://localhost:80")
assertEquals(
GatewayEndpointConfig(
host = "localhost",
port = 80,
tls = false,
displayUrl = "http://gateway.example:80",
displayUrl = "http://localhost:80",
),
parsed,
)
@@ -133,6 +260,43 @@ class GatewayConfigResolverTest {
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeRejectsNonLoopbackCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCode(setupCode)
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 =
@@ -155,9 +319,13 @@ class GatewayConfigResolverTest {
resolveGatewayConnectConfig(
useSetupCode = true,
setupCode = setupCode,
manualHost = "",
manualPort = "",
manualTls = true,
savedManualHost = "",
savedManualPort = "",
savedManualTls = true,
manualHostInput = "",
manualPortInput = "",
manualTlsInput = true,
fallbackBootstrapToken = "",
fallbackToken = "shared-token",
fallbackPassword = "shared-password",
)
@@ -179,9 +347,13 @@ class GatewayConfigResolverTest {
resolveGatewayConnectConfig(
useSetupCode = true,
setupCode = setupCode,
manualHost = "",
manualPort = "",
manualTls = true,
savedManualHost = "",
savedManualPort = "",
savedManualTls = true,
manualHostInput = "",
manualPortInput = "",
manualTlsInput = true,
fallbackBootstrapToken = "",
fallbackToken = "shared-token",
fallbackPassword = "shared-password",
)
@@ -194,6 +366,96 @@ class GatewayConfigResolverTest {
assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
}
@Test
fun resolveGatewayConnectConfigManualPreservesBootstrapTokenWhenNoReplacementAuthExists() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "127.0.0.1",
savedManualPort = "18789",
savedManualTls = false,
manualHostInput = "127.0.0.1",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "",
)
assertEquals("127.0.0.1", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
assertEquals("bootstrap-1", resolved?.bootstrapToken)
assertEquals("", resolved?.token)
assertEquals("", resolved?.password)
}
@Test
fun resolveGatewayConnectConfigManualDropsBootstrapTokenWhenReplacementPasswordExists() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "127.0.0.1",
savedManualPort = "18789",
savedManualTls = false,
manualHostInput = "127.0.0.1",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "password-1",
)
assertEquals("", resolved?.bootstrapToken)
assertEquals("", resolved?.token)
assertEquals("password-1", resolved?.password)
}
@Test
fun resolveGatewayConnectConfigManualDropsBootstrapTokenWhenEndpointChanges() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "127.0.0.1",
savedManualPort = "18789",
savedManualTls = false,
manualHostInput = "127.0.0.2",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "",
)
assertEquals("", resolved?.bootstrapToken)
assertEquals("127.0.0.2", resolved?.host)
}
@Test
fun resolveGatewayConnectConfigAllowsPrivateLanManualCleartextEndpoint() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
setupCode = "",
savedManualHost = "",
savedManualPort = "",
savedManualTls = false,
manualHostInput = "192.168.31.100",
manualPortInput = "18789",
manualTlsInput = false,
fallbackBootstrapToken = "bootstrap-1",
fallbackToken = "",
fallbackPassword = "",
)
assertEquals("192.168.31.100", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
}
private fun encodeSetupCode(payloadJson: String): String {
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
}

View File

@@ -0,0 +1,27 @@
package ai.openclaw.app.ui
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OnboardingFlowLogicTest {
@Test
fun blocksFinishWhenOnlyOperatorIsConnected() {
assertFalse(canFinishOnboarding(isConnected = true, isNodeConnected = false))
}
@Test
fun blocksFinishWhenDisconnected() {
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = false))
}
@Test
fun blocksFinishWhenOnlyNodeIsConnected() {
assertFalse(canFinishOnboarding(isConnected = false, isNodeConnected = true))
}
@Test
fun allowsFinishOnlyWhenOperatorAndNodeAreConnected() {
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true))
}
}

View File

@@ -0,0 +1,35 @@
package ai.openclaw.app.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class SettingsSheetNotificationAppsTest {
@Test
fun resolveNotificationCandidatePackages_keepsConfiguredPackagesVisible() {
val packages =
resolveNotificationCandidatePackages(
launcherPackages = setOf("com.example.launcher"),
recentPackages = listOf("com.example.recent", "com.example.launcher"),
configuredPackages = setOf("com.example.configured"),
appPackageName = "ai.openclaw.app",
)
assertEquals(
setOf("com.example.launcher", "com.example.recent", "com.example.configured"),
packages,
)
}
@Test
fun resolveNotificationCandidatePackages_filtersBlankAndSelfPackages() {
val packages =
resolveNotificationCandidatePackages(
launcherPackages = setOf(" ", "ai.openclaw.app"),
recentPackages = listOf("com.example.recent", " "),
configuredPackages = setOf("ai.openclaw.app", "com.example.configured"),
appPackageName = "ai.openclaw.app",
)
assertEquals(setOf("com.example.recent", "com.example.configured"), packages)
}
}

View File

@@ -0,0 +1,44 @@
package ai.openclaw.app.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatComposerDraftTest {
@Test
fun clearsLastAppliedDraftWhenViewModelDraftResets() {
val consumed =
applyDraftText(
draftText = "repeat this",
currentInput = "",
lastAppliedDraft = null,
)
assertTrue(consumed.consumed)
assertEquals("repeat this", consumed.input)
assertEquals("repeat this", consumed.lastAppliedDraft)
val cleared =
applyDraftText(
draftText = null,
currentInput = consumed.input,
lastAppliedDraft = consumed.lastAppliedDraft,
)
assertFalse(cleared.consumed)
assertEquals("repeat this", cleared.input)
assertEquals(null, cleared.lastAppliedDraft)
val repeated =
applyDraftText(
draftText = "repeat this",
currentInput = cleared.input,
lastAppliedDraft = cleared.lastAppliedDraft,
)
assertTrue(repeated.consumed)
assertEquals("repeat this", repeated.input)
assertEquals("repeat this", repeated.lastAppliedDraft)
}
}

View File

@@ -0,0 +1,72 @@
package ai.openclaw.app.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlinx.coroutines.runBlocking
class ChatSheetContentTest {
@Test
fun resolvesPendingAssistantAutoSendOnlyWhenChatIsReady() {
assertNull(
resolvePendingAssistantAutoSend(
pendingPrompt = "summarize mail",
healthOk = false,
pendingRunCount = 0,
),
)
assertNull(
resolvePendingAssistantAutoSend(
pendingPrompt = "summarize mail",
healthOk = true,
pendingRunCount = 1,
),
)
assertEquals(
"summarize mail",
resolvePendingAssistantAutoSend(
pendingPrompt = " summarize mail ",
healthOk = true,
pendingRunCount = 0,
),
)
}
@Test
fun keepsPendingAssistantAutoSendWhenDispatchRejected() = runBlocking {
var dispatchedPrompt: String? = null
val consumed =
dispatchPendingAssistantAutoSend(
pendingPrompt = "summarize mail",
healthOk = true,
pendingRunCount = 0,
) { prompt ->
dispatchedPrompt = prompt
false
}
assertFalse(consumed)
assertEquals("summarize mail", dispatchedPrompt)
}
@Test
fun clearsPendingAssistantAutoSendOnlyAfterAcceptedDispatch() = runBlocking {
var dispatchedPrompt: String? = null
val consumed =
dispatchPendingAssistantAutoSend(
pendingPrompt = "summarize mail",
healthOk = true,
pendingRunCount = 0,
) { prompt ->
dispatchedPrompt = prompt
true
}
assertTrue(consumed)
assertEquals("summarize mail", dispatchedPrompt)
}
}

View File

@@ -1,8 +1,8 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 2026.3.29
OPENCLAW_MARKETING_VERSION = 2026.3.29
OPENCLAW_BUILD_VERSION = 2026032900
OPENCLAW_GATEWAY_VERSION = 2026.4.3
OPENCLAW_MARKETING_VERSION = 2026.4.3
OPENCLAW_BUILD_VERSION = 2026040301
#include? "../build/Version.xcconfig"

View File

@@ -65,9 +65,9 @@ Release behavior:
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Root `package.json.version` is the only version source for iOS.
- A root version like `2026.3.29-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.29`
- `CFBundleVersion = next TestFlight build number for 2026.3.29`
- A root version like `2026.4.1-beta.1` becomes:
- `CFBundleShortVersionString = 2026.4.1`
- `CFBundleVersion = next TestFlight build number for 2026.4.1`
Required env for beta builds:

View File

@@ -69,6 +69,13 @@ enum GatewaySettingsStore {
account: self.preferredGatewayStableIDAccount)
}
static func clearPreferredGatewayStableID(defaults: UserDefaults = .standard) {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.preferredGatewayStableIDAccount)
defaults.removeObject(forKey: self.preferredGatewayStableIDDefaultsKey)
}
static func loadLastDiscoveredGatewayStableID() -> String? {
if let value = KeychainStore.loadString(
service: self.gatewayService,
@@ -89,6 +96,13 @@ enum GatewaySettingsStore {
account: self.lastDiscoveredGatewayStableIDAccount)
}
static func clearLastDiscoveredGatewayStableID(defaults: UserDefaults = .standard) {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.lastDiscoveredGatewayStableIDAccount)
defaults.removeObject(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
}
static func loadGatewayToken(instanceId: String) -> String? {
let account = self.gatewayTokenAccount(instanceId: instanceId)
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
@@ -119,6 +133,12 @@ enum GatewaySettingsStore {
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
}
static func clearGatewayBootstrapToken(instanceId: String) {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
}
static func loadGatewayPassword(instanceId: String) -> String? {
KeychainStore.loadString(
service: self.gatewayService,

View File

@@ -1,4 +1,4 @@
import ActivityKit
@preconcurrency import ActivityKit
import Foundation
import os

View File

@@ -33,6 +33,19 @@ extension NodeAppModel {
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"
}
/// Normalize a URL string for trust comparison: lowercase scheme/host and strip fragment.
/// This matches the normalization applied by ScreenController.isTrustedCanvasUIURL so that
/// SPA hash-routing fragments and scheme/host casing do not silently prevent trust being set.
static func normalizeURLForTrustComparison(_ raw: String) -> String {
guard let url = URL(string: raw),
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return raw }
components.fragment = nil
components.scheme = components.scheme?.lowercased()
components.host = components.host?.lowercased()
return components.url?.absoluteString ?? raw
}
func showA2UIOnConnectIfNeeded() async {
await MainActor.run {
// Keep the bundled home canvas as the default connected view.
@@ -46,7 +59,7 @@ extension NodeAppModel {
guard let initialUrl = await self.resolveA2UIHostURLWithCapabilityRefresh() else {
return .hostNotConfigured
}
self.screen.navigate(to: initialUrl)
self.screen.navigate(to: initialUrl, trustA2UIActions: true)
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
return .ready(initialUrl)
}
@@ -54,7 +67,7 @@ extension NodeAppModel {
// First render can fail when scoped capability rotates between reconnects.
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return .hostUnavailable }
guard let refreshedUrl = await self.resolveA2UIHostURL() else { return .hostUnavailable }
self.screen.navigate(to: refreshedUrl)
self.screen.navigate(to: refreshedUrl, trustA2UIActions: true)
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
return .ready(refreshedUrl)
}

View File

@@ -851,7 +851,8 @@ final class NodeAppModel {
if url.isEmpty {
self.screen.showDefaultCanvas()
} else {
self.screen.navigate(to: url)
let trustedA2UIURL = await self.resolveA2UIHostURL()
self.screen.navigate(to: url, trustA2UIActions: trustedA2UIURL == Self.normalizeURLForTrustComparison(url))
}
return BridgeInvokeResponse(id: req.id, ok: true)
case OpenClawCanvasCommand.hide.rawValue:
@@ -859,7 +860,9 @@ final class NodeAppModel {
return BridgeInvokeResponse(id: req.id, ok: true)
case OpenClawCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON)
self.screen.navigate(to: params.url)
let trimmedURL = params.url.trimmingCharacters(in: .whitespacesAndNewlines)
let trustedA2UIURL = await self.resolveA2UIHostURL()
self.screen.navigate(to: trimmedURL, trustA2UIActions: trustedA2UIURL == Self.normalizeURLForTrustComparison(trimmedURL))
return BridgeInvokeResponse(id: req.id, ok: true)
case OpenClawCanvasCommand.evalJS.rawValue:
let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON)
@@ -1697,14 +1700,24 @@ extension NodeAppModel {
password: password,
nodeOptions: connectOptions)
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
self.startOperatorGatewayLoop(
url: url,
stableID: effectiveStableID,
if self.shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions,
sessionBox: sessionBox)
stableID: effectiveStableID)
{
self.startOperatorGatewayLoop(
url: url,
stableID: effectiveStableID,
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions,
sessionBox: sessionBox)
} else {
self.operatorGatewayTask = nil
Task { await self.operatorGateway.disconnect() }
}
self.startNodeGatewayLoop(
url: url,
stableID: effectiveStableID,
@@ -1785,6 +1798,86 @@ private extension NodeAppModel {
self.apnsLastRegisteredTokenHex = nil
}
func shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
stableID _: String) -> Bool
{
Self.shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: bootstrapToken,
password: password,
hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator"))
}
func hasStoredGatewayRoleToken(_ role: String) -> Bool {
let identity = DeviceIdentityStore.loadOrCreate()
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
}
static func shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
hasStoredOperatorToken: Bool) -> Bool
{
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedToken.isEmpty {
return true
}
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPassword.isEmpty {
return true
}
let trimmedBootstrapToken = bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedBootstrapToken.isEmpty {
return false
}
return hasStoredOperatorToken
}
static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
guard let config else { return nil }
let trimmedBootstrapToken = config.bootstrapToken?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmedBootstrapToken.isEmpty else { return config }
return GatewayConnectConfig(
url: config.url,
stableID: config.stableID,
tls: config.tls,
token: config.token,
bootstrapToken: nil,
password: config.password,
nodeOptions: config.nodeOptions)
}
func currentGatewayReconnectAuth(
fallbackToken: String?,
fallbackBootstrapToken: String?,
fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
{
if let cfg = self.activeGatewayConnectConfig {
return (cfg.token, cfg.bootstrapToken, cfg.password)
}
return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
}
func clearPersistedGatewayBootstrapTokenIfNeeded() {
// Always drop the in-memory bootstrap token after the first successful
// bootstrap connect so reconnect loops cannot reuse a spent token.
self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig)
let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmedInstanceId.isEmpty else { return }
guard
GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: trimmedInstanceId) != nil
else { return }
GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId)
}
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
guard self.isBackgrounded else { return }
guard !self.backgroundReconnectSuppressed else { return }
@@ -1841,11 +1934,15 @@ private extension NodeAppModel {
displayName: nodeOptions.clientDisplayName)
do {
let reconnectAuth = self.currentGatewayReconnectAuth(
fallbackToken: token,
fallbackBootstrapToken: bootstrapToken,
fallbackPassword: password)
try await self.operatorGateway.connect(
url: url,
token: token,
bootstrapToken: bootstrapToken,
password: password,
token: reconnectAuth.token,
bootstrapToken: reconnectAuth.bootstrapToken,
password: reconnectAuth.password,
connectOptions: operatorOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
@@ -1948,12 +2045,16 @@ private extension NodeAppModel {
do {
let epochMs = Int(Date().timeIntervalSince1970 * 1000)
let reconnectAuth = self.currentGatewayReconnectAuth(
fallbackToken: token,
fallbackBootstrapToken: bootstrapToken,
fallbackPassword: password)
GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)")
try await self.nodeGateway.connect(
url: url,
token: token,
bootstrapToken: bootstrapToken,
password: password,
token: reconnectAuth.token,
bootstrapToken: reconnectAuth.bootstrapToken,
password: reconnectAuth.password,
connectOptions: currentOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
@@ -1965,6 +2066,30 @@ private extension NodeAppModel {
self.screen.errorText = nil
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
}
let usedBootstrapToken =
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty == false
if usedBootstrapToken {
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 {
(
sessionKey: self.mainSessionKey,
@@ -1975,8 +2100,8 @@ private extension NodeAppModel {
ShareGatewayRelaySettings.saveConfig(
ShareGatewayRelayConfig(
gatewayURLString: url.absoluteString,
token: token,
password: password,
token: reconnectAuth.token,
password: reconnectAuth.password,
sessionKey: relayData.sessionKey,
deliveryChannel: relayData.deliveryChannel,
deliveryTo: relayData.deliveryTo))
@@ -3015,6 +3140,20 @@ extension NodeAppModel {
static func _test_currentDeepLinkKey() -> String {
self.expectedDeepLinkKey()
}
static func _test_shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
hasStoredOperatorToken: Bool) -> Bool
{
self.shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: bootstrapToken,
password: password,
hasStoredOperatorToken: hasStoredOperatorToken)
}
}
#endif
// swiftlint:enable type_body_length file_length

View File

@@ -7,6 +7,7 @@ import WebKit
@Observable
final class ScreenController {
private weak var activeWebView: WKWebView?
private var trustedRemoteA2UIURL: URL?
var urlString: String = ""
var errorText: String?
@@ -26,10 +27,11 @@ final class ScreenController {
self.reload()
}
func navigate(to urlString: String) {
func navigate(to urlString: String, trustA2UIActions: Bool = false) {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
self.urlString = ""
self.trustedRemoteA2UIURL = nil
self.reload()
return
}
@@ -43,6 +45,7 @@ final class ScreenController {
return
}
self.urlString = (trimmed == "/" ? "" : trimmed)
self.trustedRemoteA2UIURL = trustA2UIActions ? Self.normalizeTrustedRemoteA2UIURL(from: trimmed) : nil
self.reload()
}
@@ -72,6 +75,7 @@ final class ScreenController {
func showDefaultCanvas() {
self.urlString = ""
self.trustedRemoteA2UIURL = nil
self.reload()
}
@@ -237,28 +241,17 @@ final class ScreenController {
subdirectory: "CanvasScaffold")
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
guard url.isFileURL else { return false }
let std = url.standardizedFileURL
if let expected = Self.canvasScaffoldURL,
std == expected.standardizedFileURL
{
return true
if url.isFileURL {
let std = url.standardizedFileURL
if let expected = Self.canvasScaffoldURL,
std == expected.standardizedFileURL
{
return true
}
return false
}
return false
}
private func applyScrollBehavior() {
guard let webView = self.activeWebView else { return }
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
let allowScroll = !trimmed.isEmpty
let scrollView = webView.scrollView
// Default canvas needs raw touch events; external pages should scroll.
scrollView.isScrollEnabled = allowScroll
scrollView.bounces = allowScroll
}
func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
guard let trusted = self.trustedRemoteA2UIURL else { return false }
return Self.normalizeTrustedRemoteA2UIURL(from: url) == trusted
}
nonisolated static func parseA2UIActionBody(_ body: Any) -> [String: Any]? {
@@ -278,6 +271,36 @@ final class ScreenController {
}
return nil
}
private func applyScrollBehavior() {
guard let webView = self.activeWebView else { return }
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
let allowScroll = !trimmed.isEmpty
let scrollView = webView.scrollView
// Default canvas needs raw touch events; external pages should scroll.
scrollView.isScrollEnabled = allowScroll
scrollView.bounces = allowScroll
}
private static func normalizeTrustedRemoteA2UIURL(from raw: String) -> URL? {
guard let url = URL(string: raw) else { return nil }
return self.normalizeTrustedRemoteA2UIURL(from: url)
}
private static func normalizeTrustedRemoteA2UIURL(from url: URL) -> URL? {
guard !url.isFileURL else { return nil }
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return nil
}
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
return nil
}
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.scheme = scheme
components?.host = host.lowercased()
components?.fragment = nil
return components?.url
}
}
extension Double {

View File

@@ -180,12 +180,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
guard let controller else { return }
guard let url = message.webView?.url else { return }
if url.isFileURL {
guard controller.isTrustedCanvasUIURL(url) else { return }
} else {
// For security, only accept actions from local-network pages (e.g. the canvas host).
guard controller.isLocalNetworkCanvasURL(url) else { return }
}
guard controller.isTrustedCanvasUIURL(url) else { return }
guard let body = ScreenController.parseA2UIActionBody(message.body) else { return }

View File

@@ -1008,6 +1008,11 @@ struct SettingsTab: View {
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
GatewaySettingsStore.clearLastGatewayConnection()
GatewaySettingsStore.clearPreferredGatewayStableID()
GatewaySettingsStore.clearLastDiscoveredGatewayStableID()
// Resetting onboarding should also forget trusted gateway TLS fingerprints.
// Otherwise a restarted dev gateway can stay stuck in a local TLS cancel loop.
GatewayTLSStore.clearAllFingerprints()
OnboardingStateStore.reset()
// RootCanvas also short-circuits onboarding when these are true.

View File

@@ -5,6 +5,7 @@ import Testing
@testable import OpenClaw
@Suite(.serialized) struct GatewayConnectionSecurityTests {
@MainActor
private func makeController() -> GatewayConnectionController {
GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
}
@@ -32,8 +33,7 @@ import Testing
}
private func clearTLSFingerprint(stableID: String) {
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
suite.removeObject(forKey: "gateway.tls.\(stableID)")
GatewayTLSStore.clearFingerprint(stableID: stableID)
}
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
@@ -126,4 +126,21 @@ import Testing
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net.", port: 0, useTLS: true) == 443)
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789)
}
@Test @MainActor func clearAllTLSFingerprints_removesStoredPins() async {
let stableID1 = "test|\(UUID().uuidString)"
let stableID2 = "test|\(UUID().uuidString)"
defer { GatewayTLSStore.clearAllFingerprints() }
GatewayTLSStore.saveFingerprint("11", stableID: stableID1)
GatewayTLSStore.saveFingerprint("22", stableID: stableID2)
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == "11")
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == "22")
GatewayTLSStore.clearAllFingerprints()
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == nil)
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == nil)
}
}

View File

@@ -96,6 +96,64 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
#expect(appModel.mainSessionKey == "agent:agent-123:main")
}
@Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() {
#expect(
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
hasStoredOperatorToken: true)
)
#expect(
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
token: nil,
bootstrapToken: nil,
password: nil,
hasStoredOperatorToken: false)
)
#expect(
NodeAppModel._test_shouldStartOperatorGatewayLoop(
token: nil,
bootstrapToken: nil,
password: nil,
hasStoredOperatorToken: true)
)
#expect(
NodeAppModel._test_shouldStartOperatorGatewayLoop(
token: "shared-token",
bootstrapToken: "fresh-bootstrap-token",
password: nil,
hasStoredOperatorToken: false)
)
}
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() {
let config = GatewayConnectConfig(
url: URL(string: "wss://gateway.example")!,
stableID: "test-gateway",
tls: nil,
token: nil,
bootstrapToken: "spent-bootstrap-token",
password: nil,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: nil))
let cleared = NodeAppModel.clearingBootstrapToken(in: config)
#expect(cleared?.bootstrapToken == nil)
#expect(cleared?.url == config.url)
#expect(cleared?.stableID == config.stableID)
#expect(cleared?.token == config.token)
#expect(cleared?.password == config.password)
#expect(cleared?.nodeOptions.role == config.nodeOptions.role)
}
@Test @MainActor func handleInvokeRejectsBackgroundCommands() async {
let appModel = NodeAppModel()
appModel.setScenePhase(.background)

View File

@@ -66,17 +66,26 @@ private func mountScreen(_ screen: ScreenController) throws -> (ScreenWebViewCoo
}
}
@Test @MainActor func localNetworkCanvasURLsAreAllowed() {
@Test @MainActor func trustedRemoteA2UIURLMustMatchExactly() {
let screen = ScreenController()
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://openclaw.local:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://peters-mac-studio-1:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "https://peters-mac-studio-1.ts.net:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://192.168.0.10:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.0.10:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://100.123.224.76:18789/")!) == true) // Tailscale CGNAT
#expect(screen.isLocalNetworkCanvasURL(URL(string: "https://example.com/")!) == false)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://8.8.8.8/")!) == false)
let trusted = "https://node.ts.net:18789/__openclaw__/a2ui/?platform=ios"
screen.navigate(to: trusted, trustA2UIActions: true)
#expect(screen.isTrustedCanvasUIURL(URL(string: trusted)!) == true)
// Fragment differences must not affect trust (SPA hash routing).
#expect(screen.isTrustedCanvasUIURL(URL(string: "https://node.ts.net:18789/__openclaw__/a2ui/?platform=ios#step2")!) == true)
#expect(screen.isTrustedCanvasUIURL(URL(string: "https://node.ts.net:18789/__openclaw__/a2ui/?platform=android")!) == false)
#expect(screen.isTrustedCanvasUIURL(URL(string: "https://node.ts.net:18789/__openclaw__/canvas/")!) == false)
#expect(screen.isTrustedCanvasUIURL(URL(string: "https://evil.ts.net:18789/__openclaw__/a2ui/?platform=ios")!) == false)
#expect(screen.isTrustedCanvasUIURL(URL(string: "http://192.168.0.10:18789/")!) == false)
}
@Test @MainActor func genericNavigationClearsTrustedRemoteA2UIURL() {
let screen = ScreenController()
screen.navigate(to: "https://node.ts.net:18789/__openclaw__/a2ui/?platform=ios", trustA2UIActions: true)
screen.navigate(to: "https://evil.ts.net:18789/")
#expect(screen.isTrustedCanvasUIURL(URL(string: "https://node.ts.net:18789/__openclaw__/a2ui/?platform=ios")!) == false)
}
@Test func parseA2UIActionBodyAcceptsJSONString() throws {

View File

@@ -136,6 +136,17 @@ final class AppState {
forKey: voicePushToTalkEnabledKey) } }
}
var voiceWakeTriggersTalkMode: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeTriggersTalkMode, forKey: voiceWakeTriggersTalkModeKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
}
var talkEnabled: Bool {
didSet {
self.ifNotPreview {
@@ -275,6 +286,8 @@ final class AppState {
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
self.voicePushToTalkEnabled = UserDefaults.standard
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
self.voiceWakeTriggersTalkMode = UserDefaults.standard
.object(forKey: voiceWakeTriggersTalkModeKey) as? Bool ?? false
self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
self.seamColorHex = nil
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {

View File

@@ -22,6 +22,7 @@ let voiceWakeMicNameKey = "openclaw.voiceWakeMicName"
let voiceWakeLocaleKey = "openclaw.voiceWakeLocaleID"
let voiceWakeAdditionalLocalesKey = "openclaw.voiceWakeAdditionalLocaleIDs"
let voicePushToTalkEnabledKey = "openclaw.voicePushToTalkEnabled"
let voiceWakeTriggersTalkModeKey = "openclaw.voiceWakeTriggersTalkMode"
let talkEnabledKey = "openclaw.talkEnabled"
let iconOverrideKey = "openclaw.iconOverride"
let connectionModeKey = "openclaw.connectionMode"

View File

@@ -558,12 +558,16 @@ extension GatewayConnection {
func skillsInstall(
name: String,
installId: String,
dangerouslyForceUnsafeInstall: Bool? = nil,
timeoutMs: Int? = nil) async throws -> SkillInstallResult
{
var params: [String: AnyCodable] = [
"name": AnyCodable(name),
"installId": AnyCodable(installId),
]
if let dangerouslyForceUnsafeInstall {
params["dangerouslyForceUnsafeInstall"] = AnyCodable(dangerouslyForceUnsafeInstall)
}
if let timeoutMs {
params["timeoutMs"] = AnyCodable(timeoutMs)
}
@@ -614,11 +618,13 @@ extension GatewayConnection {
func chatHistory(
sessionKey: String,
limit: Int? = nil,
maxChars: Int? = nil,
timeoutMs: Int? = nil) async throws -> OpenClawChatHistoryPayload
{
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
if let limit { params["limit"] = AnyCodable(limit) }
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
let timeout = timeoutMs.map { Double($0) }
return try await self.requestDecoded(
method: .chatHistory,

View File

@@ -16,9 +16,20 @@ enum HostEnvSecurityPolicy {
"RUBYOPT",
"BASH_ENV",
"ENV",
"BROWSER",
"GIT_EDITOR",
"GIT_EXTERNAL_DIFF",
"GIT_EXEC_PATH",
"GIT_SEQUENCE_EDITOR",
"GIT_TEMPLATE_DIR",
"GIT_SSL_NO_VERIFY",
"GIT_SSL_CAINFO",
"GIT_SSL_CAPATH",
"CC",
"CXX",
"CARGO_BUILD_RUSTC",
"CMAKE_C_COMPILER",
"CMAKE_CXX_COMPILER",
"SHELL",
"SHELLOPTS",
"PS4",
@@ -46,6 +57,9 @@ enum HostEnvSecurityPolicy {
"GIT_SSH",
"GIT_PROXY_COMMAND",
"GIT_ASKPASS",
"GIT_SSL_NO_VERIFY",
"GIT_SSL_CAINFO",
"GIT_SSL_CAPATH",
"SSH_ASKPASS",
"LESSOPEN",
"LESSCLOSE",
@@ -74,6 +88,54 @@ enum HostEnvSecurityPolicy {
"PHP_INI_SCAN_DIR",
"DENO_DIR",
"BUN_CONFIG_REGISTRY",
"YARN_RC_FILENAME",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"NODE_TLS_REJECT_UNAUTHORIZED",
"NODE_EXTRA_CA_CERTS",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
"DOCKER_HOST",
"DOCKER_TLS_VERIFY",
"DOCKER_CERT_PATH",
"PIP_INDEX_URL",
"PIP_PYPI_URL",
"PIP_EXTRA_INDEX_URL",
"PIP_CONFIG_FILE",
"PIP_FIND_LINKS",
"PIP_TRUSTED_HOST",
"UV_INDEX",
"UV_INDEX_URL",
"UV_PYTHON",
"UV_EXTRA_INDEX_URL",
"UV_DEFAULT_INDEX",
"DOCKER_HOST",
"DOCKER_TLS_VERIFY",
"DOCKER_CERT_PATH",
"DOCKER_CONTEXT",
"LIBRARY_PATH",
"CPATH",
"C_INCLUDE_PATH",
"CPLUS_INCLUDE_PATH",
"OBJC_INCLUDE_PATH",
"NODE_EXTRA_CA_CERTS",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
"GOPROXY",
"GONOSUMCHECK",
"GONOSUMDB",
"GONOPROXY",
"GOPRIVATE",
"GOENV",
"GOPATH",
"PYTHONUSERBASE",
"VIRTUAL_ENV",
"LUA_PATH",
"LUA_CPATH",
"GEM_HOME",
@@ -86,7 +148,8 @@ enum HostEnvSecurityPolicy {
static let blockedOverridePrefixes: [String] = [
"GIT_CONFIG_",
"NPM_CONFIG_"
"NPM_CONFIG_",
"CARGO_REGISTRIES_"
]
static let blockedPrefixes: [String] = [

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.29</string>
<string>2026.4.3</string>
<key>CFBundleVersion</key>
<string>2026032900</string>
<string>2026040301</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -18,6 +18,12 @@ final class TalkModeController {
TalkOverlayController.shared.dismiss()
}
await TalkModeRuntime.shared.setEnabled(enabled)
// Resume voice wake listener *after* TalkMode audio is fully torn down.
// Check swabbleEnabled (not voiceWakeTriggersTalkMode) so the paused wake listener
// resumes even if the user toggled "Trigger Talk Mode" off during the session.
if !enabled, AppStateStore.shared.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) }
}
}
func updatePhase(_ phase: TalkModePhase) {

View File

@@ -335,6 +335,11 @@ actor TalkModeRuntime {
self.lastHeard = nil
self.phase = .thinking
await MainActor.run { TalkModeController.shared.updatePhase(.thinking) }
// Play "send" chime when the user's speech is finalized and about to be sent
let sendChime = await MainActor.run { AppStateStore.shared.voiceWakeSendChime }
if sendChime != .none {
await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "talk.send") }
}
await self.stopRecognition()
await self.sendAndSpeak(text)
}
@@ -456,6 +461,7 @@ actor TalkModeRuntime {
private func playAssistant(text: String) async {
guard let input = await self.preparePlaybackInput(text: text) else { return }
switch Self.playbackPlan(apiKey: input.apiKey, voiceId: input.voiceId) {
case let .elevenLabsThenSystemVoice(apiKey, voiceId):
do {

View File

@@ -82,6 +82,7 @@ actor VoiceWakeRuntime {
let localeID: String?
let triggerChime: VoiceWakeChime
let sendChime: VoiceWakeChime
let triggersTalkMode: Bool
}
private struct RecognitionUpdate {
@@ -100,7 +101,8 @@ actor VoiceWakeRuntime {
micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID,
localeID: state.voiceWakeLocaleID.isEmpty ? nil : state.voiceWakeLocaleID,
triggerChime: state.voiceWakeTriggerChime,
sendChime: state.voiceWakeSendChime)
sendChime: state.voiceWakeSendChime,
triggersTalkMode: state.voiceWakeTriggersTalkMode)
return (enabled, config)
}
@@ -529,6 +531,21 @@ actor VoiceWakeRuntime {
}
private func beginCapture(command: String, triggerEndTime: TimeInterval?, config: RuntimeConfig) async {
// When "Trigger Talk Mode" is enabled, skip the capture/overlay flow entirely
// and activate Talk Mode immediately. Talk Mode handles its own STT pipeline.
// Pause the wake listener to avoid two audio pipelines competing on the mic
// (mirrors the push-to-talk coordination pattern).
if config.triggersTalkMode {
self.logger.info("voicewake trigger -> activating Talk Mode (skipping capture)")
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "triggerTalkMode")
if config.triggerChime != .none {
await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "voicewake.trigger") }
}
self.pauseForPushToTalk()
await AppStateStore.shared.setTalkEnabled(true)
return
}
self.listeningState = .voiceWake
self.isCapturing = true
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")

View File

@@ -53,6 +53,16 @@ struct VoiceWakeSettings: View {
binding: self.voiceWakeBinding)
.disabled(!voiceWakeSupported)
SettingsToggleRow(
title: "Trigger Talk Mode",
subtitle: """
When a wake phrase is detected, activate Talk Mode for a full voice \
conversation (STT, LLM response, TTS playback) instead of sending a \
text message to the chat.
""",
binding: self.$state.voiceWakeTriggersTalkMode)
.disabled(!self.state.swabbleEnabled)
SettingsToggleRow(
title: "Hold Right Option to talk",
subtitle: """

View File

@@ -14,10 +14,11 @@ struct WideAreaGatewayBeacon: Equatable {
}
enum WideAreaGatewayDiscovery {
private static let maxCandidates = 40
private static let digPath = "/usr/bin/dig"
private static let defaultTimeoutSeconds: TimeInterval = 0.2
private static let nameserverProbeConcurrency = 6
// Security: wide-area discovery must trust only the Tailscale MagicDNS resolver.
// Probing arbitrary tailnet peers lets the fastest responder become DNS-SD authority.
private static let tailscaleDNSResolver = "100.100.100.100"
struct DiscoveryContext {
var tailscaleStatus: @Sendable () -> String?
@@ -39,27 +40,16 @@ enum WideAreaGatewayDiscovery {
timeoutSeconds - Date().timeIntervalSince(startedAt)
}
guard let ips = collectTailnetIPv4s(
statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
var candidates = Array(ips.prefix(self.maxCandidates))
guard let nameserver = findNameserver(
candidates: &candidates,
guard let statusJson = context.tailscaleStatus(),
!collectTailnetIPv4s(statusJson: statusJson).isEmpty,
let discovery = loadWideAreaPtrRecords(
remaining: remaining,
dig: context.dig)
else {
return []
}
else { return [] }
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return [] }
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_openclaw-gw._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
!ptrLines.isEmpty
else {
return []
}
let domainTrimmed = discovery.domainTrimmed
let ptrLines = discovery.ptrLines
let nameserver = self.tailscaleDNSResolver
var beacons: [WideAreaGatewayBeacon] = []
for raw in ptrLines {
@@ -148,68 +138,26 @@ enum WideAreaGatewayDiscovery {
return output
}
private static func findNameserver(
candidates: inout [String],
private static func loadWideAreaPtrRecords(
remaining: () -> TimeInterval,
dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String?
dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?)
-> (domainTrimmed: String, ptrLines: [Substring])?
{
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return nil }
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_openclaw-gw._tcp.\(domainTrimmed)"
let budget = max(0, remaining())
if budget <= 0 { return nil }
let ips = candidates
candidates.removeAll(keepingCapacity: true)
if ips.isEmpty { return nil }
final class ProbeState: @unchecked Sendable {
let lock = NSLock()
var nextIndex = 0
var found: String?
guard let stdout = dig(
["+short", "+time=1", "+tries=1", "@\(self.tailscaleDNSResolver)", probeName, "PTR"],
min(defaultTimeoutSeconds, budget)),
let ptrLines = stdout.split(whereSeparator: \.isNewline).nonEmpty
else {
return nil
}
let state = ProbeState()
let deadline = Date().addingTimeInterval(max(0, remaining()))
let workerCount = min(self.nameserverProbeConcurrency, ips.count)
let group = DispatchGroup()
for _ in 0..<workerCount {
group.enter()
DispatchQueue.global(qos: .utility).async {
defer { group.leave() }
while Date() < deadline {
state.lock.lock()
if state.found != nil {
state.lock.unlock()
return
}
let i = state.nextIndex
state.nextIndex += 1
state.lock.unlock()
if i >= ips.count { return }
let ip = ips[i]
let budget = deadline.timeIntervalSinceNow
if budget <= 0 { return }
if let stdout = dig(
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
min(defaultTimeoutSeconds, budget)),
stdout.split(whereSeparator: \.isNewline).isEmpty == false
{
state.lock.lock()
if state.found == nil {
state.found = ip
}
state.lock.unlock()
return
}
}
}
}
_ = group.wait(timeout: .now() + max(0.0, remaining()))
return state.found
return (domainTrimmed, ptrLines)
}
private static func runDig(args: [String], timeout: TimeInterval) -> String? {

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?
@@ -3710,18 +3782,22 @@ public struct DevicePairResolvedEvent: Codable, Sendable {
public struct ChatHistoryParams: Codable, Sendable {
public let sessionkey: String
public let limit: Int?
public let maxchars: Int?
public init(
sessionkey: String,
limit: Int?)
limit: Int?,
maxchars: Int?)
{
self.sessionkey = sessionkey
self.limit = limit
self.maxchars = maxchars
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case limit
case maxchars = "maxChars"
}
}

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