Compare commits

..

397 Commits

Author SHA1 Message Date
Tak Hoffman
91625aa9f3 feat capability CLI on latest main 2026-04-06 17:30:59 -05:00
Peter Steinberger
80c8567f9d fix: resolve merge conflicts and preserve runtime test fixes 2026-04-06 22:46:33 +01:00
Peter Steinberger
9d7459f182 fix(auth): recover Codex OAuth refresh after store reload
Co-authored-by: Owen <oh.whenever@gmail.com>
2026-04-06 22:45:04 +01:00
Peter Steinberger
f7109c15f5 refactor: dedupe core account record helpers 2026-04-06 22:44:14 +01:00
Peter Steinberger
16ec0b5a8c style: format doctor memory search helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
5a4ca2f608 refactor: dedupe doctor memory record helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
223a6a1d9f refactor: dedupe doctor channel record helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
b1905c1423 refactor: dedupe qmd manager record helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
9bee2a4ede refactor: dedupe feishu security record helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
0cc4f50576 refactor: dedupe tlon monitor string helper 2026-04-06 22:44:14 +01:00
Peter Steinberger
e88c39b0a1 refactor: dedupe memory-core error formatting 2026-04-06 22:44:14 +01:00
Peter Steinberger
1ad4926839 test(ci): update web provider artifact runtime expectations 2026-04-06 22:33:42 +01:00
Peter Steinberger
5ab1b16098 test: add cli backend live image probe 2026-04-06 22:33:05 +01:00
Peter Steinberger
d56e343d30 refactor: dedupe tlon monitor error formatting 2026-04-06 22:32:52 +01:00
Peter Steinberger
daa0a755df refactor: dedupe xai x search config record helper 2026-04-06 22:32:52 +01:00
Peter Steinberger
d780eb1301 refactor: dedupe xai web search record helper 2026-04-06 22:32:52 +01:00
Peter Steinberger
7ebd78cf1b refactor: dedupe tlon monitor record helper 2026-04-06 22:32:52 +01:00
Peter Steinberger
bd71ddabbd refactor: dedupe xai setup record helper 2026-04-06 22:32:52 +01:00
Peter Steinberger
9ba1b91936 refactor: dedupe feishu monitor string helper 2026-04-06 22:32:52 +01:00
Peter Steinberger
d0a1ecb768 refactor: dedupe feishu monitor record helper 2026-04-06 22:32:52 +01:00
Peter Steinberger
61fbc9ad2e refactor: dedupe memory-core record helper 2026-04-06 22:32:52 +01:00
Peter Steinberger
5417d88569 refactor: dedupe memory dreaming normalizers 2026-04-06 22:32:52 +01:00
Vincent Koc
377637ca67 perf(secrets): load bundled web providers from public artifacts 2026-04-06 22:31:10 +01:00
Peter Steinberger
1a63f5b972 fix: preserve plugin auto-enable activation context 2026-04-06 22:28:45 +01:00
Peter Steinberger
0a34c40e10 fix: restore legacy config snapshot compatibility 2026-04-06 22:28:45 +01:00
Vincent Koc
58696ef3a2 fix(ci): restore bundled vitest lane 2026-04-06 22:22:41 +01:00
Peter Steinberger
238d9a6510 refactor: dedupe feishu record helper 2026-04-06 22:21:01 +01:00
Peter Steinberger
c390e7c6ce refactor: dedupe acp text normalization helper 2026-04-06 22:21:01 +01:00
Peter Steinberger
961f527842 refactor: dedupe cli tagline mode parser 2026-04-06 22:21:01 +01:00
Peter Steinberger
1a08d23e09 refactor: dedupe finite number coercion helper 2026-04-06 22:21:01 +01:00
Peter Steinberger
cfebdee073 refactor: dedupe qa cli shutdown handling 2026-04-06 22:21:01 +01:00
Peter Steinberger
5f7fa588db refactor: dedupe memory wiki cli summary runners 2026-04-06 22:21:01 +01:00
Peter Steinberger
3700f3a22c refactor: dedupe lobster managed flow tool handling 2026-04-06 22:21:01 +01:00
Peter Steinberger
a41e50efbc refactor: dedupe plugin contract string normalization 2026-04-06 22:21:00 +01:00
Peter Steinberger
106b2794c5 refactor: dedupe webhook optional field mapping 2026-04-06 22:21:00 +01:00
Peter Steinberger
1a893132f6 refactor: dedupe qa mock input text extraction 2026-04-06 22:21:00 +01:00
Peter Steinberger
efd9aaea3f refactor: dedupe memory wiki source page writes 2026-04-06 22:21:00 +01:00
Peter Steinberger
79a84f070d refactor: dedupe google video size parsing 2026-04-06 22:21:00 +01:00
Peter Steinberger
c03071d36c refactor: dedupe sdk chat metadata builder 2026-04-06 22:21:00 +01:00
Peter Steinberger
7a3497c8bd refactor: dedupe image generation runtime surface 2026-04-06 22:21:00 +01:00
Peter Steinberger
bda7131367 refactor: dedupe plugin enable-state wrappers 2026-04-06 22:21:00 +01:00
Vincent Koc
c3f806c9e4 perf(secrets): lighten channel contract loading 2026-04-06 22:17:32 +01:00
Vincent Koc
e92c2b63f9 fix(memory-core): align embedding cache db typing 2026-04-06 22:16:12 +01:00
Devin Robison
48a3511233 fix: lower trust background runtime output is injecte (#327) (#62111)
* fix: lower trust background runtime output is injecte (#327)

* fix: lower trust background runtime output is injecte (#327)

---------

Co-authored-by: OpenClaw Dummy Agent <octriage-dummy@example.invalid>
2026-04-06 15:14:52 -06:00
Tak Hoffman
079494aee5 fix: normalize cached prompt token accounting 2026-04-06 16:11:51 -05:00
Peter Steinberger
a29b501ec9 perf(test): fix unit shard config regressions 2026-04-06 22:09:03 +01:00
Peter Steinberger
4ae1599ea5 perf: extract memory adapter registration helper 2026-04-06 22:04:23 +01:00
Peter Steinberger
d806682f78 perf: extract memory embedding state helpers 2026-04-06 22:04:23 +01:00
Peter Steinberger
0e05a304b6 fix(ci): repair main gate drift 2026-04-06 21:59:48 +01:00
Vincent Koc
b96589b1fc fix(memory-core): align vector write db typing 2026-04-06 21:54:32 +01:00
Vincent Koc
c7e0150af2 fix(contracts): update plugin-sdk boundary expectations 2026-04-06 21:52:05 +01:00
Peter Steinberger
c6b54e1cef perf(auto-reply): trim command runtime paths 2026-04-06 21:34:26 +01:00
Peter Steinberger
ef252976bc fix(plugins): harden doctor contract record guards 2026-04-06 21:34:26 +01:00
Peter Steinberger
c9f288ceaf perf: extract memory atomic reindex helpers 2026-04-06 21:28:29 +01:00
Peter Steinberger
6b6c95b443 perf: extract memory sqlite write helpers 2026-04-06 21:28:29 +01:00
Peter Steinberger
ca27d932b4 perf: extract memory search preflight helpers 2026-04-06 21:28:29 +01:00
Agustin Rivera
5b6e552b51 fix(hooks): mark wake hook events untrusted (#62003)
* fix(hooks): mark wake hook events untrusted

* docs(changelog): add wake-hook trust entry

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-06 14:17:48 -06:00
Vincent Koc
ca26489fe8 fix(memory-core): repair sync helper typing drift 2026-04-06 21:11:06 +01:00
Peter Steinberger
0b55c0ec81 test(auto-reply): mark full-runtime directive suites 2026-04-06 21:09:43 +01:00
Peter Steinberger
61d9143b63 perf(auto-reply): separate fast and full reply runtime 2026-04-06 21:09:43 +01:00
Vincent Koc
ae79210ddd test(lint): refresh suppression tail guardrail 2026-04-06 21:05:18 +01:00
Vincent Koc
4e266253ce fix(xai): drop dead x-search import 2026-04-06 21:05:15 +01:00
Vincent Koc
87bcfe796f fix(check): tighten lobster and webhook mutation types 2026-04-06 21:03:50 +01:00
Peter Steinberger
aa4cb43627 test: speed up cli backend live lane 2026-04-06 21:01:45 +01:00
Peter Steinberger
51c6b1c2bc fix: default fallback decision log targets 2026-04-06 20:57:10 +01:00
Peter Steinberger
48f2c2097d fix: stabilize telegram doctor config repairs 2026-04-06 20:55:51 +01:00
Peter Steinberger
ed64ce3983 build: type plugin sdk exports for xai 2026-04-06 20:55:51 +01:00
Peter Steinberger
7c256bfdf4 test: trim memory manager test startup 2026-04-06 20:52:08 +01:00
Peter Steinberger
6e9382b5c8 perf: extract memory provider state helpers 2026-04-06 20:52:08 +01:00
Devin Robison
37d7c716f4 fix: the bundled qq bot extension extensions qqbot pe (#329) (#62082) 2026-04-06 13:50:33 -06:00
Peter Steinberger
7e3f345ee9 refactor: dedupe plugin interactive namespace helpers 2026-04-06 20:45:32 +01:00
Peter Steinberger
e3d6209599 refactor: dedupe model fallback failure tracking 2026-04-06 20:45:32 +01:00
Peter Steinberger
901fb18217 refactor: dedupe status-all gateway surface types 2026-04-06 20:45:32 +01:00
Peter Steinberger
55ae9addc1 refactor: dedupe subsystem logger emit path 2026-04-06 20:45:32 +01:00
Peter Steinberger
1919332fd3 refactor: dedupe subagent session metrics 2026-04-06 20:45:31 +01:00
Peter Steinberger
1b9a1328a1 refactor: dedupe memory wiki cli result formatting 2026-04-06 20:45:31 +01:00
Peter Steinberger
23d4aec907 refactor: dedupe memory wiki cli helpers 2026-04-06 20:45:31 +01:00
Peter Steinberger
f2bbf2b8e7 refactor: dedupe lobster runtime helpers 2026-04-06 20:45:31 +01:00
Peter Steinberger
95df6d9332 refactor: dedupe lobster managed flow formatting 2026-04-06 20:45:31 +01:00
Vincent Koc
7d54f2a3c2 fix(config): apply filtered doctor compat at read time 2026-04-06 20:45:07 +01:00
Vincent Koc
78639eff76 perf(secrets): narrow channel secret sdk seam 2026-04-06 20:40:11 +01:00
Peter Steinberger
c0d3743cdb fix: type xai x_search tool result 2026-04-06 20:34:19 +01:00
Peter Steinberger
a89f171865 fix: repair latest main gates 2026-04-06 20:31:34 +01:00
Peter Steinberger
78a948ee32 refactor: dedupe xai x search tool helpers 2026-04-06 20:30:20 +01:00
Peter Steinberger
f5bb8cbb98 refactor: dedupe memory wiki source path helpers 2026-04-06 20:30:20 +01:00
Peter Steinberger
ccfdfec43f refactor: dedupe doctor account streaming matchers 2026-04-06 20:30:20 +01:00
Peter Steinberger
1366b943e5 refactor: dedupe webhook view type shapes 2026-04-06 20:30:20 +01:00
Peter Steinberger
ce61cb48ec refactor: dedupe webhook flow mutation mapping 2026-04-06 20:30:20 +01:00
Peter Steinberger
e7ab634830 refactor: dedupe discord account streaming detection 2026-04-06 20:30:20 +01:00
Peter Steinberger
b83ddf3cef refactor: dedupe discord media detection helper 2026-04-06 20:30:20 +01:00
Peter Steinberger
6d52014ef8 refactor: dedupe doctor compat record helper 2026-04-06 20:30:20 +01:00
Peter Steinberger
38e4fb3642 refactor: dedupe feishu comment helpers 2026-04-06 20:30:20 +01:00
Peter Steinberger
4bcc58fc6d refactor: dedupe speech core vitest shim 2026-04-06 20:30:20 +01:00
Peter Steinberger
1dc1635851 refactor: dedupe channel core surface 2026-04-06 20:30:20 +01:00
Vincent Koc
bfc37b42a5 perf(brave): lazy-load web search runtime 2026-04-06 20:27:55 +01:00
Peter Steinberger
673a08ccf5 Tests: fix e2e Docker cache expectation 2026-04-07 03:23:58 +08:00
Peter Steinberger
f4d8393bf4 perf: extract memory manager state helpers 2026-04-06 20:21:46 +01:00
Peter Steinberger
8d2daf7ef2 perf: extract memory sync control helpers 2026-04-06 20:21:46 +01:00
Peter Steinberger
4ad1d96e5d fix(ci): repair typing drift on main 2026-04-06 20:20:30 +01:00
Devin Robison
681931345b fix: this is a real approval boundary bypass that tur (#323) (#62078) 2026-04-06 13:13:35 -06:00
Vincent Koc
153d06f890 test(lint): guard production suppression tail 2026-04-06 20:13:25 +01:00
Devin Robison
7306cf3707 fix: multiple dangerous build tool environment variab (#317) (#62079) 2026-04-06 13:10:38 -06:00
Vincent Koc
43f84890ce perf(test): trim runtime coverage batch overhead 2026-04-06 20:06:41 +01:00
Peter Steinberger
66aeb5ce23 CLI: fix stale secret gateway path expectation 2026-04-07 03:02:41 +08:00
Peter Steinberger
06f2b90a0f perf(auto-reply): skip provider runtime in fast reply tests 2026-04-06 20:00:35 +01:00
Peter Steinberger
8065586d13 fix: widen plugin docker restart timeout 2026-04-06 19:59:30 +01:00
Peter Steinberger
1a7c3eb4fc CLI: scope agent secret targets 2026-04-07 02:58:10 +08:00
Peter Steinberger
5ac49b01c6 refactor: dedupe provider registry helpers 2026-04-06 19:57:57 +01:00
Peter Steinberger
a5b5632809 refactor: inline openai realtime config readers 2026-04-06 19:57:57 +01:00
Peter Steinberger
637bc8e458 refactor: dedupe qqbot result logging helper 2026-04-06 19:57:57 +01:00
Peter Steinberger
24f4322141 refactor: dedupe openai codex string helper 2026-04-06 19:57:57 +01:00
Peter Steinberger
b523d6559c refactor: dedupe openai synthetic catalog helper 2026-04-06 19:57:56 +01:00
Peter Steinberger
fd05e7ca1a refactor: dedupe openai codex url helper 2026-04-06 19:57:56 +01:00
Peter Steinberger
60fb7a318e refactor: dedupe openai base url helper 2026-04-06 19:57:56 +01:00
Peter Steinberger
413a5ef75a refactor: dedupe qqbot photo send helper 2026-04-06 19:57:56 +01:00
Peter Steinberger
1013cb3a5d refactor: dedupe openai data url helper 2026-04-06 19:57:56 +01:00
Peter Steinberger
a336c31962 refactor: dedupe elevenlabs provider helpers 2026-04-06 19:57:56 +01:00
Peter Steinberger
d6d999eda6 refactor: dedupe speech provider scalar coercion helpers 2026-04-06 19:57:56 +01:00
Peter Steinberger
9d36e7a73a refactor: dedupe speech provider coercion helpers 2026-04-06 19:57:56 +01:00
Peter Steinberger
501977106c perf(auto-reply): trim fast reply runtime path 2026-04-06 19:54:28 +01:00
Peter Steinberger
1b7e16668e fix: finalize arcee provider landing (#62068) (thanks @arthurbr11) 2026-04-06 19:53:27 +01:00
Peter Steinberger
8ff570ee42 refactor: resolve channel env vars from plugin manifests 2026-04-06 19:53:27 +01:00
Peter Steinberger
bc18e69fbf fix: separate arcee auth envs from openrouter 2026-04-06 19:53:27 +01:00
Peter Steinberger
b7d3a26356 refactor: extract arcee provider cleanup seams 2026-04-06 19:53:27 +01:00
Peter Steinberger
177be0f237 fix: remove provider hardcoding and fix arcee openrouter 2026-04-06 19:53:27 +01:00
arthurbr11
95106be59b feat: enhance Arcee AI provider with OpenRouter support and update onboarding instructions 2026-04-06 19:53:27 +01:00
arthurbr11
1dc3ee6165 fix: update maxTokens for Arcee model catalog entries 2026-04-06 19:53:27 +01:00
arthurbr11
5ac2f58c57 feat: add Arcee AI provider plugin
Add a bundled Arcee AI provider plugin with ARCEEAI_API_KEY onboarding,
Trinity model catalog (mini, large-preview, large-thinking), and
OpenAI-compatible API support.

- Trinity Large Thinking: 256K context, reasoning enabled
- Trinity Large Preview: 128K context, general-purpose
- Trinity Mini 26B: 128K context, fast and cost-efficient
2026-04-06 19:53:27 +01:00
Peter Steinberger
8f421f0e78 test: stabilize auto-reply and doctor suites 2026-04-06 19:52:42 +01:00
Peter Steinberger
134ff61754 test: stabilize agent auth and config suites 2026-04-06 19:52:42 +01:00
Peter Steinberger
aaa5dea358 build: type live media runner 2026-04-06 19:52:42 +01:00
Peter Steinberger
b6e0a24d50 fix: align session status transcript fallback 2026-04-06 19:49:35 +01:00
Peter Steinberger
f9c721d5bf fix: add vydra kling live lane 2026-04-06 19:47:43 +01:00
Peter Steinberger
66405d5e8a perf: extract memory status state helpers 2026-04-06 19:45:31 +01:00
Peter Steinberger
c50e3c676a test: stub memory host events in promotion tests 2026-04-06 19:45:31 +01:00
Peter Steinberger
d4130e83c6 refactor: dedupe discord mutable allow entry helper 2026-04-06 19:36:01 +01:00
Peter Steinberger
50628ef62c refactor: dedupe security audit helper coercion 2026-04-06 19:36:01 +01:00
Peter Steinberger
a2be2abc28 refactor: dedupe qqbot chunk send loops 2026-04-06 19:36:01 +01:00
Peter Steinberger
2edc3c8a3e style: format dashscope video helpers 2026-04-06 19:36:01 +01:00
Peter Steinberger
5656f6c7ff refactor: dedupe dashscope video helpers 2026-04-06 19:36:01 +01:00
Peter Steinberger
27dc1bd0fc fix(qa-lab): improve health timeout error message and fix port-free check 2026-04-06 19:35:18 +01:00
Peter Steinberger
37b7e22e13 fix(qa-lab): increase health check timeout to 240s 2026-04-06 19:35:15 +01:00
Peter Steinberger
7a736bff90 perf(test): split reply queue seams and unit shards 2026-04-06 19:31:20 +01:00
Peter Steinberger
06d57e5107 fix: stabilize docker live tests 2026-04-06 19:31:16 +01:00
Peter Steinberger
a040de33f1 fix(ci): repair provider typing drift 2026-04-06 19:28:55 +01:00
Peter Steinberger
b4ec7d77ce test: reuse memory temp fixtures 2026-04-06 19:28:18 +01:00
Peter Steinberger
c185413a8e perf: serialize short-term recall writes in-process 2026-04-06 19:28:18 +01:00
Peter Steinberger
ff414df15f Agents: narrow cli runner hotspot tests 2026-04-07 02:26:55 +08:00
Peter Steinberger
9f4c2caf06 fix(qa-lab): skip render when poll data unchanged and use dropdown model selectors 2026-04-06 19:25:51 +01:00
Peter Steinberger
9663343183 test(ci): align openai image edit assertion 2026-04-06 19:25:35 +01:00
Peter Steinberger
26b401c8e5 refactor: dedupe memory read execution helper 2026-04-06 19:24:43 +01:00
Peter Steinberger
a171de283f refactor: dedupe openai-compatible video helpers 2026-04-06 19:24:43 +01:00
Peter Steinberger
283b103e75 refactor: dedupe doctor account streaming checks 2026-04-06 19:24:43 +01:00
Peter Steinberger
b589de7a4f refactor: dedupe memory read fallback helper 2026-04-06 19:24:43 +01:00
Peter Steinberger
5116ce2d5e refactor: dedupe webhook view mappers 2026-04-06 19:24:43 +01:00
Peter Steinberger
3826af6c40 refactor: dedupe qqbot media target helpers 2026-04-06 19:24:43 +01:00
Peter Steinberger
800ac580b1 refactor: dedupe qqbot text dispatch helper 2026-04-06 19:24:43 +01:00
Peter Steinberger
bf24bd16f3 refactor: dedupe discord split reply helpers 2026-04-06 19:24:43 +01:00
Peter Steinberger
ab7777b169 refactor: dedupe discord payload chunk helper 2026-04-06 19:24:42 +01:00
Peter Steinberger
cae4538a86 refactor: dedupe openai speech provider helpers 2026-04-06 19:24:42 +01:00
Peter Steinberger
8fdaa5da49 refactor: dedupe vydra provider request helpers 2026-04-06 19:24:42 +01:00
Peter Steinberger
6dfdc92bd4 refactor: dedupe openai realtime provider helpers 2026-04-06 19:24:42 +01:00
Peter Steinberger
dab4a4790d refactor: dedupe mutable allowlist doctor warnings 2026-04-06 19:24:42 +01:00
Peter Steinberger
2d0618f8b5 refactor: dedupe discord payload text helper 2026-04-06 19:24:42 +01:00
Peter Steinberger
d9f21433a8 refactor: dedupe acp binding helpers 2026-04-06 19:24:42 +01:00
Peter Steinberger
33cdb342cb refactor(discord): split voice receive and capture helpers 2026-04-06 19:24:07 +01:00
Peter Steinberger
ec55902989 perf(test): tighten reply fast paths and split unit shards 2026-04-06 19:23:17 +01:00
Peter Steinberger
0cebe9d593 fix(qa-lab): add Slack-style chat sidebar and fix light mode theming 2026-04-06 19:21:24 +01:00
Peter Steinberger
e43a1f235e fix(ci): type live media runner 2026-04-06 19:18:59 +01:00
Peter Steinberger
41ea5316aa test: add shared media live harness 2026-04-06 19:15:31 +01:00
Peter Steinberger
dd978bf975 fix: stabilize media live provider coverage 2026-04-06 19:15:31 +01:00
Peter Steinberger
58d7df7985 fix(ci): restore contracts and type gates 2026-04-06 19:10:31 +01:00
Peter Steinberger
0419bf6324 Commands: split slow agent runtime tests 2026-04-07 02:07:17 +08:00
Vincent Koc
88dd641c6a test(media): accept minimax fetch abort signal 2026-04-06 18:57:00 +01:00
Vincent Koc
3d5668c305 fix(discord): export security contract api 2026-04-06 18:57:00 +01:00
Vincent Koc
739ce82015 fix(plugins): prefer usable bundled plugin trees 2026-04-06 18:57:00 +01:00
Vincent Koc
e77d72a91d fix(config): lazily resolve bundled channel runtimes 2026-04-06 18:57:00 +01:00
Peter Steinberger
10802e20d6 test: trim memory hotspot fixture setup 2026-04-06 18:55:54 +01:00
Peter Steinberger
e6b95624d9 test: reuse qmd manager fixture cleanup 2026-04-06 18:55:54 +01:00
Peter Steinberger
f8fc7f3e41 fix: run claude cli live lanes against anthropic models 2026-04-06 18:50:00 +01:00
Peter Steinberger
7ae8a10087 fix: improve claude cli live discovery 2026-04-06 18:49:59 +01:00
Peter Steinberger
226e1afa4d Commands: narrow agent snapshot test seam 2026-04-07 01:48:35 +08:00
Peter Steinberger
95fe63e63f perf(auto-reply): fast-path getReply test bootstrap 2026-04-06 18:46:26 +01:00
Peter Steinberger
a211f09259 docs: note discord voice recovery fix (#41536) (thanks @wit-oc) 2026-04-06 18:44:19 +01:00
Peter Steinberger
dfa14001a4 fix: harden discord voice receive recovery (#41536) (thanks @wit-oc) 2026-04-06 18:44:19 +01:00
OpenClaw Contributor
37e89b930f fix(discord): restore voice receive path and reply playback 2026-04-06 18:44:19 +01:00
Peter Steinberger
80789809a4 Agents: trim cli runner test hotspots 2026-04-07 01:44:01 +08:00
Peter Steinberger
41da6faa9e fix(qa-lab): tear down previous docker stack before starting new one 2026-04-06 18:41:17 +01:00
Peter Steinberger
dd0cd5dcda refactor: dedupe whatsapp security contract helpers 2026-04-06 18:40:05 +01:00
Peter Steinberger
00e46301a4 refactor: dedupe webhook task view helpers 2026-04-06 18:40:05 +01:00
Peter Steinberger
90f33ed5da refactor: dedupe discord structured send context 2026-04-06 18:40:05 +01:00
Peter Steinberger
0153d102d7 refactor: dedupe discord media batch helper 2026-04-06 18:40:05 +01:00
Peter Steinberger
d1a4cf28cc refactor: dedupe subagents dispatch helpers 2026-04-06 18:40:05 +01:00
Peter Steinberger
b66915a957 refactor: tidy discord thread binding lifecycle imports 2026-04-06 18:40:05 +01:00
Peter Steinberger
54f2de7e1c refactor: dedupe discord thread binding lifecycle exports 2026-04-06 18:40:05 +01:00
Peter Steinberger
6243ca50e0 refactor: dedupe qqbot channel config helpers 2026-04-06 18:40:05 +01:00
Peter Steinberger
3921bb2df6 perf: extract memory manager state helpers 2026-04-06 18:39:06 +01:00
Peter Steinberger
b5c9a46633 test: pre-register memory embedding adapters 2026-04-06 18:39:06 +01:00
Peter Steinberger
ff8f46884a test(auto-reply): speed up reply prompt tests 2026-04-06 18:34:08 +01:00
Peter Steinberger
e6c1e9c64b refactor(auto-reply): extract reply prompt prelude 2026-04-06 18:34:08 +01:00
Peter Steinberger
b98cccc06e refactor: dedupe mattermost channel config helpers 2026-04-06 18:30:16 +01:00
Peter Steinberger
81b0f280be refactor: dedupe discord media-only delivery helper 2026-04-06 18:30:16 +01:00
Peter Steinberger
4c8bb05c89 refactor: dedupe whatsapp login qr wrappers 2026-04-06 18:30:16 +01:00
Peter Steinberger
8f7792317d test: move bundled channel config runtime into test helpers 2026-04-06 18:30:09 +01:00
Peter Steinberger
6a052ca4b8 CLI: speed up command secret gateway tests 2026-04-07 01:24:24 +08:00
Peter Steinberger
a47c49bbf3 feat(qa-lab): redesign UI with sidebar layout, Slack-like chat, and light/dark mode 2026-04-06 18:21:33 +01:00
Peter Steinberger
510fca655a fix(plugins): avoid helper reentry loads 2026-04-06 18:20:33 +01:00
Peter Steinberger
348cd6b17a fix(test): restore bundled loader coverage 2026-04-06 18:18:30 +01:00
Peter Steinberger
96b39e01b4 test: move android policy fixtures into test helpers 2026-04-06 18:18:03 +01:00
Peter Steinberger
f8818a574c test: remove dead prompt runtime shim 2026-04-06 18:18:03 +01:00
Peter Steinberger
317e3f631a refactor: dedupe xai search response parsing 2026-04-06 18:15:54 +01:00
Peter Steinberger
fe7059696b refactor: dedupe opencode model default helpers 2026-04-06 18:15:54 +01:00
Peter Steinberger
673878188d refactor: dedupe preview streaming helpers 2026-04-06 18:15:53 +01:00
Peter Steinberger
8ae6cf32bb refactor: dedupe matrix thread binding projections 2026-04-06 18:15:53 +01:00
Peter Steinberger
71dd337628 refactor: dedupe matrix migration auth precedence 2026-04-06 18:15:53 +01:00
Peter Steinberger
ab96703b5c refactor: dedupe matrix env auth helpers 2026-04-06 18:15:53 +01:00
Peter Steinberger
a484d08f5c refactor: dedupe discord thread binding session helpers 2026-04-06 18:15:53 +01:00
Peter Steinberger
09fc834c75 refactor: dedupe legacy config record helpers 2026-04-06 18:15:53 +01:00
Peter Steinberger
b4785525df refactor: dedupe video generation runtime surface 2026-04-06 18:15:53 +01:00
Peter Steinberger
4610ceb2a5 refactor: dedupe media understanding runtime surface 2026-04-06 18:15:53 +01:00
Vincent Koc
8301ddfa84 fix(test): clean up vitest child process groups 2026-04-06 18:10:44 +01:00
Peter Steinberger
a22e44f259 Secrets: fast-path core target discovery 2026-04-07 01:10:30 +08:00
Peter Steinberger
1430de95a5 test: move channel session-binding fixtures into test helpers 2026-04-06 18:10:10 +01:00
Peter Steinberger
f3c00048cf test: move prompt composition fixtures into test helpers 2026-04-06 18:10:10 +01:00
Peter Steinberger
f88b6ffb48 test: restore plugin contract testkit imports 2026-04-06 18:09:37 +01:00
Vincent Koc
48fea1021a fix(channels): harden bundled runtime sidecar resolution 2026-04-06 18:06:51 +01:00
Peter Steinberger
7d9a6b5572 fix(plugins): detect reentrant plugin loads 2026-04-06 18:05:33 +01:00
Peter Steinberger
f8920e96d0 test: add missing provider runtime mock export 2026-04-06 18:04:18 +01:00
Peter Steinberger
c817bb87d4 test: move plugin helper seams into test helpers 2026-04-06 18:03:35 +01:00
Peter Steinberger
24492b51c9 test: move channel contract fixtures into test helpers 2026-04-06 18:03:35 +01:00
Peter Steinberger
bb29c8696a fix: harden qa lab docker launcher startup 2026-04-06 18:01:08 +01:00
Vincent Koc
8f2ff2497a test(channels): mock bundled channel runtime seam 2026-04-06 18:00:38 +01:00
Vincent Koc
8e2ecd053f fix(secrets): restore source-mode contract loading 2026-04-06 17:59:53 +01:00
Peter Steinberger
725cbcc362 fix(plugins): narrow provider hook reentry guard 2026-04-06 17:57:49 +01:00
Peter Steinberger
309154085b fix(ci): repair heartbeat test import path 2026-04-06 17:56:54 +01:00
Peter Steinberger
c1fa747f69 refactor: dedupe config write policy helpers 2026-04-06 17:56:41 +01:00
Peter Steinberger
a5a7ea0e39 refactor: dedupe provider stream family surface 2026-04-06 17:56:41 +01:00
Peter Steinberger
f1d7e9b569 refactor: dedupe volc model catalog helpers 2026-04-06 17:56:41 +01:00
Peter Steinberger
23f3a2d59d refactor: dedupe plugin account resolution surface 2026-04-06 17:56:41 +01:00
Peter Steinberger
6b543cafee refactor: dedupe plugin enable-state adapters 2026-04-06 17:56:41 +01:00
Peter Steinberger
0eb6cec32b refactor: dedupe plugin discovery boundary opens 2026-04-06 17:56:41 +01:00
Peter Steinberger
a4223f836d refactor: dedupe plugin release package scanning 2026-04-06 17:56:41 +01:00
Peter Steinberger
345c71f264 refactor: dedupe plugin activation helpers 2026-04-06 17:56:41 +01:00
Peter Steinberger
87617c44ba refactor: dedupe channel text chunking helpers 2026-04-06 17:56:41 +01:00
Peter Steinberger
40ea257792 fix(test): retry flaky cli backend connect 2026-04-06 17:52:23 +01:00
Peter Steinberger
7f6de686bb fix(ci): repair contracts and whatsapp regressions 2026-04-06 17:52:05 +01:00
Peter Steinberger
c5973755fd chore(acpx): clarify runtime asset packaging 2026-04-06 17:51:21 +01:00
Peter Steinberger
1acadc5bbf refactor(deadcode): remove orphaned plugin wrappers 2026-04-06 17:51:21 +01:00
Peter Steinberger
a20bc8640b test: move dead helper fixtures into test helpers 2026-04-06 17:51:21 +01:00
EVA
594ea6e1b9 fix(agents): backfill missing sessionKey in embedded PI runner — prevents undefined key in model selection and live-switch (#60555)
Merged via squash.

Prepared head SHA: 8081345f1c
Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-06 09:51:05 -07:00
Peter Steinberger
b4e1747391 feat: add one-command qa lab docker launcher 2026-04-06 17:47:17 +01:00
Peter Steinberger
d733786cf7 test: slim memory cli runtime mock imports 2026-04-06 17:46:48 +01:00
Peter Steinberger
30c686423f perf: avoid full config resolution in qmd sync 2026-04-06 17:46:48 +01:00
Peter Steinberger
d1414477a4 fix: finish rebase conflict cleanup 2026-04-06 17:45:29 +01:00
Peter Steinberger
6acb43f294 fix: resolve channel typing regressions 2026-04-06 17:43:57 +01:00
Peter Steinberger
1880b104ed style: normalize test import order 2026-04-06 17:42:19 +01:00
Peter Steinberger
f7f861082a fix(ci): repair boundary guards 2026-04-06 17:42:19 +01:00
Peter Steinberger
51f77b5e04 Agents: align Claude fixture with rebased tests 2026-04-07 00:37:37 +08:00
Peter Steinberger
0f224724dc Agents: slim cli-runner test seams 2026-04-07 00:37:37 +08:00
Peter Steinberger
ec359f5942 Discord: trim monitor test import cost 2026-04-07 00:37:37 +08:00
Peter Steinberger
67520b6abf fix(ci): restore bundled channel loading 2026-04-06 17:35:47 +01:00
Peter Steinberger
0335a8783c perf(test): shard full vitest runs 2026-04-06 17:34:11 +01:00
Peter Steinberger
a47cb0a3b3 refactor: dedupe approval gateway resolver setup 2026-04-06 17:31:16 +01:00
Peter Steinberger
c7cc89904e fix: unblock claude docker live lanes 2026-04-06 17:31:11 +01:00
Peter Steinberger
e7e3f11b20 refactor: dedupe legacy private-network doctor contracts 2026-04-06 17:28:11 +01:00
Peter Steinberger
ce30557399 refactor(deadcode): remove orphaned core helpers 2026-04-06 17:26:25 +01:00
Peter Steinberger
591347113e refactor(deadcode): prune extension test shims 2026-04-06 17:26:25 +01:00
Peter Steinberger
943d7de240 refactor: dedupe doctor compatibility adapters 2026-04-06 17:25:36 +01:00
Vincent Koc
e7fe087677 fix(openai): normalize prompt overlay personality config 2026-04-06 17:24:51 +01:00
Vincent Koc
6dc3e1f770 perf(test): flatten async channel secret mocks 2026-04-06 17:24:38 +01:00
Peter Steinberger
5f906c926d refactor: remove qa-e2e compatibility facade 2026-04-06 17:23:35 +01:00
Peter Steinberger
350238d402 feat: add interactive qa lab suite runner 2026-04-06 17:23:35 +01:00
Peter Steinberger
e70168212d refactor: dedupe script and matrix send helpers 2026-04-06 17:21:52 +01:00
Vincent Koc
7af1def025 perf(test): isolate secret target registry docs checks 2026-04-06 17:21:49 +01:00
Peter Steinberger
7422e90053 fix(ci): restore shared test seams 2026-04-06 17:20:38 +01:00
huntharo
f2cd2c00b0 fix(plugin-sdk): add bundled entry error context
(cherry picked from commit 6099405ba1e8b98caa92cce4487808d212dc3544)
2026-04-06 12:19:05 -04:00
Peter Steinberger
d25491aa6d refactor: dedupe release git range helpers 2026-04-06 17:18:36 +01:00
Peter Steinberger
1c5cbad0a6 refactor: dedupe account conversation bindings 2026-04-06 17:18:36 +01:00
Peter Steinberger
1aee8c55ce refactor: dedupe channel doctor compat helpers 2026-04-06 17:18:36 +01:00
Peter Steinberger
a86fa3b211 refactor(deadcode): drop orphaned extension helpers 2026-04-06 17:18:03 +01:00
Peter Steinberger
ce87d5e242 refactor(deadcode): remove extension wrapper shims 2026-04-06 17:18:03 +01:00
Peter Steinberger
5d7a73380f fix(ci): repair tsgo test harnesses 2026-04-06 17:16:01 +01:00
Vincent Koc
c01b4981af test(memory-core): seed qmd manager provider registry 2026-04-06 17:10:18 +01:00
Peter Steinberger
bedfa576a3 fix(ci): clean poll-timeout test merge artifact 2026-04-06 17:06:22 +01:00
Peter Steinberger
645c331200 fix(ci): repair type and extension regressions 2026-04-06 17:06:22 +01:00
Vincent Koc
79a0c71874 chore(lint): drop stale transcript type import 2026-04-06 17:06:18 +01:00
Vincent Koc
a797068206 refactor(lint): tighten channel and config defaults 2026-04-06 17:06:18 +01:00
Peter Steinberger
5d0e8336ab perf(test): trim bundled channel bootstrap 2026-04-06 17:05:59 +01:00
Peter Steinberger
8b79cbcd06 build(plugins): align package versions to 2026.4.6 2026-04-06 17:05:30 +01:00
Peter Steinberger
860721f28d build(plugins): sync bundled versions to 2026.4.6 2026-04-06 17:05:30 +01:00
Peter Steinberger
220d10cad3 docs(changelog): add unreleased entries for 2026.4.6 2026-04-06 17:05:30 +01:00
Peter Steinberger
723c0ea2b7 test: speed up memory manager hotspot tests 2026-04-06 17:04:13 +01:00
Peter Steinberger
6f841ff121 test: cache memory manager helper imports 2026-04-06 17:04:13 +01:00
Peter Steinberger
e1a047c43f fix: repair gateway fingerprint callback 2026-04-06 17:02:10 +01:00
Peter Steinberger
a8436f0220 fix: resolve rebased type drift 2026-04-06 17:02:10 +01:00
Peter Steinberger
821a30981a test: refresh agent harness and latest-main type fixes 2026-04-06 17:02:10 +01:00
Peter Steinberger
2fef1ccbe7 fix: avoid leading spaces when stripping model tokens 2026-04-06 17:02:10 +01:00
Peter Steinberger
0ffceca50a test: align agent auth and model expectations 2026-04-06 17:02:10 +01:00
Peter Steinberger
1b9ec88d9c fix: centralize HTTP/1.1 SSRF dispatchers (#61777) (thanks @zozo123) 2026-04-06 17:02:10 +01:00
Yossi Eliaz
0f5919a4ba fix(ssrf): disable HTTP/2 for pinned SSRF-guard dispatchers (undici 8.0 compat)
Undici 8.0 defaults HTTPS clients to negotiate HTTP/2 via ALPN, which is
incompatible with the custom `connect.lookup` callback used for SSRF DNS
pinning. This caused `TypeError: fetch failed` in web_fetch/web_search.

Explicitly set `allowH2: false` on all dispatcher creation paths (Agent,
EnvHttpProxyAgent, ProxyAgent) to restore HTTP/1.1 behavior and keep the
pinned DNS lookup working reliably.

Closes #61738
2026-04-06 17:02:10 +01:00
Peter Steinberger
a65f9971b7 refactor(deadcode): remove duplicate barrels and helper shims 2026-04-06 17:00:40 +01:00
Peter Steinberger
0b36423f97 docs: reorder unreleased changelog entries 2026-04-06 16:58:28 +01:00
Vincent Koc
38c520acc3 chore(memory-core): type embedding test mocks 2026-04-06 16:58:14 +01:00
Vincent Koc
84c182deb2 fix(secrets): keep legacy x_search auth resolving 2026-04-06 16:57:23 +01:00
Vincent Koc
096d0cf412 chore(lint): type script and test helpers 2026-04-06 16:55:50 +01:00
Peter Steinberger
e79d2ecd9e fix(check): repair latest type drift on main 2026-04-06 16:54:34 +01:00
Peter Steinberger
21f59a0ad5 fix: suppress commentary history leaks (#61747) (thanks @afurm) 2026-04-06 16:54:34 +01:00
Peter Steinberger
672fcb187d refactor(plugins): move provider seams to owning extensions 2026-04-06 16:54:18 +01:00
Peter Steinberger
9100923395 fix(ci): repair tsgo regressions 2026-04-06 16:53:21 +01:00
Peter Steinberger
f2a710ce63 fix(ci): align stale test expectations 2026-04-06 16:53:21 +01:00
Vincent Koc
87b2a6a16a refactor(lint): type tool factories and runtime helpers 2026-04-06 16:53:02 +01:00
Vincent Koc
506b4decbd test(secrets): mock bundled channel secrets seam 2026-04-06 16:52:59 +01:00
Peter Steinberger
9c82974082 refactor: dedupe discord send target parsing 2026-04-06 16:52:42 +01:00
Peter Steinberger
93338ffbcc refactor: dedupe media generation action helpers 2026-04-06 16:52:42 +01:00
Peter Steinberger
c88870ac93 refactor: dedupe windows cmd runner helpers 2026-04-06 16:52:41 +01:00
Peter Steinberger
ad9481e2d1 refactor: dedupe auth and session helpers 2026-04-06 16:52:41 +01:00
Peter Steinberger
e8c7481fd2 refactor: dedupe outbound helpers 2026-04-06 16:52:41 +01:00
Peter Steinberger
4a84412b3a refactor: dedupe channel plugin helpers 2026-04-06 16:52:41 +01:00
Peter Steinberger
8aeee0dc6d refactor: dedupe plugin config helpers 2026-04-06 16:52:41 +01:00
Peter Steinberger
a830f4de4b test(commands): fix moved session store refs 2026-04-06 16:49:28 +01:00
Peter Steinberger
8a33a8d607 perf(test): trim runtime lookups and add changed bench 2026-04-06 16:49:28 +01:00
Peter Steinberger
8477f1841a refactor(deadcode): remove orphaned core wrappers 2026-04-06 16:47:03 +01:00
Peter Steinberger
d60149c655 test: move provider tests into owning extensions 2026-04-06 16:47:03 +01:00
Vincent Koc
c109a7623b refactor(lint): type shared runtime seams 2026-04-06 16:46:08 +01:00
Peter Steinberger
eef80f31cf Tests: fix stale expectations and weak token generation 2026-04-06 23:44:26 +08:00
Peter Steinberger
074e6d5047 fix(discord): use ws for gateway sockets 2026-04-06 16:43:47 +01:00
Vincent Koc
6775611c5d refactor(gateway): type inline tool auth helpers 2026-04-06 16:42:16 +01:00
Vincent Koc
9e41b2ffd6 style(contracts): normalize registry formatting 2026-04-06 16:40:54 +01:00
Vincent Koc
6b12e3ebf6 fix(contracts): stabilize bundled channel artifact loading 2026-04-06 16:40:54 +01:00
Vincent Koc
c3b19d204a perf(test): lazy-load bundled channel secrets 2026-04-06 16:40:41 +01:00
Peter Steinberger
349a1c58f9 refactor: re-duplicate auth and session helpers 2026-04-06 16:38:57 +01:00
Peter Steinberger
cdf321b320 refactor: re-duplicate outbound helpers 2026-04-06 16:38:57 +01:00
Peter Steinberger
9c24bda43b refactor: re-duplicate channel plugin helpers 2026-04-06 16:38:57 +01:00
Peter Steinberger
a6a379b37c refactor: re-duplicate plugin config helpers 2026-04-06 16:38:57 +01:00
Vincent Koc
00f256dd31 refactor(gateway): type tool resolution paths 2026-04-06 16:36:51 +01:00
Peter Steinberger
aa6f6135db fix: tighten TUI phase handling and heartbeat session guards (#61463) (thanks @100yenadmin) 2026-04-06 16:35:22 +01:00
Eva
2d481c9329 fix(heartbeat): add subagent guard to resolveHeartbeatSession production code 2026-04-06 16:35:22 +01:00
Eva
aaf5307638 fix(gateway): seq-based cursor pagination + sanitize SSE fast path
- Pagination now searches by message seq value instead of using
  cursorSeq-1 as array index. After sanitization drops rows, seqs
  become sparse and positional indexing breaks cursor traversal.
- SSE unbounded fast path now sanitizes incremental messages through
  sanitizeChatHistoryMessages before emitting, so NO_REPLY and
  directive messages are suppressed consistently with initial history.
2026-04-06 16:35:22 +01:00
Eva
22d8e47a50 fix(agents,gateway): adopt phase-aware assistant text extraction 2026-04-06 16:35:22 +01:00
Peter Steinberger
0b9993df95 fix(agents): keep phaseless OpenAI WS text buffered until phase resolves (#61968)
* fix(agents): gate WS text delta emission on valid phase value, not map key existence

When output_item.added arrives without phase metadata, outputItemPhaseById
stores undefined. The previous .has() check returned true for undefined
values, bypassing the buffering gate and leaking commentary as unphased
visible content.

Fix: change .has() to .get() !== undefined on both delta and done handlers.

Fixes #61477

* docs: note WS phase buffering fix (#61954) (thanks @100yenadmin)

* test(agents): cover phaseless WS output_text.done buffering (#61954)

* test(commands): fix session-store import path for tsgo (#61968)

---------

Co-authored-by: Eva <eva@100yen.org>
2026-04-06 16:35:16 +01:00
Vincent Koc
56136c83b7 refactor(plugins): type sync hook handlers 2026-04-06 16:35:11 +01:00
Peter Steinberger
c22372dec6 fix(ci): restore discord and feishu lifecycle tests 2026-04-06 16:32:41 +01:00
Peter Steinberger
de20d3a024 refactor(plugin-sdk): add simple completion runtime entrypoint 2026-04-06 16:29:43 +01:00
Peter Steinberger
7785dc21e6 fix(discord): drop generated thread title temperature 2026-04-06 16:29:43 +01:00
Peter Steinberger
6cc54e5059 fix(extensions): restore lint-safe xai imports 2026-04-06 16:27:38 +01:00
Peter Steinberger
7a5e65c71b test(channels): fix add and facade fixtures 2026-04-06 16:27:38 +01:00
Vincent Koc
44cd91b0a9 fix(feishu): load lifecycle mocks before card action imports 2026-04-06 16:26:48 +01:00
Mason
2d7d99f66e docs: quote plan title frontmatter (#61962) 2026-04-06 23:25:57 +08:00
jjjojoj
281ea15550 fix: narrow queryTokenHint guard to only auth-specific errors, remove overly broad connect failed check 2026-04-06 23:24:29 +08:00
jjjojoj
39c721d382 fix: detect ?token= and suggest #token= fragment syntax
When users visit the Control UI with ?token=<token>, they see
"device identity required" with no hint about the correct URL format.

This change:
- Detects when token is read from query string vs URL fragment
- Warns via console when ?token= is used
- Shows an inline hint in the overview error area directing users
  to use #token=<token> instead

Fixes #54842
2026-04-06 23:24:29 +08:00
Peter Steinberger
cfb7779584 refactor(deadcode): remove agent command shims 2026-04-06 16:24:12 +01:00
Peter Steinberger
d5bfc79112 fix(discord): preserve stack hints for empty gateway type errors 2026-04-06 16:20:36 +01:00
Vincent Koc
90d246959b fix(matrix): align forged mention test with route precheck 2026-04-06 16:19:13 +01:00
Vincent Koc
4ef8f4f53c docs: add media overview page and consolidate TTS duplicate 2026-04-06 16:18:45 +01:00
Peter Steinberger
41c700fe9e refactor(deadcode): remove command auth shims 2026-04-06 16:18:20 +01:00
Vincent Koc
d425aa0912 fix(feishu): await websocket startup in cleanup test 2026-04-06 16:16:53 +01:00
Peter Steinberger
514328a9ad style(repo): format touched helpers and tests 2026-04-06 16:16:10 +01:00
Peter Steinberger
9ca935720c style(preview): format dream diary preview files 2026-04-06 16:16:10 +01:00
Vincent Koc
ab564f8446 docs: add async task lifecycle to video and music generation 2026-04-06 16:15:57 +01:00
Peter Steinberger
0c5e6037b0 fix(openai): clarify auth routes in picker and docs 2026-04-06 16:14:51 +01:00
Peter Steinberger
2b6e08bbfa refactor: remove confirmed dead helpers 2026-04-06 16:13:26 +01:00
Peter Steinberger
d82644cdc8 chore(deadcode): fix knip scan config 2026-04-06 16:13:26 +01:00
Peter Steinberger
d7e3df5eaa perf(test): expand light lane routing 2026-04-06 16:13:21 +01:00
jjjojoj
c1c1c0f351 fix: increase padding-right to 70px to fully clear two action buttons 2026-04-06 23:11:13 +08:00
jjjojoj
c52d896ef0 fix: remove accidental log file and add has-copy class to chat bubbles
- Remove mistakenly committed openclaw-2026-04-03.log
- Add 'has-copy' CSS class to chat bubbles when copy button is present,
  so the .chat-bubble.has-copy padding-right rule actually applies
2026-04-06 23:11:13 +08:00
jjjojoj
a55d45de3c fix: prevent Canvas/Copy icons from overlapping chat bubble text
Increase right padding on .chat-bubble.has-copy from 36px to 62px to
accommodate both copy and canvas action buttons without obscuring text.

Fixes #61514
2026-04-06 23:11:13 +08:00
Peter Steinberger
16d0f0567e fix: preserve legacy replay phase boundaries (#61529) (thanks @100yenadmin) 2026-04-06 23:09:29 +08:00
Eva
a200a746fc fix(agents): correct phase-buffering test expectation for mid-stream deltas 2026-04-06 23:09:29 +08:00
Eva
a58726e1ed fix(agents): inherit message-level phase for untagged blocks during replay splitting
Fixes #61476

Untagged text blocks in mixed assistant messages were forced to undefined
phase when any sibling had an explicit textSignature phase. Now they
correctly inherit the message-level assistantMessagePhase, preventing
commentary leaks during history replay.

Removes the hasExplicitBlockPhase scan — untagged blocks always inherit
m.phase. Blocks with explicit textSignature.phase still use their own.

94/94 tests pass. Regression test added for mixed explicit/untagged blocks.
2026-04-06 23:09:29 +08:00
Vincent Koc
f94a018191 perf(test): slim secrets runtime coverage hotspot 2026-04-06 16:08:05 +01:00
Peter Steinberger
1fb44f0aad fix: separate selected session model resolution 2026-04-06 16:07:50 +01:00
jjjojoj
0f8480ca0b fix: add max-height, flex layout, and scrollable command preview for mobile approval card 2026-04-06 23:06:09 +08:00
jjjojoj
77f9f6112e fix: add bottom safe-area-inset for mobile approval overlay 2026-04-06 23:06:09 +08:00
Vincent Koc
eef20a87d0 refactor(lint): report unused disable directives in root oxlint 2026-04-06 16:02:38 +01:00
Vincent Koc
9c3d9c5c18 chore(lint): drop stale repo lint comments 2026-04-06 16:01:23 +01:00
Peter Steinberger
7f336aba56 fix(discord): normalize gateway fatal type errors 2026-04-06 15:59:56 +01:00
Vincent Koc
c7a562683a chore(agents): drop stale lint comments 2026-04-06 15:59:22 +01:00
Vincent Koc
cb770057b0 chore(lint): drop stale config and gateway lint comments 2026-04-06 15:57:32 +01:00
Vincent Koc
2537ae503d chore(plugins): drop stale core channel lint comments 2026-04-06 15:56:41 +01:00
Peter Steinberger
378b2c2f5c fix(check): absorb latest main lint drift 2026-04-06 15:56:02 +01:00
Peter Steinberger
d12029a15a fix(check): repair plugin runtime type drift batch 2026-04-06 15:54:12 +01:00
Vincent Koc
8fe7b3730f fix(check): restore gateway status tls mock typing 2026-04-06 15:53:16 +01:00
Lewis
1234c873bc fix(msteams): add SSRF validation to file consent upload URL (#23596)
* fix(msteams): add SSRF validation to file consent upload URL

The uploadToConsentUrl() function previously accepted any URL from the
fileConsent/invoke response without validation. A malicious Teams tenant
user could craft an invoke activity with an attacker-controlled uploadUrl,
causing the bot to PUT file data to arbitrary destinations (SSRF).

This commit adds validateConsentUploadUrl() which enforces:

1. HTTPS-only protocol
2. Hostname must match a strict allowlist of Microsoft/SharePoint
   domains (sharepoint.com, graph.microsoft.com, onedrive.com, etc.)
3. DNS resolution check rejects private/reserved IPs (RFC 1918,
   loopback, link-local) to prevent DNS rebinding attacks

The CONSENT_UPLOAD_HOST_ALLOWLIST is intentionally narrower than the
existing DEFAULT_MEDIA_HOST_ALLOWLIST, excluding overly broad domains
like blob.core.windows.net and trafficmanager.net that any Azure
customer can create endpoints under.

Includes 47 tests covering IPv4/IPv6 private IP detection, protocol
enforcement, hostname allowlist matching, DNS failure handling, and
end-to-end upload validation.

* fix(msteams): validate all DNS answers for consent uploads

* fix(msteams): restore changelog header

---------

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
2026-04-06 09:52:56 -05:00
Vincent Koc
c921a6ecad refactor(lint): report unused extension lint disables 2026-04-06 15:52:08 +01:00
Peter Steinberger
a010ce462f perf(test): split light vitest lanes and restore hooks 2026-04-06 15:51:00 +01:00
Vincent Koc
5765c4cb2a fix(check): repair latest command and stream type drift 2026-04-06 15:46:53 +01:00
Vincent Koc
4d405ac5ae chore(plugins): drop final dead test any suppressions 2026-04-06 15:46:31 +01:00
jjjojoj
b35b176837 fix: recognize api.grok.x.ai as xAI-native endpoint
Fixes #61377

The provider attribution code only recognized api.x.ai as the xAI-native
endpoint. Some users have api.grok.x.ai configured (or it appears in
certain DNS/config scenarios) which would not resolve as xAI-native,
causing web_search tool failures.

This change adds api.grok.x.ai as an alias for xAI-native endpoint
classification alongside api.x.ai.
2026-04-06 15:45:34 +01:00
Vincent Koc
6067f2d9ad chore(plugins): drop dead channel test any suppressions 2026-04-06 15:45:18 +01:00
Peter Steinberger
baf4119ae3 fix: harden local TLS gateway probes (#61935) (thanks @ThanhNguyxn07) 2026-04-06 15:44:30 +01:00
ThanhNguyxn07
b77964f704 fix(gateway-status): use local TLS probe targets with fingerprint
When gateway.tls.enabled is true, gateway status probes now target local loopback/tailnet over wss and pass the local TLS fingerprint for localLoopback probes. This avoids false unreachable results for healthy local TLS gateways.

Fixes #61767

Co-authored-by: ThanhNguyxn <thanhnguyentuan2007@gmail.com>
2026-04-06 15:44:30 +01:00
Vincent Koc
3ded10f52a chore(plugins): drop dead test any suppressions 2026-04-06 15:43:48 +01:00
Peter Steinberger
5a54005b4d fix(plugins): restore green runtime gates 2026-04-06 15:42:51 +01:00
Nimrod Gutman
6f566585d8 fix(ios): harden watch exec approval review (#61757)
* fix(ios): harden watch exec approval review

* fix(ios): address watch approval review feedback

* fix(ios): finalize watch approval background recovery

* fix(ios): finalize watch approval background recovery (#61757) (thanks @ngutman)
2026-04-06 17:42:42 +03:00
openperf
e777a2b230 fix(process ): migrate legacy command-queue singleton missing activeTaskWaiters
After a SIGUSR1 in-process restart following an npm upgrade from v2026.4.2
to v2026.4.5, the globalThis singleton created by the old code version
lacks the activeTaskWaiters field added in v2026.4.5.  resolveGlobalSingleton
returns the stale object as-is, causing notifyActiveTaskWaiters() to call
Array.from(undefined) and crash the gateway in a loop.

Add a schema migration step in getQueueState() that patches the missing
field on legacy singleton objects.  Add a regression test that plants a
v2026.4.2-shaped state object and verifies resetAllLanes() and
waitForActiveTasks() succeed without throwing.

Fixes #61905
2026-04-06 15:41:14 +01:00
Vincent Koc
a36bb119be test(secrets): reduce serial vitest drag 2026-04-06 15:40:35 +01:00
Vincent Koc
2815e8ecc0 chore(telegram): drop dead bot helper lint comments 2026-04-06 15:40:13 +01:00
1466 changed files with 45035 additions and 35607 deletions

4
.github/labeler.yml vendored
View File

@@ -257,6 +257,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/acpx/**"
"extensions: arcee":
- changed-files:
- any-glob-to-any-file:
- "extensions/arcee/**"
"extensions: byteplus":
- changed-files:
- any-glob-to-any-file:

View File

@@ -6,35 +6,68 @@ Docs: https://docs.openclaw.ai
### Changes
- CLI/capabilities: add a first-class `openclaw capability ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks, with capability inspection, provider discovery, and consistent JSON output. Thanks @Takhoffman.
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, and doctor flows again, and keep the Docker Claude CLI live lane aligned with the restored guidance.
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
- Tools/media: document per-provider music and video generation capabilities, and add shared live video-to-video sweep coverage for providers that support local reference clips.
### Fixes
- Providers/Google: recognize Gemma model ids in native Google forward-compat resolution, keep the requested provider when cloning fallback templates, and force Gemma reasoning off so Gemma 4 routes stop failing through the Google catalog fallback. (#61507) Thanks @eyjohn.
- Providers/Anthropic: skip `service_tier` injection for OAuth-authenticated stream wrapper requests so Claude OAuth requests stop failing with HTTP 401. (#60356) thanks @openperf.
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, and fail loud on invalid elevated cross-host overrides. (#61739) Thanks @obviyus.
- Agents/history: suppress commentary-only visible-text leaks in streaming and chat history views, and keep sanitized SSE history sequence numbers monotonic after transcript-only refreshes. (#61829) Thanks @100yenadmin.
- Plugins/Windows: load plugin entrypoints through `file://` import specifiers on Windows without breaking plugin SDK alias resolution, fixing `ERR_UNSUPPORTED_ESM_URL_SCHEME` for absolute plugin paths. (#61832) Thanks @Zeesejo.
- CLI/capabilities: keep provider-backed capability behavior aligned with actual runtime execution by fixing explicit TTS override handling, profile-aware gateway TTS prefs resolution, per-request transcription `prompt`/`language` overrides, image output MIME/extension mismatches, configured web-search fallback behavior, and agent-vs-CLI web-search execution drift.
- Channels/secrets: keep bundled channel artifact and secret-contract loading stable under lazy loading so bundled channel secrets continue to appear in `openclaw secret`, status, and security-audit surfaces.
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again so native xAI web-search attribution keeps working on Grok-hosted base URLs. (#61377) Thanks @jjjojoj.
- Providers/Anthropic/cache: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so Anthropic prompt-cache prefixes keep matching after thinking turns. (#61793)
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded so memory indexing no longer reports false-success while semantic recall is impaired.
- MS Teams/security: validate file-consent upload URLs against HTTPS, Microsoft/SharePoint host allowlists, and private-IP DNS checks before uploading attachments, blocking SSRF-style consent-upload abuse. (#23596)
- Discord/gateway monitor: use `ws://` again for gateway monitor sockets so Discord monitor connections recover reliably after recent gateway socket changes.
- Control UI/auth URLs: detect mistaken `?token=` links, show the correct `#token=` fragment hint only on real auth failures, and stop masking the real problem behind a generic device-identity error. (#54842)
- Control UI/chat layout: keep Copy and Canvas actions plus mobile exec-approval overlays from covering chat text or command previews on narrow screens. (#61514)
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps its content attached to the correct list item. (#60997) Thanks @gucasbrg.
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
- Secrets/x_search: keep legacy `x_search` auth resolution working so older xAI web-search configs continue to load after the plugin-owned auth move.
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including background-safe reconnects, persisted pending approvals, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
- Discord/forwarding: recover forwarded referenced message text and attachments when Discord omits snapshot payloads, so forwarded-message relays keep the original content. (#61670) Thanks @artwalker.
- Plugins/install: preserve plugin-schema defaults during fresh-install raw config validation so bundled plugin installs stop failing when required fields rely on schema defaults. (#61856) Thanks @SuperMarioYL.
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
- Agents/message tool: add a `read` plus `threadId` discoverability hint when the configured channel actions support threaded message reads.
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
- Agents/heartbeat: stop truncating live session transcripts after no-op heartbeat acks, move heartbeat cleanup to prompt assembly and compaction, and keep post-filter context-engine ingestion aligned with the real session baseline. (#60998) Thanks @nxmxbbd.
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
- Memory/dreaming: strip managed Light Sleep and REM blocks before daily-note ingestion so dreaming summaries stop re-ingesting their own staged output into new candidates. (#61720) Thanks @MonkeyLeeT.
- Plugins/Windows: disable native Jiti loading for setup and doctor contract registries on Windows so onboarding and config-doctor plugin probes stop crashing with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. (#61836, #61853)
- Gateway/history: seed SSE startup history and raw transcript sequence tracking from one initial transcript snapshot so first history events cannot diverge from subsequent message sequence numbering. (#61855) Thanks @100yenadmin.
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one repair pass, and restore a total-context overflow backstop during tool loops so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
- Gateway/containers: auto-bind to `0.0.0.0` during container startup for Docker and Podman compatibility, while keeping host-side status and doctor checks on the hardened loopback default when `gateway.bind` is unset. (#61818) Thanks @openperf.
- TUI/status: route `/status` through the shared session-status command and move the old gateway-wide diagnostic summary to `/gateway-status` (`/gwstatus`). Thanks @vincentkoc.
- Agents/history: use one shared assistant-visible sanitizer across embedded delivery and chat-history extraction so leaked `<tool_call>` and `<tool_result>` XML blocks stay hidden from user-facing replies. (#61729) Thanks @openperf.
- Gateway/TUI: defer terminal chat finalization for per-attempt lifecycle errors so fallback retries keep streaming before the run is marked failed. (#60043) Thanks @jwchmodx.
- TUI/history and heartbeat: keep assistant commentary hidden on both streamed and reloaded TUI history views, preserve the phase-sanitized REST history contract, and stop forced heartbeat runs from targeting subagent sessions. (#61463) Thanks @100yenadmin.
- TUI/command messages: strip inbound envelope metadata before rendering command/system messages so async completion notices stop leaking raw wrappers into the operator terminal. (#59985) Thanks @MoerAI.
- TUI/terminal: restore Kitty keyboard protocol and `modifyOtherKeys` state on TUI exit and fatal CLI crashes so parent shells stop inheriting broken keyboard input after `openclaw tui` exits. (#49130) Thanks @biefan.
- Plugins/Windows: load plugin entrypoints through `file://` import specifiers on Windows without breaking plugin SDK alias resolution, fixing `ERR_UNSUPPORTED_ESM_URL_SCHEME` for absolute plugin paths. (#61832) Thanks @Zeesejo.
- Plugins/Windows: disable native Jiti loading for setup and doctor contract registries on Windows so onboarding and config-doctor plugin probes stop crashing with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. (#61836, #61853)
- Plugins/install: preserve plugin-schema defaults during fresh-install raw config validation so bundled plugin installs stop failing when required fields rely on schema defaults. (#61856) Thanks @SuperMarioYL.
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
- Gateway/containers: auto-bind to `0.0.0.0` during container startup for Docker and Podman compatibility, while keeping host-side status and doctor checks on the hardened loopback default when `gateway.bind` is unset. (#61818) Thanks @openperf.
- Gateway/status: probe local TLS gateways over `wss://`, forward the local cert fingerprint for self-signed loopback probes, and warn when the local TLS runtime cannot load the configured cert. (#61935) Thanks @ThanhNguyxn07.
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
- Discord/voice: re-arm DAVE receive passthrough without suppressing decrypt-failure rejoin recovery, and clear capture state before finalize teardown so rapid speaker restarts keep their next utterance. (#41536) Thanks @wit-oc.
- Providers/Google: recognize Gemma model ids in native Google forward-compat resolution, keep the requested provider when cloning fallback templates, and force Gemma reasoning off so Gemma 4 routes stop failing through the Google catalog fallback. (#61507) Thanks @eyjohn.
- Providers/Anthropic: skip `service_tier` injection for OAuth-authenticated stream wrapper requests so Claude OAuth requests stop failing with HTTP 401. (#60356) thanks @openperf.
- Providers/OpenAI: keep WebSocket text buffered until a real assistant phase arrives, even when text deltas land before a phaseless `output_item.added` announcement. (#61954) Thanks @100yenadmin.
- Providers/OpenAI: accept case-insensitive `plugins.entries.openai.config.personality` values, keep unknown overrides on the friendly overlay path, and add `on` as an alias for `friendly`. Thanks @vincentkoc.
- Discord/thread titles: stop forcing a hardcoded temperature for generated auto-thread names so Codex-backed thread title generation works on `openai-codex/*` models again. (#59525)
- Agents/message tool: add a `read` plus `threadId` discoverability hint when the configured channel actions support threaded message reads.
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one repair pass, and restore a total-context overflow backstop during tool loops so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, and fail loud on invalid elevated cross-host overrides. (#61739) Thanks @obviyus.
- Agents/heartbeat: stop truncating live session transcripts after no-op heartbeat acks, move heartbeat cleanup to prompt assembly and compaction, and keep post-filter context-engine ingestion aligned with the real session baseline. (#60998) Thanks @nxmxbbd.
- Gateway/TUI: defer terminal chat finalization for per-attempt lifecycle errors so fallback retries keep streaming before the run is marked failed. (#60043) Thanks @jwchmodx.
- Gateway/history: seed SSE startup history and raw transcript sequence tracking from one initial transcript snapshot so first history events cannot diverge from subsequent message sequence numbering. (#61855) Thanks @100yenadmin.
- Agents/history: keep history-based reply reads and subagent completion summaries on `final_answer` text only so internal commentary stops leaking into user-visible follow-up replies. (#61747) Thanks @afurm.
- Agents/history: suppress commentary-only visible-text leaks in streaming and chat history views, and keep sanitized SSE history sequence numbers monotonic after transcript-only refreshes. (#61829) Thanks @100yenadmin.
- Agents/history: use one shared assistant-visible sanitizer across embedded delivery and chat-history extraction so leaked `<tool_call>` and `<tool_result>` XML blocks stay hidden from user-facing replies. (#61729) Thanks @openperf.
- Agents/history: keep truly legacy unsigned replay text unphased when mixed with phased OpenAI WS assistant blocks, while still inheriting message phase for id-only replay signatures. (#61529) Thanks @100yenadmin.
- Memory/dreaming: strip managed Light Sleep and REM blocks before daily-note ingestion so dreaming summaries stop re-ingesting their own staged output into new candidates. (#61720) Thanks @MonkeyLeeT.
- Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819.
- Gateway/command queue: migrate legacy global queue state after in-process SIGUSR1 restarts so pre-4.5 hot-upgrade singletons missing `activeTaskWaiters` stop crashing restart recovery. (#61933) Thanks @openperf.
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
- Agents/session keys: backfill `sessionKey` from `sessionId` in the embedded PI runner when callers omit it, so hooks, LCM, and compaction receive a valid key; also normalize whitespace-only session keys to `undefined` before downstream consumers see them. (#60555) Thanks @100yenadmin.
- Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951)
- Discord/voice: re-arm DAVE receive passthrough without suppressing decrypt-failure rejoin recovery, and clear capture state before finalize teardown so rapid speaker restarts keep their next utterance. (#41536) Thanks @wit-oc.
- Agents/exec: keep `strictInlineEval` commands blocked after approval timeouts on both gateway and node exec hosts, so timeout fallback no longer turns timed-out inline interpreter prompts into automatic execution.
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
- Exec/runtime events: mark background `notifyOnExit` summaries and ACP parent-stream relays as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text.
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
## 2026.4.5
@@ -46,6 +79,7 @@ Docs: https://docs.openclaw.ai
- Agents/video generation: add the built-in `video_generate` tool so agents can create videos through configured providers and return the generated media directly in the reply.
- Agents/music generation: ignore unsupported optional hints such as `durationSeconds` with a warning instead of hard-failing requests on providers like Google Lyria.
- Providers/Arcee AI: add a bundled Arcee AI provider plugin with `ARCEEAI_API_KEY` onboarding, Trinity model catalog (mini, large-preview, large-thinking), OpenAI-compatible API support, and OpenRouter as an alternative auth path. (#62068) Thanks @arthurbr11.
- Providers/ComfyUI: add a bundled `comfy` workflow media plugin for local ComfyUI and Comfy Cloud workflows, including shared `image_generate`, `video_generate`, and workflow-backed `music_generate` support, with prompt injection, optional reference-image upload, live tests, and output download.
- Tools/music generation: add the built-in `music_generate` tool with bundled Google (Lyria) and MiniMax providers plus workflow-backed Comfy support, including async task tracking and follow-up delivery of finished audio.
- Providers: add bundled Qwen, Fireworks AI, and StepFun providers, plus MiniMax TTS, Ollama Web Search, and MiniMax Search integrations for chat, speech, and search workflows. (#60032, #55921, #59318, #54648)
@@ -927,6 +961,7 @@ Docs: https://docs.openclaw.ai
- Security/path resolution: prefer non-user-writable absolute helper binaries for OpenClaw CLI, ffmpeg, and OpenSSL resolution so PATH hijacks cannot replace trusted helpers with attacker-controlled executables.
- Security/gateway command scopes: require `operator.admin` before Telegram target writeback and Talk Voice `/voice set` config writes persist through gateway message flows.
- Security/OpenShell mirror: exclude workspace `hooks/` from mirror sync so untrusted sandbox files cannot become trusted host hooks on gateway startup.
- Exec env policy: block Mercurial config redirects, Rust compiler wrappers, and GNU make flag env vars in host exec sanitization so inherited env and request-scoped overrides cannot redirect build-tool execution.
## 2026.3.24-beta.2

View File

@@ -64,7 +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 scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/

View File

@@ -6,7 +6,7 @@
<title>2026.4.5</title>
<pubDate>Mon, 06 Apr 2026 04:55:17 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026040501</sparkle:version>
<sparkle:version>2026040590</sparkle:version>
<sparkle:shortVersionString>2026.4.5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.5</h2>
@@ -436,4 +436,4 @@
<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>
</channel>
</rss>
</rss>

View File

@@ -3,19 +3,23 @@
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
#include? "../.local-signing.xcconfig"
#include? "../LocalSigning.xcconfig"
CODE_SIGN_STYLE = Automatic
CODE_SIGN_STYLE = $(OPENCLAW_CODE_SIGN_STYLE)
CODE_SIGN_IDENTITY = Apple Development
DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
DEVELOPMENT_TEAM = $(OPENCLAW_DEVELOPMENT_TEAM)
// Let Xcode manage provisioning for the selected local team.
// Let Xcode manage provisioning for the selected local team unless a local override pins one.
PROVISIONING_PROFILE_SPECIFIER =

View File

@@ -13,3 +13,5 @@ OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =

View File

@@ -148,6 +148,9 @@ pnpm ios:beta
- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
- Recommended gateway-host storage for the APNs `.p8` file is `~/.openclaw/credentials/apns/AuthKey_<KEYID>.p8` with restrictive permissions, then point `OPENCLAW_APNS_PRIVATE_KEY_PATH` at that file.
- `apps/ios/fastlane/.env` only covers App Store Connect / Fastlane auth; it does not provide gateway APNs credentials for local direct-push testing.
- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
## APNs Expectations For Official Builds

View File

@@ -61,9 +61,10 @@ final class NodeAppModel {
let request: AgentDeepLink
}
struct ExecApprovalPrompt: Identifiable, Equatable {
struct ExecApprovalPrompt: Identifiable, Equatable, Codable, Sendable {
let id: String
let commandText: String
let commandPreview: String?
let allowedDecisions: [String]
let host: String?
let nodeId: String?
@@ -82,11 +83,17 @@ final class NodeAppModel {
case failed(message: String)
}
private struct PersistedWatchExecApprovalBridgeState: Codable {
var approvals: [ExecApprovalPrompt]
var pendingApprovalIDs: [String]?
}
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchExecApproval")
private let execApprovalNotificationLogger = Logger(
subsystem: "ai.openclaw.ios",
category: "ExecApprovalNotification")
@@ -166,6 +173,8 @@ final class NodeAppModel {
private var backgroundReconnectLeaseUntil: Date?
private var lastSignificantLocationWakeAt: Date?
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
private var pendingWatchExecApprovalRecoveryIDs: [String] = []
private var pendingForegroundActionDrainInFlight = false
private var gatewayConnected = false
@@ -179,6 +188,8 @@ final class NodeAppModel {
var operatorSession: GatewayNodeSession { self.operatorGateway }
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind?
var cameraFlashNonce: Int = 0
@@ -213,12 +224,40 @@ final class NodeAppModel {
self.watchMessagingService = watchMessagingService
self.talkMode = talkMode
self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey)
self.restorePersistedWatchExecApprovalBridgeState()
GatewayDiagnostics.bootstrap()
GatewayDiagnostics.log("node app model: init start")
self.watchMessagingService.setStatusHandler { [weak self] status in
Task { @MainActor in
GatewayDiagnostics.log(
"node app model: watch status callback reachable=\(status.reachable) activation=\(status.activationState) backgrounded=\(self?.isBackgrounded ?? false)")
await self?.handleWatchMessagingStatusChanged(status)
}
}
self.watchMessagingService.setReplyHandler { [weak self] event in
Task { @MainActor in
await self?.handleWatchQuickReply(event)
}
}
self.watchMessagingService.setExecApprovalResolveHandler { [weak self] event in
Task { @MainActor in
await self?.handleWatchExecApprovalResolve(event)
}
}
self.watchMessagingService.setExecApprovalSnapshotRequestHandler { [weak self] event in
Task { @MainActor in
guard let self else { return }
GatewayDiagnostics.log(
"node app model: watch snapshot request id=\(event.requestId) backgrounded=\(self.isBackgrounded)")
guard self.isBackgrounded else {
self.watchExecApprovalLogger.debug(
"watch exec approval snapshot skipped reason=watch_request_foreground")
GatewayDiagnostics.log("node app model: watch snapshot request skipped in foreground")
return
}
await self.refreshWatchExecApprovalSnapshotOnDemand(reason: "watch_request")
}
}
self.voiceWake.configure { [weak self] cmd in
guard let self else { return }
@@ -335,6 +374,7 @@ final class NodeAppModel {
func setScenePhase(_ phase: ScenePhase) {
let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
GatewayDiagnostics.log("node app model: scene phase=\(String(describing: phase))")
switch phase {
case .background:
self.isBackgrounded = true
@@ -2476,6 +2516,7 @@ extension NodeAppModel {
func onNodeGatewayConnected() async {
await self.registerAPNsTokenIfNeeded()
await self.flushQueuedWatchRepliesIfConnected()
await self.syncWatchExecApprovalSnapshot(reason: "node_connected")
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
}
@@ -2622,6 +2663,378 @@ extension NodeAppModel {
return lines.joined(separator: "\n")
}
private func restorePersistedWatchExecApprovalBridgeState() {
guard let data = UserDefaults.standard.data(forKey: Self.watchExecApprovalBridgeStateKey),
let state = try? JSONDecoder().decode(PersistedWatchExecApprovalBridgeState.self, from: data)
else {
return
}
self.watchExecApprovalPromptsByID = Dictionary(
uniqueKeysWithValues: state.approvals.map { ($0.id, $0) })
self.pendingWatchExecApprovalRecoveryIDs = (state.pendingApprovalIDs ?? [])
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.sorted()
self.pruneExpiredWatchExecApprovalPrompts()
}
private func persistWatchExecApprovalBridgeState() {
self.pruneExpiredWatchExecApprovalPrompts()
let approvals = self.watchExecApprovalPromptsByID.values.sorted { lhs, rhs in
let lhsExpires = lhs.expiresAtMs ?? Int.max
let rhsExpires = rhs.expiresAtMs ?? Int.max
if lhsExpires != rhsExpires {
return lhsExpires < rhsExpires
}
return lhs.id < rhs.id
}
let pendingApprovalIDs = self.pendingWatchExecApprovalRecoveryIDs
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.sorted()
guard let data = try? JSONEncoder().encode(
PersistedWatchExecApprovalBridgeState(
approvals: approvals,
pendingApprovalIDs: pendingApprovalIDs))
else {
return
}
UserDefaults.standard.set(data, forKey: Self.watchExecApprovalBridgeStateKey)
}
private func pruneExpiredWatchExecApprovalPrompts(nowMs: Int? = nil) {
let currentNowMs = nowMs ?? Int(Date().timeIntervalSince1970 * 1000)
self.watchExecApprovalPromptsByID = self.watchExecApprovalPromptsByID.filter { _, prompt in
guard let expiresAtMs = prompt.expiresAtMs else { return true }
return expiresAtMs > currentNowMs
}
}
private func handleWatchMessagingStatusChanged(_ status: WatchMessagingStatus) async {
GatewayDiagnostics.log(
"watch exec approval: status changed reachable=\(status.reachable) activation=\(status.activationState) backgrounded=\(self.isBackgrounded)")
guard self.isBackgrounded else { return }
guard status.supported, status.paired, status.appInstalled else { return }
guard status.reachable || status.activationState == "activated" else { return }
let reason = status.reachable ? "watch_reachable" : "watch_activated"
await self.syncWatchExecApprovalSnapshot(reason: reason)
}
private func appendPendingWatchExecApprovalRecoveryID(_ approvalId: String) {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
guard !self.pendingWatchExecApprovalRecoveryIDs.contains(normalizedApprovalID) else { return }
self.pendingWatchExecApprovalRecoveryIDs.append(normalizedApprovalID)
self.pendingWatchExecApprovalRecoveryIDs.sort()
GatewayDiagnostics.log(
"watch exec approval: queued recovery id=\(normalizedApprovalID) pendingCount=\(self.pendingWatchExecApprovalRecoveryIDs.count)")
self.persistWatchExecApprovalBridgeState()
}
private func removePendingWatchExecApprovalRecoveryID(_ approvalId: String) {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
let originalCount = self.pendingWatchExecApprovalRecoveryIDs.count
self.pendingWatchExecApprovalRecoveryIDs.removeAll { $0 == normalizedApprovalID }
guard self.pendingWatchExecApprovalRecoveryIDs.count != originalCount else { return }
GatewayDiagnostics.log(
"watch exec approval: cleared recovery id=\(normalizedApprovalID) pendingCount=\(self.pendingWatchExecApprovalRecoveryIDs.count)")
self.persistWatchExecApprovalBridgeState()
}
private func upsertWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
self.watchExecApprovalPromptsByID[prompt.id] = prompt
self.removePendingWatchExecApprovalRecoveryID(prompt.id)
self.persistWatchExecApprovalBridgeState()
}
private func removeWatchExecApprovalPrompt(_ approvalId: String) {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
self.watchExecApprovalPromptsByID.removeValue(forKey: normalizedApprovalID)
self.removePendingWatchExecApprovalRecoveryID(normalizedApprovalID)
self.persistWatchExecApprovalBridgeState()
}
private static func makeWatchExecApprovalItem(from prompt: ExecApprovalPrompt) -> OpenClawWatchExecApprovalItem {
let decisions = prompt.allowedDecisions.compactMap { decision in
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
return OpenClawWatchExecApprovalDecision(rawValue: normalizedDecision)
}
let preview = Self.trimmedOrNil(prompt.commandPreview) ?? Self.trimmedOrNil(prompt.commandText)
return OpenClawWatchExecApprovalItem(
id: prompt.id,
commandText: prompt.commandText,
commandPreview: preview,
host: Self.trimmedOrNil(prompt.host),
nodeId: Self.trimmedOrNil(prompt.nodeId),
agentId: Self.trimmedOrNil(prompt.agentId),
expiresAtMs: prompt.expiresAtMs,
allowedDecisions: decisions,
// Prefer the watch's neutral/default presentation until exec.approval.get
// carries an explicit risk signal for exec approvals.
risk: nil)
}
nonisolated private static func shouldResetWatchExecApprovalResolvingStateOnPrompt(
reason: String) -> Bool
{
reason == "resolve_retry"
}
private func publishWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt, reason: String) async {
let message = OpenClawWatchExecApprovalPromptMessage(
approval: Self.makeWatchExecApprovalItem(from: prompt),
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
deliveryId: UUID().uuidString,
resetResolvingState: Self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason))
do {
_ = try await self.watchMessagingService.sendExecApprovalPrompt(message)
self.watchExecApprovalLogger.debug(
"watch exec approval prompt sent id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
} catch {
self.watchExecApprovalLogger.error(
"watch exec approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
}
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot")
}
private func publishWatchExecApprovalResolved(
approvalId: String,
decision: OpenClawWatchExecApprovalDecision?,
source: String) async
{
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
self.removeWatchExecApprovalPrompt(normalizedApprovalID)
let message = OpenClawWatchExecApprovalResolvedMessage(
approvalId: normalizedApprovalID,
decision: decision,
resolvedAtMs: Int(Date().timeIntervalSince1970 * 1000),
source: source)
do {
_ = try await self.watchMessagingService.sendExecApprovalResolved(message)
} catch {
self.watchExecApprovalLogger.error(
"watch exec approval resolved update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
}
await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot")
}
private func publishWatchExecApprovalExpired(
approvalId: String,
reason: OpenClawWatchExecApprovalCloseReason) async
{
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
self.removeWatchExecApprovalPrompt(normalizedApprovalID)
let message = OpenClawWatchExecApprovalExpiredMessage(
approvalId: normalizedApprovalID,
reason: reason,
expiredAtMs: Int(Date().timeIntervalSince1970 * 1000))
do {
_ = try await self.watchMessagingService.sendExecApprovalExpired(message)
} catch {
self.watchExecApprovalLogger.error(
"watch exec approval expiry update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
}
await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)")
}
private func syncWatchExecApprovalSnapshot(reason: String) async {
self.pruneExpiredWatchExecApprovalPrompts()
GatewayDiagnostics.log(
"watch exec approval: sync snapshot start reason=\(reason) cacheCount=\(self.watchExecApprovalPromptsByID.count) backgrounded=\(self.isBackgrounded)")
let approvals = self.watchExecApprovalPromptsByID.values
.sorted { lhs, rhs in
let lhsExpires = lhs.expiresAtMs ?? Int.max
let rhsExpires = rhs.expiresAtMs ?? Int.max
if lhsExpires != rhsExpires {
return lhsExpires < rhsExpires
}
return lhs.id < rhs.id
}
.map(Self.makeWatchExecApprovalItem)
let message = OpenClawWatchExecApprovalSnapshotMessage(
approvals: approvals,
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
snapshotId: UUID().uuidString)
do {
_ = try await self.watchMessagingService.syncExecApprovalSnapshot(message)
GatewayDiagnostics.log(
"watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)")
self.watchExecApprovalLogger.debug(
"watch exec approval snapshot sent reason=\(reason, privacy: .public) count=\(approvals.count, privacy: .public)")
} catch {
GatewayDiagnostics.log(
"watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)")
self.watchExecApprovalLogger.error(
"watch exec approval snapshot failed reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
}
}
private func refreshWatchExecApprovalSnapshotOnDemand(reason: String) async {
GatewayDiagnostics.log("watch exec approval: refresh on demand start reason=\(reason)")
await self.hydrateWatchExecApprovalCacheIfNeeded(reason: reason)
await self.syncWatchExecApprovalSnapshot(reason: reason)
GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)")
}
nonisolated private static func watchExecApprovalIDsNeedingFetch(
candidateIDs: [String],
cachedApprovalIDs: [String]) -> [String]
{
let cachedIDs = Set(cachedApprovalIDs.compactMap { id -> String? in
let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
return normalizedID.isEmpty ? nil : normalizedID
})
var idsToFetch: [String] = []
var seen = Set<String>()
for rawID in candidateIDs {
let normalizedID = rawID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { continue }
guard seen.insert(normalizedID).inserted else { continue }
guard !cachedIDs.contains(normalizedID) else { continue }
idsToFetch.append(normalizedID)
}
return idsToFetch
}
private func hydrateWatchExecApprovalCacheIfNeeded(reason: String) async {
self.pruneExpiredWatchExecApprovalPrompts()
let approvalIDs = await self.pendingExecApprovalIDsForWatchRecovery()
let missingApprovalIDs = Self.watchExecApprovalIDsNeedingFetch(
candidateIDs: approvalIDs,
cachedApprovalIDs: Array(self.watchExecApprovalPromptsByID.keys))
GatewayDiagnostics.log(
"watch exec approval: hydrate candidates reason=\(reason) ids=\(approvalIDs.joined(separator: ",")) missing=\(missingApprovalIDs.joined(separator: ",")) cached=\(self.watchExecApprovalPromptsByID.count)")
guard !missingApprovalIDs.isEmpty else {
self.watchExecApprovalLogger.debug(
"watch exec approval hydrate skipped reason=\(reason, privacy: .public): no missing approval ids")
return
}
for approvalId in missingApprovalIDs {
GatewayDiagnostics.log(
"watch exec approval: hydrate fetch start id=\(approvalId) reason=\(reason)")
let outcome = await self.fetchExecApprovalPrompt(
approvalId: approvalId,
sourceReason: reason)
switch outcome {
case let .loaded(prompt):
GatewayDiagnostics.log("watch exec approval: hydrate fetch loaded id=\(approvalId)")
self.upsertWatchExecApprovalPrompt(prompt)
case .stale:
GatewayDiagnostics.log("watch exec approval: hydrate fetch stale id=\(approvalId)")
self.removePendingWatchExecApprovalRecoveryID(approvalId)
await ExecApprovalNotificationBridge.removeNotifications(
forApprovalID: approvalId,
notificationCenter: self.notificationCenter)
case let .failed(message):
self.watchExecApprovalLogger.error(
"watch exec approval hydrate failed id=\(approvalId, privacy: .public) reason=\(reason, privacy: .public) error=\(message, privacy: .public)")
}
}
}
private func pendingExecApprovalIDsForWatchRecovery() async -> [String] {
var ids: [String] = []
var seen = Set<String>()
func append(_ rawID: String?) {
let approvalId = rawID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !approvalId.isEmpty, seen.insert(approvalId).inserted else { return }
ids.append(approvalId)
}
append(self.pendingExecApprovalPrompt?.id)
for approvalId in self.pendingWatchExecApprovalRecoveryIDs {
append(approvalId)
}
for approvalId in self.watchExecApprovalPromptsByID.keys.sorted() {
append(approvalId)
}
let delivered = await self.notificationCenter.deliveredNotifications()
GatewayDiagnostics.log("watch exec approval: delivered notifications count=\(delivered.count)")
for snapshot in delivered {
guard ExecApprovalNotificationBridge.payloadKind(userInfo: snapshot.userInfo)
== ExecApprovalNotificationBridge.requestedKind
else {
continue
}
append(ExecApprovalNotificationBridge.approvalID(from: snapshot.userInfo))
}
return ids
}
private func handleWatchExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) async {
let normalizedApprovalID = event.approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = true
self.pendingExecApprovalPromptErrorText = nil
}
let outcome = await self.resolveExecApprovalNotificationDecision(
approvalId: normalizedApprovalID,
decision: event.decision.rawValue,
sourceReason: "watch_resolve")
if case let .failed(message) = outcome {
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = message
}
if let prompt = self.watchExecApprovalPromptsByID[normalizedApprovalID] {
await self.publishWatchExecApprovalPrompt(prompt, reason: "resolve_retry")
}
}
}
func handleExecApprovalRequestedRemotePush(approvalId: String) async -> Bool {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return false }
self.appendPendingWatchExecApprovalRecoveryID(normalizedApprovalID)
let fetchedPrompt = await self.fetchExecApprovalPrompt(
approvalId: normalizedApprovalID,
sourceReason: "push_request")
switch fetchedPrompt {
case let .loaded(prompt):
self.upsertWatchExecApprovalPrompt(prompt)
await self.publishWatchExecApprovalPrompt(prompt, reason: "push_request")
return true
case .stale:
await ExecApprovalNotificationBridge.removeNotifications(
forApprovalID: normalizedApprovalID,
notificationCenter: self.notificationCenter)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
await self.publishWatchExecApprovalExpired(
approvalId: normalizedApprovalID,
reason: .notFound)
return true
case let .failed(message):
self.watchExecApprovalLogger.error(
"watch exec approval push fetch failed id=\(normalizedApprovalID, privacy: .public) error=\(message, privacy: .public)")
return false
}
}
func handleExecApprovalResolvedRemotePush(approvalId: String) async {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
let hadWatchPrompt = self.watchExecApprovalPromptsByID[normalizedApprovalID] != nil
let hadPendingPrompt = self.pendingExecApprovalPrompt?.id == normalizedApprovalID
let hadPendingRecoveryID = self.pendingWatchExecApprovalRecoveryIDs.contains(normalizedApprovalID)
guard hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID else {
return
}
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .resolved)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
}
func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool {
let wakeId = Self.makePushWakeAttemptID()
guard Self.isSilentPushPayload(userInfo) else {
@@ -2641,13 +3054,24 @@ extension NodeAppModel {
notificationCenter: self.notificationCenter)
{
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
self.clearPendingExecApprovalPromptIfMatches(approvalId)
await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
}
self.execApprovalNotificationLogger.info(
"Handled exec approval cleanup push wakeId=\(wakeId, privacy: .public)")
return true
}
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo) == ExecApprovalNotificationBridge.requestedKind,
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
{
let handled = await self.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
if handled {
self.execApprovalNotificationLogger.info(
"Handled exec approval request push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)")
}
return handled
}
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
let outcomeMessage =
"Silent push outcome wakeId=\(wakeId) "
@@ -2832,6 +3256,7 @@ extension NodeAppModel {
private struct ExecApprovalGetResponse: Decodable {
var id: String
var commandText: String
var commandPreview: String?
var allowedDecisions: [String]
var host: String?
var nodeId: String?
@@ -2861,6 +3286,7 @@ extension NodeAppModel {
forApprovalID: approvalId,
notificationCenter: self.notificationCenter)
self.clearPendingExecApprovalPromptIfMatches(approvalId)
await self.publishWatchExecApprovalExpired(approvalId: approvalId, reason: .notFound)
case let .failed(message):
self.execApprovalNotificationLogger.error(
"Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
@@ -2877,6 +3303,10 @@ extension NodeAppModel {
self.pendingExecApprovalPrompt = prompt
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = nil
self.upsertWatchExecApprovalPrompt(prompt)
Task { @MainActor [weak self] in
await self?.publishWatchExecApprovalPrompt(prompt, reason: "present_prompt")
}
}
private static func makeExecApprovalPrompt(from details: ExecApprovalGetResponse) -> ExecApprovalPrompt? {
@@ -2886,6 +3316,7 @@ extension NodeAppModel {
return ExecApprovalPrompt(
id: approvalId,
commandText: commandText,
commandPreview: details.commandPreview?.trimmingCharacters(in: .whitespacesAndNewlines),
allowedDecisions: details.allowedDecisions.compactMap { decision in
let trimmed = decision.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
@@ -2896,9 +3327,46 @@ extension NodeAppModel {
expiresAtMs: details.expiresAtMs)
}
private func fetchExecApprovalPrompt(approvalId: String) async -> ExecApprovalPromptFetchOutcome {
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
nonisolated private static func shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: String,
isBackgrounded: Bool) -> Bool
{
guard isBackgrounded else { return false }
switch sourceReason {
case "watch_request", "push_request", "watch_resolve", "notification_action":
return true
default:
return false
}
}
private func fetchExecApprovalPrompt(
approvalId: String,
sourceReason: String? = nil) async -> ExecApprovalPromptFetchOutcome
{
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
let fetchReason: String
if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
fetchReason = normalizedSourceReason
} else {
fetchReason = "direct"
}
GatewayDiagnostics.log(
"watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)")
let connected: Bool
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: fetchReason,
isBackgrounded: self.isBackgrounded)
{
connected = await self.ensureOperatorApprovalConnectionForWatchReview(
timeoutMs: 12_000,
reason: fetchReason)
} else {
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
}
guard connected else {
GatewayDiagnostics.log(
"watch exec approval: fetch prompt operator not connected id=\(approvalId) reason=\(fetchReason)")
return .failed(message: "operator_not_connected")
}
@@ -2910,13 +3378,21 @@ extension NodeAppModel {
timeoutSeconds: 12)
let details = try JSONDecoder().decode(ExecApprovalGetResponse.self, from: response)
guard let prompt = Self.makeExecApprovalPrompt(from: details) else {
GatewayDiagnostics.log(
"watch exec approval: fetch prompt invalid payload id=\(approvalId) reason=\(fetchReason)")
return .failed(message: "invalid_prompt_payload")
}
GatewayDiagnostics.log(
"watch exec approval: fetch prompt loaded id=\(approvalId) reason=\(fetchReason)")
return .loaded(prompt)
} catch {
if Self.isApprovalNotificationStaleError(error) {
GatewayDiagnostics.log(
"watch exec approval: fetch prompt stale id=\(approvalId) reason=\(fetchReason)")
return .stale
}
GatewayDiagnostics.log(
"watch exec approval: fetch prompt failed id=\(approvalId) reason=\(fetchReason) error=\(error.localizedDescription)")
return .failed(message: error.localizedDescription)
}
}
@@ -2950,17 +3426,56 @@ extension NodeAppModel {
}
}
private func resolveExecApprovalNotificationDecision(
func handleExecApprovalNotificationDecision(
approvalId: String,
decision: String
) async {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = true
self.pendingExecApprovalPromptErrorText = nil
}
let outcome = await self.resolveExecApprovalNotificationDecision(
approvalId: normalizedApprovalID,
decision: decision)
switch outcome {
case .resolved, .stale, .unavailable:
break
case let .failed(message):
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = message
}
}
}
private func resolveExecApprovalNotificationDecision(
approvalId: String,
decision: String,
sourceReason: String? = nil
) async -> ExecApprovalResolutionOutcome {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolutionReason = (normalizedSourceReason?.isEmpty == false) ? normalizedSourceReason! : "direct"
guard !normalizedApprovalID.isEmpty, !normalizedDecision.isEmpty else {
return .failed(message: "Invalid approval request.")
}
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
let connected: Bool
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: resolutionReason,
isBackgrounded: self.isBackgrounded)
{
connected = await self.ensureOperatorApprovalConnectionForWatchReview(
timeoutMs: 12_000,
reason: resolutionReason)
} else {
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
}
guard connected else {
self.execApprovalNotificationLogger.error(
"Exec approval action failed id=\(normalizedApprovalID, privacy: .public): operator not connected")
@@ -2978,6 +3493,10 @@ extension NodeAppModel {
forApprovalID: normalizedApprovalID,
notificationCenter: self.notificationCenter)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
await self.publishWatchExecApprovalResolved(
approvalId: normalizedApprovalID,
decision: OpenClawWatchExecApprovalDecision(rawValue: normalizedDecision),
source: "iphone")
return .resolved
} catch {
if Self.isApprovalNotificationStaleError(error) {
@@ -2985,6 +3504,7 @@ extension NodeAppModel {
forApprovalID: normalizedApprovalID,
notificationCenter: self.notificationCenter)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .notFound)
return .stale
}
if Self.isApprovalNotificationUnavailableError(error) {
@@ -2992,6 +3512,7 @@ extension NodeAppModel {
forApprovalID: normalizedApprovalID,
notificationCenter: self.notificationCenter)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .unavailable)
return .unavailable
}
let logMessage =
@@ -3096,6 +3617,96 @@ extension NodeAppModel {
sessionBox: sessionBox)
}
private func ensureOperatorApprovalConnectionForWatchReview(timeoutMs: Int, reason: String) async -> Bool {
let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
let reconnectReason = normalizedReason.isEmpty ? "watch_request" : normalizedReason
if await self.isOperatorConnected() {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_connected reason=\(reconnectReason) phase=already_connected")
return true
}
guard self.isBackgrounded else {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=false strategy=default")
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: timeoutMs)
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") reason=\(reconnectReason) phase=foreground_delegate")
return connected
}
guard self.gatewayAutoReconnectEnabled else {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_timeout reason=\(reconnectReason) phase=auto_reconnect_disabled")
return false
}
guard let cfg = self.activeGatewayConnectConfig else {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_timeout reason=\(reconnectReason) phase=no_active_gateway_config")
return false
}
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=true")
let leaseSeconds = min(45.0, max(15.0, Double(max(timeoutMs, 1_000)) / 1000.0 + 8.0))
self.grantBackgroundReconnectLease(seconds: leaseSeconds, reason: "watch_review_\(reconnectReason)")
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_lease_granted reason=\(reconnectReason) seconds=\(leaseSeconds)")
let hadReconnectLoop = self.operatorGatewayTask != nil
let canStartReconnectLoop = hadReconnectLoop || self.shouldStartOperatorGatewayLoop(
token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
stableID: cfg.effectiveStableID)
guard canStartReconnectLoop else {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_timeout reason=\(reconnectReason) phase=no_operator_reconnect_auth")
return false
}
self.ensureOperatorReconnectLoopIfNeeded()
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_loop_\(hadReconnectLoop ? "reused" : "started") reason=\(reconnectReason)")
let initialWaitMs = min(2_500, max(750, timeoutMs / 4))
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_wait reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)")
if await self.waitForOperatorConnection(timeoutMs: initialWaitMs, pollMs: 200) {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_connected reason=\(reconnectReason) phase=initial")
return true
}
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_restart reason=\(reconnectReason)")
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.operatorGateway.disconnect()
self.operatorConnected = false
self.talkMode.updateGatewayConnected(false)
self.stopGatewayHealthMonitor()
let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
self.startOperatorGatewayLoop(
url: cfg.url,
stableID: cfg.effectiveStableID,
token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
nodeOptions: cfg.nodeOptions,
sessionBox: sessionBox)
let remainingWaitMs = max(250, timeoutMs - initialWaitMs)
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_wait reason=\(reconnectReason) phase=restart timeoutMs=\(remainingWaitMs)")
let connected = await self.waitForOperatorConnection(timeoutMs: remainingWaitMs, pollMs: 200)
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") reason=\(reconnectReason) phase=restart")
return connected
}
private func ensureOperatorApprovalConnection(timeoutMs: Int) async -> Bool {
if await self.isOperatorConnected() {
return true
@@ -3526,6 +4137,18 @@ extension NodeAppModel {
self.pendingExecApprovalPrompt
}
func _test_recordPendingWatchExecApprovalRecoveryID(_ approvalId: String) {
self.appendPendingWatchExecApprovalRecoveryID(approvalId)
}
func _test_pendingWatchExecApprovalRecoveryIDs() -> [String] {
self.pendingWatchExecApprovalRecoveryIDs
}
func _test_pendingExecApprovalIDsForWatchRecovery() async -> [String] {
await self.pendingExecApprovalIDsForWatchRecovery()
}
nonisolated static func _test_isApprovalNotificationStaleError(_ error: Error) -> Bool {
self.isApprovalNotificationStaleError(error)
}
@@ -3534,6 +4157,30 @@ extension NodeAppModel {
self.isApprovalNotificationUnavailableError(error)
}
nonisolated static func _test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: String,
isBackgrounded: Bool) -> Bool
{
self.shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: sourceReason,
isBackgrounded: isBackgrounded)
}
nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
candidateIDs: [String],
cachedApprovalIDs: [String]) -> [String]
{
self.watchExecApprovalIDsNeedingFetch(
candidateIDs: candidateIDs,
cachedApprovalIDs: cachedApprovalIDs)
}
nonisolated static func _test_shouldResetWatchExecApprovalResolvingStateOnPrompt(
reason: String) -> Bool
{
self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason)
}
static func _test_makeExecApprovalPrompt(
id: String,
commandText: String,
@@ -3547,6 +4194,7 @@ extension NodeAppModel {
from: ExecApprovalGetResponse(
id: id,
commandText: commandText,
commandPreview: nil,
allowedDecisions: allowedDecisions,
host: host,
nodeId: nodeId,
@@ -3558,6 +4206,10 @@ extension NodeAppModel {
self.expectedDeepLinkKey()
}
static func _test_resetPersistedWatchExecApprovalBridgeState() {
UserDefaults.standard.removeObject(forKey: self.watchExecApprovalBridgeStateKey)
}
nonisolated static func _test_shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,

View File

@@ -15,6 +15,11 @@ private struct PendingWatchPromptAction {
private typealias PendingExecApprovalPrompt = ExecApprovalNotificationPrompt
@MainActor
enum OpenClawAppModelRegistry {
static var appModel: NodeAppModel?
}
@MainActor
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
@@ -24,10 +29,12 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
private var pendingAPNsDeviceToken: Data?
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
private var pendingExecApprovalPrompts: [PendingExecApprovalPrompt] = []
private var pendingExecApprovalRequestedPushIDs: [String] = []
private var pendingExecApprovalResolvedPushIDs: [String] = []
weak var appModel: NodeAppModel? {
didSet {
guard let model = self.appModel else { return }
guard let model = self.resolvedAppModel() else { return }
if let token = self.pendingAPNsDeviceToken {
self.pendingAPNsDeviceToken = nil
Task { @MainActor in
@@ -56,22 +63,56 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
}
}
if !self.pendingExecApprovalRequestedPushIDs.isEmpty {
let pending = self.pendingExecApprovalRequestedPushIDs
self.pendingExecApprovalRequestedPushIDs.removeAll()
Task { @MainActor in
for approvalId in pending {
_ = await model.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
}
}
}
if !self.pendingExecApprovalResolvedPushIDs.isEmpty {
let pending = self.pendingExecApprovalResolvedPushIDs
self.pendingExecApprovalResolvedPushIDs.removeAll()
Task { @MainActor in
for approvalId in pending {
await model.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
}
}
}
}
}
private func resolvedAppModel() -> NodeAppModel? {
self.appModel ?? OpenClawAppModelRegistry.appModel
}
#if DEBUG
func _test_resolvedAppModel() -> NodeAppModel? {
self.resolvedAppModel()
}
#endif
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool
{
GatewayDiagnostics.log("app delegate: didFinishLaunching")
if self.appModel == nil {
self.appModel = OpenClawAppModelRegistry.appModel
}
self.registerBackgroundWakeRefreshTask()
UNUserNotificationCenter.current().delegate = self
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.delegate = self
ExecApprovalNotificationBridge.registerCategory(center: notificationCenter)
application.registerForRemoteNotifications()
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
if let appModel = self.appModel {
if let appModel = self.resolvedAppModel() {
Task { @MainActor in
appModel.updateAPNsDeviceToken(deviceToken)
}
@@ -98,12 +139,22 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
notificationCenter: notificationCenter)
{
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
self.appModel?.dismissPendingExecApprovalPrompt(approvalId: approvalId)
if let appModel = self.resolvedAppModel() {
await appModel.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
} else {
self.pendingExecApprovalResolvedPushIDs.append(approvalId)
}
}
completionHandler(.newData)
return
}
guard let appModel = self.appModel else {
guard let appModel = self.resolvedAppModel() else {
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo)
== ExecApprovalNotificationBridge.requestedKind,
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
{
self.pendingExecApprovalRequestedPushIDs.append(approvalId)
}
self.logger.info("APNs wake skipped: appModel unavailable")
self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model")
completionHandler(.noData)
@@ -119,6 +170,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
func scenePhaseChanged(_ phase: ScenePhase) {
GatewayDiagnostics.log("app delegate: scene phase changed=\(String(describing: phase))")
if phase == .background {
self.scheduleBackgroundWakeRefresh(afterSeconds: 120, reason: "scene_background")
}
@@ -163,7 +215,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
self.backgroundWakeTask?.cancel()
let wakeTask = Task { @MainActor [weak self] in
guard let self, let appModel = self.appModel else { return false }
guard let self, let appModel = self.resolvedAppModel() else { return false }
return await appModel.handleBackgroundRefreshWake(trigger: "bg_app_refresh")
}
self.backgroundWakeTask = wakeTask
@@ -248,7 +300,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async {
guard let appModel = self.appModel else {
guard let appModel = self.resolvedAppModel() else {
self.pendingWatchPromptActions.append(action)
return
}
@@ -261,7 +313,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
private func routeExecApprovalPrompt(_ prompt: PendingExecApprovalPrompt) {
guard let appModel = self.appModel else {
guard let appModel = self.resolvedAppModel() else {
self.pendingExecApprovalPrompts.append(prompt)
return
}
@@ -561,6 +613,7 @@ struct OpenClawApp: App {
Self.installUncaughtExceptionLogger()
GatewaySettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
OpenClawAppModelRegistry.appModel = appModel
_appModel = State(initialValue: appModel)
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
}

View File

@@ -8,9 +8,30 @@ struct ExecApprovalNotificationPrompt: Sendable, Equatable {
enum ExecApprovalNotificationBridge {
static let requestedKind = "exec.approval.requested"
static let resolvedKind = "exec.approval.resolved"
static let categoryIdentifier = "openclaw.exec-approval"
static let reviewActionIdentifier = "openclaw.exec-approval.review"
private static let localRequestPrefix = "exec.approval."
static func registerCategory(center: UNUserNotificationCenter = .current()) {
let category = UNNotificationCategory(
identifier: self.categoryIdentifier,
actions: [
UNNotificationAction(
identifier: self.reviewActionIdentifier,
title: "Review",
options: [.foreground]),
],
intentIdentifiers: [],
options: [])
center.getNotificationCategories { categories in
var updated = categories
updated.update(with: category)
center.setNotificationCategories(updated)
}
}
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
self.payloadKind(userInfo: userInfo) == self.requestedKind
}
@@ -20,7 +41,11 @@ enum ExecApprovalNotificationBridge {
userInfo: [AnyHashable: Any]
) -> ExecApprovalNotificationPrompt?
{
guard actionIdentifier == UNNotificationDefaultActionIdentifier else { return nil }
guard actionIdentifier == UNNotificationDefaultActionIdentifier
|| actionIdentifier == self.reviewActionIdentifier
else {
return nil
}
guard self.payloadKind(userInfo: userInfo) == self.requestedKind else { return nil }
guard let approvalId = self.approvalID(from: userInfo) else { return nil }
return ExecApprovalNotificationPrompt(approvalId: approvalId)
@@ -71,7 +96,7 @@ enum ExecApprovalNotificationBridge {
"\(self.localRequestPrefix)\(approvalId)"
}
private static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
let raw = self.openClawPayload(userInfo: userInfo)?["kind"] as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "unknown" : trimmed

View File

@@ -88,6 +88,20 @@ struct WatchQuickReplyEvent: Sendable, Equatable {
var transport: String
}
struct WatchExecApprovalResolveEvent: Sendable, Equatable {
var replyId: String
var approvalId: String
var decision: OpenClawWatchExecApprovalDecision
var sentAtMs: Int?
var transport: String
}
struct WatchExecApprovalSnapshotRequestEvent: Sendable, Equatable {
var requestId: String
var sentAtMs: Int?
var transport: String
}
struct WatchNotificationSendResult: Sendable, Equatable {
var deliveredImmediately: Bool
var queuedForDelivery: Bool
@@ -96,10 +110,22 @@ struct WatchNotificationSendResult: Sendable, Equatable {
protocol WatchMessagingServicing: AnyObject, Sendable {
func status() async -> WatchMessagingStatus
func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?)
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?)
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?)
func setExecApprovalSnapshotRequestHandler(
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
func sendNotification(
id: String,
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
func sendExecApprovalPrompt(
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
func sendExecApprovalResolved(
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
func sendExecApprovalExpired(
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
func syncExecApprovalSnapshot(
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
}
extension CameraController: CameraServicing {}

View File

@@ -0,0 +1,363 @@
import Foundation
import OSLog
@preconcurrency import WatchConnectivity
private struct WatchConnectivityTransportCallbacks {
var statusUpdateHandler: (@Sendable (WatchMessagingStatus) -> Void)?
var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
}
private func sendReachableWatchMessage(_ payload: [String: Any], with session: WCSession) async throws {
// WatchConnectivity replies arrive on its own queue. Keep this continuation explicitly
// nonisolated so Swift 6 does not inherit a caller actor (for example MainActor) into the
// Objective-C callback boundary and trap on the reply callback executor check.
try await withCheckedThrowingContinuation(isolation: nil) {
(continuation: CheckedContinuation<Void, Error>) in
session.sendMessage(
payload,
replyHandler: { _ in
continuation.resume(returning: ())
},
errorHandler: { error in
continuation.resume(throwing: error)
}
)
}
}
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
private let session: WCSession?
private let callbacksLock = NSLock()
private var callbacks = WatchConnectivityTransportCallbacks()
override init() {
if WCSession.isSupported() {
self.session = WCSession.default
} else {
self.session = nil
}
super.init()
if let session = self.session {
session.delegate = self
session.activate()
}
}
nonisolated static func isSupportedOnDevice() -> Bool {
WCSession.isSupported()
}
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
guard WCSession.isSupported() else {
return WatchMessagingStatus(
supported: false,
paired: false,
appInstalled: false,
reachable: false,
activationState: "unsupported")
}
return self.status(for: WCSession.default)
}
func status() async -> WatchMessagingStatus {
await self.ensureActivated()
return self.currentStatusSnapshot()
}
func currentStatusSnapshot() -> WatchMessagingStatus {
guard let session = self.session else {
return WatchMessagingStatus(
supported: false,
paired: false,
appInstalled: false,
reachable: false,
activationState: "unsupported")
}
return Self.status(for: session)
}
func setStatusUpdateHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
self.updateCallbacks { $0.statusUpdateHandler = handler }
}
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
self.updateCallbacks { $0.replyHandler = handler }
}
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
self.updateCallbacks { $0.execApprovalResolveHandler = handler }
}
func setExecApprovalSnapshotRequestHandler(
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
{
self.updateCallbacks { $0.execApprovalSnapshotRequestHandler = handler }
}
func sendPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult {
await self.ensureActivated()
let session = try self.requireReadySession()
if session.isReachable {
do {
try await sendReachableWatchMessage(payload, with: session)
return WatchNotificationSendResult(
deliveredImmediately: true,
queuedForDelivery: false,
transport: "sendMessage")
} catch {
Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)")
}
}
_ = session.transferUserInfo(payload)
return WatchNotificationSendResult(
deliveredImmediately: false,
queuedForDelivery: true,
transport: "transferUserInfo")
}
func sendSnapshotPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult {
await self.ensureActivated()
let session = try self.requireReadySession()
if session.isReachable {
do {
try await sendReachableWatchMessage(payload, with: session)
return WatchNotificationSendResult(
deliveredImmediately: true,
queuedForDelivery: false,
transport: "sendMessage")
} catch {
Self.logger.error(
"watch snapshot sendMessage failed: \(error.localizedDescription, privacy: .public)")
}
}
do {
try session.updateApplicationContext(payload)
return WatchNotificationSendResult(
deliveredImmediately: false,
queuedForDelivery: true,
transport: "applicationContext")
} catch {
Self.logger.error(
"watch updateApplicationContext failed: \(error.localizedDescription, privacy: .public)")
_ = session.transferUserInfo(payload)
return WatchNotificationSendResult(
deliveredImmediately: false,
queuedForDelivery: true,
transport: "transferUserInfo")
}
}
private func updateCallbacks(_ update: (inout WatchConnectivityTransportCallbacks) -> Void) {
self.callbacksLock.lock()
defer { self.callbacksLock.unlock() }
update(&self.callbacks)
}
private func callbacksSnapshot() -> WatchConnectivityTransportCallbacks {
self.callbacksLock.lock()
defer { self.callbacksLock.unlock() }
return self.callbacks
}
private func requireReadySession() throws -> WCSession {
guard let session = self.session else {
throw WatchMessagingError.unsupported
}
let snapshot = Self.status(for: session)
guard snapshot.paired else {
throw WatchMessagingError.notPaired
}
guard snapshot.appInstalled else {
throw WatchMessagingError.watchAppNotInstalled
}
return session
}
private func ensureActivated() async {
guard let session = self.session else { return }
if session.activationState == .activated {
return
}
session.activate()
for _ in 0..<8 {
if session.activationState == .activated {
return
}
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
private func emitStatusUpdate(_ snapshot: WatchMessagingStatus) {
guard let handler = self.callbacksSnapshot().statusUpdateHandler else {
return
}
Task { @MainActor in
handler(snapshot)
}
}
private func emitReply(_ event: WatchQuickReplyEvent) {
guard let handler = self.callbacksSnapshot().replyHandler else {
return
}
Task { @MainActor in
handler(event)
}
}
private func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
guard let handler = self.callbacksSnapshot().execApprovalResolveHandler else {
return
}
Task { @MainActor in
handler(event)
}
}
private func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
guard let handler = self.callbacksSnapshot().execApprovalSnapshotRequestHandler else {
return
}
Task { @MainActor in
handler(event)
}
}
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
WatchMessagingStatus(
supported: true,
paired: session.isPaired,
appInstalled: session.isWatchAppInstalled,
reachable: session.isReachable,
activationState: self.activationStateLabel(session.activationState))
}
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
switch state {
case .notActivated:
"notActivated"
case .inactive:
"inactive"
case .activated:
"activated"
@unknown default:
"unknown"
}
}
}
extension WatchConnectivityTransport: WCSessionDelegate {
func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: (any Error)?)
{
GatewayDiagnostics.log(
"watch messaging: activation complete state=\(Self.activationStateLabel(activationState)) error=\(error?.localizedDescription ?? "none")")
if let error {
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
} else {
Self.logger.debug(
"watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
}
self.emitStatusUpdate(Self.status(for: session))
}
func sessionDidBecomeInactive(_: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
GatewayDiagnostics.log("watch messaging: session did deactivate; reactivating")
session.activate()
self.emitStatusUpdate(Self.status(for: session))
}
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
let type = (message["type"] as? String) ?? "unknown"
GatewayDiagnostics.log("watch messaging: didReceiveMessage type=\(type)")
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") {
self.emitReply(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
message,
transport: "sendMessage")
{
self.emitExecApprovalResolve(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
message,
transport: "sendMessage")
{
self.emitExecApprovalSnapshotRequest(event)
}
}
func session(
_: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void)
{
let type = (message["type"] as? String) ?? "unknown"
GatewayDiagnostics.log("watch messaging: didReceiveMessageWithReply type=\(type)")
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") {
replyHandler(["ok": true])
self.emitReply(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
message,
transport: "sendMessage")
{
replyHandler(["ok": true])
self.emitExecApprovalResolve(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
message,
transport: "sendMessage")
{
replyHandler(["ok": true])
self.emitExecApprovalSnapshotRequest(event)
return
}
replyHandler(["ok": false, "error": "unsupported_payload"])
}
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
let type = (userInfo["type"] as? String) ?? "unknown"
GatewayDiagnostics.log("watch messaging: didReceiveUserInfo type=\(type)")
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(
userInfo,
transport: "transferUserInfo")
{
self.emitReply(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
userInfo,
transport: "transferUserInfo")
{
self.emitExecApprovalResolve(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
userInfo,
transport: "transferUserInfo")
{
self.emitExecApprovalSnapshotRequest(event)
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
GatewayDiagnostics.log(
"watch messaging: reachability changed reachable=\(session.isReachable) paired=\(session.isPaired) installed=\(session.isWatchAppInstalled)")
self.emitStatusUpdate(Self.status(for: session))
}
}

View File

@@ -0,0 +1,219 @@
import Foundation
import OpenClawKit
enum WatchMessagingPayloadCodec {
static func nowMs() -> Int {
Int(Date().timeIntervalSince1970 * 1000)
}
static func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
static func encodeNotificationPayload(
id: String,
params: OpenClawWatchNotifyParams) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.notify.rawValue,
"id": id,
"title": params.title,
"body": params.body,
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
"sentAtMs": nowMs(),
]
if let promptId = nonEmpty(params.promptId) {
payload["promptId"] = promptId
}
if let sessionKey = nonEmpty(params.sessionKey) {
payload["sessionKey"] = sessionKey
}
if let kind = nonEmpty(params.kind) {
payload["kind"] = kind
}
if let details = nonEmpty(params.details) {
payload["details"] = details
}
if let expiresAtMs = params.expiresAtMs {
payload["expiresAtMs"] = expiresAtMs
}
if let risk = params.risk {
payload["risk"] = risk.rawValue
}
if let actions = params.actions, !actions.isEmpty {
payload["actions"] = actions.map { action in
var encoded: [String: Any] = [
"id": action.id,
"label": action.label,
]
if let style = nonEmpty(action.style) {
encoded["style"] = style
}
return encoded
}
}
return payload
}
static func encodeExecApprovalItem(_ item: OpenClawWatchExecApprovalItem) -> [String: Any] {
var payload: [String: Any] = [
"id": item.id,
"commandText": item.commandText,
"allowedDecisions": item.allowedDecisions.map(\.rawValue),
]
if let commandPreview = nonEmpty(item.commandPreview) {
payload["commandPreview"] = commandPreview
}
if let host = nonEmpty(item.host) {
payload["host"] = host
}
if let nodeId = nonEmpty(item.nodeId) {
payload["nodeId"] = nodeId
}
if let agentId = nonEmpty(item.agentId) {
payload["agentId"] = agentId
}
if let expiresAtMs = item.expiresAtMs {
payload["expiresAtMs"] = expiresAtMs
}
if let risk = item.risk {
payload["risk"] = risk.rawValue
}
return payload
}
static func encodeExecApprovalPromptPayload(
_ message: OpenClawWatchExecApprovalPromptMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue,
"approval": encodeExecApprovalItem(message.approval),
]
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
if let deliveryId = nonEmpty(message.deliveryId) {
payload["deliveryId"] = deliveryId
}
if message.resetResolvingState == true {
payload["resetResolvingState"] = true
}
return payload
}
static func encodeExecApprovalResolvedPayload(
_ message: OpenClawWatchExecApprovalResolvedMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.execApprovalResolved.rawValue,
"approvalId": message.approvalId,
]
if let decision = message.decision {
payload["decision"] = decision.rawValue
}
if let resolvedAtMs = message.resolvedAtMs {
payload["resolvedAtMs"] = resolvedAtMs
}
if let source = nonEmpty(message.source) {
payload["source"] = source
}
return payload
}
static func encodeExecApprovalExpiredPayload(
_ message: OpenClawWatchExecApprovalExpiredMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.execApprovalExpired.rawValue,
"approvalId": message.approvalId,
"reason": message.reason.rawValue,
]
if let expiredAtMs = message.expiredAtMs {
payload["expiredAtMs"] = expiredAtMs
}
return payload
}
static func encodeExecApprovalSnapshotPayload(
_ message: OpenClawWatchExecApprovalSnapshotMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
"approvals": message.approvals.map(encodeExecApprovalItem),
]
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
if let snapshotId = nonEmpty(message.snapshotId) {
payload["snapshotId"] = snapshotId
}
return payload
}
static func parseQuickReplyPayload(
_ payload: [String: Any],
transport: String) -> WatchQuickReplyEvent?
{
guard (payload["type"] as? String) == OpenClawWatchPayloadType.reply.rawValue else {
return nil
}
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
return nil
}
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
let note = nonEmpty(payload["note"] as? String)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchQuickReplyEvent(
replyId: replyId,
promptId: promptId,
actionId: actionId,
actionLabel: actionLabel,
sessionKey: sessionKey,
note: note,
sentAtMs: sentAtMs,
transport: transport)
}
static func parseExecApprovalResolvePayload(
_ payload: [String: Any],
transport: String) -> WatchExecApprovalResolveEvent?
{
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalResolve.rawValue else {
return nil
}
guard let approvalId = nonEmpty(payload["approvalId"] as? String),
let rawDecision = nonEmpty(payload["decision"] as? String),
let decision = OpenClawWatchExecApprovalDecision(rawValue: rawDecision)
else {
return nil
}
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchExecApprovalResolveEvent(
replyId: replyId,
approvalId: approvalId,
decision: decision,
sentAtMs: sentAtMs,
transport: transport)
}
static func parseExecApprovalSnapshotRequestPayload(
_ payload: [String: Any],
transport: String) -> WatchExecApprovalSnapshotRequestEvent?
{
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else {
return nil
}
let requestId = nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchExecApprovalSnapshotRequestEvent(
requestId: requestId,
sentAtMs: sentAtMs,
transport: transport)
}
}

View File

@@ -1,7 +1,5 @@
import Foundation
import OpenClawKit
import OSLog
@preconcurrency import WatchConnectivity
enum WatchMessagingError: LocalizedError {
case unsupported
@@ -21,272 +19,136 @@ enum WatchMessagingError: LocalizedError {
}
@MainActor
final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServicing {
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
private let session: WCSession?
private var pendingActivationContinuations: [CheckedContinuation<Void, Never>] = []
final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
private let transport: WatchConnectivityTransport
private var statusHandler: (@Sendable (WatchMessagingStatus) -> Void)?
private var lastEmittedStatus: WatchMessagingStatus?
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
private var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
private var execApprovalSnapshotRequestHandler: (
@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
override init() {
if WCSession.isSupported() {
self.session = WCSession.default
} else {
self.session = nil
init(transport: WatchConnectivityTransport = WatchConnectivityTransport()) {
self.transport = transport
self.transport.setStatusUpdateHandler { [weak self] snapshot in
Task { @MainActor [weak self] in
self?.emitStatusIfChanged(snapshot)
}
}
super.init()
if let session = self.session {
session.delegate = self
session.activate()
self.transport.setReplyHandler { [weak self] event in
Task { @MainActor [weak self] in
self?.emitReply(event)
}
}
self.transport.setExecApprovalResolveHandler { [weak self] event in
Task { @MainActor [weak self] in
self?.emitExecApprovalResolve(event)
}
}
self.transport.setExecApprovalSnapshotRequestHandler { [weak self] event in
Task { @MainActor [weak self] in
self?.emitExecApprovalSnapshotRequest(event)
}
}
}
nonisolated static func isSupportedOnDevice() -> Bool {
WCSession.isSupported()
WatchConnectivityTransport.isSupportedOnDevice()
}
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
guard WCSession.isSupported() else {
return WatchMessagingStatus(
supported: false,
paired: false,
appInstalled: false,
reachable: false,
activationState: "unsupported")
}
let session = WCSession.default
return status(for: session)
WatchConnectivityTransport.currentStatusSnapshot()
}
func status() async -> WatchMessagingStatus {
await self.ensureActivated()
guard let session = self.session else {
return WatchMessagingStatus(
supported: false,
paired: false,
appInstalled: false,
reachable: false,
activationState: "unsupported")
await self.transport.status()
}
func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
self.statusHandler = handler
guard let handler else {
self.lastEmittedStatus = nil
GatewayDiagnostics.log("watch messaging: cleared status handler")
return
}
return Self.status(for: session)
let snapshot = self.transport.currentStatusSnapshot()
self.lastEmittedStatus = snapshot
GatewayDiagnostics.log(
"watch messaging: set status handler supported=\(snapshot.supported) paired=\(snapshot.paired) appInstalled=\(snapshot.appInstalled) reachable=\(snapshot.reachable) activation=\(snapshot.activationState)")
handler(snapshot)
}
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
self.replyHandler = handler
}
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
self.execApprovalResolveHandler = handler
}
func setExecApprovalSnapshotRequestHandler(
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
{
self.execApprovalSnapshotRequestHandler = handler
}
func sendNotification(
id: String,
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
{
await self.ensureActivated()
guard let session = self.session else {
throw WatchMessagingError.unsupported
}
let snapshot = Self.status(for: session)
guard snapshot.paired else { throw WatchMessagingError.notPaired }
guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled }
var payload: [String: Any] = [
"type": "watch.notify",
"id": id,
"title": params.title,
"body": params.body,
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
"sentAtMs": Int(Date().timeIntervalSince1970 * 1000),
]
if let promptId = Self.nonEmpty(params.promptId) {
payload["promptId"] = promptId
}
if let sessionKey = Self.nonEmpty(params.sessionKey) {
payload["sessionKey"] = sessionKey
}
if let kind = Self.nonEmpty(params.kind) {
payload["kind"] = kind
}
if let details = Self.nonEmpty(params.details) {
payload["details"] = details
}
if let expiresAtMs = params.expiresAtMs {
payload["expiresAtMs"] = expiresAtMs
}
if let risk = params.risk {
payload["risk"] = risk.rawValue
}
if let actions = params.actions, !actions.isEmpty {
payload["actions"] = actions.map { action in
var encoded: [String: Any] = [
"id": action.id,
"label": action.label,
]
if let style = Self.nonEmpty(action.style) {
encoded["style"] = style
}
return encoded
}
}
if snapshot.reachable {
do {
try await self.sendReachableMessage(payload, with: session)
return WatchNotificationSendResult(
deliveredImmediately: true,
queuedForDelivery: false,
transport: "sendMessage")
} catch {
Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)")
}
}
_ = session.transferUserInfo(payload)
return WatchNotificationSendResult(
deliveredImmediately: false,
queuedForDelivery: true,
transport: "transferUserInfo")
let payload = WatchMessagingPayloadCodec.encodeNotificationPayload(id: id, params: params)
return try await self.transport.sendPayload(payload)
}
private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws {
try await withCheckedThrowingContinuation { continuation in
session.sendMessage(
payload,
replyHandler: { _ in
continuation.resume()
},
errorHandler: { error in
continuation.resume(throwing: error)
}
)
func sendExecApprovalPrompt(
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
{
try await self.transport.sendPayload(
WatchMessagingPayloadCodec.encodeExecApprovalPromptPayload(message))
}
func sendExecApprovalResolved(
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
{
try await self.transport.sendPayload(
WatchMessagingPayloadCodec.encodeExecApprovalResolvedPayload(message))
}
func sendExecApprovalExpired(
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
{
try await self.transport.sendPayload(
WatchMessagingPayloadCodec.encodeExecApprovalExpiredPayload(message))
}
func syncExecApprovalSnapshot(
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
{
try await self.transport.sendSnapshotPayload(
WatchMessagingPayloadCodec.encodeExecApprovalSnapshotPayload(message))
}
private func emitStatusIfChanged(_ snapshot: WatchMessagingStatus) {
guard snapshot != self.lastEmittedStatus else {
return
}
self.lastEmittedStatus = snapshot
GatewayDiagnostics.log(
"watch messaging: status supported=\(snapshot.supported) paired=\(snapshot.paired) appInstalled=\(snapshot.appInstalled) reachable=\(snapshot.reachable) activation=\(snapshot.activationState)")
self.statusHandler?(snapshot)
}
private func emitReply(_ event: WatchQuickReplyEvent) {
self.replyHandler?(event)
}
nonisolated private static func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
private func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
self.execApprovalResolveHandler?(event)
}
nonisolated private static func parseQuickReplyPayload(
_ payload: [String: Any],
transport: String) -> WatchQuickReplyEvent?
{
guard (payload["type"] as? String) == "watch.reply" else {
return nil
}
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
return nil
}
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
let note = nonEmpty(payload["note"] as? String)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchQuickReplyEvent(
replyId: replyId,
promptId: promptId,
actionId: actionId,
actionLabel: actionLabel,
sessionKey: sessionKey,
note: note,
sentAtMs: sentAtMs,
transport: transport)
}
private func ensureActivated() async {
guard let session = self.session else { return }
if session.activationState == .activated { return }
session.activate()
await withCheckedContinuation { continuation in
self.pendingActivationContinuations.append(continuation)
}
}
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
WatchMessagingStatus(
supported: true,
paired: session.isPaired,
appInstalled: session.isWatchAppInstalled,
reachable: session.isReachable,
activationState: activationStateLabel(session.activationState))
}
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
switch state {
case .notActivated:
"notActivated"
case .inactive:
"inactive"
case .activated:
"activated"
@unknown default:
"unknown"
}
private func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
GatewayDiagnostics.log(
"watch messaging: snapshot request id=\(event.requestId) transport=\(event.transport) sentAtMs=\(event.sentAtMs ?? -1)")
self.execApprovalSnapshotRequestHandler?(event)
}
}
extension WatchMessagingService: WCSessionDelegate {
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: (any Error)?)
{
if let error {
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
} else {
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
}
// Always resume all waiters so callers never hang, even on error.
Task { @MainActor in
let waiters = self.pendingActivationContinuations
self.pendingActivationContinuations.removeAll()
for continuation in waiters {
continuation.resume()
}
}
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) {
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
return
}
Task { @MainActor in
self.emitReply(event)
}
}
nonisolated func session(
_: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void)
{
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
replyHandler(["ok": false, "error": "unsupported_payload"])
return
}
replyHandler(["ok": true])
Task { @MainActor in
self.emitReply(event)
}
}
nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else {
return
}
Task { @MainActor in
self.emitReply(event)
}
}
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {}
}

View File

@@ -49,6 +49,32 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-123"))
}
@Test func parsePromptMapsReviewAction() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: ExecApprovalNotificationBridge.reviewActionIdentifier,
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-456",
],
])
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-456"))
}
@Test func parsePromptIgnoresUnexpectedActionIdentifiers() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: "openclaw.exec-approval.allow-once",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-789",
],
])
#expect(prompt == nil)
}
@Test @MainActor func handleResolvedPushRemovesMatchingNotifications() async {
let center = MockNotificationCenter()
center.delivered = [

View File

@@ -46,16 +46,37 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
transport: "sendMessage")
var sendError: Error?
var lastSent: (id: String, params: OpenClawWatchNotifyParams)?
var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage?
var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage?
var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage?
var lastSentExecApprovalSnapshot: OpenClawWatchExecApprovalSnapshotMessage?
private var statusHandler: (@Sendable (WatchMessagingStatus) -> Void)?
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
private var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
private var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
func status() async -> WatchMessagingStatus {
self.currentStatus
}
func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
self.statusHandler = handler
}
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
self.replyHandler = handler
}
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
self.execApprovalResolveHandler = handler
}
func setExecApprovalSnapshotRequestHandler(
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
{
self.execApprovalSnapshotRequestHandler = handler
}
func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult {
self.lastSent = (id: id, params: params)
if let sendError = self.sendError {
@@ -64,9 +85,57 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
return self.nextSendResult
}
func sendExecApprovalPrompt(
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
{
self.lastSentExecApprovalPrompt = message
if let sendError = self.sendError {
throw sendError
}
return self.nextSendResult
}
func sendExecApprovalResolved(
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
{
self.lastSentExecApprovalResolved = message
if let sendError = self.sendError {
throw sendError
}
return self.nextSendResult
}
func sendExecApprovalExpired(
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
{
self.lastSentExecApprovalExpired = message
if let sendError = self.sendError {
throw sendError
}
return self.nextSendResult
}
func syncExecApprovalSnapshot(
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
{
self.lastSentExecApprovalSnapshot = message
if let sendError = self.sendError {
throw sendError
}
return self.nextSendResult
}
func emitReply(_ event: WatchQuickReplyEvent) {
self.replyHandler?(event)
}
func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
self.execApprovalResolveHandler?(event)
}
func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
self.execApprovalSnapshotRequestHandler?(event)
}
}
private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable {
@@ -184,6 +253,118 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(prompt.id == "approval-active")
}
@Test @MainActor func presentingExecApprovalPromptSyncsWatchPrompt() async throws {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
let prompt = try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-watch-sync",
commandText: "npm publish",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: "node-1",
agentId: "main",
expiresAtMs: 1234))
appModel._test_presentExecApprovalPrompt(prompt)
await Task.yield()
let sent = try #require(watchService.lastSentExecApprovalPrompt)
#expect(sent.approval.id == "approval-watch-sync")
#expect(sent.approval.allowedDecisions == [.allowOnce, .deny])
#expect(sent.approval.host == "gateway")
#expect(sent.approval.risk == nil)
#expect(sent.resetResolvingState != true)
}
@Test @MainActor func watchExecApprovalSnapshotRequestPublishesCachedApprovalsInBackground() async throws {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60_000
appModel._test_presentExecApprovalPrompt(
try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-watch-snapshot",
commandText: "echo from watch",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: futureExpiryMs)))
await Task.yield()
appModel.setScenePhase(.background)
watchService.emitExecApprovalSnapshotRequest(
WatchExecApprovalSnapshotRequestEvent(
requestId: "snapshot-1",
sentAtMs: 111,
transport: "sendMessage"))
await Task.yield()
let snapshot = try #require(watchService.lastSentExecApprovalSnapshot)
#expect(snapshot.approvals.map(\.id) == ["approval-watch-snapshot"])
}
@Test @MainActor func watchExecApprovalSnapshotRequestSkipsForegroundRecovery() async throws {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60_000
appModel._test_presentExecApprovalPrompt(
try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-watch-foreground-skip",
commandText: "echo foreground",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: futureExpiryMs)))
await Task.yield()
watchService.lastSentExecApprovalSnapshot = nil
watchService.emitExecApprovalSnapshotRequest(
WatchExecApprovalSnapshotRequestEvent(
requestId: "snapshot-foreground",
sentAtMs: 222,
transport: "sendMessage"))
await Task.yield()
#expect(watchService.lastSentExecApprovalSnapshot == nil)
}
@Test @MainActor func pendingWatchRecoveryIDsAreIncludedWithoutDeliveredNotifications() async {
NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState()
defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() }
let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter())
appModel._test_recordPendingWatchExecApprovalRecoveryID("approval-watch-recovery")
let ids = await appModel._test_pendingExecApprovalIDsForWatchRecovery()
#expect(ids == ["approval-watch-recovery"])
}
@Test @MainActor func presentingExecApprovalPromptClearsPendingWatchRecoveryID() throws {
NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState()
defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() }
let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter())
appModel._test_recordPendingWatchExecApprovalRecoveryID("approval-watch-clear")
#expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"])
appModel._test_presentExecApprovalPrompt(
try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-watch-clear",
commandText: "echo clear",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60_000)))
#expect(appModel._test_pendingWatchExecApprovalRecoveryIDs().isEmpty)
}
@Test func approvalNotificationErrorClassificationPrefersStructuredDetails() {
let staleError = GatewayResponseError(
method: "exec.approval.get",
@@ -200,6 +381,48 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(NodeAppModel._test_isApprovalNotificationUnavailableError(unavailableError))
}
@Test func backgroundAwareExecApprovalReconnectCoversWatchAndPushPaths() {
#expect(
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: "watch_request",
isBackgrounded: true)
)
#expect(
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: "push_request",
isBackgrounded: true)
)
#expect(
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: "watch_resolve",
isBackgrounded: true)
)
#expect(
!NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: "direct",
isBackgrounded: true)
)
#expect(
!NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: "watch_request",
isBackgrounded: false)
)
}
@Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() {
let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch(
candidateIDs: ["cached", "pending", "cached", "other", "", " pending "],
cachedApprovalIDs: ["cached", "also-cached"])
#expect(idsToFetch == ["pending", "other"])
}
@Test func watchExecApprovalRetryPromptResetsResolvingStateOnlyForRetryReason() {
#expect(NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "resolve_retry"))
#expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "push_request"))
#expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "present_prompt"))
}
@Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() {
#expect(
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
@@ -590,6 +813,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
note: nil,
sentAtMs: 1234,
transport: "transferUserInfo"))
await Task.yield()
#expect(appModel._test_queuedWatchReplyCount() == 1)
}

View File

@@ -0,0 +1,26 @@
import Testing
@testable import OpenClaw
@Suite(.serialized) struct OpenClawAppDelegateTests {
@Test @MainActor func resolvesRegistryModelBeforeViewTaskAssignsDelegateModel() {
let registryModel = NodeAppModel()
OpenClawAppModelRegistry.appModel = registryModel
defer { OpenClawAppModelRegistry.appModel = nil }
let delegate = OpenClawAppDelegate()
#expect(delegate._test_resolvedAppModel() === registryModel)
}
@Test @MainActor func prefersExplicitDelegateModelOverRegistryFallback() {
let registryModel = NodeAppModel()
let explicitModel = NodeAppModel()
OpenClawAppModelRegistry.appModel = registryModel
defer { OpenClawAppModelRegistry.appModel = nil }
let delegate = OpenClawAppDelegate()
delegate.appModel = explicitModel
#expect(delegate._test_resolvedAppModel() === explicitModel)
}
}

View File

@@ -2,27 +2,79 @@ import SwiftUI
@main
struct OpenClawWatchApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var inboxStore = WatchInboxStore()
@State private var receiver: WatchConnectivityReceiver?
@State private var execApprovalRefreshTask: Task<Void, Never>?
var body: some Scene {
WindowGroup {
WatchInboxView(store: self.inboxStore) { action in
guard let receiver = self.receiver else { return }
let draft = self.inboxStore.makeReplyDraft(action: action)
self.inboxStore.markReplySending(actionLabel: action.label)
Task { @MainActor in
let result = await receiver.sendReply(draft)
self.inboxStore.markReplyResult(result, actionLabel: action.label)
}
}
WatchInboxView(
store: self.inboxStore,
onAction: { action in
guard let receiver = self.receiver else { return }
let draft = self.inboxStore.makeReplyDraft(action: action)
self.inboxStore.markReplySending(actionLabel: action.label)
Task { @MainActor in
let result = await receiver.sendReply(draft)
self.inboxStore.markReplyResult(result, actionLabel: action.label)
}
},
onExecApprovalDecision: { approvalId, decision in
guard let receiver = self.receiver else { return }
self.inboxStore.markExecApprovalSending(approvalId: approvalId, decision: decision)
Task { @MainActor in
let result = await receiver.sendExecApprovalResolve(
approvalId: approvalId,
decision: decision)
self.inboxStore.markExecApprovalSendResult(
approvalId: approvalId,
decision: decision,
result: result)
}
},
onRefreshExecApprovalReview: {
self.refreshExecApprovalReview(force: true)
})
.task {
if self.receiver == nil {
let receiver = WatchConnectivityReceiver(store: self.inboxStore)
receiver.activate()
self.receiver = receiver
}
self.refreshExecApprovalReview()
}
.onChange(of: self.scenePhase) { _, newPhase in
guard newPhase == .active else { return }
self.refreshExecApprovalReview()
}
}
}
private func refreshExecApprovalReview(force: Bool = false) {
guard let receiver = self.receiver else { return }
guard force || self.inboxStore.shouldAutoRequestExecApprovalSnapshot else { return }
self.execApprovalRefreshTask?.cancel()
self.execApprovalRefreshTask = Task { @MainActor in
self.inboxStore.beginExecApprovalReviewLoading()
for attempt in 0..<5 {
if Task.isCancelled { return }
await receiver.requestExecApprovalSnapshot()
if !self.inboxStore.execApprovals.isEmpty
|| self.inboxStore.hasCompletedExecApprovalSnapshotRefresh
{
self.inboxStore.markExecApprovalReviewLoaded()
return
}
if attempt < 4 {
try? await Task.sleep(nanoseconds: 700_000_000)
}
}
if self.inboxStore.execApprovals.isEmpty {
self.inboxStore.markExecApprovalReviewUnavailable(
"Couldn't load approval from your iPhone yet.")
}
}
}
}

View File

@@ -52,6 +52,31 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
}
func requestExecApprovalSnapshot() async {
await self.ensureActivated()
guard let session = self.session else { return }
let request = WatchExecApprovalSnapshotRequestMessage(
requestId: UUID().uuidString,
sentAtMs: Self.nowMs())
let payload = Self.encodeSnapshotRequestPayload(request)
if session.isReachable {
do {
try await withCheckedThrowingContinuation(isolation: nil) {
(continuation: CheckedContinuation<Void, Error>) in
session.sendMessage(payload, replyHandler: { _ in
continuation.resume(returning: ())
}, errorHandler: { error in
continuation.resume(throwing: error)
})
}
return
} catch {
// Fall through to queued delivery.
}
}
_ = session.transferUserInfo(payload)
}
func sendReply(_ draft: WatchReplyDraft) async -> WatchReplySendResult {
await self.ensureActivated()
guard let session = self.session else {
@@ -63,7 +88,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
var payload: [String: Any] = [
"type": "watch.reply",
"type": WatchPayloadType.reply.rawValue,
"replyId": draft.replyId,
"promptId": draft.promptId,
"actionId": draft.actionId,
@@ -83,11 +108,38 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
payload["note"] = note
}
return await self.sendPayload(payload, session: session)
}
func sendExecApprovalResolve(
approvalId: String,
decision: WatchExecApprovalDecision) async -> WatchReplySendResult
{
await self.ensureActivated()
guard let session = self.session else {
return WatchReplySendResult(
deliveredImmediately: false,
queuedForDelivery: false,
transport: "none",
errorMessage: "watch session unavailable")
}
let payload = Self.encodeExecApprovalResolvePayload(
WatchExecApprovalResolveMessage(
approvalId: approvalId,
decision: decision,
replyId: UUID().uuidString,
sentAtMs: Self.nowMs()))
return await self.sendPayload(payload, session: session)
}
private func sendPayload(_ payload: [String: Any], session: WCSession) async -> WatchReplySendResult {
if session.isReachable {
do {
try await withCheckedThrowingContinuation { continuation in
try await withCheckedThrowingContinuation(isolation: nil) {
(continuation: CheckedContinuation<Void, Error>) in
session.sendMessage(payload, replyHandler: { _ in
continuation.resume()
continuation.resume(returning: ())
}, errorHandler: { error in
continuation.resume(throwing: error)
})
@@ -110,6 +162,10 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
errorMessage: nil)
}
private static func nowMs() -> Int {
Int(Date().timeIntervalSince1970 * 1000)
}
private static func normalizeObject(_ value: Any) -> [String: Any]? {
if let object = value as? [String: Any] {
return object
@@ -147,7 +203,9 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
private static func parseNotificationPayload(_ payload: [String: Any]) -> WatchNotifyMessage? {
guard let type = payload["type"] as? String, type == "watch.notify" else {
guard let type = payload["type"] as? String,
type == WatchPayloadType.notify.rawValue
else {
return nil
}
@@ -189,6 +247,153 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
risk: risk,
actions: actions)
}
private static func parseExecApprovalDecision(_ value: Any?) -> WatchExecApprovalDecision? {
let raw = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return WatchExecApprovalDecision(rawValue: raw)
}
private static func parseExecApprovalItem(_ value: Any?) -> WatchExecApprovalItem? {
guard let payload = value.flatMap(Self.normalizeObject) else {
return nil
}
let id = (payload["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let commandText = (payload["commandText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !id.isEmpty, !commandText.isEmpty else {
return nil
}
let commandPreview = (payload["commandPreview"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let host = (payload["host"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let nodeId = (payload["nodeId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let agentId = (payload["agentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue
let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let risk = WatchRiskLevel(rawValue: riskRaw)
let allowedDecisions = (payload["allowedDecisions"] as? [Any] ?? []).compactMap {
Self.parseExecApprovalDecision($0)
}
return WatchExecApprovalItem(
id: id,
commandText: commandText,
commandPreview: commandPreview,
host: host,
nodeId: nodeId,
agentId: agentId,
expiresAtMs: expiresAtMs,
allowedDecisions: allowedDecisions,
risk: risk)
}
private static func parseExecApprovalPromptPayload(
_ payload: [String: Any]) -> WatchExecApprovalPromptMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalPrompt.rawValue,
let approval = Self.parseExecApprovalItem(payload["approval"])
else {
return nil
}
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let deliveryId = (payload["deliveryId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let resetResolvingState = payload["resetResolvingState"] as? Bool
return WatchExecApprovalPromptMessage(
approval: approval,
sentAtMs: sentAtMs,
deliveryId: deliveryId,
resetResolvingState: resetResolvingState)
}
private static func parseExecApprovalResolvedPayload(
_ payload: [String: Any]) -> WatchExecApprovalResolvedMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalResolved.rawValue
else {
return nil
}
let approvalId = (payload["approvalId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !approvalId.isEmpty else { return nil }
let decision = Self.parseExecApprovalDecision(payload["decision"])
let resolvedAtMs = (payload["resolvedAtMs"] as? Int)
?? (payload["resolvedAtMs"] as? NSNumber)?.intValue
let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalResolvedMessage(
approvalId: approvalId,
decision: decision,
resolvedAtMs: resolvedAtMs,
source: source)
}
private static func parseExecApprovalExpiredPayload(
_ payload: [String: Any]) -> WatchExecApprovalExpiredMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalExpired.rawValue
else {
return nil
}
let approvalId = (payload["approvalId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let rawReason = (payload["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !approvalId.isEmpty,
let reason = WatchExecApprovalCloseReason(rawValue: rawReason)
else {
return nil
}
let expiredAtMs = (payload["expiredAtMs"] as? Int) ?? (payload["expiredAtMs"] as? NSNumber)?.intValue
return WatchExecApprovalExpiredMessage(
approvalId: approvalId,
reason: reason,
expiredAtMs: expiredAtMs)
}
private static func parseExecApprovalSnapshotPayload(
_ payload: [String: Any]) -> WatchExecApprovalSnapshotMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalSnapshot.rawValue
else {
return nil
}
let approvals = (payload["approvals"] as? [Any] ?? []).compactMap { item in
Self.parseExecApprovalItem(item)
}
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalSnapshotMessage(
approvals: approvals,
sentAtMs: sentAtMs,
snapshotId: snapshotId)
}
private static func encodeSnapshotRequestPayload(
_ request: WatchExecApprovalSnapshotRequestMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": WatchPayloadType.execApprovalSnapshotRequest.rawValue,
"requestId": request.requestId,
]
if let sentAtMs = request.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
return payload
}
private static func encodeExecApprovalResolvePayload(
_ message: WatchExecApprovalResolveMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": WatchPayloadType.execApprovalResolve.rawValue,
"approvalId": message.approvalId,
"decision": message.decision.rawValue,
"replyId": message.replyId,
]
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
return payload
}
}
extension WatchConnectivityReceiver: WCSessionDelegate {
@@ -196,13 +401,14 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
_: WCSession,
activationDidCompleteWith _: WCSessionActivationState,
error _: (any Error)?)
{}
{
Task {
await self.requestExecApprovalSnapshot()
}
}
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
guard let incoming = Self.parseNotificationPayload(message) else { return }
Task { @MainActor in
self.store.consume(message: incoming, transport: "sendMessage")
}
self.consumeIncomingPayload(message, transport: "sendMessage")
}
func session(
@@ -210,27 +416,47 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void)
{
guard let incoming = Self.parseNotificationPayload(message) else {
replyHandler(["ok": false])
return
}
replyHandler(["ok": true])
Task { @MainActor in
self.store.consume(message: incoming, transport: "sendMessage")
}
self.consumeIncomingPayload(message, transport: "sendMessage")
}
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
guard let incoming = Self.parseNotificationPayload(userInfo) else { return }
Task { @MainActor in
self.store.consume(message: incoming, transport: "transferUserInfo")
}
self.consumeIncomingPayload(userInfo, transport: "transferUserInfo")
}
func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
guard let incoming = Self.parseNotificationPayload(applicationContext) else { return }
Task { @MainActor in
self.store.consume(message: incoming, transport: "applicationContext")
self.consumeIncomingPayload(applicationContext, transport: "applicationContext")
}
private func consumeIncomingPayload(_ payload: [String: Any], transport: String) {
if let incoming = Self.parseNotificationPayload(payload) {
Task { @MainActor in
self.store.consume(message: incoming, transport: transport)
}
return
}
if let prompt = Self.parseExecApprovalPromptPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalPrompt: prompt, transport: transport)
}
return
}
if let resolved = Self.parseExecApprovalResolvedPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalResolved: resolved)
}
return
}
if let expired = Self.parseExecApprovalExpiredPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalExpired: expired)
}
return
}
if let snapshot = Self.parseExecApprovalSnapshotPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalSnapshot: snapshot, transport: transport)
}
}
}
}

View File

@@ -3,6 +3,86 @@ import Observation
import UserNotifications
import WatchKit
enum WatchPayloadType: String, Codable, Sendable, Equatable {
case notify = "watch.notify"
case reply = "watch.reply"
case execApprovalPrompt = "watch.execApproval.prompt"
case execApprovalResolve = "watch.execApproval.resolve"
case execApprovalResolved = "watch.execApproval.resolved"
case execApprovalExpired = "watch.execApproval.expired"
case execApprovalSnapshot = "watch.execApproval.snapshot"
case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest"
}
enum WatchRiskLevel: String, Codable, Sendable, Equatable {
case low
case medium
case high
}
enum WatchExecApprovalDecision: String, Codable, Sendable, Equatable {
case allowOnce = "allow-once"
case deny
}
enum WatchExecApprovalCloseReason: String, Codable, Sendable, Equatable {
case expired
case notFound = "not-found"
case unavailable
case replaced
case resolved
}
struct WatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
var id: String
var commandText: String
var commandPreview: String?
var host: String?
var nodeId: String?
var agentId: String?
var expiresAtMs: Int?
var allowedDecisions: [WatchExecApprovalDecision]
var risk: WatchRiskLevel?
}
struct WatchExecApprovalPromptMessage: Codable, Sendable, Equatable {
var approval: WatchExecApprovalItem
var sentAtMs: Int?
var deliveryId: String?
var resetResolvingState: Bool?
}
struct WatchExecApprovalResolvedMessage: Codable, Sendable, Equatable {
var approvalId: String
var decision: WatchExecApprovalDecision?
var resolvedAtMs: Int?
var source: String?
}
struct WatchExecApprovalExpiredMessage: Codable, Sendable, Equatable {
var approvalId: String
var reason: WatchExecApprovalCloseReason
var expiredAtMs: Int?
}
struct WatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable {
var approvals: [WatchExecApprovalItem]
var sentAtMs: Int?
var snapshotId: String?
}
struct WatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable {
var requestId: String
var sentAtMs: Int?
}
struct WatchExecApprovalResolveMessage: Codable, Sendable, Equatable {
var approvalId: String
var decision: WatchExecApprovalDecision
var replyId: String
var sentAtMs: Int?
}
struct WatchPromptAction: Codable, Sendable, Equatable, Identifiable {
var id: String
var label: String
@@ -23,6 +103,18 @@ struct WatchNotifyMessage: Sendable {
var actions: [WatchPromptAction]
}
struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
var approval: WatchExecApprovalItem
var transport: String
var updatedAt: Date
var isResolving: Bool
var pendingDecision: WatchExecApprovalDecision?
var statusText: String?
var statusAt: Date?
var id: String { self.approval.id }
}
@MainActor @Observable final class WatchInboxStore {
private struct PersistedState: Codable {
var title: String
@@ -39,13 +131,20 @@ struct WatchNotifyMessage: Sendable {
var actions: [WatchPromptAction]?
var replyStatusText: String?
var replyStatusAt: Date?
var execApprovals: [WatchExecApprovalRecord]
var selectedExecApprovalID: String?
var lastExecApprovalSnapshotID: String?
var lastExecApprovalOutcomeText: String?
var lastExecApprovalOutcomeAt: Date?
}
private static let persistedStateKey = "watch.inbox.state.v1"
private static let persistedStateKey = "watch.inbox.state.v2"
private static let defaultTitle = "OpenClaw"
private static let defaultBody = "Waiting for messages from your iPhone."
private let defaults: UserDefaults
var title = "OpenClaw"
var body = "Waiting for messages from your iPhone."
var title = WatchInboxStore.defaultTitle
var body = WatchInboxStore.defaultBody
var transport = "none"
var updatedAt: Date?
var promptId: String?
@@ -58,16 +157,88 @@ struct WatchNotifyMessage: Sendable {
var replyStatusText: String?
var replyStatusAt: Date?
var isReplySending = false
var execApprovals: [WatchExecApprovalRecord] = []
var selectedExecApprovalID: String?
var lastExecApprovalOutcomeText: String?
var lastExecApprovalOutcomeAt: Date?
var isExecApprovalReviewLoading = false
var execApprovalReviewStatusText: String?
var execApprovalReviewStatusAt: Date?
private var lastExecApprovalSnapshotID: String?
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
private var lastDeliveryKey: String?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.restorePersistedState()
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
Task {
await self.ensureNotificationAuthorization()
}
}
var sortedExecApprovals: [WatchExecApprovalRecord] {
self.execApprovals.sorted { lhs, rhs in
let lhsExpires = lhs.approval.expiresAtMs ?? Int.max
let rhsExpires = rhs.approval.expiresAtMs ?? Int.max
if lhsExpires != rhsExpires {
return lhsExpires < rhsExpires
}
return lhs.updatedAt > rhs.updatedAt
}
}
var activeExecApproval: WatchExecApprovalRecord? {
if let selectedExecApprovalID,
let selected = self.execApprovals.first(where: { $0.id == selectedExecApprovalID })
{
return selected
}
return self.sortedExecApprovals.first
}
var shouldAutoRequestExecApprovalSnapshot: Bool {
self.execApprovals.isEmpty
&& self.actions.isEmpty
&& self.title == Self.defaultTitle
&& self.body == Self.defaultBody
&& !self.hasCompletedExecApprovalSnapshotRefreshInSession
}
var hasCompletedExecApprovalSnapshotRefresh: Bool {
self.hasCompletedExecApprovalSnapshotRefreshInSession
}
var shouldShowExecApprovalReviewStatus: Bool {
self.execApprovals.isEmpty && !(self.execApprovalReviewStatusText?.isEmpty ?? true)
}
func beginExecApprovalReviewLoading() {
guard self.execApprovals.isEmpty else {
self.markExecApprovalReviewLoaded()
return
}
self.isExecApprovalReviewLoading = true
self.execApprovalReviewStatusText = "Loading approval from iPhone…"
self.execApprovalReviewStatusAt = Date()
}
func markExecApprovalReviewLoaded() {
self.isExecApprovalReviewLoading = false
self.execApprovalReviewStatusText = nil
self.execApprovalReviewStatusAt = nil
}
func markExecApprovalReviewUnavailable(_ message: String) {
guard self.execApprovals.isEmpty else {
self.markExecApprovalReviewLoaded()
return
}
self.isExecApprovalReviewLoading = false
self.execApprovalReviewStatusText = message
self.execApprovalReviewStatusAt = Date()
}
func consume(message: WatchNotifyMessage, transport: String) {
let messageID = message.id?
.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -82,6 +253,7 @@ struct WatchNotifyMessage: Sendable {
self.title = normalizedTitle
self.body = message.body
self.transport = transport
self.markExecApprovalReviewLoaded()
self.updatedAt = Date()
self.promptId = message.promptId
self.sessionKey = message.sessionKey
@@ -105,6 +277,209 @@ struct WatchNotifyMessage: Sendable {
}
}
func consume(
execApprovalPrompt message: WatchExecApprovalPromptMessage,
transport: String)
{
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
self.upsertExecApproval(
message.approval,
transport: transport,
keepSelectionIfPossible: true,
resetResolvingState: message.resetResolvingState == true)
self.markExecApprovalReviewLoaded()
self.lastExecApprovalOutcomeText = nil
self.lastExecApprovalOutcomeAt = nil
Task {
await self.postLocalNotification(
identifier: "watch.execApproval.\(message.approval.id)",
title: "Exec approval required",
body: message.approval.commandPreview ?? message.approval.commandText,
risk: message.approval.risk?.rawValue)
}
}
func consume(
execApprovalSnapshot message: WatchExecApprovalSnapshotMessage,
transport: String)
{
let snapshotID = message.snapshotId?.trimmingCharacters(in: .whitespacesAndNewlines)
if let snapshotID, !snapshotID.isEmpty, snapshotID == self.lastExecApprovalSnapshotID {
return
}
let existingRecordsByID = Dictionary(
uniqueKeysWithValues: self.execApprovals.map { ($0.id, $0) })
self.execApprovals = message.approvals.map { approval in
self.mergedExecApprovalRecord(
approval: approval,
transport: transport,
existingRecord: existingRecordsByID[approval.id])
}
self.lastExecApprovalSnapshotID = snapshotID
self.hasCompletedExecApprovalSnapshotRefreshInSession = true
if let selectedExecApprovalID,
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
{
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
} else if self.selectedExecApprovalID == nil {
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
}
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
self.markExecApprovalReviewLoaded()
self.persistState()
}
func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) {
self.removeExecApproval(id: message.approvalId)
let statusText: String
switch message.decision {
case .allowOnce:
statusText = "Allowed once"
case .deny:
statusText = "Denied"
case nil:
statusText = "Approval resolved"
}
self.lastExecApprovalOutcomeText = statusText
self.lastExecApprovalOutcomeAt = Date()
self.persistState()
}
func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) {
self.removeExecApproval(id: message.approvalId)
let statusText: String
switch message.reason {
case .expired:
statusText = "Approval expired"
case .notFound:
statusText = "Approval no longer available"
case .resolved:
statusText = "Approval resolved elsewhere"
case .replaced:
statusText = "Approval replaced"
case .unavailable:
statusText = "Approval unavailable"
}
self.lastExecApprovalOutcomeText = statusText
self.lastExecApprovalOutcomeAt = Date()
self.persistState()
}
func selectExecApproval(id: String) {
let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { return }
guard self.execApprovals.contains(where: { $0.id == normalizedID }) else { return }
self.selectedExecApprovalID = normalizedID
self.persistState()
}
func markExecApprovalSending(approvalId: String, decision: WatchExecApprovalDecision) {
guard let index = self.execApprovals.firstIndex(where: { $0.id == approvalId }) else { return }
self.execApprovals[index].isResolving = true
self.execApprovals[index].pendingDecision = decision
self.execApprovals[index].statusText = "Sending \(Self.decisionLabel(decision))"
self.execApprovals[index].statusAt = Date()
self.persistState()
}
func markExecApprovalSendResult(
approvalId: String,
decision: WatchExecApprovalDecision,
result: WatchReplySendResult)
{
guard let index = self.execApprovals.firstIndex(where: { $0.id == approvalId }) else { return }
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
self.execApprovals[index].isResolving = false
self.execApprovals[index].statusText = "Failed: \(errorMessage)"
} else if result.deliveredImmediately {
self.execApprovals[index].isResolving = true
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): sent"
} else if result.queuedForDelivery {
self.execApprovals[index].isResolving = true
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): queued"
} else {
self.execApprovals[index].isResolving = true
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): sent"
}
self.execApprovals[index].pendingDecision = result.errorMessage == nil ? decision : nil
self.execApprovals[index].statusAt = Date()
self.persistState()
}
private func upsertExecApproval(
_ approval: WatchExecApprovalItem,
transport: String,
keepSelectionIfPossible: Bool,
resetResolvingState: Bool = false)
{
if let index = self.execApprovals.firstIndex(where: { $0.id == approval.id }) {
self.execApprovals[index] = self.mergedExecApprovalRecord(
approval: approval,
transport: transport,
existingRecord: self.execApprovals[index],
resetResolvingState: resetResolvingState)
} else {
self.execApprovals.append(
self.mergedExecApprovalRecord(
approval: approval,
transport: transport,
existingRecord: nil,
resetResolvingState: resetResolvingState))
}
if !keepSelectionIfPossible || self.selectedExecApprovalID == nil {
self.selectedExecApprovalID = approval.id
}
self.persistState()
}
private func mergedExecApprovalRecord(
approval: WatchExecApprovalItem,
transport: String,
existingRecord: WatchExecApprovalRecord?,
resetResolvingState: Bool = false) -> WatchExecApprovalRecord
{
// Preserve in-flight state across ordinary snapshot/prompt refreshes so duplicate
// submissions stay disabled, but clear it when the iPhone explicitly republishes a
// prompt after a failed resolve so the watch can retry.
let isResolving = resetResolvingState ? false : (existingRecord?.isResolving ?? false)
let pendingDecision = resetResolvingState ? nil : existingRecord?.pendingDecision
let statusText = resetResolvingState ? nil : existingRecord?.statusText
let statusAt = resetResolvingState ? nil : existingRecord?.statusAt
return WatchExecApprovalRecord(
approval: approval,
transport: transport,
updatedAt: Date(),
isResolving: isResolving,
pendingDecision: pendingDecision,
statusText: statusText,
statusAt: statusAt)
}
private func removeExecApproval(id: String) {
let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { return }
self.execApprovals.removeAll { $0.id == normalizedID }
if self.selectedExecApprovalID == normalizedID {
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
}
self.persistState()
}
private func pruneExpiredExecApprovals(nowMs: Int) {
self.execApprovals.removeAll { record in
guard let expiresAtMs = record.approval.expiresAtMs else { return false }
return expiresAtMs <= nowMs
}
if let selectedExecApprovalID,
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
{
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
}
self.persistState()
}
private func restorePersistedState() {
guard let data = self.defaults.data(forKey: Self.persistedStateKey),
let state = try? JSONDecoder().decode(PersistedState.self, from: data)
@@ -126,10 +501,15 @@ struct WatchNotifyMessage: Sendable {
self.actions = state.actions ?? []
self.replyStatusText = state.replyStatusText
self.replyStatusAt = state.replyStatusAt
self.execApprovals = state.execApprovals
self.selectedExecApprovalID = state.selectedExecApprovalID
self.lastExecApprovalSnapshotID = state.lastExecApprovalSnapshotID
self.lastExecApprovalOutcomeText = state.lastExecApprovalOutcomeText
self.lastExecApprovalOutcomeAt = state.lastExecApprovalOutcomeAt
}
private func persistState() {
guard let updatedAt = self.updatedAt else { return }
let updatedAt = self.updatedAt ?? self.lastExecApprovalOutcomeAt ?? Date()
let state = PersistedState(
title: self.title,
body: self.body,
@@ -144,7 +524,12 @@ struct WatchNotifyMessage: Sendable {
risk: self.risk,
actions: self.actions,
replyStatusText: self.replyStatusText,
replyStatusAt: self.replyStatusAt)
replyStatusAt: self.replyStatusAt,
execApprovals: self.execApprovals,
selectedExecApprovalID: self.selectedExecApprovalID,
lastExecApprovalSnapshotID: self.lastExecApprovalSnapshotID,
lastExecApprovalOutcomeText: self.lastExecApprovalOutcomeText,
lastExecApprovalOutcomeAt: self.lastExecApprovalOutcomeAt)
guard let data = try? JSONEncoder().encode(state) else { return }
self.defaults.set(data, forKey: Self.persistedStateKey)
}
@@ -187,7 +572,7 @@ struct WatchNotifyMessage: Sendable {
actionLabel: action.label,
sessionKey: self.sessionKey,
note: nil,
sentAtMs: Int(Date().timeIntervalSince1970 * 1000))
sentAtMs: Self.nowMs())
}
func markReplySending(actionLabel: String) {
@@ -227,4 +612,17 @@ struct WatchNotifyMessage: Sendable {
_ = try? await UNUserNotificationCenter.current().add(request)
WKInterfaceDevice.current().play(self.mapHapticRisk(risk))
}
private static func decisionLabel(_ decision: WatchExecApprovalDecision) -> String {
switch decision {
case .allowOnce:
"Allow Once"
case .deny:
"Deny"
}
}
private static func nowMs() -> Int {
Int(Date().timeIntervalSince1970 * 1000)
}
}

View File

@@ -1,7 +1,246 @@
import SwiftUI
struct WatchInboxView: View {
@Bindable var store: WatchInboxStore
var store: WatchInboxStore
var onAction: ((WatchPromptAction) -> Void)?
var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)?
var onRefreshExecApprovalReview: (() -> Void)?
var body: some View {
NavigationStack {
if self.store.sortedExecApprovals.count == 1,
let record = self.store.activeExecApproval
{
WatchExecApprovalDetailView(
store: self.store,
record: record,
onDecision: self.onExecApprovalDecision)
} else if !self.store.sortedExecApprovals.isEmpty {
WatchExecApprovalListView(
store: self.store,
onDecision: self.onExecApprovalDecision)
} else if self.store.shouldShowExecApprovalReviewStatus {
WatchExecApprovalLoadingView(
store: self.store,
onRetry: self.onRefreshExecApprovalReview)
} else {
WatchGenericInboxView(store: self.store, onAction: self.onAction)
}
}
}
}
private struct WatchExecApprovalLoadingView: View {
var store: WatchInboxStore
var onRetry: (() -> Void)?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Text("Exec approval")
.font(.headline)
if self.store.isExecApprovalReviewLoading {
ProgressView()
.frame(maxWidth: .infinity, alignment: .leading)
}
if let statusText = self.store.execApprovalReviewStatusText, !statusText.isEmpty {
Text(statusText)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
if !self.store.isExecApprovalReviewLoading {
Button("Retry") {
self.onRetry?()
}
}
Text("Keep your iPhone nearby and unlocked if review details take a moment to appear.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.navigationTitle("Exec approval")
}
}
private struct WatchExecApprovalListView: View {
var store: WatchInboxStore
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
var body: some View {
List {
Section("Exec approvals") {
ForEach(self.store.sortedExecApprovals) { record in
NavigationLink {
WatchExecApprovalDetailView(
store: self.store,
record: record,
onDecision: self.onDecision)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(record.approval.commandPreview ?? record.approval.commandText)
.font(.headline)
.lineLimit(2)
Text(self.metadataLine(for: record))
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
if let statusText = record.statusText, !statusText.isEmpty {
Text(statusText)
.font(.footnote)
.foregroundStyle(record.isResolving ? Color.secondary : Color.red)
.lineLimit(2)
}
}
}
}
}
if let outcome = self.store.lastExecApprovalOutcomeText, !outcome.isEmpty {
Section("Last result") {
Text(outcome)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Approvals")
}
private func metadataLine(for record: WatchExecApprovalRecord) -> String {
var parts: [String] = []
if let host = record.approval.host, !host.isEmpty {
parts.append(host)
}
if let nodeId = record.approval.nodeId, !nodeId.isEmpty {
parts.append(nodeId)
}
if let expiresText = Self.expiresText(record.approval.expiresAtMs) {
parts.append(expiresText)
}
return parts.isEmpty ? "Pending review" : parts.joined(separator: " · ")
}
private static func expiresText(_ expiresAtMs: Int?) -> String? {
guard let expiresAtMs else { return nil }
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
if deltaSeconds < 60 {
return "Expires in <1m"
}
return "Expires in \(deltaSeconds / 60)m"
}
}
private struct WatchExecApprovalDetailView: View {
var store: WatchInboxStore
let record: WatchExecApprovalRecord
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Text(self.record.approval.commandText)
.font(.headline)
.fixedSize(horizontal: false, vertical: true)
if let host = self.record.approval.host, !host.isEmpty {
self.metadataRow(label: "Host", value: host)
}
if let nodeId = self.record.approval.nodeId, !nodeId.isEmpty {
self.metadataRow(label: "Node", value: nodeId)
}
if let agentId = self.record.approval.agentId, !agentId.isEmpty {
self.metadataRow(label: "Agent", value: agentId)
}
if let expiresText = Self.expiresText(self.record.approval.expiresAtMs) {
self.metadataRow(label: "Expires", value: expiresText)
}
if let riskText = self.riskText(self.record.approval.risk) {
self.metadataRow(label: "Risk", value: riskText)
}
if let statusText = self.currentRecord?.statusText, !statusText.isEmpty {
Text(statusText)
.font(.footnote)
.foregroundStyle((self.currentRecord?.isResolving ?? false) ? Color.secondary : Color.red)
}
if let currentRecord,
currentRecord.approval.allowedDecisions.contains(.allowOnce)
{
Button("Allow Once") {
self.onDecision?(currentRecord.id, .allowOnce)
}
.disabled(currentRecord.isResolving)
}
if let currentRecord,
currentRecord.approval.allowedDecisions.contains(.deny)
{
Button(role: .destructive) {
self.onDecision?(currentRecord.id, .deny)
} label: {
Text("Deny")
.frame(maxWidth: .infinity)
}
.disabled(currentRecord.isResolving)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.navigationTitle("Exec approval")
.onAppear {
self.store.selectExecApproval(id: self.record.id)
}
}
private var currentRecord: WatchExecApprovalRecord? {
self.store.execApprovals.first(where: { $0.id == self.record.id })
}
private func metadataRow(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value)
.font(.footnote)
.fixedSize(horizontal: false, vertical: true)
}
}
private func riskText(_ risk: WatchRiskLevel?) -> String? {
switch risk {
case .high:
return "High"
case .medium:
return "Medium"
case .low:
return "Low"
case nil:
return nil
}
}
private static func expiresText(_ expiresAtMs: Int?) -> String? {
guard let expiresAtMs else { return nil }
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
if deltaSeconds < 60 {
return "<1 minute"
}
return "\(deltaSeconds / 60) minutes"
}
}
private struct WatchGenericInboxView: View {
var store: WatchInboxStore
var onAction: ((WatchPromptAction) -> Void)?
private func role(for action: WatchPromptAction) -> ButtonRole? {
@@ -18,40 +257,46 @@ struct WatchInboxView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text(store.title)
Text(self.store.title)
.font(.headline)
.lineLimit(2)
Text(store.body)
Text(self.store.body)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
if let details = store.details, !details.isEmpty {
if let details = self.store.details, !details.isEmpty {
Text(details)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if !store.actions.isEmpty {
ForEach(store.actions) { action in
if let outcome = self.store.lastExecApprovalOutcomeText, !outcome.isEmpty {
Text(outcome)
.font(.footnote)
.foregroundStyle(.secondary)
}
if !self.store.actions.isEmpty {
ForEach(self.store.actions) { action in
Button(role: self.role(for: action)) {
self.onAction?(action)
} label: {
Text(action.label)
.frame(maxWidth: .infinity)
}
.disabled(store.isReplySending)
.disabled(self.store.isReplySending)
}
}
if let replyStatusText = store.replyStatusText, !replyStatusText.isEmpty {
if let replyStatusText = self.store.replyStatusText, !replyStatusText.isEmpty {
Text(replyStatusText)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let updatedAt = store.updatedAt {
if let updatedAt = self.store.updatedAt {
Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -60,5 +305,6 @@ struct WatchInboxView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.navigationTitle("OpenClaw")
}
}

View File

@@ -29,6 +29,8 @@ ASC_KEYCHAIN_SERVICE=openclaw-asc-key
ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
```
Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth and optional beta-archive settings. It does **not** configure gateway-side direct APNs push delivery for local iOS builds.
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
```bash
@@ -53,6 +55,8 @@ IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
Tip: run `scripts/ios-team-id.sh` from repo root to print a Team ID for `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing.
For local/manual iOS builds that stay on direct APNs, configure the gateway host separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`. Those gateway runtime env vars are separate from Fastlane's `.env`.
Validate auth:
```bash

View File

@@ -237,12 +237,19 @@ targets:
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
attributes:
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
settings:
base:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ENABLE_APPINTENTS_METADATA: NO
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_APP_PROFILE)"
info:
path: WatchApp/Info.plist
properties:
@@ -265,9 +272,16 @@ targets:
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
attributes:
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_EXTENSION_PROFILE)"
info:
path: WatchExtension/Info.plist
properties:

View File

@@ -28,6 +28,8 @@ enum HostEnvSecurityPolicy {
"CC",
"CXX",
"CARGO_BUILD_RUSTC",
"CARGO_BUILD_RUSTC_WRAPPER",
"RUSTC_WRAPPER",
"CMAKE_C_COMPILER",
"CMAKE_CXX_COMPILER",
"SHELL",
@@ -44,9 +46,12 @@ enum HostEnvSecurityPolicy {
"DOTNET_ADDITIONAL_DEPS",
"GLIBC_TUNABLES",
"MAVEN_OPTS",
"MAKEFLAGS",
"MFLAGS",
"SBT_OPTS",
"GRADLE_OPTS",
"ANT_OPTS"
"ANT_OPTS",
"HGRCPATH"
]
static let blockedOverrideKeys: Set<String> = [
@@ -83,6 +88,8 @@ enum HostEnvSecurityPolicy {
"CGO_CFLAGS",
"CGO_LDFLAGS",
"GOFLAGS",
"MAKEFLAGS",
"MFLAGS",
"CORECLR_PROFILER_PATH",
"PHPRC",
"PHP_INI_SCAN_DIR",
@@ -134,7 +141,9 @@ enum HostEnvSecurityPolicy {
"GOPRIVATE",
"GOENV",
"GOPATH",
"HGRCPATH",
"PYTHONUSERBASE",
"RUSTC_WRAPPER",
"VIRTUAL_ENV",
"LUA_PATH",
"LUA_CPATH",
@@ -142,6 +151,7 @@ enum HostEnvSecurityPolicy {
"GEM_PATH",
"BUNDLE_GEMFILE",
"COMPOSER_HOME",
"CARGO_BUILD_RUSTC_WRAPPER",
"XDG_CONFIG_HOME",
"AWS_CONFIG_FILE"
]

View File

@@ -361,14 +361,6 @@
}
}
},
"update_plan": {
"emoji": "🗺️",
"title": "Update Plan",
"detailKeys": [
"explanation",
"plan.0.step"
]
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",

View File

@@ -5,12 +5,36 @@ public enum OpenClawWatchCommand: String, Codable, Sendable {
case notify = "watch.notify"
}
public enum OpenClawWatchPayloadType: String, Codable, Sendable, Equatable {
case notify = "watch.notify"
case reply = "watch.reply"
case execApprovalPrompt = "watch.execApproval.prompt"
case execApprovalResolve = "watch.execApproval.resolve"
case execApprovalResolved = "watch.execApproval.resolved"
case execApprovalExpired = "watch.execApproval.expired"
case execApprovalSnapshot = "watch.execApproval.snapshot"
case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest"
}
public enum OpenClawWatchRisk: String, Codable, Sendable, Equatable {
case low
case medium
case high
}
public enum OpenClawWatchExecApprovalDecision: String, Codable, Sendable, Equatable {
case allowOnce = "allow-once"
case deny
}
public enum OpenClawWatchExecApprovalCloseReason: String, Codable, Sendable, Equatable {
case expired
case notFound = "not-found"
case unavailable
case replaced
case resolved
}
public struct OpenClawWatchAction: Codable, Sendable, Equatable {
public var id: String
public var label: String
@@ -23,6 +47,151 @@ public struct OpenClawWatchAction: Codable, Sendable, Equatable {
}
}
public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
public var id: String
public var commandText: String
public var commandPreview: String?
public var host: String?
public var nodeId: String?
public var agentId: String?
public var expiresAtMs: Int?
public var allowedDecisions: [OpenClawWatchExecApprovalDecision]
public var risk: OpenClawWatchRisk?
public init(
id: String,
commandText: String,
commandPreview: String? = nil,
host: String? = nil,
nodeId: String? = nil,
agentId: String? = nil,
expiresAtMs: Int? = nil,
allowedDecisions: [OpenClawWatchExecApprovalDecision] = [],
risk: OpenClawWatchRisk? = nil)
{
self.id = id
self.commandText = commandText
self.commandPreview = commandPreview
self.host = host
self.nodeId = nodeId
self.agentId = agentId
self.expiresAtMs = expiresAtMs
self.allowedDecisions = allowedDecisions
self.risk = risk
}
}
public struct OpenClawWatchExecApprovalPromptMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approval: OpenClawWatchExecApprovalItem
public var sentAtMs: Int?
public var deliveryId: String?
public var resetResolvingState: Bool?
public init(
approval: OpenClawWatchExecApprovalItem,
sentAtMs: Int? = nil,
deliveryId: String? = nil,
resetResolvingState: Bool? = nil)
{
self.type = .execApprovalPrompt
self.approval = approval
self.sentAtMs = sentAtMs
self.deliveryId = deliveryId
self.resetResolvingState = resetResolvingState
}
}
public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvalId: String
public var decision: OpenClawWatchExecApprovalDecision
public var replyId: String
public var sentAtMs: Int?
public init(
approvalId: String,
decision: OpenClawWatchExecApprovalDecision,
replyId: String,
sentAtMs: Int? = nil)
{
self.type = .execApprovalResolve
self.approvalId = approvalId
self.decision = decision
self.replyId = replyId
self.sentAtMs = sentAtMs
}
}
public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvalId: String
public var decision: OpenClawWatchExecApprovalDecision?
public var resolvedAtMs: Int?
public var source: String?
public init(
approvalId: String,
decision: OpenClawWatchExecApprovalDecision? = nil,
resolvedAtMs: Int? = nil,
source: String? = nil)
{
self.type = .execApprovalResolved
self.approvalId = approvalId
self.decision = decision
self.resolvedAtMs = resolvedAtMs
self.source = source
}
}
public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvalId: String
public var reason: OpenClawWatchExecApprovalCloseReason
public var expiredAtMs: Int?
public init(
approvalId: String,
reason: OpenClawWatchExecApprovalCloseReason,
expiredAtMs: Int? = nil)
{
self.type = .execApprovalExpired
self.approvalId = approvalId
self.reason = reason
self.expiredAtMs = expiredAtMs
}
}
public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvals: [OpenClawWatchExecApprovalItem]
public var sentAtMs: Int?
public var snapshotId: String?
public init(
approvals: [OpenClawWatchExecApprovalItem],
sentAtMs: Int? = nil,
snapshotId: String? = nil)
{
self.type = .execApprovalSnapshot
self.approvals = approvals
self.sentAtMs = sentAtMs
self.snapshotId = snapshotId
}
}
public struct OpenClawWatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var requestId: String
public var sentAtMs: Int?
public init(requestId: String, sentAtMs: Int? = nil) {
self.type = .execApprovalSnapshotRequest
self.requestId = requestId
self.sentAtMs = sentAtMs
}
}
public struct OpenClawWatchStatusPayload: Codable, Sendable, Equatable {
public var supported: Bool
public var paired: Bool

View File

@@ -1,2 +1,2 @@
23bfae10a189a7d0548bc7213a9180841bbb1125e97ce1d2d0b7a765773a92fd plugin-sdk-api-baseline.json
6c64b352b19368015c867b4c16225d676110544943497238c2f78602ad2fb519 plugin-sdk-api-baseline.jsonl
08615a28ed3deb20a96c9cd8fd7237a4cbb209ceec93dca03b543979304459e4 plugin-sdk-api-baseline.json
683c1249dc15529d8e79bc75e9c00484551cb74126befee507fffcf786e01833 plugin-sdk-api-baseline.jsonl

View File

@@ -75,10 +75,13 @@ self-check, and writes a Markdown report under `.artifacts/qa-e2e/`.
Private debugger UI:
```bash
pnpm qa:lab:build
pnpm openclaw qa ui
pnpm qa:lab:up
```
That one command builds the QA site, starts the Docker-backed gateway + QA Lab
stack, and prints the QA Lab URL. From that site you can pick scenarios, choose
the model lane, launch individual runs, and watch results live.
Full repo-backed QA suite:
```bash
@@ -96,10 +99,10 @@ Current scope is intentionally narrow:
- threaded routing grammar
- channel-owned message actions
- Markdown reporting
- Docker-backed QA site with run controls
Follow-up work will add:
- Dockerized OpenClaw orchestration
- provider/model matrix execution
- richer scenario discovery
- OpenClaw-native orchestration later

116
docs/cli/capability.md Normal file
View File

@@ -0,0 +1,116 @@
---
summary: "Capability-first CLI for provider-backed model, media, web, and embedding workflows"
read_when:
- Adding or modifying `openclaw capability` commands
- Designing stable headless capability automation
title: "Capability CLI"
---
# Capability CLI
`openclaw capability` is the canonical headless surface for provider-backed capabilities.
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
## Command tree
```text
openclaw capability
list
inspect
model
run
list
inspect
providers
auth login
auth logout
auth status
media
image
generate
edit
describe
describe-many
providers
audio
transcribe
providers
tts
convert
voices
providers
status
enable
disable
set-provider
video
generate
describe
providers
web
search
fetch
providers
memory
embedding
create
providers
```
## Transport
Supported transport flags:
- `--local`
- `--gateway`
Default transport is implicit auto at the command-family level:
- Stateless execution commands default to local.
- Gateway-managed state commands default to gateway.
Examples:
```bash
openclaw capability model run --prompt "hello" --json
openclaw capability media image generate --prompt "friendly lobster" --json
openclaw capability media tts status --json
openclaw capability embedding create --text "hello world" --json
```
## JSON output
Capability commands normalize JSON output under a shared envelope:
```json
{
"ok": true,
"capability": "media.image.generate",
"transport": "local",
"provider": "openai",
"model": "gpt-image-1",
"attempts": [],
"outputs": []
}
```
Top-level fields are stable:
- `ok`
- `capability`
- `transport`
- `provider`
- `model`
- `attempts`
- `outputs`
- `error`
## Notes
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
- `media tts status` defaults to gateway because it reflects gateway-managed TTS state.

View File

@@ -35,6 +35,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`logs`](/cli/logs)
- [`system`](/cli/system)
- [`models`](/cli/models)
- [`capability`](/cli/capability)
- [`memory`](/cli/memory)
- [`directory`](/cli/directory)
- [`nodes`](/cli/nodes)
@@ -248,6 +249,16 @@ openclaw [--dev] [--profile <name>] <command>
fallbacks list|add|remove|clear
image-fallbacks list|add|remove|clear
scan
capability
list
inspect
model run|list|inspect|providers|auth login|logout|status
media image generate|edit|describe|describe-many|providers
media audio transcribe|providers
media tts convert|voices|providers|status|enable|disable|set-provider
media video generate|describe|providers
web search|fetch|providers
embedding create|providers
auth add|login|login-github-copilot|setup-token|paste-token
auth order get|set|clear
sandbox

View File

@@ -21,13 +21,21 @@ Current pieces:
- `qa/`: repo-backed seed assets for the kickoff task and baseline QA
scenarios.
The long-term goal is a two-pane QA site:
The current QA operator flow is a two-pane QA site:
- Left: Gateway dashboard (Control UI) with the agent.
- Right: QA Lab, showing the Slack-ish transcript and scenario plan.
That lets an operator or automation loop give the agent a QA mission, observe
real channel behavior, and record what worked, failed, or stayed blocked.
Run it with:
```bash
pnpm qa:lab:up
```
That builds the QA site, starts the Docker-backed gateway lane, and exposes the
QA Lab page where an operator or automation loop can give the agent a QA
mission, observe real channel behavior, and record what worked, failed, or
stayed blocked.
## Repo-backed seeds

View File

@@ -1158,6 +1158,7 @@
{
"group": "Tools",
"pages": [
"tools/media-overview",
"tools/apply-patch",
{
"group": "Web Browser",
@@ -1230,6 +1231,7 @@
"pages": [
"providers/alibaba",
"providers/anthropic",
"providers/arcee",
"providers/bedrock",
"providers/bedrock-mantle",
"providers/chutes",

View File

@@ -657,6 +657,31 @@ for usage/billing and raise limits as needed.
OpenClaw supports **OpenAI Code (Codex)** via OAuth (ChatGPT sign-in). Onboarding can run the OAuth flow and will set the default model to `openai-codex/gpt-5.4` when appropriate. See [Model providers](/concepts/model-providers) and [Onboarding (CLI)](/start/wizard).
</Accordion>
<Accordion title="Why does ChatGPT GPT-5.4 not unlock openai/gpt-5.4 in OpenClaw?">
OpenClaw treats the two routes separately:
- `openai-codex/gpt-5.4` = ChatGPT/Codex OAuth
- `openai/gpt-5.4` = direct OpenAI Platform API
In OpenClaw, ChatGPT/Codex sign-in is wired to the `openai-codex/*` route,
not the direct `openai/*` route. If you want the direct API path in
OpenClaw, set `OPENAI_API_KEY` (or the equivalent OpenAI provider config).
If you want ChatGPT/Codex sign-in in OpenClaw, use `openai-codex/*`.
</Accordion>
<Accordion title="Why can Codex OAuth limits differ from ChatGPT web?">
`openai-codex/*` uses the Codex OAuth route, and its usable quota windows are
OpenAI-managed and plan-dependent. In practice, those limits can differ from
the ChatGPT website/app experience, even when both are tied to the same account.
OpenClaw can show the currently visible provider usage/quota windows in
`openclaw models status`, but it does not invent or normalize ChatGPT-web
entitlements into direct API access. If you want the direct OpenAI Platform
billing/limit path, use `openai/*` with an API key.
</Accordion>
<Accordion title="Do you support OpenAI subscription auth (Codex OAuth)?">
Yes. OpenClaw fully supports **OpenAI Code (Codex) subscription OAuth**.
OpenAI explicitly allows subscription OAuth usage in external tools/workflows

View File

@@ -26,6 +26,7 @@ Most days:
- Faster local full-suite run on a roomy machine: `pnpm test:max`
- Direct Vitest watch loop: `pnpm test:watch`
- Direct file targeting now routes extension/channel paths too: `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts`
- Docker-backed QA site: `pnpm qa:lab:up`
When you touch tests or want extra confidence:
@@ -46,7 +47,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
### Unit / integration (default)
- Command: `pnpm test`
- Config: native Vitest `projects` via `vitest.config.ts`
- Config: five sequential shard runs (`vitest.full-*.config.ts`) over the existing scoped Vitest projects
- Files: core/unit inventories under `src/**/*.test.ts`, `packages/**/*.test.ts`, `test/**/*.test.ts`, and the whitelisted `ui` node tests covered by `vitest.unit.config.ts`
- Scope:
- Pure unit tests
@@ -57,9 +58,13 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- No real keys required
- Should be fast and stable
- Projects note:
- Untargeted `pnpm test` still uses the native Vitest root `projects` config.
- Untargeted `pnpm test` now runs eight smaller shard configs (`core-unit-src`, `core-unit-security`, `core-unit-support`, `core-contracts`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
- `pnpm test --watch` still uses the native root `vitest.config.ts` project graph, because a multi-shard watch loop is not practical.
- `pnpm test`, `pnpm test:watch`, and `pnpm test:perf:imports` route explicit file/directory targets through scoped lanes first, so `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts` avoids paying the full root project startup tax.
- `pnpm test:changed` expands changed git paths into the same scoped lanes when the diff only touches routable source/test files; config/setup edits still fall back to the broad root-project rerun.
- Selected `plugin-sdk` and `commands` tests also route through dedicated light lanes that skip `test/setup-openclaw-runtime.ts`; stateful/runtime-heavy files stay on the existing lanes.
- Selected `plugin-sdk` and `commands` helper source files also map changed-mode runs to explicit sibling tests in those light lanes, so helper edits avoid rerunning the full heavy suite for that directory.
- `auto-reply` now has three dedicated buckets: top-level core helpers, top-level `reply.*` integration tests, and the `src/auto-reply/reply/**` subtree. This keeps the heaviest reply harness work off the cheap status/chunk/token tests.
- Embedded runner note:
- When you change message-tool discovery inputs or compaction runtime context,
keep both levels of coverage.
@@ -75,7 +80,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Base Vitest config now defaults to `threads`.
- The shared Vitest config also fixes `isolate: false` and uses the non-isolated runner across the root projects, e2e, and live configs.
- The root UI lane keeps its `jsdom` setup and optimizer, but now runs on the shared non-isolated runner too.
- `pnpm test` inherits the same `threads` + `isolate: false` defaults from the root `vitest.config.ts` projects config.
- Each `pnpm test` shard inherits the same `threads` + `isolate: false` defaults from the shared Vitest config.
- The shared `scripts/run-vitest.mjs` launcher now also adds `--no-maglev` for Vitest child Node processes by default to reduce V8 compile churn during big local runs. Set `OPENCLAW_VITEST_ENABLE_MAGLEV=1` if you need to compare against stock V8 behavior.
- Fast-local iteration note:
- `pnpm test:changed` routes through scoped lanes when the changed paths map cleanly to a smaller suite.
@@ -86,6 +91,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Perf-debug note:
- `pnpm test:perf:imports` enables Vitest import-duration reporting plus import-breakdown output.
- `pnpm test:perf:imports:changed` scopes the same profiling view to files changed since `origin/main`.
- `pnpm test:perf:changed:bench -- --ref <git-ref>` compares routed `test:changed` against the native root-project path for that committed diff and prints wall time plus macOS max RSS.
- `pnpm test:perf:changed:bench -- --worktree` benchmarks the current dirty tree by routing the changed file list through `scripts/test-projects.mjs` and the root Vitest config.
- `pnpm test:perf:profile:main` writes a main-thread CPU profile for Vitest/Vite startup and transform overhead.
- `pnpm test:perf:profile:runner` writes runner CPU+heap profiles for the unit suite with file parallelism disabled.
@@ -447,6 +454,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Test: `src/image-generation/runtime.live.test.ts`
- Command: `pnpm test:live src/image-generation/runtime.live.test.ts`
- Harness: `pnpm test:live:media image`
- Scope:
- Enumerates every registered image-generation provider plugin
- Loads missing provider env vars from your login shell (`~/.profile`) before probing
@@ -471,6 +479,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Test: `extensions/music-generation-providers.live.test.ts`
- Enable: `OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/music-generation-providers.live.test.ts`
- Harness: `pnpm test:live:media music`
- Scope:
- Exercises the shared bundled music-generation provider path
- Currently covers Google and MiniMax
@@ -494,6 +503,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Test: `extensions/video-generation-providers.live.test.ts`
- Enable: `OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/video-generation-providers.live.test.ts`
- Harness: `pnpm test:live:media video`
- Scope:
- Exercises the shared bundled video-generation provider path
- Loads provider env vars from your login shell (`~/.profile`) before probing
@@ -501,20 +511,39 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Skips providers with no usable auth/profile/model
- Runs both declared runtime modes when available:
- `generate` with prompt-only input
- `imageToVideo` when the provider declares `capabilities.imageToVideo.enabled`
- `imageToVideo` when the provider declares `capabilities.imageToVideo.enabled` and the selected provider/model accepts buffer-backed local image input in the shared sweep
- `videoToVideo` when the provider declares `capabilities.videoToVideo.enabled` and the selected provider/model accepts buffer-backed local video input in the shared sweep
- Current declared-but-skipped `imageToVideo` providers in the shared sweep:
- `vydra` because bundled `veo3` is text-only and bundled `kling` requires a remote image URL
- Provider-specific Vydra coverage:
- `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_VYDRA_VIDEO=1 pnpm test:live -- extensions/vydra/vydra.live.test.ts`
- that file runs `veo3` text-to-video plus a `kling` lane that uses a remote image URL fixture by default
- Current `videoToVideo` live coverage:
- `google`
- `openai`
- `runway` only when the selected model is `runway/gen4_aleph`
- Current declared-but-skipped `videoToVideo` providers in the shared sweep:
- `alibaba`, `qwen`, `xai` because those paths currently require remote `http(s)` / MP4 reference URLs
- `google` because the current shared Gemini/Veo lane uses local buffer-backed input and that path is not accepted in the shared sweep
- `openai` because the current shared lane lacks org-specific video inpaint/remix access guarantees
- Optional narrowing:
- `OPENCLAW_LIVE_VIDEO_GENERATION_PROVIDERS="google,openai,runway"`
- `OPENCLAW_LIVE_VIDEO_GENERATION_MODELS="google/veo-3.1-fast-generate-preview,openai/sora-2,runway/gen4_aleph"`
- Optional auth behavior:
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides
## Media live harness
- Command: `pnpm test:live:media`
- Purpose:
- Runs the shared image, music, and video live suites through one repo-native entrypoint
- Auto-loads missing provider env vars from `~/.profile`
- Auto-narrows each suite to providers that currently have usable auth by default
- Reuses `scripts/test-live.mjs`, so heartbeat and quiet-mode behavior stay consistent
- Examples:
- `pnpm test:live:media`
- `pnpm test:live:media image video --providers openai,google,minimax`
- `pnpm test:live:media video --video-providers openai,runway --all-providers`
- `pnpm test:live:media music --quiet`
## Docker runners (optional "works in Linux" checks)
These Docker runners split into two buckets:

View File

@@ -1,5 +1,5 @@
---
title: refactor: Make plugin-sdk a real workspace package incrementally
title: "refactor: Make plugin-sdk a real workspace package incrementally"
type: refactor
status: active
date: 2026-04-05

View File

@@ -161,6 +161,22 @@ export OPENCLAW_APNS_KEY_ID="KEYID"
export OPENCLAW_APNS_PRIVATE_KEY_P8="$(cat /path/to/AuthKey_KEYID.p8)"
```
These are gateway-host runtime env vars, not Fastlane settings. `apps/ios/fastlane/.env` only stores
App Store Connect / TestFlight auth such as `ASC_KEY_ID` and `ASC_ISSUER_ID`; it does not configure
direct APNs delivery for local iOS builds.
Recommended gateway-host storage:
```bash
mkdir -p ~/.openclaw/credentials/apns
chmod 700 ~/.openclaw/credentials/apns
mv /path/to/AuthKey_KEYID.p8 ~/.openclaw/credentials/apns/AuthKey_KEYID.p8
chmod 600 ~/.openclaw/credentials/apns/AuthKey_KEYID.p8
export OPENCLAW_APNS_PRIVATE_KEY_PATH="$HOME/.openclaw/credentials/apns/AuthKey_KEYID.p8"
```
Do not commit the `.p8` file or place it under the repo checkout.
## Discovery paths
### Bonjour (LAN)

View File

@@ -609,8 +609,9 @@ conversation, and it runs after core approval handling finishes.
Provider plugins now have two layers:
- manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before
runtime load, plus `providerAuthChoices` for cheap onboarding/auth-choice
- manifest metadata: `providerAuthEnvVars` for cheap provider env-auth lookup
before runtime load, `channelEnvVars` for cheap channel env/setup lookup
before runtime load, plus `providerAuthChoices` for cheap onboarding/auth-choice
labels and CLI flag metadata before runtime load
- config-time hooks: `catalog` / legacy `discovery` plus `applyConfigDefaults`
- runtime hooks: `normalizeModelId`, `normalizeTransport`,
@@ -645,6 +646,10 @@ one-flag auth wiring without loading provider runtime. Keep provider runtime
`envVars` for operator-facing hints such as onboarding labels or OAuth
client-id/client-secret setup vars.
Use manifest `channelEnvVars` when a channel has env-driven auth or setup that
generic shell-env fallback, config/status checks, or setup prompts should see
without loading channel runtime.
### Hook order and usage
For model/provider plugins, OpenClaw calls hooks in this rough order.

View File

@@ -93,6 +93,9 @@ Those belong in your plugin code and `package.json`.
"providerAuthEnvVars": {
"openrouter": ["OPENROUTER_API_KEY"]
},
"channelEnvVars": {
"openrouter-chatops": ["OPENROUTER_CHATOPS_TOKEN"]
},
"providerAuthChoices": [
{
"provider": "openrouter",
@@ -142,6 +145,7 @@ Those belong in your plugin code and `package.json`.
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
| `channelEnvVars` | No | `Record<string, string[]>` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. |
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
| `contracts` | No | `object` | Static bundled capability snapshot for speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. |
| `channelConfigs` | No | `Record<string, object>` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. |
@@ -436,6 +440,9 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s
- `providerAuthEnvVars` is the cheap metadata path for auth probes, env-marker
validation, and similar provider-auth surfaces that should not boot plugin
runtime just to inspect env names.
- `channelEnvVars` is the cheap metadata path for shell-env fallback, setup
prompts, and similar channel surfaces that should not boot plugin runtime
just to inspect env names.
- `providerAuthChoices` is the cheap metadata path for auth-choice pickers,
`--auth-choice` resolution, preferred-provider mapping, and simple onboarding
CLI flag registration before provider runtime loads. For runtime wizard

View File

@@ -108,9 +108,15 @@ For setup specifically:
- `openclaw/plugin-sdk/channel-setup` covers the optional-install setup
builders plus a few setup-safe primitives:
`createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`,
`createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`,
`createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and
`splitSetupEntries`
If your channel supports env-driven setup or auth and generic startup/config
flows should know those env names before runtime loads, declare them in the
plugin manifest with `channelEnvVars`. Keep channel runtime `envVars` or local
constants for operator-facing copy only.
`createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`,
`createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and
`splitSetupEntries`
- use the broader `openclaw/plugin-sdk/setup` seam only when you also need the
heavier shared setup/config helpers such as
`moveSingleAccountChannelSectionToDefaultAccount(...)`

View File

@@ -114,6 +114,7 @@ explicitly promotes one as public.
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
| `plugin-sdk/channel-contract` | Channel contract types |
| `plugin-sdk/channel-feedback` | Feedback/reaction wiring |
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract helpers such as `collectSimpleChannelFieldAssignments`, `getChannelSurface`, `pushAssignment`, and secret target types |
</Accordion>
<Accordion title="Provider subpaths">
@@ -154,6 +155,7 @@ explicitly promotes one as public.
| `plugin-sdk/command-detection` | Shared command detection helpers |
| `plugin-sdk/command-surface` | Command-body normalization and command-surface helpers |
| `plugin-sdk/allow-from` | `formatAllowFromLowercase` |
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract collection helpers for channel/plugin secret surfaces |
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, and SSRF policy helpers |

87
docs/providers/arcee.md Normal file
View File

@@ -0,0 +1,87 @@
---
title: "Arcee AI"
summary: "Arcee AI setup (auth + model selection)"
read_when:
- You want to use Arcee AI with OpenClaw
- You need the API key env var or CLI auth choice
---
# Arcee AI
[Arcee AI](https://arcee.ai) provides access to the Trinity family of mixture-of-experts models through an OpenAI-compatible API. All Trinity models are Apache 2.0 licensed.
Arcee AI models can be accessed directly via the Arcee platform or through [OpenRouter](/providers/openrouter).
- Provider: `arcee`
- Auth: `ARCEEAI_API_KEY` (direct) or `OPENROUTER_API_KEY` (via OpenRouter)
- API: OpenAI-compatible
- Base URL: `https://api.arcee.ai/api/v1` (direct) or `https://openrouter.ai/api/v1` (OpenRouter)
## Quick start
1. Get an API key from [Arcee AI](https://chat.arcee.ai/) or [OpenRouter](https://openrouter.ai/keys).
2. Set the API key (recommended: store it for the Gateway):
```bash
# Direct (Arcee platform)
openclaw onboard --auth-choice arceeai-api-key
# Via OpenRouter
openclaw onboard --auth-choice arceeai-openrouter
```
3. Set a default model:
```json5
{
agents: {
defaults: {
model: { primary: "arcee/trinity-large-thinking" },
},
},
}
```
## Non-interactive example
```bash
# Direct (Arcee platform)
openclaw onboard --non-interactive \
--mode local \
--auth-choice arceeai-api-key \
--arceeai-api-key "$ARCEEAI_API_KEY"
# Via OpenRouter
openclaw onboard --non-interactive \
--mode local \
--auth-choice arceeai-openrouter \
--openrouter-api-key "$OPENROUTER_API_KEY"
```
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure `ARCEEAI_API_KEY`
(or `OPENROUTER_API_KEY`) is available to that process (for example, in
`~/.openclaw/.env` or via `env.shellEnv`).
## Built-in catalog
OpenClaw currently ships this bundled Arcee catalog:
| Model ref | Name | Input | Context | Cost (in/out per 1M) | Notes |
| ------------------------------ | ---------------------- | ----- | ------- | -------------------- | ----------------------------------------- |
| `arcee/trinity-large-thinking` | Trinity Large Thinking | text | 256K | $0.25 / $0.90 | Default model; reasoning enabled |
| `arcee/trinity-large-preview` | Trinity Large Preview | text | 128K | $0.25 / $1.00 | General-purpose; 400B params, 13B active |
| `arcee/trinity-mini` | Trinity Mini 26B | text | 128K | $0.045 / $0.15 | Fast and cost-efficient; function calling |
The same model refs work for both direct and OpenRouter setups (for example `arcee/trinity-large-thinking`).
The onboarding preset sets `arcee/trinity-large-thinking` as the default model.
## Supported features
- Streaming
- Tool use / function calling
- Structured output (JSON mode and JSON schema)
- Extended thinking (Trinity Large Thinking)

View File

@@ -29,6 +29,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Alibaba Model Studio](/providers/alibaba)
- [Amazon Bedrock](/providers/bedrock)
- [Anthropic (API + Claude CLI)](/providers/anthropic)
- [Arcee AI (Trinity models)](/providers/arcee)
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
- [Chutes](/providers/chutes)
- [ComfyUI](/providers/comfy)

View File

@@ -28,6 +28,7 @@ Config key:
Allowed values:
- `"friendly"`: default; enable the OpenAI-specific overlay.
- `"on"`: alias for `"friendly"`.
- `"off"`: disable the overlay and use the base OpenClaw prompt only.
Scope:
@@ -77,11 +78,20 @@ You can also set it directly with the config CLI:
openclaw config set plugins.entries.openai.config.personality off
```
OpenClaw normalizes this setting case-insensitively at runtime, so values like
`"Off"` still disable the friendly overlay.
## Option A: OpenAI API key (OpenAI Platform)
**Best for:** direct API access and usage-based billing.
Get your API key from the OpenAI dashboard.
Route summary:
- `openai/gpt-5.4` = direct OpenAI Platform API route
- Requires `OPENAI_API_KEY` (or equivalent OpenAI provider config)
- In OpenClaw, ChatGPT/Codex sign-in is routed through `openai-codex/*`, not `openai/*`
### CLI setup
```bash
@@ -172,6 +182,12 @@ parameters, provider selection, and failover behavior.
**Best for:** using ChatGPT/Codex subscription access instead of an API key.
Codex cloud requires ChatGPT sign-in, while the Codex CLI supports ChatGPT or API key sign-in.
Route summary:
- `openai-codex/gpt-5.4` = ChatGPT/Codex OAuth route
- Uses ChatGPT/Codex sign-in, not a direct OpenAI Platform API key
- Provider-side limits for `openai-codex/*` can differ from the ChatGPT web/app experience
### CLI setup (Codex OAuth)
```bash
@@ -193,6 +209,10 @@ openclaw models auth login --provider openai-codex
OpenAI's current Codex docs list `gpt-5.4` as the current Codex model. OpenClaw
maps that to `openai-codex/gpt-5.4` for ChatGPT/Codex OAuth usage.
This route is intentionally separate from `openai/gpt-5.4`. If you want the
direct OpenAI Platform API path, use `openai/*` with an API key. If you want
ChatGPT/Codex sign-in, use `openai-codex/*`.
If onboarding reuses an existing Codex CLI login, those credentials stay
managed by Codex CLI. On expiry, OpenClaw re-reads the external Codex source
first and, when the provider can refresh it, writes the refreshed credential

View File

@@ -85,8 +85,28 @@ Notes:
- `vydra/veo3` is bundled as text-to-video only.
- `vydra/kling` currently requires a remote image URL reference. Local file uploads are rejected up front.
- Vydra's current `kling` HTTP route has been inconsistent about whether it requires `image_url` or `video_url`; the bundled provider maps the same remote image URL into both fields.
- The bundled plugin stays conservative and does not forward undocumented style knobs such as aspect ratio, resolution, watermark, or generated audio.
Provider-specific live coverage:
```bash
OPENCLAW_LIVE_TEST=1 \
OPENCLAW_LIVE_VYDRA_VIDEO=1 \
pnpm test:live -- extensions/vydra/vydra.live.test.ts
```
The bundled Vydra live file now covers:
- `vydra/veo3` text-to-video
- `vydra/kling` image-to-video using a remote image URL
Override the remote image fixture when needed:
```bash
export OPENCLAW_LIVE_VYDRA_KLING_IMAGE_URL="https://example.com/reference.png"
```
See [Video Generation](/tools/video-generation) for shared tool behavior.
## Speech synthesis

View File

@@ -41,7 +41,6 @@ Scope intent:
- `talk.providers.*.apiKey`
- `messages.tts.providers.*.apiKey`
- `tools.web.fetch.firecrawl.apiKey`
- `plugins.entries.firecrawl.config.webFetch.apiKey`
- `plugins.entries.brave.config.webSearch.apiKey`
- `plugins.entries.google.config.webSearch.apiKey`
- `plugins.entries.xai.config.webSearch.apiKey`

View File

@@ -13,13 +13,18 @@ title: "Tests"
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test:coverage:changed`: Runs unit coverage only for files changed since `origin/main`.
- `pnpm test:changed`: expands changed git paths into scoped Vitest lanes when the diff only touches routable source/test files. Config/setup changes still fall back to the native root projects run so wiring edits rerun broadly when needed.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes, but still falls back to the native root projects run when you do a full untargeted sweep.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute eight sequential shard configs (`vitest.full-core-unit-src.config.ts`, `vitest.full-core-unit-security.config.ts`, `vitest.full-core-unit-support.config.ts`, `vitest.full-core-contracts.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
- Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes.
- Selected `plugin-sdk` and `commands` helper source files also map `pnpm test:changed` to explicit sibling tests in those light lanes, so small helper edits avoid rerunning the heavy runtime-backed suites.
- `auto-reply` now also splits into three dedicated configs (`core`, `top-level`, `reply`) so the reply harness does not dominate the lighter top-level status/token/helper tests.
- Base Vitest config now defaults to `pool: "threads"` and `isolate: false`, with the shared non-isolated runner enabled across the repo configs.
- `pnpm test:channels` runs `vitest.channels.config.ts`.
- `pnpm test:extensions` runs `vitest.extensions.config.ts`.
- `pnpm test:extensions`: runs extension/plugin suites.
- `pnpm test:perf:imports`: enables Vitest import-duration + import-breakdown reporting, while still using scoped lane routing for explicit file/directory targets.
- `pnpm test:perf:imports:changed`: same import profiling, but only for files changed since `origin/main`.
- `pnpm test:perf:changed:bench -- --ref <git-ref>` benchmarks the routed changed-mode path against the native root-project run for the same committed git diff.
- `pnpm test:perf:changed:bench -- --worktree` benchmarks the current worktree change set without committing first.
- `pnpm test:perf:profile:main`: writes a CPU profile for the Vitest main thread (`.artifacts/vitest-main-profile`).
- `pnpm test:perf:profile:runner`: writes CPU + heap profiles for the unit runner (`.artifacts/vitest-runner-profile`).
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.

View File

@@ -0,0 +1,60 @@
---
summary: "Unified landing page for media generation, understanding, and speech capabilities"
read_when:
- Looking for an overview of media capabilities
- Deciding which media provider to configure
- Understanding how async media generation works
title: "Media Overview"
---
# Media Generation and Understanding
OpenClaw generates images, videos, and music, understands inbound media (images, audio, video), and speaks replies aloud with text-to-speech. All media capabilities are tool-driven: the agent decides when to use them based on the conversation, and each tool only appears when at least one backing provider is configured.
## Capabilities at a glance
| Capability | Tool | Providers | What it does |
| -------------------- | ---------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| Image generation | `image_generate` | ComfyUI, fal, Google, MiniMax, OpenAI, Vydra | Creates or edits images from text prompts or references |
| Video generation | `video_generate` | Alibaba, BytePlus, ComfyUI, fal, Google, MiniMax, OpenAI, Qwen, Runway, Together, Vydra, xAI | Creates videos from text, images, or existing videos |
| Music generation | `music_generate` | ComfyUI, Google, MiniMax | Creates music or audio tracks from text prompts |
| Text-to-speech (TTS) | `tts` | ElevenLabs, Microsoft, MiniMax, OpenAI | Converts outbound replies to spoken audio |
| Media understanding | (automatic) | Any vision/audio-capable model provider, plus CLI fallbacks | Summarizes inbound images, audio, and video |
## Provider capability matrix
This table shows which providers support which media capabilities across the platform.
| Provider | Image | Video | Music | TTS | STT / Transcription | Media Understanding |
| ---------- | ----- | ----- | ----- | --- | ------------------- | ------------------- |
| Alibaba | | Yes | | | | |
| BytePlus | | Yes | | | | |
| ComfyUI | Yes | Yes | Yes | | | |
| Deepgram | | | | | Yes | |
| ElevenLabs | | | | Yes | | |
| fal | Yes | Yes | | | | |
| Google | Yes | Yes | Yes | | | Yes |
| Microsoft | | | | Yes | | |
| MiniMax | Yes | Yes | Yes | Yes | | |
| OpenAI | Yes | Yes | | Yes | Yes | Yes |
| Qwen | | Yes | | | | |
| Runway | | Yes | | | | |
| Together | | Yes | | | | |
| Vydra | Yes | Yes | | | | |
| xAI | | Yes | | | | |
<Note>
Media understanding uses any vision-capable or audio-capable model registered in your provider config. The table above highlights providers with dedicated media-understanding support; most LLM providers with multimodal models (Anthropic, Google, OpenAI, etc.) can also understand inbound media when configured as the active reply model.
</Note>
## How async generation works
Video and music generation run as background tasks because provider processing typically takes 30 seconds to several minutes. When the agent calls `video_generate` or `music_generate`, OpenClaw submits the request to the provider, returns a task ID immediately, and tracks the job in the task ledger. The agent continues responding to other messages while the job runs. When the provider finishes, OpenClaw wakes the agent so it can post the finished media back into the original channel. Image generation and TTS are synchronous and complete inline with the reply.
## Quick links
- [Image Generation](/tools/image-generation) -- generating and editing images
- [Video Generation](/tools/video-generation) -- text-to-video, image-to-video, and video-to-video
- [Music Generation](/tools/music-generation) -- creating music and audio tracks
- [Text-to-Speech](/tools/tts) -- converting replies to spoken audio
- [Media Understanding](/nodes/media-understanding) -- understanding inbound images, audio, and video

View File

@@ -144,6 +144,25 @@ ignored with a warning when the selected provider or model cannot honor them.
- Prompt hint: later user/manual turns in the same session get a small runtime hint when a music task is already in flight so the model does not blindly call `music_generate` again.
- No-session fallback: direct/local contexts without a real agent session still run inline and return the final audio result in the same turn.
### Task lifecycle
Each `music_generate` request moves through four states:
1. **queued** -- task created, waiting for the provider to accept it.
2. **running** -- provider is processing (typically 30 seconds to 3 minutes depending on provider and duration).
3. **succeeded** -- track ready; the agent wakes and posts it to the conversation.
4. **failed** -- provider error or timeout; the agent wakes with error details.
Check status from the CLI:
```bash
openclaw tasks list
openclaw tasks show <taskId>
openclaw tasks cancel <taskId>
```
Duplicate prevention: if a music task is already `queued` or `running` for the current session, `music_generate` returns the existing task status instead of starting a new one. Use `action: "status"` to check explicitly without triggering a new generation.
## Configuration
### Model selection
@@ -229,6 +248,12 @@ Opt-in live coverage for the shared bundled providers:
OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/music-generation-providers.live.test.ts
```
Repo wrapper:
```bash
pnpm test:live:media music
```
This live file loads missing provider env vars from `~/.profile`, prefers
live/env API keys ahead of stored auth profiles by default, and runs both
`generate` and declared `edit` coverage when the provider enables edit mode.

View File

@@ -57,6 +57,25 @@ While a job is in flight, duplicate `video_generate` calls in the same session r
Outside of session-backed agent runs (for example, direct tool invocations), the tool falls back to inline generation and returns the final media path in the same turn.
### Task lifecycle
Each `video_generate` request moves through four states:
1. **queued** -- task created, waiting for the provider to accept it.
2. **running** -- provider is processing (typically 30 seconds to 5 minutes depending on provider and resolution).
3. **succeeded** -- video ready; the agent wakes and posts it to the conversation.
4. **failed** -- provider error or timeout; the agent wakes with error details.
Check status from the CLI:
```bash
openclaw tasks list
openclaw tasks show <taskId>
openclaw tasks cancel <taskId>
```
Duplicate prevention: if a video task is already `queued` or `running` for the current session, `video_generate` returns the existing task status instead of starting a new one. Use `action: "status"` to check explicitly without triggering a new generation.
## Supported providers
| Provider | Default model | Text | Image ref | Video ref | API key |
@@ -84,20 +103,20 @@ runtime modes at runtime.
This is the explicit mode contract used by `video_generate`, contract tests,
and the shared live sweep.
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
| -------- | ---------- | -------------- | -------------- | ---------------------------------------------------------------------------------------------------------- |
| Alibaba | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| BytePlus | Yes | Yes | No | `generate`, `imageToVideo` |
| ComfyUI | Yes | Yes | No | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
| fal | Yes | Yes | No | `generate`, `imageToVideo` |
| Google | Yes | Yes | Yes | `generate`, `imageToVideo`, `videoToVideo` |
| MiniMax | Yes | Yes | No | `generate`, `imageToVideo` |
| OpenAI | Yes | Yes | Yes | `generate`, `imageToVideo`, `videoToVideo` |
| Qwen | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| Runway | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
| Together | Yes | Yes | No | `generate`, `imageToVideo` |
| Vydra | Yes | Yes | No | `generate`, `imageToVideo` |
| xAI | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
| -------- | ---------- | -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Alibaba | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| BytePlus | Yes | Yes | No | `generate`, `imageToVideo` |
| ComfyUI | Yes | Yes | No | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
| fal | Yes | Yes | No | `generate`, `imageToVideo` |
| Google | Yes | Yes | Yes | `generate`, `imageToVideo`; shared `videoToVideo` skipped because the current buffer-backed Gemini/Veo sweep does not accept that input |
| MiniMax | Yes | Yes | No | `generate`, `imageToVideo` |
| OpenAI | Yes | Yes | Yes | `generate`, `imageToVideo`; shared `videoToVideo` skipped because this org/input path currently needs provider-side inpaint/remix access |
| Qwen | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| Runway | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
| Together | Yes | Yes | No | `generate`, `imageToVideo` |
| Vydra | Yes | Yes | No | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL |
| xAI | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
## Tool parameters
@@ -121,7 +140,7 @@ and the shared live sweep.
| Parameter | Type | Description |
| ----------------- | ------- | ------------------------------------------------------------------------ |
| `aspectRatio` | string | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` |
| `resolution` | string | `480P`, `720P`, or `1080P` |
| `resolution` | string | `480P`, `720P`, `768P`, or `1080P` |
| `durationSeconds` | number | Target duration in seconds (rounded to nearest provider-supported value) |
| `size` | string | Size hint when the provider supports it |
| `audio` | boolean | Enable generated audio when supported |
@@ -235,6 +254,12 @@ Opt-in live coverage for the shared bundled providers:
OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/video-generation-providers.live.test.ts
```
Repo wrapper:
```bash
pnpm test:live:media video
```
This live file loads missing provider env vars from `~/.profile`, prefers
live/env API keys ahead of stored auth profiles by default, and runs the
declared modes it can exercise safely with local media:
@@ -246,8 +271,6 @@ declared modes it can exercise safely with local media:
Today the shared `videoToVideo` live lane covers:
- `google`
- `openai`
- `runway` only when you select `runway/gen4_aleph`
## Configuration

View File

@@ -1,452 +1,6 @@
---
summary: "Text-to-speech (TTS) for outbound replies"
read_when:
- Enabling text-to-speech for replies
- Configuring TTS providers or limits
- Using /tts commands
title: "Text-to-Speech (legacy path)"
title: "Text-to-Speech"
redirect: /tools/tts
---
# Text-to-speech (TTS)
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, MiniMax, or OpenAI.
It works anywhere OpenClaw can send audio.
## Supported services
- **ElevenLabs** (primary or fallback provider)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
- **MiniMax** (primary or fallback provider; uses the T2A v2 API)
- **OpenAI** (primary or fallback provider; also used for summaries)
### Microsoft speech notes
The bundled Microsoft speech provider currently uses Microsoft Edge's online
neural TTS service via the `node-edge-tts` library. It's a hosted service (not
local), uses Microsoft endpoints, and does not require an API key.
`node-edge-tts` exposes speech configuration options and output formats, but
not all options are supported by the service. Legacy config and directive input
using `edge` still works and is normalized to `microsoft`.
Because this path is a public web service without a published SLA or quota,
treat it as best-effort. If you need guaranteed limits and support, use OpenAI
or ElevenLabs.
## Optional keys
If you want OpenAI, ElevenLabs, or MiniMax:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `MINIMAX_API_KEY`
- `OPENAI_API_KEY`
Microsoft speech does **not** require an API key.
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
so that provider must also be authenticated if you enable summaries.
## Service links
- [OpenAI Text-to-Speech guide](https://platform.openai.com/docs/guides/text-to-speech)
- [OpenAI Audio API reference](https://platform.openai.com/docs/api-reference/audio)
- [ElevenLabs Text to Speech](https://elevenlabs.io/docs/api-reference/text-to-speech)
- [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication)
- [MiniMax T2A v2 API](https://platform.minimaxi.com/document/T2A%20V2)
- [node-edge-tts](https://github.com/SchneeHertz/node-edge-tts)
- [Microsoft Speech output formats](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech#audio-outputs)
## Is it enabled by default?
No. AutoTTS is **off** by default. Enable it in config with
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
When `messages.tts.provider` is unset, OpenClaw picks the first configured
speech provider in registry auto-select order.
## Config
TTS config lives under `messages.tts` in `openclaw.json`.
Full schema is in [Gateway configuration](/gateway/configuration).
### Minimal config (enable + provider)
```json5
{
messages: {
tts: {
auto: "always",
provider: "elevenlabs",
},
},
}
```
### OpenAI primary with ElevenLabs fallback
```json5
{
messages: {
tts: {
auto: "always",
provider: "openai",
summaryModel: "openai/gpt-4.1-mini",
modelOverrides: {
enabled: true,
},
providers: {
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
},
},
},
},
}
```
### Microsoft primary (no API key)
```json5
{
messages: {
tts: {
auto: "always",
provider: "microsoft",
providers: {
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
},
},
},
}
```
### MiniMax primary
```json5
{
messages: {
tts: {
auto: "always",
provider: "minimax",
providers: {
minimax: {
apiKey: "minimax_api_key",
baseUrl: "https://api.minimax.io",
model: "speech-2.8-hd",
voiceId: "English_expressive_narrator",
speed: 1.0,
vol: 1.0,
pitch: 0,
},
},
},
},
}
```
### Disable Microsoft speech
```json5
{
messages: {
tts: {
providers: {
microsoft: {
enabled: false,
},
},
},
},
}
```
### Custom limits + prefs path
```json5
{
messages: {
tts: {
auto: "always",
maxTextLength: 4000,
timeoutMs: 30000,
prefsPath: "~/.openclaw/settings/tts.json",
},
},
}
```
### Only reply with audio after an inbound voice message
```json5
{
messages: {
tts: {
auto: "inbound",
},
},
}
```
### Disable auto-summary for long replies
```json5
{
messages: {
tts: {
auto: "always",
},
},
}
```
Then run:
```
/tts summary off
```
### Notes on fields
- `auto`: autoTTS mode (`off`, `always`, `inbound`, `tagged`).
- `inbound` only sends audio after an inbound voice message.
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, `"minimax"`, or `"openai"` (fallback is automatic).
- If `provider` is **unset**, OpenClaw uses the first configured speech provider in registry auto-select order.
- Legacy `provider: "edge"` still works and is normalized to `microsoft`.
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `allowProvider` defaults to `false` (provider switching is opt-in).
- `providers.<id>`: provider-owned settings keyed by speech provider id.
- Legacy direct provider blocks (`messages.tts.openai`, `messages.tts.elevenlabs`, `messages.tts.microsoft`, `messages.tts.edge`) are auto-migrated to `messages.tts.providers.<id>` on load.
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `MINIMAX_API_KEY`, `OPENAI_API_KEY`).
- `providers.elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `providers.openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.providers.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
- `providers.elevenlabs.voiceSettings`:
- `stability`, `similarityBoost`, `style`: `0..1`
- `useSpeakerBoost`: `true|false`
- `speed`: `0.5..2.0` (1.0 = normal)
- `providers.elevenlabs.applyTextNormalization`: `auto|on|off`
- `providers.elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `providers.elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `providers.minimax.baseUrl`: override MiniMax API base URL (default `https://api.minimax.io`, env: `MINIMAX_API_HOST`).
- `providers.minimax.model`: TTS model (default `speech-2.8-hd`, env: `MINIMAX_TTS_MODEL`).
- `providers.minimax.voiceId`: voice identifier (default `English_expressive_narrator`, env: `MINIMAX_TTS_VOICE_ID`).
- `providers.minimax.speed`: playback speed `0.5..2.0` (default 1.0).
- `providers.minimax.vol`: volume `(0, 10]` (default 1.0; must be greater than 0).
- `providers.minimax.pitch`: pitch shift `-12..12` (default 0).
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `providers.microsoft.lang`: language code (e.g. `en-US`).
- `providers.microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
- `providers.microsoft.rate` / `providers.microsoft.pitch` / `providers.microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `providers.microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `providers.microsoft.proxy`: proxy URL for Microsoft speech requests.
- `providers.microsoft.timeoutMs`: request timeout override (ms).
- `edge.*`: legacy alias for the same Microsoft settings.
## Model-driven overrides (default on)
By default, the model **can** emit TTS directives for a single reply.
When `messages.tts.auto` is `tagged`, these directives are required to trigger audio.
When enabled, the model can emit `[[tts:...]]` directives to override the voice
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
provide expressive tags (laughter, singing cues, etc) that should only appear in
the audio.
`provider=...` directives are ignored unless `modelOverrides.allowProvider: true`.
Example reply payload:
```
Here you go.
[[tts:voiceId=pMsXgVXv3BLzUgSXRplE model=eleven_v3 speed=1.1]]
[[tts:text]](laughs) Read the song once more.[[/tts:text]]
```
Available directive keys (when enabled):
- `provider` (registered speech provider id, for example `openai`, `elevenlabs`, `minimax`, or `microsoft`; requires `allowProvider: true`)
- `voice` (OpenAI voice) or `voiceId` (ElevenLabs / MiniMax)
- `model` (OpenAI TTS model, ElevenLabs model id, or MiniMax model)
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
- `vol` / `volume` (MiniMax volume, 0-10)
- `pitch` (MiniMax pitch, -12 to 12)
- `applyTextNormalization` (`auto|on|off`)
- `languageCode` (ISO 639-1)
- `seed`
Disable all model overrides:
```json5
{
messages: {
tts: {
modelOverrides: {
enabled: false,
},
},
},
}
```
Optional allowlist (enable provider switching while keeping other knobs configurable):
```json5
{
messages: {
tts: {
modelOverrides: {
enabled: true,
allowProvider: true,
allowSeed: false,
},
},
},
}
```
## Per-user preferences
Slash commands write local overrides to `prefsPath` (default:
`~/.openclaw/settings/tts.json`, override with `OPENCLAW_TTS_PREFS` or
`messages.tts.prefsPath`).
Stored fields:
- `enabled`
- `provider`
- `maxLength` (summary threshold; default 1500 chars)
- `summarize` (default `true`)
These override `messages.tts.*` for that host.
## Output formats (fixed)
- **Feishu / Matrix / Telegram / WhatsApp**: Opus voice message (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
- 48kHz / 64kbps is a good voice message tradeoff.
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
- 44.1kHz / 128kbps is the default balance for speech clarity.
- **MiniMax**: MP3 (`speech-2.8-hd` model, 32kHz sample rate). Voice-note format not natively supported; use OpenAI or ElevenLabs for guaranteed Opus voice messages.
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
guaranteed Opus voice messages.
- If the configured Microsoft output format fails, OpenClaw retries with MP3.
OpenAI/ElevenLabs output formats are fixed per channel (see above).
## Auto-TTS behavior
When enabled, OpenClaw:
- skips TTS if the reply already contains media or a `MEDIA:` directive.
- skips very short replies (< 10 chars).
- summarizes long replies when enabled using `agents.defaults.model.primary` (or `summaryModel`).
- attaches the generated audio to the reply.
If the reply exceeds `maxLength` and summary is off (or no API key for the
summary model), audio
is skipped and the normal text reply is sent.
## Flow diagram
```
Reply -> TTS enabled?
no -> send text
yes -> has media / MEDIA: / short?
yes -> send text
no -> length > limit?
no -> TTS -> attach audio
yes -> summary enabled?
no -> send text
yes -> summarize (summaryModel or agents.defaults.model.primary)
-> TTS -> attach audio
```
## Slash command usage
There is a single command: `/tts`.
See [Slash commands](/tools/slash-commands) for enablement details.
Discord note: `/tts` is a built-in Discord command, so OpenClaw registers
`/voice` as the native command there. Text `/tts ...` still works.
```
/tts off
/tts always
/tts inbound
/tts tagged
/tts status
/tts provider openai
/tts limit 2000
/tts summary off
/tts audio Hello from OpenClaw
```
Notes:
- Commands require an authorized sender (allowlist/owner rules still apply).
- `commands.text` or native command registration must be enabled.
- `off|always|inbound|tagged` are persession toggles (`/tts on` is an alias for `/tts always`).
- `limit` and `summary` are stored in local prefs, not the main config.
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
- `/tts status` includes fallback visibility for the latest attempt:
- success fallback: `Fallback: <primary> -> <used>` plus `Attempts: ...`
- failure: `Error: ...` plus `Attempts: ...`
- detailed diagnostics: `Attempt details: provider:outcome(reasonCode) latency`
- OpenAI and ElevenLabs API failures now include parsed provider error detail and request id (when returned by the provider), which is surfaced in TTS errors/logs.
## Agent tool
The `tts` tool converts text to speech and returns an audio attachment for
reply delivery. When the channel is Feishu, Matrix, Telegram, or WhatsApp,
the audio is delivered as a voice message rather than a file attachment.
## Gateway RPC
Gateway methods:
- `tts.status`
- `tts.enable`
- `tts.disable`
- `tts.convert`
- `tts.setProvider`
- `tts.providers`
This page has moved to [Text-to-Speech](/tools/tts).

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.4.5",
"version": "2026.4.6",
"description": "OpenClaw ACP runtime backend",
"type": "module",
"dependencies": {

55
extensions/acpx/src/acpx-runtime.d.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
declare module "acpx/runtime" {
export const ACPX_BACKEND_ID: string;
export type AcpRuntimeDoctorReport =
import("../../../src/acp/runtime/types.js").AcpRuntimeDoctorReport;
export type AcpRuntimeEnsureInput =
import("../../../src/acp/runtime/types.js").AcpRuntimeEnsureInput;
export type AcpRuntimeEvent = import("../../../src/acp/runtime/types.js").AcpRuntimeEvent;
export type AcpRuntimeHandle = import("../../../src/acp/runtime/types.js").AcpRuntimeHandle;
export type AcpRuntimeTurnInput = import("../../../src/acp/runtime/types.js").AcpRuntimeTurnInput;
export type AcpRuntimeStatus = import("../../../src/acp/runtime/types.js").AcpRuntimeStatus;
export type AcpRuntimeCapabilities =
import("../../../src/acp/runtime/types.js").AcpRuntimeCapabilities;
export type AcpSessionStore = {
load(sessionId: string): Promise<unknown>;
save(record: unknown): Promise<void>;
};
export type AcpAgentRegistry = {
resolve(agentId: string): string;
list(): string[];
};
export type AcpRuntimeOptions = {
cwd: string;
sessionStore: AcpSessionStore;
agentRegistry: AcpAgentRegistry;
permissionMode: string;
mcpServers?: unknown[];
nonInteractivePermissions?: unknown;
timeoutMs?: number;
};
export class AcpxRuntime {
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
isHealthy(): boolean;
probeAvailability(): Promise<void>;
doctor(): Promise<AcpRuntimeDoctorReport>;
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities(input?: { handle?: AcpRuntimeHandle }): AcpRuntimeCapabilities;
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
}
export function createAcpRuntime(...args: unknown[]): unknown;
export function createAgentRegistry(...args: unknown[]): AcpAgentRegistry;
export function createFileSessionStore(...args: unknown[]): AcpSessionStore;
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
}

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseControlJsonError } from "./control-errors.js";
describe("parseControlJsonError", () => {
it("reads structured control-command errors", () => {
expect(
parseControlJsonError({
error: {
code: "NO_SESSION",
message: "No matching session",
retryable: false,
},
}),
).toEqual({
code: "NO_SESSION",
message: "No matching session",
retryable: false,
});
});
it("returns null when payload has no error object", () => {
expect(parseControlJsonError({ action: "session_ensured" })).toBeNull();
expect(parseControlJsonError("bad")).toBeNull();
});
});

View File

@@ -1,27 +0,0 @@
import {
asOptionalBoolean,
asOptionalString,
asTrimmedString,
type AcpxErrorEvent,
isRecord,
} from "./shared.js";
export function parseControlJsonError(value: unknown): AcpxErrorEvent | null {
if (!isRecord(value)) {
return null;
}
const error = isRecord(value.error) ? value.error : null;
if (!error) {
return null;
}
const message = asTrimmedString(error.message) || "acpx reported an error";
const codeValue = error.code;
return {
message,
code:
typeof codeValue === "number" && Number.isFinite(codeValue)
? String(codeValue)
: asOptionalString(codeValue),
retryable: asOptionalBoolean(error.retryable),
};
}

View File

@@ -1,81 +0,0 @@
import { describe, expect, it } from "vitest";
import { parsePromptEventLine } from "./events.js";
describe("parsePromptEventLine", () => {
it("parses raw ACP session/update agent_message_chunk lines", () => {
const line = JSON.stringify({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: "s1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "hello" },
},
},
});
expect(parsePromptEventLine(line)).toEqual({
type: "text_delta",
text: "hello",
stream: "output",
tag: "agent_message_chunk",
});
});
it("parses usage_update with stable metadata", () => {
const line = JSON.stringify({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: "s1",
update: {
sessionUpdate: "usage_update",
used: 12,
size: 500,
},
},
});
expect(parsePromptEventLine(line)).toEqual({
type: "status",
text: "usage updated: 12/500",
tag: "usage_update",
used: 12,
size: 500,
});
});
it("parses tool_call_update without using call ids as primary fallback label", () => {
const line = JSON.stringify({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: "s1",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "call_ABC123",
status: "in_progress",
},
},
});
expect(parsePromptEventLine(line)).toEqual({
type: "tool_call",
text: "tool call (in_progress)",
tag: "tool_call_update",
toolCallId: "call_ABC123",
status: "in_progress",
title: "tool call",
});
});
it("keeps compatibility with simplified text/done lines", () => {
expect(parsePromptEventLine(JSON.stringify({ type: "text", content: "alpha" }))).toEqual({
type: "text_delta",
text: "alpha",
stream: "output",
});
expect(parsePromptEventLine(JSON.stringify({ type: "done", stopReason: "end_turn" }))).toEqual({
type: "done",
stopReason: "end_turn",
});
});
});

View File

@@ -1,315 +0,0 @@
import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { z } from "zod";
import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "../../runtime-api.js";
import {
asOptionalBoolean,
asOptionalString,
asString,
asTrimmedString,
type AcpxErrorEvent,
type AcpxJsonObject,
isRecord,
} from "./shared.js";
const AcpxJsonObjectSchema = z.record(z.string(), z.unknown());
const AcpxErrorEventSchema = z.object({
type: z.literal("error"),
message: z.string().trim().min(1).catch("acpx reported an error"),
code: z.string().optional(),
retryable: z.boolean().optional(),
});
export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
const parsed = AcpxErrorEventSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
export function parseJsonLines(value: string): AcpxJsonObject[] {
const events: AcpxJsonObject[] = [];
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const parsed = safeParseJsonWithSchema(AcpxJsonObjectSchema, trimmed);
if (parsed) {
events.push(parsed);
}
}
return events;
}
function asOptionalFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function resolveStructuredPromptPayload(parsed: Record<string, unknown>): {
type: string;
payload: Record<string, unknown>;
tag?: AcpSessionUpdateTag;
} {
const method = asTrimmedString(parsed.method);
if (method === "session/update") {
const params = parsed.params;
if (isRecord(params) && isRecord(params.update)) {
const update = params.update;
const tag = asOptionalString(update.sessionUpdate) as AcpSessionUpdateTag | undefined;
return {
type: tag ?? "",
payload: update,
...(tag ? { tag } : {}),
};
}
}
const sessionUpdate = asOptionalString(parsed.sessionUpdate) as AcpSessionUpdateTag | undefined;
if (sessionUpdate) {
return {
type: sessionUpdate,
payload: parsed,
tag: sessionUpdate,
};
}
const type = asTrimmedString(parsed.type);
const tag = asOptionalString(parsed.tag) as AcpSessionUpdateTag | undefined;
return {
type,
payload: parsed,
...(tag ? { tag } : {}),
};
}
function resolveStatusTextForTag(params: {
tag: AcpSessionUpdateTag;
payload: Record<string, unknown>;
}): string | null {
const { tag, payload } = params;
if (tag === "available_commands_update") {
const commands = Array.isArray(payload.availableCommands) ? payload.availableCommands : [];
return commands.length > 0
? `available commands updated (${commands.length})`
: "available commands updated";
}
if (tag === "current_mode_update") {
const mode =
asTrimmedString(payload.currentModeId) ||
asTrimmedString(payload.modeId) ||
asTrimmedString(payload.mode);
return mode ? `mode updated: ${mode}` : "mode updated";
}
if (tag === "config_option_update") {
const id = asTrimmedString(payload.id) || asTrimmedString(payload.configOptionId);
const value =
asTrimmedString(payload.currentValue) ||
asTrimmedString(payload.value) ||
asTrimmedString(payload.optionValue);
if (id && value) {
return `config updated: ${id}=${value}`;
}
if (id) {
return `config updated: ${id}`;
}
return "config updated";
}
if (tag === "session_info_update") {
return (
asTrimmedString(payload.summary) || asTrimmedString(payload.message) || "session updated"
);
}
if (tag === "plan") {
const entries = Array.isArray(payload.entries) ? payload.entries : [];
const first = entries.find((entry) => isRecord(entry));
const content = asTrimmedString(first?.content);
return content ? `plan: ${content}` : null;
}
return null;
}
function resolveTextChunk(params: {
payload: Record<string, unknown>;
stream: "output" | "thought";
tag: AcpSessionUpdateTag;
}): AcpRuntimeEvent | null {
const contentRaw = params.payload.content;
if (isRecord(contentRaw)) {
const contentType = asTrimmedString(contentRaw.type);
if (contentType && contentType !== "text") {
return null;
}
const text = asString(contentRaw.text);
if (text && text.length > 0) {
return {
type: "text_delta",
text,
stream: params.stream,
tag: params.tag,
};
}
}
const text = asString(params.payload.text);
if (!text || text.length === 0) {
return null;
}
return {
type: "text_delta",
text,
stream: params.stream,
tag: params.tag,
};
}
function createTextDeltaEvent(params: {
content: string | null | undefined;
stream: "output" | "thought";
tag?: AcpSessionUpdateTag;
}): AcpRuntimeEvent | null {
if (params.content == null || params.content.length === 0) {
return null;
}
return {
type: "text_delta",
text: params.content,
stream: params.stream,
...(params.tag ? { tag: params.tag } : {}),
};
}
function createToolCallEvent(params: {
payload: Record<string, unknown>;
tag: AcpSessionUpdateTag;
}): AcpRuntimeEvent {
const title = asTrimmedString(params.payload.title) || "tool call";
const status = asTrimmedString(params.payload.status);
const toolCallId = asOptionalString(params.payload.toolCallId);
return {
type: "tool_call",
text: status ? `${title} (${status})` : title,
tag: params.tag,
...(toolCallId ? { toolCallId } : {}),
...(status ? { status } : {}),
title,
};
}
export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
const trimmed = line.trim();
if (!trimmed) {
return null;
}
const parsed = safeParseJsonWithSchema(AcpxJsonObjectSchema, trimmed);
if (!parsed) {
return {
type: "status",
text: trimmed,
};
}
const structured = resolveStructuredPromptPayload(parsed);
const type = structured.type;
const payload = structured.payload;
const tag = structured.tag;
switch (type) {
case "text":
return createTextDeltaEvent({
content: asString(payload.content),
stream: "output",
tag,
});
case "thought":
return createTextDeltaEvent({
content: asString(payload.content),
stream: "thought",
tag,
});
case "tool_call":
return createToolCallEvent({
payload,
tag: tag ?? "tool_call",
});
case "tool_call_update":
return createToolCallEvent({
payload,
tag: tag ?? "tool_call_update",
});
case "agent_message_chunk":
return resolveTextChunk({
payload,
stream: "output",
tag: "agent_message_chunk",
});
case "agent_thought_chunk":
return resolveTextChunk({
payload,
stream: "thought",
tag: "agent_thought_chunk",
});
case "usage_update": {
const used = asOptionalFiniteNumber(payload.used);
const size = asOptionalFiniteNumber(payload.size);
const text =
used != null && size != null ? `usage updated: ${used}/${size}` : "usage updated";
return {
type: "status",
text,
tag: "usage_update",
...(used != null ? { used } : {}),
...(size != null ? { size } : {}),
};
}
case "available_commands_update":
case "current_mode_update":
case "config_option_update":
case "session_info_update":
case "plan": {
const text = resolveStatusTextForTag({
tag: type as AcpSessionUpdateTag,
payload,
});
if (!text) {
return null;
}
return {
type: "status",
text,
tag: type as AcpSessionUpdateTag,
};
}
case "client_operation": {
const method = asTrimmedString(payload.method) || "operation";
const status = asTrimmedString(payload.status);
const summary = asTrimmedString(payload.summary);
const text = [method, status, summary].filter(Boolean).join(" ");
if (!text) {
return null;
}
return { type: "status", text, ...(tag ? { tag } : {}) };
}
case "update": {
const update = asTrimmedString(payload.update);
if (!update) {
return null;
}
return { type: "status", text: update, ...(tag ? { tag } : {}) };
}
case "done": {
return {
type: "done",
stopReason: asOptionalString(payload.stopReason),
};
}
case "error": {
const message = asTrimmedString(payload.message) || "acpx runtime error";
return {
type: "error",
message,
code: asOptionalString(payload.code),
retryable: asOptionalBoolean(payload.retryable),
};
}
default:
return null;
}
}

View File

@@ -1,67 +0,0 @@
import { describe, expect, it } from "vitest";
import { isAcpJsonRpcMessage, isJsonRpcId, normalizeJsonRpcId } from "./jsonrpc.js";
describe("jsonrpc helpers", () => {
it("validates json-rpc ids", () => {
expect(isJsonRpcId(null)).toBe(true);
expect(isJsonRpcId("abc")).toBe(true);
expect(isJsonRpcId(12)).toBe(true);
expect(isJsonRpcId(Number.NaN)).toBe(false);
expect(isJsonRpcId({})).toBe(false);
});
it("normalizes json-rpc ids", () => {
expect(normalizeJsonRpcId("abc")).toBe("abc");
expect(normalizeJsonRpcId(12)).toBe("12");
expect(normalizeJsonRpcId(null)).toBeNull();
expect(normalizeJsonRpcId(undefined)).toBeNull();
});
it("accepts request, response, and notification shapes", () => {
expect(
isAcpJsonRpcMessage({
jsonrpc: "2.0",
method: "session/prompt",
id: 1,
}),
).toBe(true);
expect(
isAcpJsonRpcMessage({
jsonrpc: "2.0",
id: 1,
result: {
stopReason: "end_turn",
},
}),
).toBe(true);
expect(
isAcpJsonRpcMessage({
jsonrpc: "2.0",
method: "session/update",
}),
).toBe(true);
});
it("rejects malformed result/error response shapes", () => {
expect(
isAcpJsonRpcMessage({
jsonrpc: "2.0",
id: 1,
}),
).toBe(false);
expect(
isAcpJsonRpcMessage({
jsonrpc: "2.0",
id: 1,
result: {},
error: {
code: -1,
message: "bad",
},
}),
).toBe(false);
});
});

View File

@@ -1,47 +0,0 @@
import { isRecord } from "./shared.js";
export type JsonRpcId = string | number | null;
function hasExclusiveResultOrError(value: Record<string, unknown>): boolean {
const hasResult = Object.hasOwn(value, "result");
const hasError = Object.hasOwn(value, "error");
return hasResult !== hasError;
}
export function isJsonRpcId(value: unknown): value is JsonRpcId {
return (
value === null ||
typeof value === "string" ||
(typeof value === "number" && Number.isFinite(value))
);
}
export function normalizeJsonRpcId(value: unknown): string | null {
if (!isJsonRpcId(value) || value == null) {
return null;
}
return String(value);
}
export function isAcpJsonRpcMessage(value: unknown): value is Record<string, unknown> {
if (!isRecord(value) || value.jsonrpc !== "2.0") {
return false;
}
const hasMethod = typeof value.method === "string" && value.method.length > 0;
const hasId = Object.hasOwn(value, "id");
if (hasMethod && !hasId) {
return true;
}
if (hasMethod && hasId) {
return isJsonRpcId(value.id);
}
if (!hasMethod && hasId) {
return isJsonRpcId(value.id) && hasExclusiveResultOrError(value);
}
return false;
}

View File

@@ -1,101 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const { spawnAndCollectMock } = vi.hoisted(() => ({
spawnAndCollectMock: vi.fn(),
}));
vi.mock("./process.js", () => ({
spawnAndCollect: spawnAndCollectMock,
}));
import { __testing, resolveAcpxAgentCommand } from "./mcp-agent-command.js";
describe("resolveAcpxAgentCommand", () => {
it.each([
["cursor", "cursor-agent acp"],
["gemini", "gemini --acp"],
["openclaw", "openclaw acp"],
["copilot", "copilot --acp --stdio"],
["pi", "npx -y pi-acp@0.0.22"],
["codex", "npx -y @zed-industries/codex-acp@0.9.5"],
["claude", "npx -y @zed-industries/claude-agent-acp@0.21.0"],
])("uses the current acpx built-in for %s by default", async (agent, expected) => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: JSON.stringify({ agents: {} }),
stderr: "",
code: 0,
error: null,
});
const command = await resolveAcpxAgentCommand({
acpxCommand: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
agent,
});
expect(command).toBe(expected);
});
it("returns null for unknown agent ids instead of falling back to raw commands", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: JSON.stringify({ agents: {} }),
stderr: "",
code: 0,
error: null,
});
const command = await resolveAcpxAgentCommand({
acpxCommand: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
agent: "sh -c whoami",
});
expect(command).toBeNull();
});
it("threads stripProviderAuthEnvVars through the config show probe", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: JSON.stringify({
agents: {
codex: {
command: "custom-codex",
},
},
}),
stderr: "",
code: 0,
error: null,
});
const command = await resolveAcpxAgentCommand({
acpxCommand: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
agent: "codex",
stripProviderAuthEnvVars: true,
});
expect(command).toBe("custom-codex");
expect(spawnAndCollectMock).toHaveBeenCalledWith(
{
command: "/plugin/node_modules/.bin/acpx",
args: ["--cwd", "/plugin", "config", "show"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
},
undefined,
);
});
});
describe("buildMcpProxyAgentCommand", () => {
it("escapes Windows-style proxy paths without double-escaping backslashes", () => {
const quoted = __testing.quoteCommandPart(
"C:\\repo\\extensions\\acpx\\src\\runtime-internals\\mcp-proxy.mjs",
);
expect(quoted).toBe(
'"C:\\\\repo\\\\extensions\\\\acpx\\\\src\\\\runtime-internals\\\\mcp-proxy.mjs"',
);
expect(quoted).not.toContain("\\\\\\");
});
});

View File

@@ -1,131 +0,0 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnAndCollect, type SpawnCommandOptions } from "./process.js";
// Keep this mirror aligned with openclaw/acpx src/agent-registry.ts built-ins.
const ACPX_BUILTIN_AGENT_COMMANDS: Record<string, string> = {
pi: "npx -y pi-acp@0.0.22",
openclaw: "openclaw acp",
codex: "npx -y @zed-industries/codex-acp@0.9.5",
claude: "npx -y @zed-industries/claude-agent-acp@0.21.0",
gemini: "gemini --acp",
cursor: "cursor-agent acp",
copilot: "copilot --acp --stdio",
droid: "droid exec --output-format acp",
iflow: "iflow --experimental-acp",
kilocode: "npx -y @kilocode/cli acp",
kimi: "kimi acp",
kiro: "kiro-cli acp",
opencode: "npx -y opencode-ai acp",
qwen: "qwen --acp",
};
const MCP_PROXY_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "mcp-proxy.mjs");
type AcpxConfigDisplay = {
agents?: Record<string, { command?: unknown }>;
};
type AcpMcpServer = {
name: string;
command: string;
args: string[];
env: Array<{ name: string; value: string }>;
};
function normalizeAgentName(value: string): string {
return value.trim().toLowerCase();
}
function quoteCommandPart(value: string): string {
if (value === "") {
return '""';
}
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
return value;
}
return `"${value.replace(/["\\]/g, "\\$&")}"`;
}
export const __testing = {
quoteCommandPart,
};
function toCommandLine(parts: string[]): string {
return parts.map(quoteCommandPart).join(" ");
}
function readConfiguredAgentOverrides(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const overrides: Record<string, string> = {};
for (const [name, entry] of Object.entries(value)) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
const command = (entry as { command?: unknown }).command;
if (typeof command !== "string" || command.trim() === "") {
continue;
}
overrides[normalizeAgentName(name)] = command.trim();
}
return overrides;
}
async function loadAgentOverrides(params: {
acpxCommand: string;
cwd: string;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<Record<string, string>> {
const result = await spawnAndCollect(
{
command: params.acpxCommand,
args: ["--cwd", params.cwd, "config", "show"],
cwd: params.cwd,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
},
params.spawnOptions,
);
if (result.error || (result.code ?? 0) !== 0) {
return {};
}
try {
const parsed = JSON.parse(result.stdout) as AcpxConfigDisplay;
return readConfiguredAgentOverrides(parsed.agents);
} catch {
return {};
}
}
export async function resolveAcpxAgentCommand(params: {
acpxCommand: string;
cwd: string;
agent: string;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<string | null> {
const normalizedAgent = normalizeAgentName(params.agent);
const overrides = await loadAgentOverrides({
acpxCommand: params.acpxCommand,
cwd: params.cwd,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? null;
}
export function buildMcpProxyAgentCommand(params: {
targetCommand: string;
mcpServers: AcpMcpServer[];
}): string {
const payload = Buffer.from(
JSON.stringify({
targetCommand: params.targetCommand,
mcpServers: params.mcpServers,
}),
"utf8",
).toString("base64url");
return toCommandLine([process.execPath, MCP_PROXY_PATH, "--payload", payload]);
}

View File

@@ -1,460 +0,0 @@
import { spawn } from "node:child_process";
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWindowsCmdShimFixture } from "../../../../src/test-helpers/windows-cmd-shim.js";
import {
resolveSpawnCommand,
spawnAndCollect,
type SpawnCommandCache,
waitForExit,
} from "./process.js";
const tempDirs: string[] = [];
function winRuntime(env: NodeJS.ProcessEnv) {
return {
platform: "win32" as const,
env,
execPath: "C:\\node\\node.exe",
};
}
async function createTempDir(): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-acpx-process-test-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
vi.unstubAllEnvs();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
await rm(dir, {
recursive: true,
force: true,
maxRetries: 8,
retryDelay: 8,
});
}
});
describe("resolveSpawnCommand", () => {
it("keeps non-windows spawns unchanged", () => {
const resolved = resolveSpawnCommand(
{
command: "acpx",
args: ["--help"],
},
undefined,
{
platform: "darwin",
env: {},
execPath: "/usr/bin/node",
},
);
expect(resolved).toEqual({
command: "acpx",
args: ["--help"],
});
});
it("routes node shebang wrappers through the current node runtime on posix", async () => {
const dir = await createTempDir();
const scriptPath = path.join(dir, "acpx");
await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8");
await chmod(scriptPath, 0o755);
const resolved = resolveSpawnCommand(
{
command: scriptPath,
args: ["--help"],
},
undefined,
{
platform: "linux",
env: {},
execPath: "/custom/node",
},
);
expect(resolved).toEqual({
command: "/custom/node",
args: [scriptPath, "--help"],
});
});
it("routes PATH-resolved node shebang wrappers through the current node runtime on posix", async () => {
const dir = await createTempDir();
const binDir = path.join(dir, "bin");
const scriptPath = path.join(binDir, "acpx");
await mkdir(binDir, { recursive: true });
await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8");
await chmod(scriptPath, 0o755);
const resolved = resolveSpawnCommand(
{
command: "acpx",
args: ["--help"],
},
undefined,
{
platform: "linux",
env: { PATH: binDir },
execPath: "/custom/node",
},
);
expect(resolved).toEqual({
command: "/custom/node",
args: [scriptPath, "--help"],
});
});
it("falls back to node on PATH when execPath is unavailable for a node shebang wrapper", async () => {
const dir = await createTempDir();
const binDir = path.join(dir, "bin");
const scriptPath = path.join(binDir, "acpx");
const nodePath = path.join(binDir, "node");
await mkdir(binDir, { recursive: true });
await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8");
await writeFile(nodePath, "#!/bin/sh\nexit 0\n", "utf8");
await chmod(scriptPath, 0o755);
await chmod(nodePath, 0o755);
const resolved = resolveSpawnCommand(
{
command: scriptPath,
args: ["--help"],
},
undefined,
{
platform: "darwin",
env: { PATH: binDir },
execPath: "/missing/node",
},
);
expect(resolved).toEqual({
command: nodePath,
args: [scriptPath, "--help"],
});
});
it("routes .js command execution through node on windows", () => {
const resolved = resolveSpawnCommand(
{
command: "C:/tools/acpx/cli.js",
args: ["--help"],
},
undefined,
winRuntime({}),
);
expect(resolved.command).toBe("C:\\node\\node.exe");
expect(resolved.args).toEqual(["C:/tools/acpx/cli.js", "--help"]);
expect(resolved.shell).toBeUndefined();
expect(resolved.windowsHide).toBe(true);
});
it("resolves a .cmd wrapper from PATH and unwraps shim entrypoint", async () => {
const dir = await createTempDir();
const binDir = path.join(dir, "bin");
const scriptPath = path.join(dir, "acpx", "dist", "index.js");
const shimPath = path.join(binDir, "acpx.cmd");
await createWindowsCmdShimFixture({
shimPath,
scriptPath,
shimLine: '"%~dp0\\..\\acpx\\dist\\index.js" %*',
});
const resolved = resolveSpawnCommand(
{
command: "acpx",
args: ["--format", "json", "agent", "status"],
},
undefined,
winRuntime({
PATH: binDir,
PATHEXT: ".CMD;.EXE;.BAT",
}),
);
expect(resolved.command).toBe("C:\\node\\node.exe");
expect(resolved.args[0]).toBe(scriptPath);
expect(resolved.args.slice(1)).toEqual(["--format", "json", "agent", "status"]);
expect(resolved.shell).toBeUndefined();
expect(resolved.windowsHide).toBe(true);
});
it("prefers executable shim targets without shell", async () => {
const dir = await createTempDir();
const wrapperPath = path.join(dir, "acpx.cmd");
const exePath = path.join(dir, "acpx.exe");
await writeFile(exePath, "", "utf8");
await writeFile(wrapperPath, ["@ECHO off", '"%~dp0\\acpx.exe" %*', ""].join("\r\n"), "utf8");
const resolved = resolveSpawnCommand(
{
command: wrapperPath,
args: ["--help"],
},
undefined,
winRuntime({}),
);
expect(resolved).toEqual({
command: exePath,
args: ["--help"],
windowsHide: true,
});
});
it("falls back to shell mode when wrapper cannot be safely unwrapped", async () => {
const dir = await createTempDir();
const wrapperPath = path.join(dir, "custom-wrapper.cmd");
await writeFile(wrapperPath, "@ECHO off\r\necho wrapper\r\n", "utf8");
const resolved = resolveSpawnCommand(
{
command: wrapperPath,
args: ["--arg", "value"],
},
undefined,
winRuntime({}),
);
expect(resolved).toEqual({
command: wrapperPath,
args: ["--arg", "value"],
shell: true,
});
});
it("fails closed in strict mode when wrapper cannot be safely unwrapped", async () => {
const dir = await createTempDir();
const wrapperPath = path.join(dir, "strict-wrapper.cmd");
await writeFile(wrapperPath, "@ECHO off\r\necho wrapper\r\n", "utf8");
expect(() =>
resolveSpawnCommand(
{
command: wrapperPath,
args: ["--arg", "value"],
},
{ strictWindowsCmdWrapper: true },
winRuntime({}),
),
).toThrow(/without shell execution/);
});
it("fails closed for wrapper fallback when args include a malicious cwd payload", async () => {
const dir = await createTempDir();
const wrapperPath = path.join(dir, "strict-wrapper.cmd");
await writeFile(wrapperPath, "@ECHO off\r\necho wrapper\r\n", "utf8");
const payload = "C:\\safe & calc.exe";
const events: Array<{ resolution: string }> = [];
expect(() =>
resolveSpawnCommand(
{
command: wrapperPath,
args: ["--cwd", payload, "agent", "status"],
},
{
strictWindowsCmdWrapper: true,
onResolved: (event) => {
events.push({ resolution: event.resolution });
},
},
winRuntime({}),
),
).toThrow(/without shell execution/);
expect(events).toEqual([{ resolution: "unresolved-wrapper" }]);
});
it("reuses resolved command when cache is provided", async () => {
const dir = await createTempDir();
const wrapperPath = path.join(dir, "acpx.cmd");
const scriptPath = path.join(dir, "acpx", "dist", "index.js");
await createWindowsCmdShimFixture({
shimPath: wrapperPath,
scriptPath,
shimLine: '"%~dp0\\acpx\\dist\\index.js" %*',
});
const cache: SpawnCommandCache = {};
const first = resolveSpawnCommand(
{
command: wrapperPath,
args: ["--help"],
},
{ cache },
winRuntime({}),
);
await rm(scriptPath, { force: true });
const second = resolveSpawnCommand(
{
command: wrapperPath,
args: ["--version"],
},
{ cache },
winRuntime({}),
);
expect(first.command).toBe("C:\\node\\node.exe");
expect(second.command).toBe("C:\\node\\node.exe");
expect(first.args[0]).toBe(scriptPath);
expect(second.args[0]).toBe(scriptPath);
});
});
describe("waitForExit", () => {
it("resolves when the child already exited before waiting starts", async () => {
const child = spawn(process.execPath, ["-e", "process.exit(0)"], {
stdio: ["pipe", "pipe", "pipe"],
});
await new Promise<void>((resolve, reject) => {
child.once("close", () => {
resolve();
});
child.once("error", reject);
});
const exit = await waitForExit(child);
expect(exit.code).toBe(0);
expect(exit.signal).toBeNull();
expect(exit.error).toBeNull();
});
});
describe("spawnAndCollect", () => {
type SpawnedEnvSnapshot = {
openai?: string;
github?: string;
hf?: string;
openclaw?: string;
shell?: string;
};
function stubProviderAuthEnv(env: Record<string, string>) {
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value);
}
}
async function collectSpawnedEnvSnapshot(options?: {
stripProviderAuthEnvVars?: boolean;
openAiEnvKey?: string;
githubEnvKey?: string;
hfEnvKey?: string;
}): Promise<SpawnedEnvSnapshot> {
const openAiEnvKey = options?.openAiEnvKey ?? "OPENAI_API_KEY";
const githubEnvKey = options?.githubEnvKey ?? "GITHUB_TOKEN";
const hfEnvKey = options?.hfEnvKey ?? "HF_TOKEN";
const result = await spawnAndCollect({
command: process.execPath,
args: [
"-e",
`process.stdout.write(JSON.stringify({openai:process.env.${openAiEnvKey},github:process.env.${githubEnvKey},hf:process.env.${hfEnvKey},openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}), () => process.exit(0))`,
],
cwd: process.cwd(),
stripProviderAuthEnvVars: options?.stripProviderAuthEnvVars,
});
expect(result.code).toBe(0);
expect(result.error).toBeNull();
return JSON.parse(result.stdout.trim()) as SpawnedEnvSnapshot;
}
it("returns abort error immediately when signal is already aborted", async () => {
const controller = new AbortController();
controller.abort();
const result = await spawnAndCollect(
{
command: process.execPath,
args: ["-e", "process.exit(0)"],
cwd: process.cwd(),
},
undefined,
{ signal: controller.signal },
);
expect(result.code).toBeNull();
expect(result.error?.name).toBe("AbortError");
});
it("terminates a running process when signal aborts", async () => {
const controller = new AbortController();
const resultPromise = spawnAndCollect(
{
command: process.execPath,
args: ["-e", "setTimeout(() => process.stdout.write('done'), 10_000)"],
cwd: process.cwd(),
},
undefined,
{ signal: controller.signal },
);
controller.abort();
const result = await resultPromise;
expect(result.error?.name).toBe("AbortError");
});
it("strips shared provider auth env vars from spawned acpx children", async () => {
stubProviderAuthEnv({
OPENAI_API_KEY: "openai-secret",
GITHUB_TOKEN: "gh-secret",
HF_TOKEN: "hf-secret",
OPENCLAW_API_KEY: "keep-me",
});
const parsed = await collectSpawnedEnvSnapshot({
stripProviderAuthEnvVars: true,
});
expect(parsed.openai).toBeUndefined();
expect(parsed.github).toBeUndefined();
expect(parsed.hf).toBeUndefined();
expect(parsed.openclaw).toBe("keep-me");
expect(parsed.shell).toBe("acp");
});
it("strips provider auth env vars case-insensitively", async () => {
stubProviderAuthEnv({
OpenAI_Api_Key: "openai-secret",
Github_Token: "gh-secret",
OPENCLAW_API_KEY: "keep-me",
});
const parsed = await collectSpawnedEnvSnapshot({
stripProviderAuthEnvVars: true,
openAiEnvKey: "OpenAI_Api_Key",
githubEnvKey: "Github_Token",
});
expect(parsed.openai).toBeUndefined();
expect(parsed.github).toBeUndefined();
expect(parsed.openclaw).toBe("keep-me");
expect(parsed.shell).toBe("acp");
});
it("preserves provider auth env vars for explicit custom commands by default", async () => {
stubProviderAuthEnv({
OPENAI_API_KEY: "openai-secret",
GITHUB_TOKEN: "gh-secret",
HF_TOKEN: "hf-secret",
OPENCLAW_API_KEY: "keep-me",
});
const parsed = await collectSpawnedEnvSnapshot();
expect(parsed.openai).toBe("openai-secret");
expect(parsed.github).toBe("gh-secret");
expect(parsed.hf).toBe("hf-secret");
expect(parsed.openclaw).toBe("keep-me");
expect(parsed.shell).toBe("acp");
});
});

View File

@@ -1,369 +0,0 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import type {
WindowsSpawnProgram,
WindowsSpawnProgramCandidate,
WindowsSpawnResolution,
} from "../../runtime-api.js";
import {
applyWindowsSpawnProgramPolicy,
listKnownProviderAuthEnvVarNames,
materializeWindowsSpawnProgram,
omitEnvKeysCaseInsensitive,
resolveWindowsSpawnProgramCandidate,
} from "../../runtime-api.js";
export type SpawnExit = {
code: number | null;
signal: NodeJS.Signals | null;
error: Error | null;
};
type ResolvedSpawnCommand = {
command: string;
args: string[];
shell?: boolean;
windowsHide?: boolean;
};
type SpawnRuntime = {
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
execPath: string;
};
export type SpawnCommandCache = {
key?: string;
candidate?: WindowsSpawnProgramCandidate;
};
export type SpawnResolution = WindowsSpawnResolution | "unresolved-wrapper";
export type SpawnResolutionEvent = {
command: string;
cacheHit: boolean;
strictWindowsCmdWrapper: boolean;
resolution: SpawnResolution;
};
export type SpawnCommandOptions = {
strictWindowsCmdWrapper?: boolean;
cache?: SpawnCommandCache;
onResolved?: (event: SpawnResolutionEvent) => void;
};
const DEFAULT_RUNTIME: SpawnRuntime = {
platform: process.platform,
env: process.env,
execPath: process.execPath,
};
function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean {
try {
const stat = statSync(filePath);
if (!stat.isFile()) {
return false;
}
if (platform === "win32") {
return true;
}
accessSync(filePath, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
function resolveExecutableFromPath(command: string, runtime: SpawnRuntime): string | undefined {
const pathEnv = runtime.env.PATH ?? runtime.env.Path;
if (!pathEnv) {
return undefined;
}
for (const entry of pathEnv.split(path.delimiter).filter(Boolean)) {
const candidate = path.join(entry, command);
if (isExecutableFile(candidate, runtime.platform)) {
return candidate;
}
}
return undefined;
}
function resolveNodeExecPath(runtime: SpawnRuntime): string {
if (runtime.execPath && isExecutableFile(runtime.execPath, runtime.platform)) {
return runtime.execPath;
}
return resolveExecutableFromPath("node", runtime) ?? runtime.execPath;
}
function resolveNodeShebangScriptPath(command: string, runtime: SpawnRuntime): string | undefined {
const commandPath =
path.isAbsolute(command) || command.includes(path.sep)
? command
: resolveExecutableFromPath(command, runtime);
if (!commandPath || !isExecutableFile(commandPath, runtime.platform)) {
return undefined;
}
try {
const firstLine = readFileSync(commandPath, "utf8").split(/\r?\n/, 1)[0] ?? "";
if (/^#!.*(?:\/usr\/bin\/env\s+node\b|\/node(?:js)?\b)/.test(firstLine)) {
return commandPath;
}
} catch {
return undefined;
}
return undefined;
}
export function resolveSpawnCommand(
params: { command: string; args: string[] },
options?: SpawnCommandOptions,
runtime: SpawnRuntime = DEFAULT_RUNTIME,
): ResolvedSpawnCommand {
if (runtime.platform !== "win32") {
const nodeShebangScript = resolveNodeShebangScriptPath(params.command, runtime);
if (nodeShebangScript) {
options?.onResolved?.({
command: params.command,
cacheHit: false,
strictWindowsCmdWrapper: options?.strictWindowsCmdWrapper === true,
resolution: "direct",
});
return {
command: resolveNodeExecPath(runtime),
args: [nodeShebangScript, ...params.args],
};
}
}
const strictWindowsCmdWrapper = options?.strictWindowsCmdWrapper === true;
const cacheKey = params.command;
const cachedProgram = options?.cache;
const cacheHit = cachedProgram?.key === cacheKey && cachedProgram.candidate != null;
let candidate =
cachedProgram?.key === cacheKey && cachedProgram.candidate
? cachedProgram.candidate
: undefined;
if (!candidate) {
candidate = resolveWindowsSpawnProgramCandidate({
command: params.command,
platform: runtime.platform,
env: runtime.env,
execPath: runtime.execPath,
packageName: "acpx",
});
if (cachedProgram) {
cachedProgram.key = cacheKey;
cachedProgram.candidate = candidate;
}
}
let program: WindowsSpawnProgram;
try {
program = applyWindowsSpawnProgramPolicy({
candidate,
allowShellFallback: !strictWindowsCmdWrapper,
});
} catch (error) {
options?.onResolved?.({
command: params.command,
cacheHit,
strictWindowsCmdWrapper,
resolution: candidate.resolution,
});
throw error;
}
const resolved = materializeWindowsSpawnProgram(program, params.args);
options?.onResolved?.({
command: params.command,
cacheHit,
strictWindowsCmdWrapper,
resolution: resolved.resolution,
});
return {
command: resolved.command,
args: resolved.argv,
shell: resolved.shell,
windowsHide: resolved.windowsHide,
};
}
function createAbortError(): Error {
const error = new Error("Operation aborted.");
error.name = "AbortError";
return error;
}
async function collectStreamOutput(stream: NodeJS.ReadableStream): Promise<string> {
let output = "";
try {
for await (const chunk of stream) {
output += String(chunk);
}
} catch {
// Return whatever was captured before the stream failed.
}
return output;
}
export function spawnWithResolvedCommand(
params: {
command: string;
args: string[];
cwd: string;
stripProviderAuthEnvVars?: boolean;
},
options?: SpawnCommandOptions,
): ChildProcessWithoutNullStreams {
const resolved = resolveSpawnCommand(
{
command: params.command,
args: params.args,
},
options,
);
const childEnv = omitEnvKeysCaseInsensitive(
process.env,
params.stripProviderAuthEnvVars ? listKnownProviderAuthEnvVarNames() : [],
);
childEnv.OPENCLAW_SHELL = "acp";
return spawn(resolved.command, resolved.args, {
cwd: params.cwd,
env: childEnv,
stdio: ["pipe", "pipe", "pipe"],
shell: resolved.shell,
windowsHide: resolved.windowsHide,
});
}
export async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<SpawnExit> {
// Handle callers that start waiting after the child has already exited.
if (child.exitCode !== null || child.signalCode !== null) {
return {
code: child.exitCode,
signal: child.signalCode,
error: null,
};
}
return await new Promise<SpawnExit>((resolve) => {
let settled = false;
const finish = (result: SpawnExit) => {
if (settled) {
return;
}
settled = true;
resolve(result);
};
child.once("error", (err) => {
finish({ code: null, signal: null, error: err });
});
child.once("close", (code, signal) => {
finish({ code, signal, error: null });
});
});
}
export async function spawnAndCollect(
params: {
command: string;
args: string[];
cwd: string;
stripProviderAuthEnvVars?: boolean;
},
options?: SpawnCommandOptions,
runtime?: {
signal?: AbortSignal;
},
): Promise<{
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
error: Error | null;
}> {
if (runtime?.signal?.aborted) {
return {
stdout: "",
stderr: "",
code: null,
signal: null,
error: createAbortError(),
};
}
const child = spawnWithResolvedCommand(params, options);
child.stdin.end();
const stdoutPromise = collectStreamOutput(child.stdout);
const stderrPromise = collectStreamOutput(child.stderr);
let abortKillTimer: NodeJS.Timeout | undefined;
let aborted = false;
const onAbort = () => {
aborted = true;
try {
child.kill("SIGTERM");
} catch {
// Ignore kill races when child already exited.
}
abortKillTimer = setTimeout(() => {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
try {
child.kill("SIGKILL");
} catch {
// Ignore kill races when child already exited.
}
}, 250);
abortKillTimer.unref?.();
};
runtime?.signal?.addEventListener("abort", onAbort, { once: true });
try {
const exit = await waitForExit(child);
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
return {
stdout,
stderr,
code: exit.code,
signal: exit.signal,
error: aborted ? createAbortError() : exit.error,
};
} finally {
runtime?.signal?.removeEventListener("abort", onAbort);
if (abortKillTimer) {
clearTimeout(abortKillTimer);
}
}
}
export function resolveSpawnFailure(
err: unknown,
cwd: string,
): "missing-command" | "missing-cwd" | null {
if (!err || typeof err !== "object") {
return null;
}
const code = (err as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
return null;
}
return directoryExists(cwd) ? "missing-command" : "missing-cwd";
}
function directoryExists(cwd: string): boolean {
if (!cwd) {
return false;
}
try {
return existsSync(cwd);
} catch {
return false;
}
}

View File

@@ -1,56 +0,0 @@
import type { ResolvedAcpxPluginConfig } from "../config.js";
export type AcpxHandleState = {
name: string;
agent: string;
cwd: string;
mode: "persistent" | "oneshot";
acpxRecordId?: string;
backendSessionId?: string;
agentSessionId?: string;
};
export type AcpxJsonObject = Record<string, unknown>;
export type AcpxErrorEvent = {
message: string;
code?: string;
retryable?: boolean;
};
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asTrimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function asOptionalString(value: unknown): string | undefined {
const text = asTrimmedString(value);
return text || undefined;
}
export function asOptionalBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
export function deriveAgentFromSessionKey(sessionKey: string, fallbackAgent: string): string {
const match = sessionKey.match(/^agent:([^:]+):/i);
const candidate = match?.[1] ? asTrimmedString(match[1]) : "";
return candidate || fallbackAgent;
}
export function buildPermissionArgs(mode: ResolvedAcpxPluginConfig["permissionMode"]): string[] {
if (mode === "approve-all") {
return ["--approve-all"];
}
if (mode === "deny-all") {
return ["--deny-all"];
}
return ["--approve-reads"];
}

View File

@@ -17,6 +17,13 @@ vi.mock("../runtime-api.js", () => ({
},
}));
vi.mock("./runtime.js", () => ({
ACPX_BACKEND_ID: "acpx",
AcpxRuntime: class {},
createAgentRegistry: vi.fn(() => ({})),
createFileSessionStore: vi.fn(() => ({})),
}));
import { getAcpRuntimeBackend } from "../runtime-api.js";
import { createAcpxRuntimeService } from "./service.js";

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/alibaba-provider",
"version": "2026.4.5",
"version": "2026.4.6",
"private": true,
"description": "OpenClaw Alibaba Model Studio video provider plugin",
"type": "module",

View File

@@ -2,48 +2,28 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
fetchWithTimeout,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
buildDashscopeVideoGenerationInput,
buildDashscopeVideoGenerationParameters,
downloadDashscopeGeneratedVideos,
extractDashscopeVideoUrls,
pollDashscopeVideoTaskUntilComplete,
} from "openclaw/plugin-sdk/video-generation";
import type {
GeneratedVideoAsset,
DashscopeVideoGenerationResponse,
VideoGenerationProvider,
VideoGenerationRequest,
VideoGenerationResult,
VideoGenerationSourceAsset,
} from "openclaw/plugin-sdk/video-generation";
const DEFAULT_ALIBABA_VIDEO_BASE_URL = "https://dashscope-intl.aliyuncs.com";
const DEFAULT_ALIBABA_VIDEO_MODEL = "wan2.6-t2v";
const DEFAULT_DURATION_SECONDS = 5;
const DEFAULT_TIMEOUT_MS = 120_000;
const POLL_INTERVAL_MS = 2_500;
const MAX_POLL_ATTEMPTS = 120;
const RESOLUTION_TO_SIZE: Record<string, string> = {
"480P": "832*480",
"720P": "1280*720",
"1080P": "1920*1080",
};
type AlibabaVideoGenerationResponse = {
output?: {
task_id?: string;
task_status?: string;
submit_time?: string;
results?: Array<{
video_url?: string;
orig_prompt?: string;
actual_prompt?: string;
}>;
video_url?: string;
code?: string;
message?: string;
};
request_id?: string;
code?: string;
message?: string;
};
function resolveAlibabaVideoBaseUrl(req: VideoGenerationRequest): string {
return req.cfg?.models?.providers?.alibaba?.baseUrl?.trim() || DEFAULT_ALIBABA_VIDEO_BASE_URL;
@@ -53,139 +33,6 @@ function resolveDashscopeAigcApiBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/u, "");
}
function resolveReferenceUrls(
inputImages: VideoGenerationSourceAsset[] | undefined,
inputVideos: VideoGenerationSourceAsset[] | undefined,
): string[] {
return [...(inputImages ?? []), ...(inputVideos ?? [])]
.map((asset) => asset.url?.trim())
.filter((value): value is string => Boolean(value));
}
function assertAlibabaReferenceInputsSupported(
inputImages: VideoGenerationSourceAsset[] | undefined,
inputVideos: VideoGenerationSourceAsset[] | undefined,
): void {
const unsupported = [...(inputImages ?? []), ...(inputVideos ?? [])].some(
(asset) => !asset.url?.trim() && asset.buffer,
);
if (unsupported) {
throw new Error(
"Alibaba Wan video generation currently requires remote http(s) URLs for reference images/videos.",
);
}
}
function buildAlibabaVideoGenerationInput(req: VideoGenerationRequest): Record<string, unknown> {
assertAlibabaReferenceInputsSupported(req.inputImages, req.inputVideos);
const input: Record<string, unknown> = {
prompt: req.prompt,
};
const referenceUrls = resolveReferenceUrls(req.inputImages, req.inputVideos);
if (
referenceUrls.length === 1 &&
(req.inputImages?.length ?? 0) === 1 &&
!req.inputVideos?.length
) {
input.img_url = referenceUrls[0];
} else if (referenceUrls.length > 0) {
input.reference_urls = referenceUrls;
}
return input;
}
function buildAlibabaVideoGenerationParameters(
req: VideoGenerationRequest,
): Record<string, unknown> | undefined {
const parameters: Record<string, unknown> = {};
const size =
req.size?.trim() || (req.resolution ? RESOLUTION_TO_SIZE[req.resolution] : undefined);
if (size) {
parameters.size = size;
}
if (req.aspectRatio?.trim()) {
parameters.aspect_ratio = req.aspectRatio.trim();
}
if (typeof req.durationSeconds === "number" && Number.isFinite(req.durationSeconds)) {
parameters.duration = Math.max(1, Math.round(req.durationSeconds));
}
if (typeof req.audio === "boolean") {
parameters.enable_audio = req.audio;
}
if (typeof req.watermark === "boolean") {
parameters.watermark = req.watermark;
}
return Object.keys(parameters).length > 0 ? parameters : undefined;
}
function extractVideoUrls(payload: AlibabaVideoGenerationResponse): string[] {
const urls = [
...(payload.output?.results?.map((entry) => entry.video_url).filter(Boolean) ?? []),
payload.output?.video_url,
].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
return [...new Set(urls)];
}
async function pollTaskUntilComplete(params: {
taskId: string;
headers: Headers;
timeoutMs?: number;
fetchFn: typeof fetch;
baseUrl: string;
}): Promise<AlibabaVideoGenerationResponse> {
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchWithTimeout(
`${params.baseUrl}/api/v1/tasks/${params.taskId}`,
{
method: "GET",
headers: params.headers,
},
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "Alibaba Wan video-generation task poll failed");
const payload = (await response.json()) as AlibabaVideoGenerationResponse;
const status = payload.output?.task_status?.trim().toUpperCase();
if (status === "SUCCEEDED") {
return payload;
}
if (status === "FAILED" || status === "CANCELED") {
throw new Error(
payload.output?.message?.trim() ||
payload.message?.trim() ||
`Alibaba Wan video generation task ${params.taskId} ${status?.toLowerCase()}`,
);
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
throw new Error(`Alibaba Wan video generation task ${params.taskId} did not finish in time`);
}
async function downloadGeneratedVideos(params: {
urls: string[];
timeoutMs?: number;
fetchFn: typeof fetch;
}): Promise<GeneratedVideoAsset[]> {
const videos: GeneratedVideoAsset[] = [];
for (const [index, url] of params.urls.entries()) {
const response = await fetchWithTimeout(
url,
{ method: "GET" },
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "Alibaba Wan generated video download failed");
const arrayBuffer = await response.arrayBuffer();
videos.push({
buffer: Buffer.from(arrayBuffer),
mimeType: response.headers.get("content-type")?.trim() || "video/mp4",
fileName: `video-${index + 1}.mp4`,
metadata: { sourceUrl: url },
});
}
return videos;
}
export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
return {
id: "alibaba",
@@ -263,11 +110,17 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
headers,
body: {
model,
input: buildAlibabaVideoGenerationInput(req),
parameters: buildAlibabaVideoGenerationParameters({
...req,
durationSeconds: req.durationSeconds ?? DEFAULT_DURATION_SECONDS,
input: buildDashscopeVideoGenerationInput({
providerLabel: "Alibaba Wan",
req,
}),
parameters: buildDashscopeVideoGenerationParameters(
{
...req,
durationSeconds: req.durationSeconds ?? DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
},
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
),
},
timeoutMs: req.timeoutMs,
fetchFn,
@@ -277,26 +130,30 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
try {
await assertOkOrThrowHttpError(response, "Alibaba Wan video generation failed");
const submitted = (await response.json()) as AlibabaVideoGenerationResponse;
const submitted = (await response.json()) as DashscopeVideoGenerationResponse;
const taskId = submitted.output?.task_id?.trim();
if (!taskId) {
throw new Error("Alibaba Wan video generation response missing task_id");
}
const completed = await pollTaskUntilComplete({
const completed = await pollDashscopeVideoTaskUntilComplete({
providerLabel: "Alibaba Wan",
taskId,
headers,
timeoutMs: req.timeoutMs,
fetchFn,
baseUrl: resolveDashscopeAigcApiBaseUrl(baseUrl),
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
const urls = extractVideoUrls(completed);
const urls = extractDashscopeVideoUrls(completed);
if (urls.length === 0) {
throw new Error("Alibaba Wan video generation completed without output video URLs");
}
const videos = await downloadGeneratedVideos({
const videos = await downloadDashscopeGeneratedVideos({
providerLabel: "Alibaba Wan",
urls,
timeoutMs: req.timeoutMs,
fetchFn,
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
return {
videos,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.4.5",
"version": "2026.4.6",
"private": true,
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
"type": "module",

View File

@@ -126,7 +126,6 @@ describe("amazon-bedrock provider plugin", () => {
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
dropThinkingBlocks: true,
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.4.5",
"version": "2026.4.6",
"private": true,
"description": "OpenClaw Amazon Bedrock provider plugin",
"type": "module",

View File

@@ -75,7 +75,6 @@ describe("anthropic-vertex provider plugin", () => {
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
dropThinkingBlocks: true,
});
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.4.5",
"version": "2026.4.6",
"private": true,
"description": "OpenClaw Anthropic Vertex provider plugin",
"type": "module",

View File

@@ -65,7 +65,6 @@ describe("anthropic provider replay hooks", () => {
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
dropThinkingBlocks: true,
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.4.5",
"version": "2026.4.6",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",

8
extensions/arcee/api.ts Normal file
View File

@@ -0,0 +1,8 @@
export { buildArceeModelDefinition, ARCEE_BASE_URL, ARCEE_MODEL_CATALOG } from "./models.js";
export { buildArceeProvider, buildArceeOpenRouterProvider } from "./provider-catalog.js";
export {
applyArceeConfig,
applyArceeOpenRouterConfig,
ARCEE_DEFAULT_MODEL_REF,
ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard.js";

View File

@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import { resolveProviderPluginChoice } from "../../src/plugins/provider-auth-choice.runtime.js";
import { resolveProviderAuthEnvVarCandidates } from "../../src/secrets/provider-env-vars.js";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import arceePlugin from "./index.js";
describe("arcee provider plugin", () => {
it("registers Arcee AI with direct and OpenRouter auth choices", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(provider.id).toBe("arcee");
expect(provider.label).toBe("Arcee AI");
expect(provider.envVars).toEqual(["ARCEEAI_API_KEY", "OPENROUTER_API_KEY"]);
expect(provider.auth).toHaveLength(2);
const directChoice = resolveProviderPluginChoice({
providers: [provider],
choice: "arceeai-api-key",
});
expect(directChoice).not.toBeNull();
expect(directChoice?.provider.id).toBe("arcee");
expect(directChoice?.method.id).toBe("arcee-platform");
const orChoice = resolveProviderPluginChoice({
providers: [provider],
choice: "arceeai-openrouter",
});
expect(orChoice).not.toBeNull();
expect(orChoice?.provider.id).toBe("arcee");
expect(orChoice?.method.id).toBe("openrouter");
});
it("stores the OpenRouter onboarding path under the OpenRouter auth profile", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
const openRouterMethod = provider.auth?.find((method) => method.id === "openrouter");
if (!openRouterMethod?.runNonInteractive) {
throw new Error("expected OpenRouter non-interactive auth");
}
const config = await openRouterMethod.runNonInteractive({
config: {},
opts: {},
env: {},
runtime: {
error: () => {},
exit: () => {},
log: () => {},
},
resolveApiKey: async () => ({
key: "sk-or-test",
source: "profile",
}),
toApiKeyCredential: () => null,
} as never);
expect(config?.auth?.profiles?.["openrouter:default"]).toMatchObject({
provider: "openrouter",
mode: "api_key",
});
expect(config?.models?.providers?.arcee).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
api: "openai-completions",
});
expect(config?.models?.providers?.arcee?.models?.map((model) => model.id)).toEqual([
"arcee/trinity-mini",
"arcee/trinity-large-preview",
"arcee/trinity-large-thinking",
]);
});
it("keeps direct Arcee auth env candidates separate from OpenRouter", () => {
const candidates = resolveProviderAuthEnvVarCandidates();
expect(candidates.arcee).toEqual(["ARCEEAI_API_KEY"]);
expect(candidates.openrouter).toEqual(["OPENROUTER_API_KEY"]);
});
it("builds the direct Arcee AI model catalog", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(provider.catalog).toBeDefined();
const catalog = await provider.catalog!.run({
config: {},
env: {},
resolveProviderApiKey: (id: string) =>
id === "arcee" ? { apiKey: "test-key" } : { apiKey: undefined },
resolveProviderAuth: () => ({
apiKey: "test-key",
mode: "api_key",
source: "env",
}),
} as never);
expect(catalog && "provider" in catalog).toBe(true);
if (!catalog || !("provider" in catalog)) {
throw new Error("expected single-provider catalog");
}
expect(catalog.provider.api).toBe("openai-completions");
expect(catalog.provider.baseUrl).toBe("https://api.arcee.ai/api/v1");
expect(catalog.provider.models?.map((model) => model.id)).toEqual([
"trinity-mini",
"trinity-large-preview",
"trinity-large-thinking",
]);
});
it("builds the OpenRouter-backed Arcee AI model catalog", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
const catalog = await provider.catalog!.run({
config: {},
env: {},
resolveProviderApiKey: (id: string) =>
id === "openrouter" ? { apiKey: "sk-or-test" } : { apiKey: undefined },
resolveProviderAuth: () => ({
apiKey: "sk-or-test",
mode: "api_key",
source: "env",
}),
} as never);
expect(catalog && "provider" in catalog).toBe(true);
if (!catalog || !("provider" in catalog)) {
throw new Error("expected single-provider catalog");
}
expect(catalog.provider.baseUrl).toBe("https://openrouter.ai/api/v1");
expect(catalog.provider.models?.map((model) => model.id)).toEqual([
"arcee/trinity-mini",
"arcee/trinity-large-preview",
"arcee/trinity-large-thinking",
]);
});
it("normalizes Arcee OpenRouter models to vendor-prefixed runtime ids", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(
provider.normalizeResolvedModel?.({
modelId: "arcee/trinity-large-thinking",
model: {
provider: "arcee",
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
},
} as never),
).toMatchObject({
id: "arcee/trinity-large-thinking",
});
expect(
provider.normalizeResolvedModel?.({
modelId: "arcee/trinity-large-thinking",
model: {
provider: "arcee",
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
api: "openai-completions",
baseUrl: "https://api.arcee.ai/api/v1",
},
} as never),
).toBeUndefined();
});
});

128
extensions/arcee/index.ts Normal file
View File

@@ -0,0 +1,128 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import {
readConfiguredProviderCatalogEntries,
type ProviderCatalogContext,
} from "openclaw/plugin-sdk/provider-catalog-shared";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import {
applyArceeConfig,
applyArceeOpenRouterConfig,
ARCEE_DEFAULT_MODEL_REF,
ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard.js";
import {
buildArceeProvider,
buildArceeOpenRouterProvider,
isArceeOpenRouterBaseUrl,
toArceeOpenRouterModelId,
} from "./provider-catalog.js";
const PROVIDER_ID = "arcee";
const OPENAI_COMPATIBLE_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
family: "openai-compatible",
});
const ARCEE_WIZARD_GROUP = {
groupId: "arcee",
groupLabel: "Arcee AI",
groupHint: "Direct API or OpenRouter",
} as const;
function buildArceeAuthMethods() {
return [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "arcee-platform",
label: "Arcee AI API key",
hint: "Direct access to Arcee platform",
optionKey: "arceeaiApiKey",
flagName: "--arceeai-api-key",
envVar: "ARCEEAI_API_KEY",
promptMessage: "Enter Arcee AI API key",
defaultModel: ARCEE_DEFAULT_MODEL_REF,
expectedProviders: [PROVIDER_ID],
applyConfig: (cfg) => applyArceeConfig(cfg),
wizard: {
choiceId: "arceeai-api-key",
choiceLabel: "Arcee AI API key",
choiceHint: "Direct (chat.arcee.ai)",
...ARCEE_WIZARD_GROUP,
},
}),
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "openrouter",
label: "OpenRouter API key",
hint: "Access Arcee models via OpenRouter",
optionKey: "openrouterApiKey",
flagName: "--openrouter-api-key",
envVar: "OPENROUTER_API_KEY",
promptMessage: "Enter OpenRouter API key",
profileId: "openrouter:default",
defaultModel: ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
expectedProviders: [PROVIDER_ID, "openrouter"],
applyConfig: (cfg) => applyArceeOpenRouterConfig(cfg),
wizard: {
choiceId: "arceeai-openrouter",
choiceLabel: "OpenRouter API key",
choiceHint: "Via OpenRouter (openrouter.ai)",
...ARCEE_WIZARD_GROUP,
},
}),
];
}
function readConfiguredArceeCatalogEntries(config: OpenClawConfig | undefined) {
return readConfiguredProviderCatalogEntries({
config,
providerId: PROVIDER_ID,
});
}
async function resolveArceeCatalog(ctx: ProviderCatalogContext) {
const directKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (directKey) {
return { provider: { ...buildArceeProvider(), apiKey: directKey } };
}
const openRouterKey = ctx.resolveProviderApiKey("openrouter").apiKey;
if (openRouterKey) {
return { provider: { ...buildArceeOpenRouterProvider(), apiKey: openRouterKey } };
}
return null;
}
function normalizeArceeResolvedModel<T extends { baseUrl?: string; id: string }>(
model: T,
): T | undefined {
if (!isArceeOpenRouterBaseUrl(model.baseUrl)) {
return undefined;
}
return {
...model,
id: toArceeOpenRouterModelId(model.id),
};
}
export default definePluginEntry({
id: PROVIDER_ID,
name: "Arcee AI Provider",
description: "Bundled Arcee AI provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "Arcee AI",
docsPath: "/providers/arcee",
envVars: ["ARCEEAI_API_KEY", "OPENROUTER_API_KEY"],
auth: buildArceeAuthMethods(),
catalog: {
run: resolveArceeCatalog,
},
augmentModelCatalog: ({ config }) => readConfiguredArceeCatalogEntries(config),
normalizeResolvedModel: ({ model }) => normalizeArceeResolvedModel(model),
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
});
},
});

View File

@@ -0,0 +1,67 @@
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
export const ARCEE_BASE_URL = "https://api.arcee.ai/api/v1";
export const ARCEE_MODEL_CATALOG: ModelDefinitionConfig[] = [
{
id: "trinity-mini",
name: "Trinity Mini 26B",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 80000,
cost: {
input: 0.045,
output: 0.15,
cacheRead: 0.045,
cacheWrite: 0.045,
},
},
{
id: "trinity-large-preview",
name: "Trinity Large Preview",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 16384,
cost: {
input: 0.25,
output: 1.0,
cacheRead: 0.25,
cacheWrite: 0.25,
},
},
{
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
reasoning: true,
input: ["text"],
contextWindow: 262144,
maxTokens: 80000,
cost: {
input: 0.25,
output: 0.9,
cacheRead: 0.25,
cacheWrite: 0.25,
},
compat: {
supportsReasoningEffort: false,
},
},
];
export function buildArceeModelDefinition(
model: (typeof ARCEE_MODEL_CATALOG)[number],
): ModelDefinitionConfig {
return {
id: model.id,
name: model.name,
api: "openai-completions",
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
...(model.compat ? { compat: model.compat } : {}),
};
}

View File

@@ -0,0 +1,47 @@
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { ARCEE_BASE_URL } from "./api.js";
import {
buildArceeCatalogModels,
buildArceeOpenRouterCatalogModels,
OPENROUTER_BASE_URL,
} from "./provider-catalog.js";
export const ARCEE_DEFAULT_MODEL_REF = "arcee/trinity-large-thinking";
export const ARCEE_OPENROUTER_DEFAULT_MODEL_REF = "arcee/trinity-large-thinking";
const arceePresetAppliers = createModelCatalogPresetAppliers({
primaryModelRef: ARCEE_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
providerId: "arcee",
api: "openai-completions",
baseUrl: ARCEE_BASE_URL,
catalogModels: buildArceeCatalogModels(),
aliases: [{ modelRef: ARCEE_DEFAULT_MODEL_REF, alias: "Arcee AI" }],
}),
});
const arceeOpenRouterPresetAppliers = createModelCatalogPresetAppliers({
primaryModelRef: ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
providerId: "arcee",
api: "openai-completions",
baseUrl: OPENROUTER_BASE_URL,
catalogModels: buildArceeOpenRouterCatalogModels(),
aliases: [{ modelRef: ARCEE_OPENROUTER_DEFAULT_MODEL_REF, alias: "Arcee AI (OpenRouter)" }],
}),
});
export function applyArceeProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return arceePresetAppliers.applyProviderConfig(cfg);
}
export function applyArceeConfig(cfg: OpenClawConfig): OpenClawConfig {
return arceePresetAppliers.applyConfig(cfg);
}
export function applyArceeOpenRouterConfig(cfg: OpenClawConfig): OpenClawConfig {
return arceeOpenRouterPresetAppliers.applyConfig(cfg);
}

View File

@@ -0,0 +1,43 @@
{
"id": "arcee",
"enabledByDefault": true,
"providers": ["arcee"],
"providerAuthEnvVars": {
"arcee": ["ARCEEAI_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "arcee",
"method": "arcee-platform",
"choiceId": "arceeai-api-key",
"choiceLabel": "Arcee AI API key",
"choiceHint": "Direct (chat.arcee.ai)",
"groupId": "arcee",
"groupLabel": "Arcee AI",
"groupHint": "Direct API or OpenRouter",
"optionKey": "arceeaiApiKey",
"cliFlag": "--arceeai-api-key",
"cliOption": "--arceeai-api-key <key>",
"cliDescription": "Arcee AI API key"
},
{
"provider": "arcee",
"method": "openrouter",
"choiceId": "arceeai-openrouter",
"choiceLabel": "OpenRouter API key",
"choiceHint": "Via OpenRouter (openrouter.ai)",
"groupId": "arcee",
"groupLabel": "Arcee AI",
"groupHint": "Direct API or OpenRouter",
"optionKey": "openrouterApiKey",
"cliFlag": "--openrouter-api-key",
"cliOption": "--openrouter-api-key <key>",
"cliDescription": "OpenRouter API key for Arcee AI models"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.4.4",
"private": true,
"description": "OpenClaw Arcee provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,49 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { buildArceeModelDefinition, ARCEE_BASE_URL, ARCEE_MODEL_CATALOG } from "./api.js";
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
function normalizeBaseUrl(baseUrl: string | undefined): string {
return String(baseUrl ?? "")
.trim()
.replace(/\/+$/, "");
}
export function isArceeOpenRouterBaseUrl(baseUrl: string | undefined): boolean {
return normalizeBaseUrl(baseUrl) === OPENROUTER_BASE_URL;
}
export function toArceeOpenRouterModelId(modelId: string): string {
const normalized = modelId.trim();
if (!normalized || normalized.startsWith("arcee/")) {
return normalized;
}
return `arcee/${normalized}`;
}
export function buildArceeCatalogModels(): NonNullable<ModelProviderConfig["models"]> {
return ARCEE_MODEL_CATALOG.map(buildArceeModelDefinition);
}
export function buildArceeOpenRouterCatalogModels(): NonNullable<ModelProviderConfig["models"]> {
return buildArceeCatalogModels().map((model) => ({
...model,
id: toArceeOpenRouterModelId(model.id),
}));
}
export function buildArceeProvider(): ModelProviderConfig {
return {
baseUrl: ARCEE_BASE_URL,
api: "openai-completions",
models: buildArceeCatalogModels(),
};
}
export function buildArceeOpenRouterProvider(): ModelProviderConfig {
return {
baseUrl: OPENROUTER_BASE_URL,
api: "openai-completions",
models: buildArceeOpenRouterCatalogModels(),
};
}

View File

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

View File

@@ -9,6 +9,10 @@ export default defineBundledChannelEntry({
specifier: "./api.js",
exportName: "bluebubblesPlugin",
},
secrets: {
specifier: "./src/secret-contract.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setBlueBubblesRuntime",

View File

@@ -1,13 +1,13 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.4.5",
"version": "2026.4.6",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.5"
"openclaw": ">=2026.4.6"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -39,13 +39,13 @@
"install": {
"npmSpec": "@openclaw/bluebubbles",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.5"
"minHostVersion": ">=2026.4.6"
},
"compat": {
"pluginApi": ">=2026.4.5"
"pluginApi": ">=2026.4.6"
},
"build": {
"openclawVersion": "2026.4.5"
"openclawVersion": "2026.4.6"
},
"release": {
"publishToClawHub": true,

View File

@@ -6,4 +6,8 @@ export default defineBundledChannelSetupEntry({
specifier: "./api.js",
exportName: "bluebubblesSetupPlugin",
},
secrets: {
specifier: "./src/secret-contract.js",
exportName: "channelSecrets",
},
});

View File

@@ -8,6 +8,10 @@ type BlueBubblesConfigPatch = {
};
type AccountEnabledMode = boolean | "preserve-or-true";
type BlueBubblesAccountEntry = {
enabled?: boolean;
[key: string]: unknown;
};
function normalizePatch(
patch: BlueBubblesConfigPatch,
@@ -51,7 +55,9 @@ export function applyBlueBubblesConnectionConfig(params: {
};
}
const currentAccount = params.cfg.channels?.bluebubbles?.accounts?.[params.accountId];
const currentAccount = params.cfg.channels?.bluebubbles?.accounts?.[params.accountId] as
| BlueBubblesAccountEntry
| undefined;
const enabled =
params.accountEnabled === "preserve-or-true"
? (currentAccount?.enabled ?? true)

View File

@@ -1,76 +1,19 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
import {
registerSessionBindingAdapter,
resolveThreadBindingConversationIdFromBindingId,
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
unregisterSessionBindingAdapter,
createAccountScopedConversationBindingManager,
resetAccountScopedConversationBindingsForTests,
type AccountScopedConversationBindingManager,
type BindingTargetKind,
type SessionBindingAdapter,
type SessionBindingRecord,
} from "openclaw/plugin-sdk/thread-bindings-runtime";
type BlueBubblesBindingTargetKind = "subagent" | "acp";
type BlueBubblesConversationBindingRecord = {
accountId: string;
conversationId: string;
targetKind: BlueBubblesBindingTargetKind;
targetSessionKey: string;
agentId?: string;
label?: string;
boundBy?: string;
boundAt: number;
lastActivityAt: number;
};
type BlueBubblesConversationBindingManager = {
accountId: string;
getByConversationId: (conversationId: string) => BlueBubblesConversationBindingRecord | undefined;
listBySessionKey: (targetSessionKey: string) => BlueBubblesConversationBindingRecord[];
bindConversation: (params: {
conversationId: string;
targetKind: BindingTargetKind;
targetSessionKey: string;
metadata?: Record<string, unknown>;
}) => BlueBubblesConversationBindingRecord | null;
touchConversation: (
conversationId: string,
at?: number,
) => BlueBubblesConversationBindingRecord | null;
unbindConversation: (conversationId: string) => BlueBubblesConversationBindingRecord | null;
unbindBySessionKey: (targetSessionKey: string) => BlueBubblesConversationBindingRecord[];
stop: () => void;
};
type BlueBubblesConversationBindingsState = {
managersByAccountId: Map<string, BlueBubblesConversationBindingManager>;
bindingsByAccountConversation: Map<string, BlueBubblesConversationBindingRecord>;
};
type BlueBubblesConversationBindingManager =
AccountScopedConversationBindingManager<BlueBubblesBindingTargetKind>;
const BLUEBUBBLES_CONVERSATION_BINDINGS_STATE_KEY = Symbol.for(
"openclaw.bluebubblesConversationBindingsState",
);
let state: BlueBubblesConversationBindingsState | undefined;
function getState(): BlueBubblesConversationBindingsState {
if (!state) {
const globalStore = globalThis as Record<PropertyKey, unknown>;
state = (globalStore[BLUEBUBBLES_CONVERSATION_BINDINGS_STATE_KEY] as
| BlueBubblesConversationBindingsState
| undefined) ?? {
managersByAccountId: new Map(),
bindingsByAccountConversation: new Map(),
};
globalStore[BLUEBUBBLES_CONVERSATION_BINDINGS_STATE_KEY] = state;
}
return state;
}
function resolveBindingKey(params: { accountId: string; conversationId: string }): string {
return `${params.accountId}:${params.conversationId}`;
}
function toSessionBindingTargetKind(raw: BlueBubblesBindingTargetKind): BindingTargetKind {
return raw === "subagent" ? "subagent" : "session";
@@ -80,221 +23,24 @@ function toBlueBubblesTargetKind(raw: BindingTargetKind): BlueBubblesBindingTarg
return raw === "subagent" ? "subagent" : "acp";
}
function toSessionBindingRecord(
record: BlueBubblesConversationBindingRecord,
defaults: { idleTimeoutMs: number; maxAgeMs: number },
): SessionBindingRecord {
const idleExpiresAt =
defaults.idleTimeoutMs > 0 ? record.lastActivityAt + defaults.idleTimeoutMs : undefined;
const maxAgeExpiresAt = defaults.maxAgeMs > 0 ? record.boundAt + defaults.maxAgeMs : undefined;
const expiresAt =
idleExpiresAt != null && maxAgeExpiresAt != null
? Math.min(idleExpiresAt, maxAgeExpiresAt)
: (idleExpiresAt ?? maxAgeExpiresAt);
return {
bindingId: resolveBindingKey({
accountId: record.accountId,
conversationId: record.conversationId,
}),
targetSessionKey: record.targetSessionKey,
targetKind: toSessionBindingTargetKind(record.targetKind),
conversation: {
channel: "bluebubbles",
accountId: record.accountId,
conversationId: record.conversationId,
},
status: "active",
boundAt: record.boundAt,
expiresAt,
metadata: {
agentId: record.agentId,
label: record.label,
boundBy: record.boundBy,
lastActivityAt: record.lastActivityAt,
idleTimeoutMs: defaults.idleTimeoutMs,
maxAgeMs: defaults.maxAgeMs,
},
};
}
export function createBlueBubblesConversationBindingManager(params: {
accountId?: string;
cfg: OpenClawConfig;
}): BlueBubblesConversationBindingManager {
const accountId = normalizeAccountId(params.accountId);
const existing = getState().managersByAccountId.get(accountId);
if (existing) {
return existing;
}
const idleTimeoutMs = resolveThreadBindingIdleTimeoutMsForChannel({
return createAccountScopedConversationBindingManager({
channel: "bluebubbles",
cfg: params.cfg,
channel: "bluebubbles",
accountId,
accountId: params.accountId,
stateKey: BLUEBUBBLES_CONVERSATION_BINDINGS_STATE_KEY,
toStoredTargetKind: toBlueBubblesTargetKind,
toSessionBindingTargetKind,
});
const maxAgeMs = resolveThreadBindingMaxAgeMsForChannel({
cfg: params.cfg,
channel: "bluebubbles",
accountId,
});
const manager: BlueBubblesConversationBindingManager = {
accountId,
getByConversationId: (conversationId) =>
getState().bindingsByAccountConversation.get(
resolveBindingKey({ accountId, conversationId }),
),
listBySessionKey: (targetSessionKey) =>
[...getState().bindingsByAccountConversation.values()].filter(
(record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey,
),
bindConversation: ({ conversationId, targetKind, targetSessionKey, metadata }) => {
const normalizedConversationId = conversationId.trim();
const normalizedTargetSessionKey = targetSessionKey.trim();
if (!normalizedConversationId || !normalizedTargetSessionKey) {
return null;
}
const now = Date.now();
const record: BlueBubblesConversationBindingRecord = {
accountId,
conversationId: normalizedConversationId,
targetKind: toBlueBubblesTargetKind(targetKind),
targetSessionKey: normalizedTargetSessionKey,
agentId:
typeof metadata?.agentId === "string" && metadata.agentId.trim()
? metadata.agentId.trim()
: resolveAgentIdFromSessionKey(normalizedTargetSessionKey),
label:
typeof metadata?.label === "string" && metadata.label.trim()
? metadata.label.trim()
: undefined,
boundBy:
typeof metadata?.boundBy === "string" && metadata.boundBy.trim()
? metadata.boundBy.trim()
: undefined,
boundAt: now,
lastActivityAt: now,
};
getState().bindingsByAccountConversation.set(
resolveBindingKey({ accountId, conversationId: normalizedConversationId }),
record,
);
return record;
},
touchConversation: (conversationId, at = Date.now()) => {
const key = resolveBindingKey({ accountId, conversationId });
const existingRecord = getState().bindingsByAccountConversation.get(key);
if (!existingRecord) {
return null;
}
const updated = { ...existingRecord, lastActivityAt: at };
getState().bindingsByAccountConversation.set(key, updated);
return updated;
},
unbindConversation: (conversationId) => {
const key = resolveBindingKey({ accountId, conversationId });
const existingRecord = getState().bindingsByAccountConversation.get(key);
if (!existingRecord) {
return null;
}
getState().bindingsByAccountConversation.delete(key);
return existingRecord;
},
unbindBySessionKey: (targetSessionKey) => {
const removed: BlueBubblesConversationBindingRecord[] = [];
for (const record of getState().bindingsByAccountConversation.values()) {
if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
continue;
}
getState().bindingsByAccountConversation.delete(
resolveBindingKey({ accountId, conversationId: record.conversationId }),
);
removed.push(record);
}
return removed;
},
stop: () => {
for (const key of getState().bindingsByAccountConversation.keys()) {
if (key.startsWith(`${accountId}:`)) {
getState().bindingsByAccountConversation.delete(key);
}
}
getState().managersByAccountId.delete(accountId);
unregisterSessionBindingAdapter({
channel: "bluebubbles",
accountId,
adapter: sessionBindingAdapter,
});
},
};
const sessionBindingAdapter: SessionBindingAdapter = {
channel: "bluebubbles",
accountId,
capabilities: {
placements: ["current"],
},
bind: async (input) => {
if (input.conversation.channel !== "bluebubbles" || input.placement === "child") {
return null;
}
const bound = manager.bindConversation({
conversationId: input.conversation.conversationId,
targetKind: input.targetKind,
targetSessionKey: input.targetSessionKey,
metadata: input.metadata,
});
return bound ? toSessionBindingRecord(bound, { idleTimeoutMs, maxAgeMs }) : null;
},
listBySession: (targetSessionKey) =>
manager
.listBySessionKey(targetSessionKey)
.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })),
resolveByConversation: (ref) => {
if (ref.channel !== "bluebubbles") {
return null;
}
const found = manager.getByConversationId(ref.conversationId);
return found ? toSessionBindingRecord(found, { idleTimeoutMs, maxAgeMs }) : null;
},
touch: (bindingId, at) => {
const conversationId = resolveThreadBindingConversationIdFromBindingId({
accountId,
bindingId,
});
if (conversationId) {
manager.touchConversation(conversationId, at);
}
},
unbind: async (input) => {
if (input.targetSessionKey?.trim()) {
return manager
.unbindBySessionKey(input.targetSessionKey.trim())
.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs }));
}
const conversationId = resolveThreadBindingConversationIdFromBindingId({
accountId,
bindingId: input.bindingId,
});
if (!conversationId) {
return [];
}
const removed = manager.unbindConversation(conversationId);
return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : [];
},
};
registerSessionBindingAdapter(sessionBindingAdapter);
getState().managersByAccountId.set(accountId, manager);
return manager;
}
export const __testing = {
resetBlueBubblesConversationBindingsForTests() {
for (const manager of getState().managersByAccountId.values()) {
manager.stop();
}
getState().managersByAccountId.clear();
getState().bindingsByAccountConversation.clear();
resetAccountScopedConversationBindingsForTests({
stateKey: BLUEBUBBLES_CONVERSATION_BINDINGS_STATE_KEY,
});
},
};

View File

@@ -1,103 +1,9 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
hasLegacyFlatAllowPrivateNetworkAlias,
migrateLegacyFlatAllowPrivateNetworkAlias,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
const contract = createLegacyPrivateNetworkDoctorContract({
channelKey: "bluebubbles",
});
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
export const legacyConfigRules = contract.legacyConfigRules;
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "bluebubbles"],
message:
'channels.bluebubbles.allowPrivateNetwork is legacy; use channels.bluebubbles.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "bluebubbles", "accounts"],
message:
'channels.bluebubbles.accounts.<id>.allowPrivateNetwork is legacy; use channels.bluebubbles.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const bluebubbles = isRecord(channels?.bluebubbles) ? channels.bluebubbles : null;
if (!bluebubbles) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedBluebubbles = bluebubbles;
let changed = false;
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedBluebubbles,
pathPrefix: "channels.bluebubbles",
changes,
});
updatedBluebubbles = topLevel.entry;
changed = changed || topLevel.changed;
const accounts = isRecord(updatedBluebubbles.accounts) ? updatedBluebubbles.accounts : null;
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = isRecord(accountValue) ? accountValue : null;
if (!account) {
continue;
}
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: account,
pathPrefix: `channels.bluebubbles.accounts.${accountId}`,
changes,
});
if (!migrated.changed) {
continue;
}
nextAccounts[accountId] = migrated.entry;
accountsChanged = true;
}
if (accountsChanged) {
updatedBluebubbles = { ...updatedBluebubbles, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
bluebubbles: updatedBluebubbles as NonNullable<OpenClawConfig["channels"]>["bluebubbles"],
},
},
changes,
};
}
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;

View File

@@ -27,7 +27,13 @@ describe("bluebubbles doctor", () => {
expect(result.config.channels?.bluebubbles?.network).toEqual({
dangerouslyAllowPrivateNetwork: true,
});
expect(result.config.channels?.bluebubbles?.accounts?.default?.network).toEqual({
expect(
(
result.config.channels?.bluebubbles?.accounts?.default as {
network?: { dangerouslyAllowPrivateNetwork?: boolean };
}
)?.network,
).toEqual({
dangerouslyAllowPrivateNetwork: false,
});
});

View File

@@ -1,105 +1,10 @@
import type {
ChannelDoctorAdapter,
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import {
hasLegacyFlatAllowPrivateNetworkAlias,
migrateLegacyFlatAllowPrivateNetworkAlias,
} from "openclaw/plugin-sdk/ssrf-runtime";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function hasLegacyAllowPrivateNetworkInAccounts(value: unknown): boolean {
const accounts = isRecord(value) ? value : null;
return Boolean(
accounts &&
Object.values(accounts).some((account) =>
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
),
);
}
function normalizeBlueBubblesCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
const bluebubbles = isRecord(channels?.bluebubbles) ? channels.bluebubbles : null;
if (!bluebubbles) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedBluebubbles = bluebubbles;
let changed = false;
const topLevel = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: updatedBluebubbles,
pathPrefix: "channels.bluebubbles",
changes,
});
updatedBluebubbles = topLevel.entry;
changed = changed || topLevel.changed;
const accounts = isRecord(updatedBluebubbles.accounts) ? updatedBluebubbles.accounts : null;
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = isRecord(accountValue) ? accountValue : null;
if (!account) {
continue;
}
const migrated = migrateLegacyFlatAllowPrivateNetworkAlias({
entry: account,
pathPrefix: `channels.bluebubbles.accounts.${accountId}`,
changes,
});
if (!migrated.changed) {
continue;
}
nextAccounts[accountId] = migrated.entry;
accountsChanged = true;
}
if (accountsChanged) {
updatedBluebubbles = { ...updatedBluebubbles, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
bluebubbles: updatedBluebubbles as NonNullable<OpenClawConfig["channels"]>["bluebubbles"],
},
},
changes,
};
}
const BLUEBUBBLES_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "bluebubbles"],
message:
'channels.bluebubbles.allowPrivateNetwork is legacy; use channels.bluebubbles.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
},
{
path: ["channels", "bluebubbles", "accounts"],
message:
'channels.bluebubbles.accounts.<id>.allowPrivateNetwork is legacy; use channels.bluebubbles.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
match: hasLegacyAllowPrivateNetworkInAccounts,
},
];
legacyConfigRules as BLUEBUBBLES_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeBlueBubblesCompatibilityConfig,
} from "./doctor-contract.js";
export const bluebubblesDoctor: ChannelDoctorAdapter = {
legacyConfigRules: BLUEBUBBLES_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeBlueBubblesCompatibilityConfig(cfg),
normalizeCompatibilityConfig: normalizeBlueBubblesCompatibilityConfig,
};

View File

@@ -201,8 +201,8 @@ export async function sendBlueBubblesMedia(params: {
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.bluebubbles?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.bluebubbles?.mediaMaxMb,
(cfg.channels?.bluebubbles?.accounts?.[accountId] as { mediaMaxMb?: number } | undefined)
?.mediaMaxMb ?? cfg.channels?.bluebubbles?.mediaMaxMb,
accountId,
});
const mediaLocalRoots = resolveMediaLocalRoots({ cfg, accountId });

View File

@@ -156,9 +156,7 @@ function getFirstDispatchCall(): DispatchReplyParams {
function installTimingAwareInboundDebouncer(core: PluginRuntime) {
// Use a timing-aware debouncer test double that respects debounceMs/buildKey/shouldDebounce.
// oxlint-disable-next-line typescript/no-explicit-any
core.channel.debounce.createInboundDebouncer = vi.fn((params: any) => {
// oxlint-disable-next-line typescript/no-explicit-any
type Item = any;
const buckets = new Map<
string,

View File

@@ -4,7 +4,7 @@ import {
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
} from "openclaw/plugin-sdk/channel-secret-runtime";
export const secretTargetRegistryEntries = [
{
@@ -52,3 +52,8 @@ export function collectRuntimeConfigAssignments(params: {
accountInactiveReason: "BlueBubbles account is disabled.",
});
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};

View File

@@ -21,8 +21,11 @@ export function setBlueBubblesDmPolicy(
const existingAllowFrom =
resolvedAccountId === "default"
? cfg.channels?.bluebubbles?.allowFrom
: (cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]?.allowFrom ??
cfg.channels?.bluebubbles?.allowFrom);
: ((
cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId] as
| { allowFrom?: ReadonlyArray<string | number> }
| undefined
)?.allowFrom ?? cfg.channels?.bluebubbles?.allowFrom);
return patchScopedAccountConfig({
cfg,
channelKey: channel,

View File

@@ -36,6 +36,9 @@ async function createBlueBubblesConfigureAdapter() {
docsPath: "/channels/bluebubbles",
blurb: "iMessage via BlueBubbles",
},
capabilities: {
chatTypes: ["direct", "group"],
},
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
@@ -213,8 +216,13 @@ describe("bluebubbles setup surface", () => {
});
const next = blueBubblesSetupWizard.dmPolicy?.setPolicy(cfg, "open");
const workAccount = next?.channels?.bluebubbles?.accounts?.work as
| {
dmPolicy?: string;
}
| undefined;
expect(next?.channels?.bluebubbles?.dmPolicy).toBe("disabled");
expect(next?.channels?.bluebubbles?.accounts?.work?.dmPolicy).toBe("open");
expect(workAccount?.dmPolicy).toBe("open");
});
it("uses configured defaultAccount when accountId is omitted in account resolution", async () => {
@@ -294,12 +302,15 @@ describe("bluebubbles setup surface", () => {
"work",
);
const workAccount = next?.channels?.bluebubbles?.accounts?.work as
| {
dmPolicy?: string;
allowFrom?: string[];
}
| undefined;
expect(next?.channels?.bluebubbles?.dmPolicy).toBeUndefined();
expect(next?.channels?.bluebubbles?.accounts?.work?.dmPolicy).toBe("open");
expect(next?.channels?.bluebubbles?.accounts?.work?.allowFrom).toEqual([
"user@example.com",
"*",
]);
expect(workAccount?.dmPolicy).toBe("open");
expect(workAccount?.allowFrom).toEqual(["user@example.com", "*"]);
});
});
@@ -439,7 +450,6 @@ describe("bluebubbles group policy", () => {
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.4.5",
"version": "2026.4.6",
"private": true,
"description": "OpenClaw Brave plugin",
"type": "module",

View File

@@ -0,0 +1,367 @@
import type { SearchConfigRecord } from "openclaw/plugin-sdk/provider-web-search";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
formatCliCommand,
normalizeFreshness,
parseIsoDateRange,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import {
type BraveLlmContextResponse,
mapBraveLlmContextResults,
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveConfig,
resolveBraveMode,
} from "./brave-web-search-provider.shared.js";
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
type BraveSearchResult = {
title?: string;
url?: string;
description?: string;
age?: string;
};
type BraveSearchResponse = {
web?: {
results?: BraveSearchResult[];
};
};
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
return (
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readProviderEnvValue(["BRAVE_API_KEY"])
);
}
function missingBraveKeyPayload() {
return {
error: "missing_brave_api_key",
message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
async function runBraveLlmContextSearch(params: {
query: string;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
freshness?: string;
}): Promise<{
results: Array<{
url: string;
title: string;
snippets: string[];
siteName?: string;
}>;
sources?: BraveLlmContextResponse["sources"];
}> {
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
url.searchParams.set("q", params.query);
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
}
return withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (response) => {
if (!response.ok) {
const detail = await response.text();
throw new Error(
`Brave LLM Context API error (${response.status}): ${detail || response.statusText}`,
);
}
const data = (await response.json()) as BraveLlmContextResponse;
return { results: mapBraveLlmContextResults(data), sources: data.sources };
},
);
}
async function runBraveWebSearch(params: {
query: string;
count: number;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
ui_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
}): Promise<Array<Record<string, unknown>>> {
const url = new URL(BRAVE_SEARCH_ENDPOINT);
url.searchParams.set("q", params.query);
url.searchParams.set("count", String(params.count));
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.ui_lang) {
url.searchParams.set("ui_lang", params.ui_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
} else if (params.dateAfter && params.dateBefore) {
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
} else if (params.dateAfter) {
url.searchParams.set(
"freshness",
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
);
} else if (params.dateBefore) {
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
}
return withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (response) => {
if (!response.ok) {
const detail = await response.text();
throw new Error(
`Brave Search API error (${response.status}): ${detail || response.statusText}`,
);
}
const data = (await response.json()) as BraveSearchResponse;
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
return results.map((entry) => {
const description = entry.description ?? "";
const title = entry.title ?? "";
const url = entry.url ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: description ? wrapWebContent(description, "web_search") : "",
published: entry.age || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
},
);
}
export async function executeBraveSearch(
args: Record<string, unknown>,
searchConfig?: SearchConfigRecord,
): Promise<Record<string, unknown>> {
const apiKey = resolveBraveApiKey(searchConfig);
if (!apiKey) {
return missingBraveKeyPayload();
}
const braveConfig = resolveBraveConfig(searchConfig);
const braveMode = resolveBraveMode(braveConfig);
const query = readStringParam(args, "query", { required: true });
const count =
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
const country = normalizeBraveCountry(readStringParam(args, "country"));
const language = readStringParam(args, "language");
const search_lang = readStringParam(args, "search_lang");
const ui_lang = readStringParam(args, "ui_lang");
const normalizedLanguage = normalizeBraveLanguageParams({
search_lang: search_lang || language,
ui_lang,
});
if (normalizedLanguage.invalidField === "search_lang") {
return {
error: "invalid_search_lang",
message:
"search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.invalidField === "ui_lang") {
return {
error: "invalid_ui_lang",
message: "ui_lang must be a language-region locale like 'en-US'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.ui_lang && braveMode === "llm-context") {
return {
error: "unsupported_ui_lang",
message:
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawFreshness = readStringParam(args, "freshness");
if (rawFreshness && braveMode === "llm-context") {
return {
error: "unsupported_freshness",
message:
"freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined;
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: "freshness must be day, week, month, or year.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawDateAfter = readStringParam(args, "date_after");
const rawDateBefore = readStringParam(args, "date_before");
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if ((rawDateAfter || rawDateBefore) && braveMode === "llm-context") {
return {
error: "unsupported_date_filter",
message:
"date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const parsedDateRange = parseIsoDateRange({
rawDateAfter,
rawDateBefore,
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
invalidDateRangeMessage: "date_after must be before date_before.",
});
if ("error" in parsedDateRange) {
return parsedDateRange;
}
const { dateAfter, dateBefore } = parsedDateRange;
const cacheKey = buildSearchCacheKey([
"brave",
braveMode,
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
country,
normalizedLanguage.search_lang,
normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
if (braveMode === "llm-context") {
const { results, sources } = await runBraveLlmContextSearch({
query,
apiKey,
timeoutSeconds,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
freshness,
});
const payload = {
query,
provider: "brave",
mode: "llm-context" as const,
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results: results.map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url,
snippets: entry.snippets.map((snippet) => wrapWebContent(snippet, "web_search")),
siteName: entry.siteName,
})),
sources,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
}
const results = await runBraveWebSearch({
query,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
apiKey,
timeoutSeconds,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
ui_lang: normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
});
const payload = {
query,
provider: "brave",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
}

View File

@@ -0,0 +1,274 @@
import { Type } from "@sinclair/typebox";
export type BraveConfig = {
mode?: string;
};
export type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
export type BraveLlmContextResponse = {
grounding: { generic?: BraveLlmContextResult[] };
sources?: { url?: string; hostname?: string; date?: string }[];
};
const BRAVE_COUNTRY_CODES = new Set([
"AR",
"AU",
"AT",
"BE",
"BR",
"CA",
"CL",
"DK",
"FI",
"FR",
"DE",
"GR",
"HK",
"IN",
"ID",
"IT",
"JP",
"KR",
"MY",
"MX",
"NL",
"NZ",
"NO",
"CN",
"PL",
"PT",
"PH",
"RU",
"SA",
"ZA",
"ES",
"SE",
"CH",
"TW",
"TR",
"GB",
"US",
"ALL",
]);
const BRAVE_SEARCH_LANG_CODES = new Set([
"ar",
"eu",
"bn",
"bg",
"ca",
"zh-hans",
"zh-hant",
"hr",
"cs",
"da",
"nl",
"en",
"en-gb",
"et",
"fi",
"fr",
"gl",
"de",
"el",
"gu",
"he",
"hi",
"hu",
"is",
"it",
"jp",
"kn",
"ko",
"lv",
"lt",
"ms",
"ml",
"mr",
"nb",
"pl",
"pt-br",
"pt-pt",
"pa",
"ro",
"ru",
"sr",
"sk",
"sl",
"es",
"sv",
"ta",
"te",
"th",
"tr",
"uk",
"vi",
]);
const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
ja: "jp",
zh: "zh-hans",
"zh-cn": "zh-hans",
"zh-hk": "zh-hant",
"zh-sg": "zh-hans",
"zh-tw": "zh-hant",
};
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
const MAX_BRAVE_SEARCH_COUNT = 10;
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase();
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
return undefined;
}
return canonical;
}
export function normalizeBraveCountry(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const canonical = trimmed.toUpperCase();
return BRAVE_COUNTRY_CODES.has(canonical) ? canonical : "ALL";
}
function normalizeBraveUiLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
if (!match) {
return undefined;
}
const [, language, region] = match;
return `${language.toLowerCase()}-${region.toUpperCase()}`;
}
export function resolveBraveConfig(searchConfig?: Record<string, unknown>): BraveConfig {
const brave = searchConfig?.brave;
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
}
export function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" {
return brave?.mode === "llm-context" ? "llm-context" : "web";
}
export function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
search_lang?: string;
ui_lang?: string;
invalidField?: "search_lang" | "ui_lang";
} {
const rawSearchLang = params.search_lang?.trim() || undefined;
const rawUiLang = params.ui_lang?.trim() || undefined;
let searchLangCandidate = rawSearchLang;
let uiLangCandidate = rawUiLang;
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
searchLangCandidate = rawUiLang;
uiLangCandidate = rawSearchLang;
}
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
if (searchLangCandidate && !search_lang) {
return { invalidField: "search_lang" };
}
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
if (uiLangCandidate && !ui_lang) {
return { invalidField: "ui_lang" };
}
return { search_lang, ui_lang };
}
function resolveSiteName(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
return new URL(url).hostname;
} catch {
return undefined;
}
}
export function mapBraveLlmContextResults(
data: BraveLlmContextResponse,
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
return genericResults.map((entry) => ({
url: entry.url ?? "",
title: entry.title ?? "",
snippets: (entry.snippets ?? []).filter(
(snippet) => typeof snippet === "string" && snippet.length > 0,
),
siteName: resolveSiteName(entry.url) || undefined,
}));
}
export function createBraveSchema() {
return Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_BRAVE_SEARCH_COUNT,
}),
),
country: Type.Optional(
Type.String({
description:
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
}),
),
language: Type.Optional(
Type.String({
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
}),
),
freshness: Type.Optional(
Type.String({
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
}),
),
date_after: Type.Optional(
Type.String({
description: "Only results published after this date (YYYY-MM-DD).",
}),
),
date_before: Type.Optional(
Type.String({
description: "Only results published before this date (YYYY-MM-DD).",
}),
),
search_lang: Type.Optional(
Type.String({
description:
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
}),
),
ui_lang: Type.Optional(
Type.String({
description:
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
}),
),
});
}

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