mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 08:21:38 +08:00
Compare commits
794 Commits
vincentkoc
...
v2026.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85377a2817 | ||
|
|
d45aa68ae8 | ||
|
|
be5de30de5 | ||
|
|
406e7aba75 | ||
|
|
cad06faafe | ||
|
|
a5a7239182 | ||
|
|
a5a6952bf2 | ||
|
|
d28fa50f8b | ||
|
|
5ef04d2822 | ||
|
|
bb5796265b | ||
|
|
7c179f9288 | ||
|
|
d9d604c6ad | ||
|
|
6cdfd2eaaa | ||
|
|
b3b4fd30c3 | ||
|
|
a951ecdd7b | ||
|
|
c6634b4083 | ||
|
|
524fb16619 | ||
|
|
1fdc20a24f | ||
|
|
925da0fe99 | ||
|
|
99ae722e57 | ||
|
|
eb8a8840d6 | ||
|
|
7c6f8bfe73 | ||
|
|
92c4a2a29e | ||
|
|
70ab91500a | ||
|
|
f175a5d6d3 | ||
|
|
02d26ced98 | ||
|
|
99a48aad08 | ||
|
|
8b80848ae9 | ||
|
|
153a4f55db | ||
|
|
578a7a82be | ||
|
|
e6f34b25aa | ||
|
|
17bb87f432 | ||
|
|
85a320de54 | ||
|
|
46b62c53f0 | ||
|
|
ca1b50908f | ||
|
|
05aa16c040 | ||
|
|
2c6616b830 | ||
|
|
80efcb75c7 | ||
|
|
ba50dfaae3 | ||
|
|
04a8f97c57 | ||
|
|
5cba9a6bab | ||
|
|
da6e6fb900 | ||
|
|
805de8537c | ||
|
|
f7f0caa5c7 | ||
|
|
7fd4328854 | ||
|
|
7bad42910b | ||
|
|
f2c37e543e | ||
|
|
806803b7ef | ||
|
|
f212351aed | ||
|
|
6408b7f81c | ||
|
|
1538813096 | ||
|
|
55c128ddc2 | ||
|
|
3ff0cf262d | ||
|
|
a50dd0bb06 | ||
|
|
8b4cdbb21d | ||
|
|
b8181e5944 | ||
|
|
7a8232187b | ||
|
|
1a0036283d | ||
|
|
4fb6da2b32 | ||
|
|
4a59d0ad98 | ||
|
|
d068fc9f9d | ||
|
|
369646a513 | ||
|
|
3460aa4dee | ||
|
|
e290f4ca41 | ||
|
|
884ca65dc7 | ||
|
|
1a52d943ed | ||
|
|
7897ffb72f | ||
|
|
5c18ba6f65 | ||
|
|
25a2fe2bea | ||
|
|
fa4ff5f3d2 | ||
|
|
ac318be405 | ||
|
|
c85bd2646a | ||
|
|
6472e03412 | ||
|
|
24fd6c8278 | ||
|
|
5cffbbda32 | ||
|
|
85d17fd429 | ||
|
|
96d56a9721 | ||
|
|
ffd3ad032a | ||
|
|
8a463af823 | ||
|
|
6bf1abf603 | ||
|
|
3a8133d587 | ||
|
|
8ac924c769 | ||
|
|
2d033d2aa8 | ||
|
|
1ec9673cc5 | ||
|
|
fdb0bf804f | ||
|
|
40f2e2b8a6 | ||
|
|
87977d7a19 | ||
|
|
9f691099db | ||
|
|
e707c97ca6 | ||
|
|
0750fc2de1 | ||
|
|
59567a8c5d | ||
|
|
8ee357fc76 | ||
|
|
9702d94196 | ||
|
|
30ab9b2068 | ||
|
|
5e1a2ea019 | ||
|
|
008e4804a6 | ||
|
|
4c32411bee | ||
|
|
91cdb703bd | ||
|
|
04ac688dff | ||
|
|
b29e913efe | ||
|
|
895abc5a64 | ||
|
|
62582fc088 | ||
|
|
57336203d5 | ||
|
|
1929151103 | ||
|
|
6ab9e00e17 | ||
|
|
2380c1b5fd | ||
|
|
493b560dfd | ||
|
|
1dd77e4106 | ||
|
|
4d52dfe85b | ||
|
|
d380ed710d | ||
|
|
03755f8463 | ||
|
|
7fdbf1202e | ||
|
|
70db52de71 | ||
|
|
15a0455d04 | ||
|
|
d3c637d193 | ||
|
|
0fb3f188b2 | ||
|
|
bf6aa7ca67 | ||
|
|
0fd77c9856 | ||
|
|
f77f1d3800 | ||
|
|
7c90ef7c52 | ||
|
|
7dadd5027b | ||
|
|
f8ed48293c | ||
|
|
96a38d5aa4 | ||
|
|
c7ec237089 | ||
|
|
1ae82be55a | ||
|
|
fd782d811e | ||
|
|
a467517b2b | ||
|
|
3eec79bd6c | ||
|
|
4ba5937ef9 | ||
|
|
6fc3f504d6 | ||
|
|
b17687b775 | ||
|
|
eca242b971 | ||
|
|
4494844d17 | ||
|
|
5193189953 | ||
|
|
fbb88d5063 | ||
|
|
c0715db3c8 | ||
|
|
20c15ccc63 | ||
|
|
16fd604219 | ||
|
|
61f29830bc | ||
|
|
47736e3432 | ||
|
|
39520ad21b | ||
|
|
bd8c3230e8 | ||
|
|
ebbb572639 | ||
|
|
3b9877dee7 | ||
|
|
40e5c6a18d | ||
|
|
11e1363d2d | ||
|
|
ee646dae82 | ||
|
|
85f01cd9eb | ||
|
|
bab5d994bc | ||
|
|
2365c6c86a | ||
|
|
53ada1e9b9 | ||
|
|
b91a22a3fb | ||
|
|
2aab6dff76 | ||
|
|
980388fcf0 | ||
|
|
3e6451f2d8 | ||
|
|
2f6718b8e7 | ||
|
|
b5350bf46f | ||
|
|
0f5f20ee6b | ||
|
|
6b6af1a64f | ||
|
|
c1b37f29f0 | ||
|
|
a3b674cc98 | ||
|
|
cdc1ef85e8 | ||
|
|
1ca69c8fd7 | ||
|
|
469cd5b464 | ||
|
|
666073ee46 | ||
|
|
747902a26a | ||
|
|
61adcea68e | ||
|
|
5ee6ca13b7 | ||
|
|
71cd337137 | ||
|
|
4d04e1a41f | ||
|
|
67e3eb85d7 | ||
|
|
1b4062defd | ||
|
|
3e4dd84511 | ||
|
|
5084621f43 | ||
|
|
346d3590fb | ||
|
|
687ef2e00f | ||
|
|
1187464041 | ||
|
|
4e4a100038 | ||
|
|
ddd71bc9f6 | ||
|
|
1a7a18d0bc | ||
|
|
4e4d94cd38 | ||
|
|
f0640b0100 | ||
|
|
46df7e2421 | ||
|
|
42626648d7 | ||
|
|
17b40c4a59 | ||
|
|
d9119f0791 | ||
|
|
586f057c24 | ||
|
|
90d8b40808 | ||
|
|
d7bafae387 | ||
|
|
588fbd5b68 | ||
|
|
ef920f2f39 | ||
|
|
57e1534df8 | ||
|
|
a48a3dbdda | ||
|
|
c3d5159121 | ||
|
|
1bd20dbdb6 | ||
|
|
a2fdc3415f | ||
|
|
ced267c5cb | ||
|
|
287606e445 | ||
|
|
f26853f14c | ||
|
|
a44843507f | ||
|
|
de09ca149f | ||
|
|
503d395780 | ||
|
|
924d9e34ef | ||
|
|
f3e6578e6c | ||
|
|
e930517154 | ||
|
|
47083460ea | ||
|
|
7de4204e57 | ||
|
|
36dfd462a8 | ||
|
|
6649c22471 | ||
|
|
596621919c | ||
|
|
9657ded2e1 | ||
|
|
282b107e99 | ||
|
|
86090b0ff2 | ||
|
|
77ecef1fde | ||
|
|
53fd7f8163 | ||
|
|
1b5ac8b0b1 | ||
|
|
f6233cfa5c | ||
|
|
61be533ad4 | ||
|
|
d76ddd61ec | ||
|
|
82101b152a | ||
|
|
439a7732f4 | ||
|
|
a96b3b406a | ||
|
|
68e982ec80 | ||
|
|
d0a3743abd | ||
|
|
0d8beeb4e5 | ||
|
|
1e8afa16f0 | ||
|
|
65dc3ee76c | ||
|
|
f4682742d9 | ||
|
|
d37ad9d866 | ||
|
|
4b3d9f4fb2 | ||
|
|
6bf84ac28c | ||
|
|
051b380d38 | ||
|
|
dee7cda1ec | ||
|
|
8824565c2a | ||
|
|
d7dda4dd1a | ||
|
|
6a42d09129 | ||
|
|
fd3ca8a34c | ||
|
|
fe14be2352 | ||
|
|
e870cee542 | ||
|
|
3e9c8721fb | ||
|
|
11c397ef46 | ||
|
|
4bfbf2dfff | ||
|
|
1d0a4d1be2 | ||
|
|
d6491d8d71 | ||
|
|
6b85ec3022 | ||
|
|
3e1ec5ad8b | ||
|
|
c5ddba52d7 | ||
|
|
381bb867ac | ||
|
|
24dcd68f42 | ||
|
|
a1b4a0066b | ||
|
|
a5b81d1c13 | ||
|
|
d3dc4e54f7 | ||
|
|
dba47f349f | ||
|
|
fe4c627432 | ||
|
|
b8b8a5f314 | ||
|
|
ea3b7dfde5 | ||
|
|
32ecd6f579 | ||
|
|
dc825e59f5 | ||
|
|
500d7cb107 | ||
|
|
1234cc4c31 | ||
|
|
abec8a4f0a | ||
|
|
41bdf2df41 | ||
|
|
c20ee11348 | ||
|
|
4d19dc8671 | ||
|
|
73e08ed7b0 | ||
|
|
5868344ade | ||
|
|
abb0252a1a | ||
|
|
55f04636f3 | ||
|
|
f22fc17c78 | ||
|
|
28c88e9fa1 | ||
|
|
58ad617e64 | ||
|
|
dc2aa1e21d | ||
|
|
8fdd1d2f05 | ||
|
|
bb60687b89 | ||
|
|
a282b459b9 | ||
|
|
de77a36579 | ||
|
|
79e114a82f | ||
|
|
7c7c22d66f | ||
|
|
ec688d809f | ||
|
|
481da215b9 | ||
|
|
132794fe74 | ||
|
|
d4ec0ed3c7 | ||
|
|
2e0f5b73d1 | ||
|
|
66397c2855 | ||
|
|
e2483a5381 | ||
|
|
c703aa0fe9 | ||
|
|
3bf19d6f40 | ||
|
|
7365aefa19 | ||
|
|
7066d5e192 | ||
|
|
350d041eaf | ||
|
|
e05bcccde8 | ||
|
|
0954b6bf5f | ||
|
|
3b3e47e15d | ||
|
|
8f3eb0f7b4 | ||
|
|
0e16749f00 | ||
|
|
7eda632324 | ||
|
|
3043e68dfa | ||
|
|
36c6b63ea6 | ||
|
|
fc1787fd4b | ||
|
|
2287d1ec13 | ||
|
|
ba5ae5b4f1 | ||
|
|
a81704e622 | ||
|
|
02eeb08e04 | ||
|
|
7cbcbbc642 | ||
|
|
903e4dff35 | ||
|
|
905c3357eb | ||
|
|
f431f20c48 | ||
|
|
d9fdec12ab | ||
|
|
f25be781c4 | ||
|
|
0d8f14fed3 | ||
|
|
842a79cf99 | ||
|
|
caae34cbaf | ||
|
|
fa47f74c0f | ||
|
|
ac11f0af73 | ||
|
|
a78ec81ae6 | ||
|
|
be578b43d3 | ||
|
|
0b5d8e5b47 | ||
|
|
b9b47f5002 | ||
|
|
319b7c68a1 | ||
|
|
6200e242b2 | ||
|
|
5b5ccb0769 | ||
|
|
0743463b88 | ||
|
|
48155729fc | ||
|
|
163f5184b3 | ||
|
|
8950c59581 | ||
|
|
29dde80c3e | ||
|
|
b5102ba4f9 | ||
|
|
7ad6a04058 | ||
|
|
e0b8b80067 | ||
|
|
44183c6eb1 | ||
|
|
f9cbcfca0d | ||
|
|
5d3032b293 | ||
|
|
11adaa15a8 | ||
|
|
3cb851be90 | ||
|
|
1fa2488db1 | ||
|
|
d3cb85eaf5 | ||
|
|
d89c25d69e | ||
|
|
f257818ea5 | ||
|
|
350ac0d824 | ||
|
|
19fafed11d | ||
|
|
7253e91300 | ||
|
|
2330c71b63 | ||
|
|
477de545f9 | ||
|
|
bd4a082b73 | ||
|
|
cbd2e8eea8 | ||
|
|
807c600ad1 | ||
|
|
a1ee605494 | ||
|
|
923ff17ff3 | ||
|
|
49687d313c | ||
|
|
11dcf96628 | ||
|
|
21a1db78b3 | ||
|
|
175c770171 | ||
|
|
1212328c8d | ||
|
|
f9025c3f55 | ||
|
|
317075ef3d | ||
|
|
2c39731846 | ||
|
|
29342c37b5 | ||
|
|
cc18e43832 | ||
|
|
6545317a2c | ||
|
|
9bde7f4fde | ||
|
|
3beb1b9da9 | ||
|
|
6358aae024 | ||
|
|
55a2d12f40 | ||
|
|
99a3db6ba9 | ||
|
|
e5597a8dd4 | ||
|
|
8e259b8310 | ||
|
|
8f995dfc7a | ||
|
|
1b61269eec | ||
|
|
ef89b48785 | ||
|
|
a183656f8f | ||
|
|
f2b37f0aa9 | ||
|
|
faa4ffec03 | ||
|
|
f7b0378ccb | ||
|
|
5f19112217 | ||
|
|
8039ef7dba | ||
|
|
43f94e3ab8 | ||
|
|
8b70ba6ab8 | ||
|
|
036bd18e2a | ||
|
|
9c9ab891c2 | ||
|
|
f7c658efb9 | ||
|
|
58cde87436 | ||
|
|
8c1e9949b3 | ||
|
|
ba3fa44c5b | ||
|
|
5897eed6e9 | ||
|
|
453a1c179d | ||
|
|
76d6514ff5 | ||
|
|
6a425d189e | ||
|
|
34daed1d1e | ||
|
|
91dd89313a | ||
|
|
5f0cbd0edc | ||
|
|
ab8b8dae70 | ||
|
|
067855e623 | ||
|
|
58e9ca2fb6 | ||
|
|
61d14e8a8a | ||
|
|
2438fde6d9 | ||
|
|
7a99027ef6 | ||
|
|
42e402dfba | ||
|
|
11aa18b525 | ||
|
|
21d6d878ce | ||
|
|
8da8756f76 | ||
|
|
a4927ed8ee | ||
|
|
1f24323583 | ||
|
|
dc8a56c857 | ||
|
|
f181b7dbe6 | ||
|
|
0f1388fa15 | ||
|
|
b782ecb7eb | ||
|
|
af637deed1 | ||
|
|
73e6dc361e | ||
|
|
866bd91c65 | ||
|
|
d98a61a977 | ||
|
|
d01e04bcec | ||
|
|
5a32a66aa8 | ||
|
|
3a08e69a05 | ||
|
|
320920d523 | ||
|
|
ad12d1fbce | ||
|
|
bfb6c6290f | ||
|
|
da8a17d8de | ||
|
|
089a8785b9 | ||
|
|
e0b91067e3 | ||
|
|
d2bb04b436 | ||
|
|
4a414c5e53 | ||
|
|
da22a9113c | ||
|
|
8937c10f1f | ||
|
|
259f6543b4 | ||
|
|
3c0ec76e8e | ||
|
|
d80144f572 | ||
|
|
54eb13893f | ||
|
|
c582a54554 | ||
|
|
cceecc8bd4 | ||
|
|
00347bda75 | ||
|
|
da05395c2a | ||
|
|
e45d26b9ed | ||
|
|
16e7fc2563 | ||
|
|
479095bcfb | ||
|
|
5b63417fec | ||
|
|
6945ba189d | ||
|
|
ab0b2c21f3 | ||
|
|
f534ea9906 | ||
|
|
15677133c1 | ||
|
|
c9d0e345cb | ||
|
|
bf0653846e | ||
|
|
3de7768b11 | ||
|
|
2937fe0351 | ||
|
|
fb5d8a9cd1 | ||
|
|
2f352306fe | ||
|
|
f7765bc151 | ||
|
|
b52561bfa3 | ||
|
|
4b50018406 | ||
|
|
7003615972 | ||
|
|
eb816e0551 | ||
|
|
b1c30f0ba9 | ||
|
|
9d30159fcd | ||
|
|
9617ac9dd5 | ||
|
|
8768487aee | ||
|
|
ee0d7ba6d6 | ||
|
|
c48a0621ff | ||
|
|
b1cc8ffe9e | ||
|
|
4cd04e4652 | ||
|
|
c424836fbe | ||
|
|
a351ab2481 | ||
|
|
15226b0b83 | ||
|
|
0cf533ac61 | ||
|
|
4985c561df | ||
|
|
160dad56c4 | ||
|
|
a3c5d21b4d | ||
|
|
9a3800d8e6 | ||
|
|
39afcee864 | ||
|
|
d979eeda9f | ||
|
|
8e48f7e353 | ||
|
|
2a2e2c3630 | ||
|
|
92bf77d9a0 | ||
|
|
a3bb7a5ee5 | ||
|
|
2b088ca125 | ||
|
|
aeeb0474c6 | ||
|
|
6df36a8b35 | ||
|
|
fbd1210ec2 | ||
|
|
26b8a70a52 | ||
|
|
e391646043 | ||
|
|
e513714103 | ||
|
|
b645654923 | ||
|
|
60130203e1 | ||
|
|
c4511df283 | ||
|
|
64abf9a925 | ||
|
|
1616113170 | ||
|
|
fcec2e364d | ||
|
|
66c1da45d4 | ||
|
|
dbbd41a2ed | ||
|
|
e1bc5cad25 | ||
|
|
62d0cfeee7 | ||
|
|
a19a7f5e6e | ||
|
|
ea1fe77c83 | ||
|
|
d486b0a925 | ||
|
|
10fb632c9e | ||
|
|
4a2329e0af | ||
|
|
14baadda2c | ||
|
|
aab87ec880 | ||
|
|
a71b8d23be | ||
|
|
6c7d012320 | ||
|
|
0956b599e1 | ||
|
|
d4b20f5295 | ||
|
|
07eaeb7350 | ||
|
|
83ec545bed | ||
|
|
6add2bcc15 | ||
|
|
fbb343ab30 | ||
|
|
e1e93d932f | ||
|
|
ee68fa86b5 | ||
|
|
0958d11478 | ||
|
|
ed55b63684 | ||
|
|
31bc2cc202 | ||
|
|
c146748d7a | ||
|
|
2a98fd3d0b | ||
|
|
ce4faedad6 | ||
|
|
1ef9a2a8ea | ||
|
|
84d9b64326 | ||
|
|
99392f9868 | ||
|
|
662f389f45 | ||
|
|
3bd0505433 | ||
|
|
dde43121c0 | ||
|
|
6a5041f3ff | ||
|
|
bcb1eb2f03 | ||
|
|
842087319b | ||
|
|
5c1eb071ca | ||
|
|
4030de6c73 | ||
|
|
c9558cdcd7 | ||
|
|
740bb77c8c | ||
|
|
63734df3b0 | ||
|
|
534168a7a7 | ||
|
|
9c1312b5e4 | ||
|
|
1727279598 | ||
|
|
d52e5e1d85 | ||
|
|
c1c20491da | ||
|
|
d21cf44452 | ||
|
|
738f5d4533 | ||
|
|
a8fe8b6bf8 | ||
|
|
82f01d6081 | ||
|
|
41c8734afd | ||
|
|
cf5702233c | ||
|
|
718d418b32 | ||
|
|
16df7ef4a9 | ||
|
|
9b8e642475 | ||
|
|
8b27582509 | ||
|
|
a6489ab5e9 | ||
|
|
83c8406f01 | ||
|
|
602f6439bd | ||
|
|
1c9deeda97 | ||
|
|
fc0d374390 | ||
|
|
0ebe0480fa | ||
|
|
8ae8056622 | ||
|
|
54382a66b4 | ||
|
|
d7ae61c412 | ||
|
|
b07589642d | ||
|
|
26b8e6d510 | ||
|
|
e339c75d5d | ||
|
|
7dac9b05dd | ||
|
|
eb35fb745d | ||
|
|
b9e820b7ed | ||
|
|
aee27d0e38 | ||
|
|
34ff873a7e | ||
|
|
310dd24ce3 | ||
|
|
d4bf07d075 | ||
|
|
d3e8b17aa6 | ||
|
|
dded569626 | ||
|
|
104d32bb64 | ||
|
|
be3a62c5e0 | ||
|
|
193ad2f4f0 | ||
|
|
a0e11e63fe | ||
|
|
07b16d5ad0 | ||
|
|
67b2dde7c5 | ||
|
|
7a55a3ca07 | ||
|
|
11562c452a | ||
|
|
eb2e20c994 | ||
|
|
24a13c05b3 | ||
|
|
20c36f7e84 | ||
|
|
db7a8a6982 | ||
|
|
4a80311628 | ||
|
|
7a7eee920a | ||
|
|
33e76db12a | ||
|
|
9a68590385 | ||
|
|
031bf0c6c0 | ||
|
|
8611fd67b5 | ||
|
|
14c93d2646 | ||
|
|
1b462ed174 | ||
|
|
18f8393b6c | ||
|
|
14e4575af5 | ||
|
|
b1592457fa | ||
|
|
31c7637e0f | ||
|
|
d5ae4b8337 | ||
|
|
0dbb92dd2b | ||
|
|
17ede52a4b | ||
|
|
be65dc8acc | ||
|
|
8828418111 | ||
|
|
4dd6c7a509 | ||
|
|
d94de5c4a1 | ||
|
|
09f49cd921 | ||
|
|
87d05592ea | ||
|
|
d74bc257d8 | ||
|
|
6edb512efa | ||
|
|
c973b053a5 | ||
|
|
a229ae6c3e | ||
|
|
2fd8264ab0 | ||
|
|
b13d48987c | ||
|
|
21708f58ce | ||
|
|
1ea42ebe98 | ||
|
|
3e5762c288 | ||
|
|
c4711a9b69 | ||
|
|
ea204e65a0 | ||
|
|
14fbd0e6b6 | ||
|
|
17c434f2f3 | ||
|
|
19f5d1345c | ||
|
|
64c443ac65 | ||
|
|
b28e472fa5 | ||
|
|
0c6db05cc0 | ||
|
|
ade46d8ab7 | ||
|
|
82247f09a7 | ||
|
|
d01e82d54a | ||
|
|
93b0724025 | ||
|
|
44270c533b | ||
|
|
dec2c9e74d | ||
|
|
6135eb3353 | ||
|
|
345abf0b20 | ||
|
|
a3d2021eea | ||
|
|
e08ba063d8 | ||
|
|
998d477f5e | ||
|
|
a49afd25ea | ||
|
|
d86c1a67e0 | ||
|
|
05b84e718b | ||
|
|
07b419a0e7 | ||
|
|
12be9a08fe | ||
|
|
ee1b147631 | ||
|
|
208a9b1ad1 | ||
|
|
0f00110f5d | ||
|
|
174f2de447 | ||
|
|
db3d8d82c1 | ||
|
|
3f2848433a | ||
|
|
663c1858b8 | ||
|
|
729ddfd7c8 | ||
|
|
f39882d57e | ||
|
|
6b7d3fb011 | ||
|
|
c63c179278 | ||
|
|
dd3f7d57ee | ||
|
|
47ef180fb7 | ||
|
|
ebe54e6903 | ||
|
|
d06ee86292 | ||
|
|
f1cab9c5e5 | ||
|
|
f4c3e483fe | ||
|
|
6aa20e91d9 | ||
|
|
4b4ea5df8b | ||
|
|
a905b6dabc | ||
|
|
44c50d9a73 | ||
|
|
ed21b63bb8 | ||
|
|
e9dd6121f2 | ||
|
|
dcf8308c8f | ||
|
|
d212721df1 | ||
|
|
a469d00345 | ||
|
|
3fb0ab7435 | ||
|
|
64ac790aa8 | ||
|
|
f1cd3ea531 | ||
|
|
c5f1cf3c3b | ||
|
|
87bd6226bd | ||
|
|
9f98d2766a | ||
|
|
944abe0a6c | ||
|
|
dbc78243f4 | ||
|
|
e41f9998f7 | ||
|
|
741e74972b | ||
|
|
693f61404d | ||
|
|
3efd224ec6 | ||
|
|
b85facfb5d | ||
|
|
e23b6fb2ba | ||
|
|
d145518f94 | ||
|
|
cd18472405 | ||
|
|
2a11a20fe2 | ||
|
|
2a2a9902d9 | ||
|
|
5561a6b659 | ||
|
|
c2d41dc473 | ||
|
|
a05b8f47b1 | ||
|
|
7d600ff4e2 | ||
|
|
38bdb0d271 | ||
|
|
32475448eb | ||
|
|
8421b2e848 | ||
|
|
f2468feb86 | ||
|
|
1fe0f848df | ||
|
|
98e5851d8a | ||
|
|
cd653c55d7 | ||
|
|
32c7242974 | ||
|
|
534f436d4e | ||
|
|
234e07fcc0 | ||
|
|
9eb70d2725 | ||
|
|
2bec80cd97 | ||
|
|
dd8c76110f | ||
|
|
158709ff62 | ||
|
|
c96234b51d | ||
|
|
1184d39e1d | ||
|
|
e303b356ba | ||
|
|
22ec577d80 | ||
|
|
9b938f2bf6 | ||
|
|
c7bf54b914 | ||
|
|
c350dc8a7b | ||
|
|
b47dc73b70 | ||
|
|
050e928985 | ||
|
|
99ee26d534 | ||
|
|
8bccb0032a | ||
|
|
d06cc77f38 | ||
|
|
0d620a56e2 | ||
|
|
09748ab109 | ||
|
|
2d8b8a17ab | ||
|
|
6ea6aca5bd | ||
|
|
7b5a410b83 | ||
|
|
6e008e93be | ||
|
|
732c4f3921 | ||
|
|
910c654807 | ||
|
|
925117d277 | ||
|
|
be8930d6f9 | ||
|
|
60b8d645de | ||
|
|
097ad88f9d | ||
|
|
6df8bd9741 | ||
|
|
21e19e42a3 | ||
|
|
2c192a3795 | ||
|
|
02bd7a2249 | ||
|
|
aa5d173bec | ||
|
|
06306501ab | ||
|
|
6513c42d2d | ||
|
|
16e85360a1 | ||
|
|
4d31c29a19 | ||
|
|
79cb5e2c9b | ||
|
|
b40d5817a2 | ||
|
|
254bb7ceee | ||
|
|
127217612c | ||
|
|
0b762e9a02 | ||
|
|
cb9bce902e | ||
|
|
848ade07da | ||
|
|
a9d572394e | ||
|
|
b02b94673f | ||
|
|
5d3f066bbd | ||
|
|
6adc93cc92 | ||
|
|
e99928f3f1 | ||
|
|
afda085b39 | ||
|
|
3980c315d1 | ||
|
|
7b38e8231e | ||
|
|
f94d6fb1f1 | ||
|
|
5fed91e624 | ||
|
|
ba3957ad77 | ||
|
|
916b0e6609 | ||
|
|
099b11fc7d | ||
|
|
f7b8e4be27 | ||
|
|
2cda78a0b0 | ||
|
|
87316e07d8 | ||
|
|
d85d3c88d5 | ||
|
|
d977af5853 | ||
|
|
7533015532 | ||
|
|
f01862bce2 | ||
|
|
8e0ca219a4 | ||
|
|
bdfd3bae6f | ||
|
|
adf2ef88c6 | ||
|
|
d95bc10425 | ||
|
|
d9ff3bf1af | ||
|
|
2b855704da | ||
|
|
c80a332def | ||
|
|
d9a8d3853d | ||
|
|
94e480f64a | ||
|
|
735216f7e4 | ||
|
|
316875582a | ||
|
|
43bffe7bdc | ||
|
|
cf67e374c0 | ||
|
|
cd011897d0 | ||
|
|
2ca5722221 | ||
|
|
3dd01c3361 | ||
|
|
79b649a25e | ||
|
|
0c2d85529a | ||
|
|
1b98879295 | ||
|
|
bff785aecc | ||
|
|
4dcb16d696 | ||
|
|
96ef6ea3cf | ||
|
|
4a8ada662e | ||
|
|
8a1465c314 | ||
|
|
f5a265a51a | ||
|
|
033c731f19 | ||
|
|
c1a46301b6 | ||
|
|
fc692d82fd | ||
|
|
8553d22428 | ||
|
|
7d44b753ff | ||
|
|
04030ddf68 | ||
|
|
fd4d157e45 | ||
|
|
fcb956a0a2 | ||
|
|
500883775b | ||
|
|
fd7774a79e | ||
|
|
5f49a5da3c | ||
|
|
756f9c9fef | ||
|
|
ad8d766f65 | ||
|
|
d358b3ac88 | ||
|
|
e427826fcf | ||
|
|
00a2456b72 | ||
|
|
5d53b61d9e | ||
|
|
29c3ce9454 | ||
|
|
ffa7c13c9b |
@@ -51,6 +51,10 @@ vendor/
|
||||
# Keep the rest of apps/ and vendor/ excluded to avoid a large build context.
|
||||
!apps/shared/
|
||||
!apps/shared/OpenClawKit/
|
||||
!apps/shared/OpenClawKit/Sources/
|
||||
!apps/shared/OpenClawKit/Sources/OpenClawKit/
|
||||
!apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/
|
||||
!apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
|
||||
!apps/shared/OpenClawKit/Tools/
|
||||
!apps/shared/OpenClawKit/Tools/CanvasA2UI/
|
||||
!apps/shared/OpenClawKit/Tools/CanvasA2UI/**
|
||||
|
||||
1
.github/actionlint.yaml
vendored
1
.github/actionlint.yaml
vendored
@@ -8,6 +8,7 @@ self-hosted-runner:
|
||||
- blacksmith-8vcpu-windows-2025
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-16vcpu-windows-2025
|
||||
- blacksmith-32vcpu-windows-2025
|
||||
- blacksmith-16vcpu-ubuntu-2404-arm
|
||||
|
||||
# Ignore patterns for known issues
|
||||
|
||||
15
.github/actions/setup-node-env/action.yml
vendored
15
.github/actions/setup-node-env/action.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Setup Node environment
|
||||
description: >
|
||||
Initialize submodules with retry, install Node 22, pnpm, optionally Bun,
|
||||
and run pnpm install. Requires actions/checkout to run first.
|
||||
and optionally run pnpm install. Requires actions/checkout to run first.
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version to install.
|
||||
@@ -15,6 +15,14 @@ inputs:
|
||||
description: Whether to install Bun alongside Node.
|
||||
required: false
|
||||
default: "true"
|
||||
use-sticky-disk:
|
||||
description: Use Blacksmith sticky disks for pnpm store caching.
|
||||
required: false
|
||||
default: "false"
|
||||
install-deps:
|
||||
description: Whether to run pnpm install after environment setup.
|
||||
required: false
|
||||
default: "true"
|
||||
frozen-lockfile:
|
||||
description: Whether to use --frozen-lockfile for install.
|
||||
required: false
|
||||
@@ -40,13 +48,14 @@ runs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
check-latest: true
|
||||
check-latest: false
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: ${{ inputs.pnpm-version }}
|
||||
cache-key-suffix: "node22"
|
||||
use-sticky-disk: ${{ inputs.use-sticky-disk }}
|
||||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
@@ -63,10 +72,12 @@ runs:
|
||||
if command -v bun &>/dev/null; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
if: inputs.install-deps == 'true'
|
||||
shell: bash
|
||||
run: echo "NODE_BIN=$(dirname "$(node -p "process.execPath")")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.install-deps == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CI: "true"
|
||||
|
||||
@@ -9,6 +9,18 @@ inputs:
|
||||
description: Suffix appended to the cache key.
|
||||
required: false
|
||||
default: "node22"
|
||||
use-sticky-disk:
|
||||
description: Use Blacksmith sticky disks instead of actions/cache for pnpm store.
|
||||
required: false
|
||||
default: "false"
|
||||
use-restore-keys:
|
||||
description: Whether to use restore-keys fallback for actions/cache.
|
||||
required: false
|
||||
default: "true"
|
||||
use-actions-cache:
|
||||
description: Whether to restore/save pnpm store with actions/cache.
|
||||
required: false
|
||||
default: "true"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -38,7 +50,22 @@ runs:
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
- name: Mount pnpm store sticky disk
|
||||
if: inputs.use-sticky-disk == 'true'
|
||||
uses: useblacksmith/stickydisk@v1
|
||||
with:
|
||||
key: ${{ github.repository }}-pnpm-store-${{ runner.os }}-${{ inputs.cache-key-suffix }}
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
|
||||
- name: Restore pnpm store cache (exact key only)
|
||||
if: inputs.use-actions-cache == 'true' && inputs.use-sticky-disk != 'true' && inputs.use-restore-keys != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Restore pnpm store cache (with fallback keys)
|
||||
if: inputs.use-actions-cache == 'true' && inputs.use-sticky-disk != 'true' && inputs.use-restore-keys == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
|
||||
164
.github/workflows/ci.yml
vendored
164
.github/workflows/ci.yml
vendored
@@ -32,12 +32,13 @@ jobs:
|
||||
# Push to main keeps broad coverage.
|
||||
changed-scope:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
run_node: ${{ steps.scope.outputs.run_node }}
|
||||
run_macos: ${{ steps.scope.outputs.run_macos }}
|
||||
run_android: ${{ steps.scope.outputs.run_android }}
|
||||
run_windows: ${{ steps.scope.outputs.run_windows }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -57,75 +58,11 @@ jobs:
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
|
||||
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
|
||||
# Fail-safe: run broad checks if detection fails.
|
||||
echo "run_node=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_macos=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_android=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_node=false
|
||||
run_macos=false
|
||||
run_android=false
|
||||
has_non_docs=false
|
||||
has_non_native_non_docs=false
|
||||
|
||||
while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
case "$path" in
|
||||
docs/*|*.md|*.mdx)
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
has_non_docs=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
# Generated protocol models are already covered by protocol:check and
|
||||
# should not force the full native macOS lane.
|
||||
apps/macos/Sources/OpenClawProtocol/*|apps/shared/OpenClawKit/Sources/OpenClawProtocol/*)
|
||||
;;
|
||||
apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*)
|
||||
run_macos=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
apps/android/*|apps/shared/*)
|
||||
run_android=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc)
|
||||
run_node=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml)
|
||||
;;
|
||||
*)
|
||||
has_non_native_non_docs=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$CHANGED"
|
||||
|
||||
# If there are non-doc files outside native app trees, keep Node checks enabled.
|
||||
if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then
|
||||
run_node=true
|
||||
fi
|
||||
|
||||
echo "run_node=${run_node}" >> "$GITHUB_OUTPUT"
|
||||
echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT"
|
||||
echo "run_android=${run_android}" >> "$GITHUB_OUTPUT"
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
build-artifacts:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
@@ -138,6 +75,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
@@ -164,6 +102,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -175,7 +114,7 @@ jobs:
|
||||
run: pnpm release:check
|
||||
|
||||
checks:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
@@ -207,6 +146,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "${{ matrix.runtime == 'bun' }}"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Configure Node test resources
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
@@ -223,8 +163,8 @@ jobs:
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -236,6 +176,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Check types and lint and oxfmt
|
||||
run: pnpm check
|
||||
@@ -275,6 +216,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Run ${{ matrix.tool }} dead-code scan
|
||||
run: ${{ matrix.command }}
|
||||
@@ -300,6 +242,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Check docs
|
||||
run: pnpm check:docs
|
||||
@@ -342,6 +285,8 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -385,15 +330,15 @@ jobs:
|
||||
run: pre-commit run --all-files pnpm-audit-prod
|
||||
|
||||
checks-windows:
|
||||
needs: [docs-scope, changed-scope, build-artifacts, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-windows-2025
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_windows == 'true')
|
||||
runs-on: blacksmith-32vcpu-windows-2025
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# Keep total concurrency predictable on the 16 vCPU runner:
|
||||
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
|
||||
OPENCLAW_TEST_WORKERS: 2
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
# Keep total concurrency predictable on the 32 vCPU runner.
|
||||
# Windows shard 2 has shown intermittent instability at 2 workers.
|
||||
OPENCLAW_TEST_WORKERS: 1
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -401,26 +346,36 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 1
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 2
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: protocol
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm protocol:check
|
||||
task: test
|
||||
shard_index: 3
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 4
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 5
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 6
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -446,31 +401,22 @@ jobs:
|
||||
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
- name: Download dist artifact (lint lane)
|
||||
if: matrix.task == 'lint'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Verify dist artifact (lint lane)
|
||||
if: matrix.task == 'lint'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/index.js
|
||||
test -s dist/plugin-sdk/index.js
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
check-latest: false
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
# Sticky disk mount currently retries/fails on every shard and adds ~50s
|
||||
# before install while still yielding zero pnpm store reuse.
|
||||
use-sticky-disk: "false"
|
||||
use-restore-keys: "false"
|
||||
use-actions-cache: "false"
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
@@ -489,7 +435,7 @@ jobs:
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Configure test shard (Windows)
|
||||
if: matrix.task == 'test'
|
||||
@@ -497,6 +443,10 @@ jobs:
|
||||
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build A2UI bundle (Windows)
|
||||
if: matrix.task == 'test'
|
||||
run: pnpm canvas:a2ui:bundle
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
@@ -738,7 +688,7 @@ jobs:
|
||||
PY
|
||||
|
||||
android:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
|
||||
16
.github/workflows/docker-release.yml
vendored
16
.github/workflows/docker-release.yml
vendored
@@ -34,8 +34,8 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Set up Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -92,14 +92,12 @@ jobs:
|
||||
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
@@ -115,8 +113,8 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Set up Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -173,14 +171,12 @@ jobs:
|
||||
|
||||
- name: Build and push arm64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
|
||||
4
.github/workflows/install-smoke.yml
vendored
4
.github/workflows/install-smoke.yml
vendored
@@ -44,10 +44,14 @@ jobs:
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
- name: Run root Dockerfile CLI smoke
|
||||
run: |
|
||||
docker build -t openclaw-dockerfile-smoke:local -f Dockerfile .
|
||||
|
||||
3
.github/workflows/sandbox-common-smoke.yml
vendored
3
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -26,6 +26,9 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
- name: Build minimal sandbox base (USER sandbox)
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,7 @@ mise.toml
|
||||
apps/android/.gradle/
|
||||
apps/android/app/build/
|
||||
apps/android/.cxx/
|
||||
apps/android/.kotlin/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
@@ -94,7 +95,7 @@ USER.md
|
||||
!.agent/workflows/
|
||||
/local/
|
||||
package-lock.json
|
||||
.claude/settings.local.json
|
||||
.claude/
|
||||
.agents/
|
||||
.agents
|
||||
.agent/
|
||||
|
||||
@@ -6,15 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { showPagedSelectList } from "./ui/paged-select";
|
||||
|
||||
interface FileInfo {
|
||||
status: string;
|
||||
@@ -108,87 +100,17 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
};
|
||||
|
||||
// Show file picker with SelectList
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
// Top border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
// Title
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0));
|
||||
|
||||
// Build select items with colored status
|
||||
const items: SelectItem[] = files.map((f) => {
|
||||
let statusColor: string;
|
||||
switch (f.status) {
|
||||
case "M":
|
||||
statusColor = theme.fg("warning", f.status);
|
||||
break;
|
||||
case "A":
|
||||
statusColor = theme.fg("success", f.status);
|
||||
break;
|
||||
case "D":
|
||||
statusColor = theme.fg("error", f.status);
|
||||
break;
|
||||
case "?":
|
||||
statusColor = theme.fg("muted", f.status);
|
||||
break;
|
||||
default:
|
||||
statusColor = theme.fg("dim", f.status);
|
||||
}
|
||||
return {
|
||||
value: f,
|
||||
label: `${statusColor} ${f.file}`,
|
||||
};
|
||||
});
|
||||
|
||||
const visibleRows = Math.min(files.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(items, visibleRows, {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => t, // Keep existing colors
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
});
|
||||
selectList.onSelect = (item) => {
|
||||
const items = files.map((file) => ({
|
||||
value: file,
|
||||
label: `${file.status} ${file.file}`,
|
||||
}));
|
||||
await showPagedSelectList({
|
||||
ctx,
|
||||
title: " Select file to diff",
|
||||
items,
|
||||
onSelect: (item) => {
|
||||
void openSelected(item.value as FileInfo);
|
||||
};
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
// Help text
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
return {
|
||||
render: (w) => container.render(w),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
// Add paging with left/right
|
||||
if (matchesKey(data, Key.left)) {
|
||||
// Page up - clamp to 0
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
// Page down - clamp to last
|
||||
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,15 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { showPagedSelectList } from "./ui/paged-select";
|
||||
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
@@ -113,82 +105,30 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
};
|
||||
|
||||
// Show file picker with SelectList
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
// Top border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
// Title
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0));
|
||||
|
||||
// Build select items with colored operations
|
||||
const items: SelectItem[] = files.map((f) => {
|
||||
const ops: string[] = [];
|
||||
if (f.operations.has("read")) {
|
||||
ops.push(theme.fg("muted", "R"));
|
||||
}
|
||||
if (f.operations.has("write")) {
|
||||
ops.push(theme.fg("success", "W"));
|
||||
}
|
||||
if (f.operations.has("edit")) {
|
||||
ops.push(theme.fg("warning", "E"));
|
||||
}
|
||||
const opsLabel = ops.join("");
|
||||
return {
|
||||
value: f,
|
||||
label: `${opsLabel} ${f.path}`,
|
||||
};
|
||||
});
|
||||
|
||||
const visibleRows = Math.min(files.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(items, visibleRows, {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => t, // Keep existing colors
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
});
|
||||
selectList.onSelect = (item) => {
|
||||
void openSelected(item.value as FileEntry);
|
||||
};
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
// Help text
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
const items = files.map((file) => {
|
||||
const ops: string[] = [];
|
||||
if (file.operations.has("read")) {
|
||||
ops.push("R");
|
||||
}
|
||||
if (file.operations.has("write")) {
|
||||
ops.push("W");
|
||||
}
|
||||
if (file.operations.has("edit")) {
|
||||
ops.push("E");
|
||||
}
|
||||
return {
|
||||
render: (w) => container.render(w),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
// Add paging with left/right
|
||||
if (matchesKey(data, Key.left)) {
|
||||
// Page up - clamp to 0
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
// Page down - clamp to last
|
||||
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
value: file,
|
||||
label: `${ops.join("")} ${file.path}`,
|
||||
};
|
||||
});
|
||||
await showPagedSelectList({
|
||||
ctx,
|
||||
title: " Select file to open",
|
||||
items,
|
||||
onSelect: (item) => {
|
||||
void openSelected(item.value as FileEntry);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,6 +114,17 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
}
|
||||
};
|
||||
|
||||
const renderPromptMatch = (ctx: ExtensionContext, match: PromptMatch) => {
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
@@ -123,14 +134,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
renderPromptMatch(ctx, match);
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
@@ -177,14 +181,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
renderPromptMatch(ctx, match);
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
|
||||
82
.pi/extensions/ui/paged-select.ts
Normal file
82
.pi/extensions/ui/paged-select.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
type CustomUiContext = {
|
||||
ui: {
|
||||
custom: <T>(
|
||||
render: (
|
||||
tui: { requestRender: () => void },
|
||||
theme: {
|
||||
fg: (tone: string, text: string) => string;
|
||||
bold: (text: string) => string;
|
||||
},
|
||||
kb: unknown,
|
||||
done: () => void,
|
||||
) => {
|
||||
render: (width: number) => string;
|
||||
invalidate: () => void;
|
||||
handleInput: (data: string) => void;
|
||||
},
|
||||
) => Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function showPagedSelectList(params: {
|
||||
ctx: CustomUiContext;
|
||||
title: string;
|
||||
items: SelectItem[];
|
||||
onSelect: (item: SelectItem) => void;
|
||||
}): Promise<void> {
|
||||
await params.ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(params.title)), 0, 0));
|
||||
|
||||
const visibleRows = Math.min(params.items.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(params.items, visibleRows, {
|
||||
selectedPrefix: (text) => theme.fg("accent", text),
|
||||
selectedText: (text) => text,
|
||||
description: (text) => theme.fg("muted", text),
|
||||
scrollInfo: (text) => theme.fg("dim", text),
|
||||
noMatch: (text) => theme.fg("warning", text),
|
||||
});
|
||||
selectList.onSelect = (item) => params.onSelect(item);
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = params.items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
return {
|
||||
render: (width) => container.render(width),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
if (matchesKey(data, Key.left)) {
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
currentIndex = Math.min(params.items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,7 @@ Input
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (end-to-end)
|
||||
Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`.
|
||||
Goal: PR must end in GitHub state = MERGED (never CLOSED). Prefer `gh pr merge --squash`; use `--rebase` only when preserving commit history is required.
|
||||
|
||||
1. Assign PR to self:
|
||||
- `gh pr edit <PR> --add-assignee @me`
|
||||
@@ -37,8 +37,8 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
|
||||
- Implement fixes + add/adjust tests
|
||||
- Update `CHANGELOG.md` and mention `#<PR>` + `@$contrib`
|
||||
9. Decide merge strategy:
|
||||
- Rebase if we want to preserve commit history
|
||||
- Squash if we want a single clean commit
|
||||
- Squash (preferred): use when we want a single clean commit
|
||||
- Rebase: use only when we explicitly want to preserve commit history
|
||||
- If unclear, ask
|
||||
10. Full gate (BEFORE commit):
|
||||
- `pnpm lint && pnpm build && pnpm test`
|
||||
@@ -54,8 +54,8 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
|
||||
```
|
||||
|
||||
13. Merge PR (must show MERGED on GitHub):
|
||||
- Rebase: `gh pr merge <PR> --rebase`
|
||||
- Squash: `gh pr merge <PR> --squash`
|
||||
- Squash (preferred): `gh pr merge <PR> --squash`
|
||||
- Rebase (history-preserving fallback): `gh pr merge <PR> --rebase`
|
||||
- Never `gh pr close` (closing is wrong)
|
||||
14. Sync main:
|
||||
- `git checkout main`
|
||||
|
||||
412
CHANGELOG.md
412
CHANGELOG.md
@@ -2,23 +2,230 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.3.2 (Unreleased)
|
||||
## 2026.3.2
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
|
||||
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
|
||||
- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
|
||||
- Secrets/SecretRef coverage: expand SecretRef support across the full supported user-supplied credential surface (64 targets total), including runtime collectors, `openclaw secrets` planning/apply/audit flows, onboarding SecretInput UX, and related docs; unresolved refs now fail fast on active surfaces while inactive surfaces report non-blocking diagnostics. (#29580) Thanks @joshavant.
|
||||
- Tools/PDF analysis: add a first-class `pdf` tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (`agents.defaults.pdfModel`, `pdfMaxBytesMb`, `pdfMaxPages`), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.
|
||||
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
|
||||
- Models/MiniMax: add first-class `MiniMax-M2.5-highspeed` support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy `MiniMax-M2.5-Lightning` compatibility for existing configs.
|
||||
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
|
||||
- Telegram/Streaming defaults: default `channels.telegram.streaming` to `partial` (from `off`) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.
|
||||
- Telegram/DM streaming: use `sendMessageDraft` for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
|
||||
- Telegram/voice mention gating: add optional `disableAudioPreflight` on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.
|
||||
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
|
||||
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
|
||||
- Memory/Ollama embeddings: add `memorySearch.provider = "ollama"` and `memorySearch.fallback = "ollama"` support, honor `models.providers.ollama` settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.
|
||||
- Zalo Personal plugin (`@openclaw/zalouser`): rebuilt channel runtime to use native `zca-js` integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
|
||||
- Plugin SDK/channel extensibility: expose `channelRuntime` on `ChannelGatewayContext` so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.
|
||||
- Plugin runtime/STT: add `api.runtime.stt.transcribeAudioFile(...)` so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.
|
||||
- Plugin hooks/session lifecycle: include `sessionKey` in `session_start`/`session_end` hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.
|
||||
- Hooks/message lifecycle: add internal hook events `message:transcribed` and `message:preprocessed`, plus richer outbound `message:sent` context (`isGroup`, `groupId`) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.
|
||||
- Media understanding/audio echo: add optional `tools.media.audio.echoTranscript` + `echoFormat` to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
|
||||
- Plugin runtime/system: expose `runtime.system.requestHeartbeatNow(...)` so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.
|
||||
- Plugin runtime/events: expose `runtime.events.onAgentEvent` and `runtime.events.onSessionTranscriptUpdate` for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.
|
||||
- CLI/Banner taglines: add `cli.banner.taglineMode` (`random` | `default` | `off`) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
|
||||
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
|
||||
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
|
||||
- Discord/lifecycle startup status: push an immediate `connected` status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.
|
||||
- Feishu/multi-app mention routing: guard mention detection in multi-bot groups by validating mention display name alongside bot `open_id`, preventing false-positive self-mentions from Feishu WebSocket remapping so only the actually mentioned bot responds under `requireMention`. (#30315) Thanks @teaguexiao.
|
||||
- Feishu/session-memory hook parity: trigger the shared `before_reset` session-memory hook path when Feishu `/new` and `/reset` commands execute so reset flows preserve memory behavior consistent with other channels. (#31437) Thanks @Linux2010.
|
||||
- Feishu/LINE group system prompts: forward per-group `systemPrompt` config into inbound context `GroupSystemPrompt` for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.
|
||||
- Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
|
||||
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
|
||||
- Feishu/group broadcast dispatch: add configurable multi-agent group broadcast dispatch with observer-session isolation, cross-account dedupe safeguards, and non-mention history buffering rules that avoid duplicate replay in broadcast/topic workflows. (#29575) Thanks @ohmyskyhigh.
|
||||
- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
|
||||
- Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.
|
||||
- Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)
|
||||
- Voice-call/runtime lifecycle: prevent `EADDRINUSE` loops by resetting failed runtime promises, making webhook `start()` idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob.
|
||||
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
|
||||
- Gateway/Plugin HTTP hardening: require explicit `auth` for plugin route registration, add route ownership guards for duplicate `path+match` registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
|
||||
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
|
||||
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
|
||||
- OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit `doctor --deep`) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.
|
||||
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
|
||||
- CLI/Config validation and routing hardening: dedupe `openclaw config validate` failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including `--json` fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed `config get/unset` with split root options). Thanks @gumadeiras.
|
||||
- Browser/Extension relay reconnect tolerance: keep `/json/version` and `/cdp` reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.
|
||||
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
|
||||
- Synology Chat/gateway lifecycle: keep `startAccount` pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.
|
||||
- Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like `/usr/bin/g++` and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
|
||||
- Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with `204` to avoid persistent `Processing...` states in Synology Chat clients. (#26635) Thanks @memphislee09-source.
|
||||
- Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.
|
||||
- Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan.
|
||||
- Slack/socket auth failure handling: fail fast on non-recoverable auth errors (`account_inactive`, `invalid_auth`, etc.) during startup and reconnect instead of retry-looping indefinitely, including `unable_to_socket_mode_start` error payload propagation. (#32377) Thanks @scoootscooob.
|
||||
- Gateway/macOS LaunchAgent hardening: write `Umask=077` in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from `HTTPS_PROXY`/`HTTP_PROXY` env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
|
||||
- Sandbox/workspace mount permissions: make primary `/workspace` bind mounts read-only whenever `workspaceAccess` is not `rw` (including `none`) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.
|
||||
- Tools/fsPolicy propagation: honor `tools.fs.workspaceOnly` for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.
|
||||
- Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like `node@22`) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.
|
||||
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
|
||||
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
|
||||
- Browser/Gateway hardening: preserve env credentials for `OPENCLAW_GATEWAY_URL` / `CLAWDBOT_GATEWAY_URL` while treating explicit `--url` as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.
|
||||
- Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
|
||||
- Control UI/Legacy browser compatibility: replace `toSorted`-dependent cron suggestion sorting in `app-render` with a compatibility helper so older browsers without `Array.prototype.toSorted` no longer white-screen. (#31775) Thanks @liuxiaopai-ai.
|
||||
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
|
||||
- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
|
||||
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
|
||||
- Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for `sessions_spawn` with `runtime="acp"` by rejecting ACP spawns from sandboxed requester sessions and rejecting `sandbox="require"` for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.
|
||||
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
|
||||
- Gemini schema sanitization: coerce malformed JSON Schema `properties` values (`null`, arrays, primitives) to `{}` before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.
|
||||
- Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.
|
||||
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
|
||||
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
|
||||
- Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up `PortInUseError` races after `browser start`/`open`. (#29538) Thanks @AaronWander.
|
||||
- OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty `function_call_output.call_id` payloads in the WS conversion path to avoid OpenAI 400 errors (`Invalid 'input[n].call_id': empty string`), with regression coverage for both inbound stream normalization and outbound payload guards.
|
||||
- Security/Nodes camera URL downloads: bind node `camera.snap`/`camera.clip` URL payload downloads to the resolved node host, enforce fail-closed behavior when node `remoteIp` is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.
|
||||
- Config/backups hardening: enforce owner-only (`0600`) permissions on rotated config backups and clean orphan `.bak.*` files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
|
||||
- Telegram/inbound media filenames: preserve original `file_name` metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.
|
||||
- Gateway/OpenAI chat completions: honor `x-openclaw-message-channel` when building `agentCommand` input for `/v1/chat/completions`, preserving caller channel identity instead of forcing `webchat`. (#30462) Thanks @bmendonca3.
|
||||
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
|
||||
- Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
|
||||
- Discord/audio preflight mentions: detect audio attachments via Discord `content_type` and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
|
||||
- Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
|
||||
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
|
||||
- Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
|
||||
- Context-window metadata warmup: add exponential config-load retry backoff (1s -> 2s -> 4s, capped at 60s) so transient startup failures recover automatically without hot-loop retries.
|
||||
- Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
|
||||
- Feishu/topic root replies: prefer `root_id` as outbound `replyTargetMessageId` when present, and parse millisecond `message_create_time` values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.
|
||||
- Feishu/DM pairing reply target: send pairing challenge replies to `chat:<chat_id>` instead of `user:<sender_open_id>` so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.
|
||||
- Feishu/Lark private DM routing: treat inbound `chat_type: "private"` as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.
|
||||
- Signal/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.
|
||||
- Discord/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.
|
||||
- Synology Chat/reply delivery: resolve webhook usernames to Chat API `user_id` values for outbound chatbot replies, avoiding mismatches between webhook user IDs and `method=chatbot` recipient IDs in multi-account setups. (#23709) Thanks @druide67.
|
||||
- Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.
|
||||
- Slack/session routing: keep top-level channel messages in one shared session when `replyToMode=off`, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.
|
||||
- Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.
|
||||
- Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.
|
||||
- Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.
|
||||
- Feishu/Send target prefixes: normalize explicit `group:`/`dm:` send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
|
||||
- Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
|
||||
- Telegram/implicit mention forum handling: exclude Telegram forum system service messages (`forum_topic_*`, `general_forum_topic_*`) from reply-chain implicit mention detection so `requireMention` does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.
|
||||
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
|
||||
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
|
||||
- Webchat/silent token leak: filter assistant `NO_REPLY`-only transcript entries from `chat.history` responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.
|
||||
- Doctor/local memory provider checks: stop false-positive local-provider warnings when `provider=local` and no explicit `modelPath` is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
|
||||
- Media understanding/parakeet CLI output parsing: read `parakeet-mlx` transcripts from `--output-dir/<media-basename>.txt` when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.
|
||||
- Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.
|
||||
- Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
|
||||
- Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
|
||||
- Gateway/Control UI basePath POST handling: return 405 for `POST` on exact basePath routes (for example `/openclaw`) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.
|
||||
- Browser/default profile selection: default `browser.defaultProfile` behavior now prefers `openclaw` (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the `chrome` relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
|
||||
- Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
|
||||
- Models/config env propagation: apply `config.env.vars` before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.
|
||||
- Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so `openclaw models status` no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
|
||||
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
|
||||
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
|
||||
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
|
||||
- Browser/CDP status accuracy: require a successful `Browser.getVersion` response over the CDP websocket (not just socket-open) before reporting `cdpReady`, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.
|
||||
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
|
||||
- Security/Node exec approvals: revalidate approval-bound `cwd` identity immediately before execution/forwarding and fail closed with an explicit denial when `cwd` drifts after approval hardening.
|
||||
- Security audit/skills workspace hardening: add `skills.workspace.symlink_escape` warning in `openclaw security audit` when workspace `skills/**/SKILL.md` resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.
|
||||
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
|
||||
- Security/fs-safe write hardening: make `writeFileWithinRoot` use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.
|
||||
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
|
||||
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
|
||||
- Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
|
||||
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
|
||||
- Agents/Sandbox workdir mapping: map container workdir paths (for example `/workspace`) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.
|
||||
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
|
||||
- Hooks/webhook ACK compatibility: return `200` (instead of `202`) for successful `/hooks/agent` requests so providers that require `200` (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.
|
||||
- Feishu/Run channel fallback: prefer `Provider` over `Surface` when inferring queued run `messageProvider` fallback (when `OriginatingChannel` is missing), preventing Feishu turns from being mislabeled as `webchat` in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
|
||||
- Skills/sherpa-onnx-tts: run the `sherpa-onnx-tts` bin under ESM (replace CommonJS `require` imports) and add regression coverage to prevent `require is not defined in ES module scope` startup crashes. (#31965) Thanks @bmendonca3.
|
||||
- Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
|
||||
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
|
||||
- Hooks/session-scoped memory context: expose ephemeral `sessionId` in embedded plugin tool contexts and `before_tool_call`/`after_tool_call` hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across `/new` and `/reset`. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.
|
||||
- Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
|
||||
- Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.
|
||||
- Feishu/File upload filenames: percent-encode non-ASCII/special-character `file_name` values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
|
||||
- Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized `kindFromMime` so mixed-case/parameterized MIME values classify consistently across message channels.
|
||||
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
|
||||
- Webchat/stream finalization: persist streamed assistant text when final events omit `message`, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
|
||||
- Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)
|
||||
- Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)
|
||||
- Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)
|
||||
- Feishu/Streaming block fallback: preserve markdown block stream text as final streaming-card content when final payload text is missing, while still suppressing non-card internal block chunk delivery. (#30663)
|
||||
- Feishu/Bitable API errors: unify Feishu Bitable tool error handling with structured `LarkApiError` responses and consistent API/context attribution across wiki/base metadata, field, and record operations. (#31450)
|
||||
- Feishu/Missing-scope grant URL fix: rewrite known invalid scope aliases (`contact:contact.base:readonly`) to valid scope names in permission grant links, so remediation URLs open with correct Feishu consent scopes. (#31943)
|
||||
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
|
||||
- WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.
|
||||
- Feishu/default account resolution: always honor explicit `channels.feishu.defaultAccount` during outbound account selection (including top-level-credential setups where the preferred id is not present in `accounts`), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.
|
||||
- Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (`contact:contact.base:readonly`) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)
|
||||
- Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.
|
||||
- Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)
|
||||
- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
|
||||
- Browser/Extension re-announce reliability: keep relay state in `connecting` when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.
|
||||
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
|
||||
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
|
||||
- Models/openai-completions developer-role compatibility: force `supportsDeveloperRole=false` for non-native endpoints, treat unparseable `baseUrl` values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.
|
||||
- Browser/Profile attach-only override: support `browser.profiles.<name>.attachOnly` (fallback to global `browser.attachOnly`) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
|
||||
- Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file `starttime` with `/proc/<pid>/stat` starttime, so stale `.jsonl.lock` files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
|
||||
- Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel `resolveDefaultTo` fallback) when `delivery.to` is omitted. (#32364) Thanks @hclsys.
|
||||
- OpenAI media capabilities: include `audio` in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.
|
||||
- Browser/Managed tab cap: limit loopback managed `openclaw` page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Gateway/Node dangerous-command parity: include `sms.send` in default onboarding node `denyCommands`, share onboarding deny defaults with the gateway dangerous-command source of truth, and include `sms.send` in phone-control `/phone arm writes` handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.
|
||||
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/CDP proxy bypass: force direct loopback agent paths and scoped `NO_PROXY` expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
|
||||
- Sessions/idle reset correctness: preserve existing `updatedAt` during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.
|
||||
- Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing `starttime` when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.
|
||||
- Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (`mtimeMs` + `sizeBytes`), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.
|
||||
- Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
|
||||
- CLI/installer Node preflight: enforce Node.js `v22.12+` consistently in both `openclaw.mjs` runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.
|
||||
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
|
||||
- Auto-reply/inline command cleanup: preserve newline structure when stripping inline `/status` and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.
|
||||
- Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like `source`/`provider`), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.
|
||||
- Hooks/runtime stability: keep the internal hook handler registry on a `globalThis` singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.
|
||||
- Hooks/after_tool_call: include embedded session context (`sessionKey`, `agentId`) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.
|
||||
- Hooks/tool-call correlation: include `runId` and `toolCallId` in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in `before_tool_call` and `after_tool_call`. (#32360) Thanks @vincentkoc.
|
||||
- Plugins/install diagnostics: reject legacy plugin package shapes without `openclaw.extensions` and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
|
||||
- Hooks/plugin context parity: ensure `llm_input` hooks in embedded attempts receive the same `trigger` and `channelId`-aware `hookCtx` used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.
|
||||
- Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (`pnpm`, `bun`) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.
|
||||
- Cron/session reaper reliability: move cron session reaper sweeps into `onTimer` `finally` and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
|
||||
- Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so `HEARTBEAT_OK` noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.
|
||||
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
|
||||
- Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (`newText` present and `oldText` absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.
|
||||
- Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example `diffs` -> bundled `@openclaw/diffs`), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
|
||||
- Web UI/inline code copy fidelity: disable forced mid-token wraps on inline `<code>` spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.
|
||||
- Restart sentinel formatting: avoid duplicate `Reason:` lines when restart message text already matches `stats.reason`, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.
|
||||
- Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
|
||||
- Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.
|
||||
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
|
||||
- Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.
|
||||
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
|
||||
- Secrets/exec resolver timeout defaults: use provider `timeoutMs` as the default inactivity (`noOutputTimeoutMs`) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.
|
||||
- Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.
|
||||
- Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing `HEARTBEAT_OK` from being delivered to users. (#32131) Thanks @adhishthite.
|
||||
- Cron/store migration: normalize legacy cron jobs with string `schedule` and top-level `command`/`timeout` fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
|
||||
- Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.
|
||||
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
|
||||
|
||||
## 2026.3.1
|
||||
|
||||
### Changes
|
||||
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.
|
||||
- README/Contributors: rank contributor avatars by composite score (commits + merged PRs + code LOC), excluding docs-only LOC to prevent bulk-generated files from inflating rankings. (#23970) Thanks @tyler6204.
|
||||
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
|
||||
- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
|
||||
- Telegram/DM topics: add per-DM `direct` + topic config (allowlists, `dmPolicy`, `skills`, `systemPrompt`, `requireTopic`), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.
|
||||
- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
|
||||
- Android/Nodes parity: add `system.notify`, `photos.latest`, `contacts.search`/`contacts.add`, `calendar.events`/`calendar.add`, and `motion.activity`/`motion.pedometer`, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.
|
||||
- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
|
||||
- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
|
||||
- CLI/Config: add `openclaw config file` to print the active config file path resolved from `OPENCLAW_CONFIG_PATH` or the default location. (#26256) thanks @cyb1278588254.
|
||||
- Feishu/Docx tables + uploads: add `feishu_doc` actions for Docx table creation/cell writing (`create_table`, `write_table_cells`, `create_table_with_values`) and image/file uploads (`upload_image`, `upload_file`) with stricter create/upload error handling for missing `document_id` and placeholder cleanup failures. (#20304) Thanks @xuhao1.
|
||||
- Feishu/Reactions: add inbound `im.message.reaction.created_v1` handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.
|
||||
@@ -40,34 +247,42 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
|
||||
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
|
||||
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
|
||||
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
|
||||
- Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
|
||||
- Browser/Extension relay reconnect tolerance: keep `/json/version` and `/cdp` reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.
|
||||
- Browser/Extension re-announce reliability: keep relay state in `connecting` when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.
|
||||
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
|
||||
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
|
||||
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
|
||||
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
|
||||
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
|
||||
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
|
||||
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
|
||||
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
|
||||
- Feishu/Streaming card text fidelity: merge throttled/fragmented partial updates without dropping content and avoid newline injection when stitching chunk-style deltas so card-stream output matches final reply text. (#29616) Thanks @HaoHuaqing.
|
||||
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
|
||||
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
|
||||
- Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (`198.18.0.0/15`) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.
|
||||
- Feishu/Sessions announce group targets: normalize `group:` and `channel:` Feishu targets to `chat_id` routing so `sessions_send` announce delivery no longer sends group chat IDs via `user_id` API params. Fixes #31426.
|
||||
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
|
||||
- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
|
||||
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
|
||||
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
|
||||
- Gateway/Control UI origins: honor `gateway.controlUi.allowedOrigins: ["*"]` wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.
|
||||
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
|
||||
- Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.
|
||||
- CLI/Install: add an npm-link fallback to fix CLI startup `Permission denied` failures (`exit 127`) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
|
||||
- Plugins/NPM spec install: fix npm-spec plugin installs when `npm pack` output is empty by detecting newly created `.tgz` archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.
|
||||
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
|
||||
- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
|
||||
- Sessions/Internal routing: preserve established external `lastTo`/`lastChannel` routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.
|
||||
- Auto-reply/NO_REPLY: strip `NO_REPLY` token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Inbound metadata/Multi-account routing: include `account_id` in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.
|
||||
- Cron/Delivery mode none: send explicit `delivery: { mode: "none" }` from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.
|
||||
- Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
|
||||
- Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
|
||||
- CLI/Ollama config: allow `config set` for Ollama `apiKey` without predeclared provider config. (#29299) Thanks @vincentkoc.
|
||||
- Agents/Ollama: demote empty-discovery logging from `warn` to `debug` to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
|
||||
- Sandbox/Browser Docker: pass `OPENCLAW_BROWSER_NO_SANDBOX=1` to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.
|
||||
- Tools/Edit workspace boundary errors: preserve the real `Path escapes workspace root` failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Browser/Open & navigate: accept `url` as an alias parameter for `open` and `navigate`. (#29260) Thanks @vincentkoc.
|
||||
- Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false `cannot create directories` failures in sandbox write mode. (#30610) Thanks @glitch418x.
|
||||
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
|
||||
- LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.
|
||||
- Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.
|
||||
- Android/Photos permissions: declare Android 14+ selected-photo access permission (`READ_MEDIA_VISUAL_USER_SELECTED`) and align Android permission/settings paths with current minSdk behavior for more reliable permission state handling.
|
||||
- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
|
||||
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
|
||||
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
|
||||
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) Thanks @icesword0760.
|
||||
- Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (`SLACK_USER_TOKEN` env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.
|
||||
- Feishu/Outbound session routing: stop assuming bare `oc_` identifiers are always group chats, honor explicit `dm:`/`group:` prefixes for `oc_` chat IDs, and default ambiguous bare `oc_` targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.
|
||||
@@ -75,8 +290,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.
|
||||
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
|
||||
- Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg.
|
||||
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
|
||||
- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Feishu/Mobile video media type: treat inbound `message_type: "media"` as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.
|
||||
- Feishu/Inbound sender fallback: fall back to `sender_id.user_id` when `sender_id.open_id` is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.
|
||||
- Feishu/Reply context metadata: include inbound `parent_id` and `root_id` as `ReplyToId`/`RootMessageId` in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu.
|
||||
@@ -95,16 +308,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack/Native commands: register Slack native status as `/agentstatus` (Slack-reserved `/status`) so manifest slash command registration stays valid while text `/status` still works. Landed from contributor PR #29032 by @maloqab. Thanks @maloqab.
|
||||
- Android/Camera clip: remove `camera.clip` HTTP-upload fallback to base64 so clip transport is deterministic and fail-loud, and reject non-positive `maxWidth` values so invalid inputs fall back to the safe resize default. (#28229) Thanks @obviyus.
|
||||
- Android/Gateway canvas capability refresh: send `node.canvas.capability.refresh` with object `params` (`{}`) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.
|
||||
- Gateway/Control UI origins: honor `gateway.controlUi.allowedOrigins: ["*"]` wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.
|
||||
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
|
||||
- Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file `starttime` with `/proc/<pid>/stat` starttime, so stale `.jsonl.lock` files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
|
||||
- Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.
|
||||
- CLI/Install: add an npm-link fallback to fix CLI startup `Permission denied` failures (`exit 127`) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
|
||||
- Onboarding/Custom providers: improve verification reliability for slower local endpoints (for example Ollama) during setup. (#27380) Thanks @Sid-Qin.
|
||||
- Plugins/NPM spec install: fix npm-spec plugin installs when `npm pack` output is empty by detecting newly created `.tgz` archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.
|
||||
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
|
||||
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
|
||||
- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
|
||||
- Daemon/macOS TLS certs: default LaunchAgent service env `NODE_EXTRA_CA_CERTS` to `/etc/ssl/cert.pem` (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
|
||||
- Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.
|
||||
@@ -116,54 +320,24 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/API quota controls: add `typingIndicator` and `resolveSenderNames` config flags (top-level and per-account) so operators can disable typing reactions and sender-name lookup requests while keeping default behavior unchanged. (#10513) Thanks @BigUncle.
|
||||
- Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted `System:` context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.
|
||||
- Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.
|
||||
- Sessions/Internal routing: preserve established external `lastTo`/`lastChannel` routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.
|
||||
- Control UI/Debug log layout: render Debug Event Log payloads at full width to prevent payload JSON from being squeezed into a narrow side column. Landed from contributor PR #30978 by @stozo04. Thanks @stozo04.
|
||||
- Auto-reply/NO_REPLY: strip `NO_REPLY` token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Install/npm: fix npm global install deprecation warnings. (#28318) Thanks @vincentkoc.
|
||||
- Update/Global npm: fallback to `--omit=optional` when global `npm update` fails so optional dependency install failures no longer abort update flows. (#24896) Thanks @xinhuagu and @vincentkoc.
|
||||
- Inbound metadata/Multi-account routing: include `account_id` in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.
|
||||
- Model directives/Auth profiles: split `/model` profile suffixes at the first `@` after the last slash so email-based auth profile IDs (for example OAuth profile IDs) resolve correctly. Landed from contributor PR #30932 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Cron/Delivery mode none: send explicit `delivery: { mode: "none" }` from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.
|
||||
- Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
|
||||
- Ollama/Embedded runner base URL precedence: prioritize configured provider `baseUrl` over model defaults for embedded Ollama runs so Docker and remote-host setups avoid localhost fetch failures. (#30964) Thanks @stakeswky.
|
||||
- Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
|
||||
- CLI/Ollama config: allow `config set` for Ollama `apiKey` without predeclared provider config. (#29299) Thanks @vincentkoc.
|
||||
- Ollama/Autodiscovery: harden autodiscovery and warning behavior. (#29201) Thanks @marcodelpin and @vincentkoc.
|
||||
- Ollama/Context window: unify context window handling across discovery, merge, and OpenAI-compatible transport paths. (#29205) Thanks @Sid-Qin, @jimmielightner, and @vincentkoc.
|
||||
- Agents/Ollama: demote empty-discovery logging from `warn` to `debug` to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
|
||||
- fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.
|
||||
- Docker/Image permissions: normalize `/app/extensions`, `/app/.agent`, and `/app/.agents` to directory mode `755` and file mode `644` during image build so plugin discovery does not block inherited world-writable paths. (#30191) Fixes #30139. Thanks @edincampara.
|
||||
- OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty `baseUrl` as non-direct, honor `compat.supportsStore=false`, and auto-inject server-side compaction `context_management` for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.
|
||||
- Sandbox/Browser Docker: pass `OPENCLAW_BROWSER_NO_SANDBOX=1` to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.
|
||||
- Usage normalization: clamp negative prompt/input token values to zero (including `prompt_tokens` alias inputs) so `/usage` and TUI usage displays cannot show nonsensical negative counts. Landed from contributor PR #31211 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Secrets/Auth profiles: normalize inline SecretRef `token`/`key` values to canonical `tokenRef`/`keyRef` before persistence, and keep explicit `keyRef` precedence when inline refs are also present. Landed from contributor PR #31047 by @minupla. Thanks @minupla.
|
||||
- Tools/Edit workspace boundary errors: preserve the real `Path escapes workspace root` failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Browser/Open & navigate: accept `url` as an alias parameter for `open` and `navigate`. (#29260) Thanks @vincentkoc.
|
||||
- Codex/Usage window: label weekly usage window as `Week` instead of `Day`. (#26267) Thanks @Sid-Qin.
|
||||
- Signal/Sync message null-handling: treat `syncMessage` presence (including `null`) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Infra/fs-safe: sanitize directory-read failures so raw `EISDIR` text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.
|
||||
- Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false `cannot create directories` failures in sandbox write mode. (#30610) Thanks @glitch418x.
|
||||
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
|
||||
- Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (`198.18.0.0/15`) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
|
||||
- Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
|
||||
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
|
||||
@@ -190,6 +364,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/Application ID fallback: parse bot application IDs from token prefixes without numeric precision loss and use token fallback only on transport/timeout failures when probing `/oauth2/applications/@me`. Landed from contributor PR #29695 by @dhananjai1729. Thanks @dhananjai1729.
|
||||
- Discord/EventQueue timeout config: expose per-account `channels.discord.accounts.<id>.eventQueue.listenerTimeout` (and related queue options) so long-running handlers can avoid Carbon listener timeout drops. Landed from contributor PR #28945 by @Glucksberg. Thanks @Glucksberg.
|
||||
- CLI/Cron run exit code: return exit code `0` only when `cron run` reports `{ ok: true, ran: true }`, and `1` for non-run/error outcomes so scripting/debugging reflects actual execution status. Landed from contributor PR #31121 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Cron/Failure delivery routing: add `failureAlert.mode` (`announce|webhook`) and `failureAlert.accountId` support, plus `cron.failureDestination` and per-job `delivery.failureDestination` routing with duplicate-target suppression, best-effort skip behavior, and global+job merge semantics. Landed from contributor PR #31059 by @kesor. Thanks @kesor.
|
||||
- CLI/JSON preflight output: keep `--json` command stdout machine-readable by suppressing doctor preflight note output while still running legacy migration/config doctor flow. (#24368) Thanks @altaywtf.
|
||||
- Nodes/Screen recording guardrails: cap `nodes` tool `screen_record` `durationMs` to 5 minutes at both schema-validation and runtime invocation layers to prevent long-running blocking captures from unbounded durations. Landed from contributor PR #31106 by @BlueBirdBack. Thanks @BlueBirdBack.
|
||||
- Telegram/Empty final replies: skip outbound send for null/undefined final text payloads without media so Telegram typing indicators do not linger on `text must be non-empty` errors, with added regression coverage for undefined final payload dispatch. Landed from contributor PRs #30969 by @haosenwang1018 and #30746 by @rylena. Thanks @haosenwang1018 and @rylena.
|
||||
@@ -203,6 +378,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/Doc create permissions: remove caller-controlled owner fields from `feishu_doc` create and bind optional grant behavior to trusted Feishu requester context (`grant_to_requester`), preventing principal selection via tool arguments. (#31184) Thanks @Takhoffman.
|
||||
- Routing/Binding peer-kind parity: treat `peer.kind` `group` and `channel` as equivalent for binding scope matching (while keeping `direct` separate) so Slack/public channel bindings do not silently fall through. Landed from contributor PR #31135 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Cron/Store EBUSY fallback: retry `rename` on `EBUSY` and use `copyFile` fallback on Windows when replacing cron store files so busy-file contention no longer causes false write failures. (#16932) Thanks @sudhanva-chakra.
|
||||
- Cron/Isolated payload selection: ignore `isError` payloads when deriving summary/output/delivery payload fallbacks, while preserving error-only fallback behavior when no non-error payload exists. (#21454) Thanks @Diaspar4u.
|
||||
- Agents/FS workspace default: honor documented host file-tool default `tools.fs.workspaceOnly=false` when unset so host `write`/`edit` calls are not incorrectly workspace-restricted unless explicitly enabled. Landed from contributor PR #31128 by @SaucePackets. Thanks @SaucePackets.
|
||||
- Cron/Timer hot-loop guard: enforce a minimum timer re-arm delay when stale past-due jobs would otherwise trigger repeated `setTimeout(0)` loops, preventing event-loop saturation and log-flood behavior. (#29853) Thanks @FlamesCN.
|
||||
- Gateway/CLI session recovery: handle expired CLI session IDs gracefully by clearing stale session state and retrying without crashing gateway runs. Landed from contributor PR #31090 by @frankekn. Thanks @frankekn.
|
||||
@@ -215,6 +391,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Audit: flag `gateway.controlUi.allowedOrigins=["*"]` as a high-risk configuration (severity based on bind exposure), and add a Feishu doc-tool warning that `owner_open_id` on `feishu_doc` create can grant document permissions.
|
||||
- Slack/download-file scoping: thread/channel-aware `download-file` actions now propagate optional scope context and reject downloads when Slack metadata definitively shows the file is outside the requested channel/thread, while preserving legacy behavior when share metadata is unavailable.
|
||||
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
- Security/Sandbox media staging: block destination symlink escapes in `stageSandboxMedia` by replacing direct destination copies with root-scoped safe writes for both local and SCP-staged attachments, preventing out-of-workspace file overwrite through `media/inbound` alias traversal. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
|
||||
- Node host/service auth env: include `OPENCLAW_GATEWAY_TOKEN` in `openclaw node install` service environments (with `CLAWDBOT_GATEWAY_TOKEN` compatibility fallback) so installed node services keep remote gateway token auth across restart/reboot. Fixes #31041. Thanks @OneStepAt4time for reporting, @byungsker, @liuxiaopai-ai, and @vincentkoc.
|
||||
- Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting.
|
||||
- Security/Workspace safe writes: harden `writeFileWithinRoot` against symlink-retarget TOCTOU races by opening existing files without truncation, creating missing files with exclusive create, deferring truncation until post-open identity+boundary validation, and removing out-of-root create artifacts on blocked races; added regression tests for truncate/create race paths. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
|
||||
@@ -259,6 +436,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/Isolated model defaults: resolve isolated cron `subagents.model` (including object-form `primary`) through allowlist-aware model selection so isolated cron runs honor subagent model defaults unless explicitly overridden by job payload model. (#11474) Thanks @AnonO6.
|
||||
- Cron/Isolated sessions list: persist the intended pre-run model/provider on isolated cron session entries so `sessions_list` reflects payload/session model overrides even when runs fail before post-run telemetry persistence. (#21279) Thanks @altaywtf.
|
||||
- Cron tool/update flat params: recover top-level update patch fields when models omit the `patch` wrapper, and allow flattened update keys through tool input schema validation so `cron.update` no longer fails with `patch required` for valid flat payloads. (#23221)
|
||||
- Cron/Announce delivery status: keep isolated cron runs in `ok` state when execution succeeds but announce delivery fails (for example transient `pairing required`), while preserving `delivered=false` and delivery error context for visibility. (#31082) Thanks @YuzuruS.
|
||||
- Agents/Message tool scoping: include other configured channels in scoped `message` tool action enum + description so isolated/cron runs can discover and invoke cross-channel actions without schema validation failures. Landed from contributor PR #20840 by @altaywtf. Thanks @altaywtf.
|
||||
- Web UI/Chat sessions: add a cron-session visibility toggle in the session selector, fix cron-key detection across `cron:*` and `agent:*:cron:*` formats, and localize the new control labels/tooltips. (#26976) Thanks @ianderrington.
|
||||
- Web UI/Cron jobs: add schedule-kind and last-run-status filters to the Jobs list, with reset control and client-side filtering over loaded results. (#9510) Thanks @guxu11.
|
||||
@@ -577,28 +755,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting.
|
||||
- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting.
|
||||
- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.
|
||||
- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.
|
||||
- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.
|
||||
- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.
|
||||
- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.
|
||||
- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728.
|
||||
- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
|
||||
- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.
|
||||
- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr.
|
||||
- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.
|
||||
- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.
|
||||
- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.
|
||||
- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.
|
||||
- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
|
||||
- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
|
||||
- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
|
||||
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
|
||||
- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach.
|
||||
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
|
||||
- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
|
||||
- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18.
|
||||
- CLI/Memory search: accept `--query <text>` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.
|
||||
- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
|
||||
|
||||
## 2026.2.23
|
||||
@@ -652,7 +810,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/Install: when npm install returns 404 for bundled channel npm specs, fallback to bundled channel sources and complete install/enable persistence instead of failing plugin install. (#12849) Thanks @vincentkoc.
|
||||
- Gemini OAuth/Auth: resolve npm global shim install layouts while discovering Gemini CLI credentials, preventing false "Gemini CLI not found" onboarding/auth failures when shim paths are on `PATH`. (#27585) Thanks @ehgamemo and @vincentkoc.
|
||||
- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras.
|
||||
- Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn.
|
||||
- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) Thanks @steipete.
|
||||
@@ -909,6 +1066,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. Thanks @tdjackey for reporting.
|
||||
- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. Thanks @tdjackey for reporting.
|
||||
- Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared `safeFetch` so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore.
|
||||
- Security/MSTeams auth redirect scoping: strip bearer auth on redirect hops outside `authAllowHosts` and gate SharePoint Graph auth-header injection by auth allowlist to prevent token bleed across redirect targets. (#25045) Thanks @bmendonca3.
|
||||
- MSTeams/reply reliability: when Bot Framework revokes thread turn-context proxies (for example debounced flush paths), fall back to proactive messaging/typing and continue pending sends without duplicating already delivered messages. (#27224) Thanks @openperf.
|
||||
- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.
|
||||
- Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
|
||||
- CI/Tests: fix TypeScript case-table typing and lint assertion regressions so `pnpm check` passes again after Synology Chat landing. (#23012) Thanks @druide67.
|
||||
@@ -1014,8 +1173,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Config: allow `gateway.customBindHost` in strict config validation when `gateway.bind="custom"` so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420.
|
||||
- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
|
||||
- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.
|
||||
- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
|
||||
- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
|
||||
- Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.
|
||||
- Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.
|
||||
- Agents/Tool display: fix exec cwd suffix inference so `pushd ... && popd ... && <command>` does not keep stale `(in <dir>)` context in summaries. (#21925) Thanks @Lukavyi.
|
||||
@@ -1388,7 +1545,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
|
||||
- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
|
||||
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
|
||||
- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus.
|
||||
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
|
||||
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
|
||||
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
|
||||
@@ -1398,7 +1554,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
|
||||
- Discord: skip text-based exec approval forwarding in favor of Discord's component-based approval UI. Thanks @thewilloftheshadow.
|
||||
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
|
||||
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
|
||||
- Gateway/Memory: initialize QMD startup sync for every configured agent (not just the default agent), so `memory.qmd.update.onBoot` is effective across multi-agent setups. (#17663) Thanks @HenryLoenwind.
|
||||
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
|
||||
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
|
||||
@@ -1889,9 +2044,6 @@ Docs: https://docs.openclaw.ai
|
||||
- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393)
|
||||
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
|
||||
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
|
||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07.
|
||||
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
|
||||
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
|
||||
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
|
||||
- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop.
|
||||
@@ -2009,7 +2161,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
|
||||
- Security/Gateway: require `operator.approvals` for in-chat `/approve` when invoked from gateway clients. Thanks @yueyueL.
|
||||
- Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek.
|
||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
|
||||
- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)
|
||||
- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed); completion prompt now handled by install/update.
|
||||
@@ -2079,62 +2230,10 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.1.31
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: onboarding/install/i18n/exec-approvals/Control UI/exe.dev/cacheRetention updates + misc nav/typos. (#3050, #3461, #4064, #4675, #4729, #4763, #5003, #5402, #5446, #5474, #5663, #5689, #5694, #5967, #6270, #6300, #6311, #6416, #6487, #6550, #6789)
|
||||
- Telegram: use shared pairing store. (#6127) Thanks @obviyus.
|
||||
- Agents: add OpenRouter app attribution headers. Thanks @alexanderatallah.
|
||||
- Agents: add system prompt safety guardrails. (#5445) Thanks @joshp123.
|
||||
- Agents: update pi-ai to 0.50.9 and rename cacheControlTtl -> cacheRetention (with back-compat mapping).
|
||||
- Agents: extend CreateAgentSessionOptions with systemPrompt/skills/contextFiles.
|
||||
- Agents: add tool policy conformance snapshot (no runtime behavior change). (#6011)
|
||||
- Auth: update MiniMax OAuth hint + portal auth note copy.
|
||||
- Discord: inherit thread parent bindings for routing. (#3892) Thanks @aerolalit.
|
||||
- Gateway: inject timestamps into agent and chat.send messages. (#3705) Thanks @conroywhitney, @CashWilliams.
|
||||
- Gateway: require TLS 1.3 minimum for TLS listeners. (#5970) Thanks @loganaden.
|
||||
- Web UI: refine chat layout + extend session active duration.
|
||||
- CI: add formal conformance + alias consistency checks. (#5723, #5807)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning).
|
||||
- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures.
|
||||
- Plugins: validate plugin/hook install paths and reject traversal-like names.
|
||||
- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.
|
||||
- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.
|
||||
- Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014)
|
||||
- Streaming: stabilize partial streaming filters.
|
||||
- Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation.
|
||||
- Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization).
|
||||
- Tools: treat `"*"` tool allowlist entries as valid to avoid spurious unknown-entry warnings.
|
||||
- Skills: update session-logs paths from .clawdbot to .openclaw. (#4502)
|
||||
- Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach.
|
||||
- Lint: satisfy curly rule after import sorting. (#6310)
|
||||
- Process: resolve Windows `spawn()` failures for npm-family CLIs by appending `.cmd` when needed. (#5815) Thanks @thejhinvirtuoso.
|
||||
- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow.
|
||||
- Tlon: add timeout to SSE client fetch calls (CWE-400). (#5926)
|
||||
- Memory search: L2-normalize local embedding vectors to fix semantic search. (#5332)
|
||||
- Agents: align embedded runner + typings with pi-coding-agent API updates (pi 0.51.0).
|
||||
- Agents: ensure OpenRouter attribution headers apply in the embedded runner.
|
||||
- Agents: cap context window resolution for compaction safeguard. (#6187) Thanks @iamEvanYT.
|
||||
- System prompt: resolve overrides and hint using session_status for current date/time. (#1897, #1928, #2108, #3677)
|
||||
- Agents: fix Pi prompt template argument syntax. (#6543)
|
||||
- Subagents: fix announce failover race (always emit lifecycle end; timeout=0 means no-timeout). (#6621)
|
||||
- Teams: gate media auth retries.
|
||||
- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.
|
||||
- Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman.
|
||||
- TUI: prevent crash when searching with digits in the model selector.
|
||||
- Agents: wire before_tool_call plugin hook into tool execution. (#6570, #6660) Thanks @ryancnelson.
|
||||
- Browser: secure Chrome extension relay CDP sessions.
|
||||
- Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42.
|
||||
- Docker: start gateway CMD by default for container deployments. (#6635) Thanks @kaizen403.
|
||||
- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.
|
||||
- Security: sanitize WhatsApp accountId to prevent path traversal. (#4610)
|
||||
- Security: restrict MEDIA path extraction to prevent LFI. (#4930)
|
||||
- Security: validate message-tool filePath/path against sandbox root. (#6398)
|
||||
- Security: block LD*/DYLD* env overrides for host exec. (#4896) Thanks @HassanFleyah.
|
||||
- Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc.
|
||||
- Security: enforce Twitch `allowFrom` allowlist gating (deny non-allowlisted senders). Thanks @MegaManSec.
|
||||
|
||||
## 2026.1.30
|
||||
|
||||
@@ -2865,7 +2964,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -2874,7 +2972,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
|
||||
- Telegram: default reaction notifications to own.
|
||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||
@@ -2914,7 +3011,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos.
|
||||
- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos.
|
||||
- WhatsApp: default response prefix only for self-chat, using identity name when set.
|
||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
||||
- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops.
|
||||
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
|
||||
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
|
||||
@@ -3011,13 +3107,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
||||
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
|
||||
#### macOS / Apps
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
|
||||
# Update OPENCLAW_DOCKER_GPG_FINGERPRINT when Docker rotates release keys.
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /tmp/docker.gpg.asc && \
|
||||
expected_fingerprint="$(printf '%s' "$OPENCLAW_DOCKER_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')" && \
|
||||
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == \"fpr\" { print toupper($10); exit }')" && \
|
||||
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == "fpr" { print toupper($10); exit }')" && \
|
||||
if [ -z "$actual_fingerprint" ] || [ "$actual_fingerprint" != "$expected_fingerprint" ]; then \
|
||||
echo "ERROR: Docker apt key fingerprint mismatch (expected $expected_fingerprint, got ${actual_fingerprint:-<empty>})" >&2; \
|
||||
exit 1; \
|
||||
|
||||
@@ -40,7 +40,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
Model note: while many providers/models are supported, for the best experience and lower prompt-injection risk use the strongest latest-generation model available to you. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ These are frequently reported but are typically closed with no code change:
|
||||
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
|
||||
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
||||
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
|
||||
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
|
||||
- Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state.
|
||||
- Missing HSTS findings on default local/loopback deployments.
|
||||
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
|
||||
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
|
||||
@@ -114,6 +116,8 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
|
||||
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
|
||||
- Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
|
||||
- Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state.
|
||||
- Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive.
|
||||
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
|
||||
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
|
||||
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
|
||||
@@ -149,6 +153,8 @@ OpenClaw's security model is "personal assistant" (one trusted operator, potenti
|
||||
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.
|
||||
- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals.
|
||||
- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries.
|
||||
- Hook/webhook-driven payloads should be treated as untrusted content; keep unsafe bypass flags disabled unless doing tightly scoped debugging (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`).
|
||||
- Weak model tiers are generally easier to prompt-inject. For tool-enabled or hook-driven agents, prefer strong modern model tiers and strict tool policy (for example `tools.profile: "messaging"` or stricter), plus sandboxing where possible.
|
||||
|
||||
## Gateway and Node trust concept
|
||||
|
||||
|
||||
389
appcast.xml
389
appcast.xml
@@ -2,6 +2,225 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.3.2</title>
|
||||
<pubDate>Tue, 03 Mar 2026 04:30:29 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026030290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.2</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Secrets/SecretRef coverage: expand SecretRef support across the full supported user-supplied credential surface (64 targets total), including runtime collectors, <code>openclaw secrets</code> planning/apply/audit flows, onboarding SecretInput UX, and related docs; unresolved refs now fail fast on active surfaces while inactive surfaces report non-blocking diagnostics. (#29580) Thanks @joshavant.</li>
|
||||
<li>Tools/PDF analysis: add a first-class <code>pdf</code> tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (<code>agents.defaults.pdfModel</code>, <code>pdfMaxBytesMb</code>, <code>pdfMaxPages</code>), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.</li>
|
||||
<li>Outbound adapters/plugins: add shared <code>sendPayload</code> support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.</li>
|
||||
<li>Models/MiniMax: add first-class <code>MiniMax-M2.5-highspeed</code> support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy <code>MiniMax-M2.5-Lightning</code> compatibility for existing configs.</li>
|
||||
<li>Sessions/Attachments: add inline file attachment support for <code>sessions_spawn</code> (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via <code>tools.sessions_spawn.attachments</code>. (#16761) Thanks @napetrov.</li>
|
||||
<li>Telegram/Streaming defaults: default <code>channels.telegram.streaming</code> to <code>partial</code> (from <code>off</code>) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.</li>
|
||||
<li>Telegram/DM streaming: use <code>sendMessageDraft</code> for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.</li>
|
||||
<li>Telegram/voice mention gating: add optional <code>disableAudioPreflight</code> on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.</li>
|
||||
<li>CLI/Config validation: add <code>openclaw config validate</code> (with <code>--json</code>) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.</li>
|
||||
<li>Tools/Diffs: add PDF file output support and rendering quality customization controls (<code>fileQuality</code>, <code>fileScale</code>, <code>fileMaxWidth</code>) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.</li>
|
||||
<li>Memory/Ollama embeddings: add <code>memorySearch.provider = "ollama"</code> and <code>memorySearch.fallback = "ollama"</code> support, honor <code>models.providers.ollama</code> settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.</li>
|
||||
<li>Zalo Personal plugin (<code>@openclaw/zalouser</code>): rebuilt channel runtime to use native <code>zca-js</code> integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.</li>
|
||||
<li>Plugin SDK/channel extensibility: expose <code>channelRuntime</code> on <code>ChannelGatewayContext</code> so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.</li>
|
||||
<li>Plugin runtime/STT: add <code>api.runtime.stt.transcribeAudioFile(...)</code> so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.</li>
|
||||
<li>Plugin hooks/session lifecycle: include <code>sessionKey</code> in <code>session_start</code>/<code>session_end</code> hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.</li>
|
||||
<li>Hooks/message lifecycle: add internal hook events <code>message:transcribed</code> and <code>message:preprocessed</code>, plus richer outbound <code>message:sent</code> context (<code>isGroup</code>, <code>groupId</code>) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.</li>
|
||||
<li>Media understanding/audio echo: add optional <code>tools.media.audio.echoTranscript</code> + <code>echoFormat</code> to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.</li>
|
||||
<li>Plugin runtime/system: expose <code>runtime.system.requestHeartbeatNow(...)</code> so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.</li>
|
||||
<li>Plugin runtime/events: expose <code>runtime.events.onAgentEvent</code> and <code>runtime.events.onSessionTranscriptUpdate</code> for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.</li>
|
||||
<li>CLI/Banner taglines: add <code>cli.banner.taglineMode</code> (<code>random</code> | <code>default</code> | <code>off</code>) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Onboarding now defaults <code>tools.profile</code> to <code>messaging</code> for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.</li>
|
||||
<li><strong>BREAKING:</strong> ACP dispatch now defaults to enabled unless explicitly disabled (<code>acp.dispatch.enabled=false</code>). If you need to pause ACP turn routing while keeping <code>/acp</code> controls, set <code>acp.dispatch.enabled=false</code>. Docs: https://docs.openclaw.ai/tools/acp-agents</li>
|
||||
<li><strong>BREAKING:</strong> Plugin SDK removed <code>api.registerHttpHandler(...)</code>. Plugins must register explicit HTTP routes via <code>api.registerHttpRoute({ path, auth, match, handler })</code>, and dynamic webhook lifecycles should use <code>registerPluginHttpRoute(...)</code>.</li>
|
||||
<li><strong>BREAKING:</strong> Zalo Personal plugin (<code>@openclaw/zalouser</code>) no longer depends on external <code>zca</code>-compatible CLI binaries (<code>openzca</code>, <code>zca-cli</code>) for runtime send/listen/login; operators should use <code>openclaw channels login --channel zalouser</code> after upgrade to refresh sessions in the new JS-native path.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (<code>trim</code> on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing <code>token.trim()</code> crashes during status/start flows. (#31973) Thanks @ningding97.</li>
|
||||
<li>Discord/lifecycle startup status: push an immediate <code>connected</code> status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.</li>
|
||||
<li>Feishu/LINE group system prompts: forward per-group <code>systemPrompt</code> config into inbound context <code>GroupSystemPrompt</code> for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.</li>
|
||||
<li>Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.</li>
|
||||
<li>Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older <code>openclaw/plugin-sdk</code> builds omit webhook default constants. (#31606)</li>
|
||||
<li>Feishu/group broadcast dispatch: add configurable multi-agent group broadcast dispatch with observer-session isolation, cross-account dedupe safeguards, and non-mention history buffering rules that avoid duplicate replay in broadcast/topic workflows. (#29575) Thanks @ohmyskyhigh.</li>
|
||||
<li>Gateway/Subagent TLS pairing: allow authenticated local <code>gateway-client</code> backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring <code>sessions_spawn</code> with <code>gateway.tls.enabled=true</code> in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.</li>
|
||||
<li>Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.</li>
|
||||
<li>Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)</li>
|
||||
<li>Voice-call/runtime lifecycle: prevent <code>EADDRINUSE</code> loops by resetting failed runtime promises, making webhook <code>start()</code> idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob.</li>
|
||||
<li>Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when <code>gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback</code> accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example <code>(a|aa)+</code>), and bound large regex-evaluation inputs for session-filter and log-redaction paths.</li>
|
||||
<li>Gateway/Plugin HTTP hardening: require explicit <code>auth</code> for plugin route registration, add route ownership guards for duplicate <code>path+match</code> registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.</li>
|
||||
<li>Browser/Profile defaults: prefer <code>openclaw</code> profile over <code>chrome</code> in headless/no-sandbox environments unless an explicit <code>defaultProfile</code> is configured. (#14944) Thanks @BenediktSchackenberg.</li>
|
||||
<li>Gateway/WS security: keep plaintext <code>ws://</code> loopback-only by default, with explicit break-glass private-network opt-in via <code>OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1</code>; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.</li>
|
||||
<li>OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit <code>doctor --deep</code>) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.</li>
|
||||
<li>Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.</li>
|
||||
<li>CLI/Config validation and routing hardening: dedupe <code>openclaw config validate</code> failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including <code>--json</code> fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed <code>config get/unset</code> with split root options). Thanks @gumadeiras.</li>
|
||||
<li>Browser/Extension relay reconnect tolerance: keep <code>/json/version</code> and <code>/cdp</code> reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.</li>
|
||||
<li>CLI/Browser start timeout: honor <code>openclaw browser --timeout <ms> start</code> and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.</li>
|
||||
<li>Synology Chat/gateway lifecycle: keep <code>startAccount</code> pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.</li>
|
||||
<li>Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like <code>/usr/bin/g++</code> and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.</li>
|
||||
<li>Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with <code>204</code> to avoid persistent <code>Processing...</code> states in Synology Chat clients. (#26635) Thanks @memphislee09-source.</li>
|
||||
<li>Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.</li>
|
||||
<li>Slack/Bolt startup compatibility: remove invalid <code>message.channels</code> and <code>message.groups</code> event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified <code>message</code> handler (<code>channel_type</code>). (#32033) Thanks @mahopan.</li>
|
||||
<li>Slack/socket auth failure handling: fail fast on non-recoverable auth errors (<code>account_inactive</code>, <code>invalid_auth</code>, etc.) during startup and reconnect instead of retry-looping indefinitely, including <code>unable_to_socket_mode_start</code> error payload propagation. (#32377) Thanks @scoootscooob.</li>
|
||||
<li>Gateway/macOS LaunchAgent hardening: write <code>Umask=077</code> in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.</li>
|
||||
<li>macOS/LaunchAgent security defaults: write <code>Umask=63</code> (octal <code>077</code>) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system <code>022</code>. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from <code>HTTPS_PROXY</code>/<code>HTTP_PROXY</code> env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.</li>
|
||||
<li>Sandbox/workspace mount permissions: make primary <code>/workspace</code> bind mounts read-only whenever <code>workspaceAccess</code> is not <code>rw</code> (including <code>none</code>) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.</li>
|
||||
<li>Tools/fsPolicy propagation: honor <code>tools.fs.workspaceOnly</code> for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.</li>
|
||||
<li>Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like <code>node@22</code>) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.</li>
|
||||
<li>Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.</li>
|
||||
<li>Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded <code>/api/channels/*</code> variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.</li>
|
||||
<li>Browser/Gateway hardening: preserve env credentials for <code>OPENCLAW_GATEWAY_URL</code> / <code>CLAWDBOT_GATEWAY_URL</code> while treating explicit <code>--url</code> as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.</li>
|
||||
<li>Gateway/Control UI basePath webhook passthrough: let non-read methods under configured <code>controlUiBasePath</code> fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.</li>
|
||||
<li>Control UI/Legacy browser compatibility: replace <code>toSorted</code>-dependent cron suggestion sorting in <code>app-render</code> with a compatibility helper so older browsers without <code>Array.prototype.toSorted</code> no longer white-screen. (#31775) Thanks @liuxiaopai-ai.</li>
|
||||
<li>macOS/PeekabooBridge: add compatibility socket symlinks for legacy <code>clawdbot</code>, <code>clawdis</code>, and <code>moltbot</code> Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.</li>
|
||||
<li>Gateway/message tool reliability: avoid false <code>Unknown channel</code> failures when <code>message.*</code> actions receive platform-specific channel ids by falling back to <code>toolContext.currentChannelProvider</code>, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.</li>
|
||||
<li>Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for <code>.cmd</code> shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.</li>
|
||||
<li>Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for <code>sessions_spawn</code> with <code>runtime="acp"</code> by rejecting ACP spawns from sandboxed requester sessions and rejecting <code>sandbox="require"</code> for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.</li>
|
||||
<li>Security/Web tools SSRF guard: keep DNS pinning for untrusted <code>web_fetch</code> and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.</li>
|
||||
<li>Gemini schema sanitization: coerce malformed JSON Schema <code>properties</code> values (<code>null</code>, arrays, primitives) to <code>{}</code> before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.</li>
|
||||
<li>Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.</li>
|
||||
<li>Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.</li>
|
||||
<li>Browser/Extension relay stale tabs: evict stale cached targets from <code>/json/list</code> when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.</li>
|
||||
<li>Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up <code>PortInUseError</code> races after <code>browser start</code>/<code>open</code>. (#29538) Thanks @AaronWander.</li>
|
||||
<li>OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty <code>function_call_output.call_id</code> payloads in the WS conversion path to avoid OpenAI 400 errors (<code>Invalid 'input[n].call_id': empty string</code>), with regression coverage for both inbound stream normalization and outbound payload guards.</li>
|
||||
<li>Security/Nodes camera URL downloads: bind node <code>camera.snap</code>/<code>camera.clip</code> URL payload downloads to the resolved node host, enforce fail-closed behavior when node <code>remoteIp</code> is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.</li>
|
||||
<li>Config/backups hardening: enforce owner-only (<code>0600</code>) permissions on rotated config backups and clean orphan <code>.bak.*</code> files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.</li>
|
||||
<li>Telegram/inbound media filenames: preserve original <code>file_name</code> metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.</li>
|
||||
<li>Gateway/OpenAI chat completions: honor <code>x-openclaw-message-channel</code> when building <code>agentCommand</code> input for <code>/v1/chat/completions</code>, preserving caller channel identity instead of forcing <code>webchat</code>. (#30462) Thanks @bmendonca3.</li>
|
||||
<li>Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.</li>
|
||||
<li>Media/MIME normalization: normalize parameterized/case-variant MIME strings in <code>kindFromMime</code> (for example <code>Audio/Ogg; codecs=opus</code>) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.</li>
|
||||
<li>Discord/audio preflight mentions: detect audio attachments via Discord <code>content_type</code> and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.</li>
|
||||
<li>Feishu/topic session routing: use <code>thread_id</code> as topic session scope fallback when <code>root_id</code> is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.</li>
|
||||
<li>Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of <code>NO_REPLY</code> and keep final-message buffering in sync, preventing partial <code>NO</code> leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.</li>
|
||||
<li>Context-window metadata warmup: add exponential config-load retry backoff (1s -> 2s -> 4s, capped at 60s) so transient startup failures recover automatically without hot-loop retries.</li>
|
||||
<li>Voice-call/Twilio external outbound: auto-register webhook-first <code>outbound-api</code> calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.</li>
|
||||
<li>Feishu/topic root replies: prefer <code>root_id</code> as outbound <code>replyTargetMessageId</code> when present, and parse millisecond <code>message_create_time</code> values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/DM pairing reply target: send pairing challenge replies to <code>chat:<chat_id></code> instead of <code>user:<sender_open_id></code> so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.</li>
|
||||
<li>Feishu/Lark private DM routing: treat inbound <code>chat_type: "private"</code> as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.</li>
|
||||
<li>Signal/message actions: allow <code>react</code> to fall back to <code>toolContext.currentMessageId</code> when <code>messageId</code> is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.</li>
|
||||
<li>Discord/message actions: allow <code>react</code> to fall back to <code>toolContext.currentMessageId</code> when <code>messageId</code> is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.</li>
|
||||
<li>Synology Chat/reply delivery: resolve webhook usernames to Chat API <code>user_id</code> values for outbound chatbot replies, avoiding mismatches between webhook user IDs and <code>method=chatbot</code> recipient IDs in multi-account setups. (#23709) Thanks @druide67.</li>
|
||||
<li>Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.</li>
|
||||
<li>Slack/session routing: keep top-level channel messages in one shared session when <code>replyToMode=off</code>, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.</li>
|
||||
<li>Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.</li>
|
||||
<li>Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.</li>
|
||||
<li>Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (<code>monitor.account-scope.test.ts</code>) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/Send target prefixes: normalize explicit <code>group:</code>/<code>dm:</code> send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Webchat/Feishu session continuation: preserve routable <code>OriginatingChannel</code>/<code>OriginatingTo</code> metadata from session delivery context in <code>chat.send</code>, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)</li>
|
||||
<li>Telegram/implicit mention forum handling: exclude Telegram forum system service messages (<code>forum_topic_*</code>, <code>general_forum_topic_*</code>) from reply-chain implicit mention detection so <code>requireMention</code> does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.</li>
|
||||
<li>Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.</li>
|
||||
<li>Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (<code>provider: "message"</code>) and normalize <code>lark</code>/<code>feishu</code> provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)</li>
|
||||
<li>Webchat/silent token leak: filter assistant <code>NO_REPLY</code>-only transcript entries from <code>chat.history</code> responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.</li>
|
||||
<li>Doctor/local memory provider checks: stop false-positive local-provider warnings when <code>provider=local</code> and no explicit <code>modelPath</code> is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.</li>
|
||||
<li>Media understanding/parakeet CLI output parsing: read <code>parakeet-mlx</code> transcripts from <code>--output-dir/<media-basename>.txt</code> when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.</li>
|
||||
<li>Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.</li>
|
||||
<li>Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.</li>
|
||||
<li>Gateway/Node browser proxy routing: honor <code>profile</code> from <code>browser.request</code> JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.</li>
|
||||
<li>Gateway/Control UI basePath POST handling: return 405 for <code>POST</code> on exact basePath routes (for example <code>/openclaw</code>) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.</li>
|
||||
<li>Browser/default profile selection: default <code>browser.defaultProfile</code> behavior now prefers <code>openclaw</code> (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the <code>chrome</code> relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.</li>
|
||||
<li>Models/config env propagation: apply <code>config.env.vars</code> before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.</li>
|
||||
<li>Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so <code>openclaw models status</code> no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.</li>
|
||||
<li>Gateway/Heartbeat model reload: treat <code>models.*</code> and <code>agents.defaults.model</code> config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.</li>
|
||||
<li>Memory/LanceDB embeddings: forward configured <code>embedding.dimensions</code> into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.</li>
|
||||
<li>Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.</li>
|
||||
<li>Browser/CDP status accuracy: require a successful <code>Browser.getVersion</code> response over the CDP websocket (not just socket-open) before reporting <code>cdpReady</code>, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.</li>
|
||||
<li>Daemon/systemd checks in containers: treat missing <code>systemctl</code> invocations (including <code>spawn systemctl ENOENT</code>/<code>EACCES</code>) as unavailable service state during <code>is-enabled</code> checks, preventing container flows from failing with <code>Gateway service check failed</code> before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.</li>
|
||||
<li>Security/Node exec approvals: revalidate approval-bound <code>cwd</code> identity immediately before execution/forwarding and fail closed with an explicit denial when <code>cwd</code> drifts after approval hardening.</li>
|
||||
<li>Security audit/skills workspace hardening: add <code>skills.workspace.symlink_escape</code> warning in <code>openclaw security audit</code> when workspace <code>skills/**/SKILL.md</code> resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.</li>
|
||||
<li>Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example <code>env sh -c ...</code>) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/fs-safe write hardening: make <code>writeFileWithinRoot</code> use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.</li>
|
||||
<li>Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.</li>
|
||||
<li>Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like <code>[System Message]</code> and line-leading <code>System:</code> in untrusted message content. (#30448)</li>
|
||||
<li>Sandbox/Docker setup command parsing: accept <code>agents.*.sandbox.docker.setupCommand</code> as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction <code>AGENTS.md</code> context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.</li>
|
||||
<li>Agents/Sandbox workdir mapping: map container workdir paths (for example <code>/workspace</code>) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Docker/Sandbox bootstrap hardening: make <code>OPENCLAW_SANDBOX</code> opt-in parsing explicit (<code>1|true|yes|on</code>), support custom Docker socket paths via <code>OPENCLAW_DOCKER_SOCKET</code>, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to <code>off</code> when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.</li>
|
||||
<li>Hooks/webhook ACK compatibility: return <code>200</code> (instead of <code>202</code>) for successful <code>/hooks/agent</code> requests so providers that require <code>200</code> (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.</li>
|
||||
<li>Feishu/Run channel fallback: prefer <code>Provider</code> over <code>Surface</code> when inferring queued run <code>messageProvider</code> fallback (when <code>OriginatingChannel</code> is missing), preventing Feishu turns from being mislabeled as <code>webchat</code> in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Skills/sherpa-onnx-tts: run the <code>sherpa-onnx-tts</code> bin under ESM (replace CommonJS <code>require</code> imports) and add regression coverage to prevent <code>require is not defined in ES module scope</code> startup crashes. (#31965) Thanks @bmendonca3.</li>
|
||||
<li>Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.</li>
|
||||
<li>Slack/Channel message subscriptions: register explicit <code>message.channels</code> and <code>message.groups</code> monitor handlers (alongside generic <code>message</code>) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.</li>
|
||||
<li>Hooks/session-scoped memory context: expose ephemeral <code>sessionId</code> in embedded plugin tool contexts and <code>before_tool_call</code>/<code>after_tool_call</code> hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across <code>/new</code> and <code>/reset</code>. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.</li>
|
||||
<li>Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.</li>
|
||||
<li>Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.</li>
|
||||
<li>Feishu/File upload filenames: percent-encode non-ASCII/special-character <code>file_name</code> values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.</li>
|
||||
<li>Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized <code>kindFromMime</code> so mixed-case/parameterized MIME values classify consistently across message channels.</li>
|
||||
<li>WhatsApp/inbound self-message context: propagate inbound <code>fromMe</code> through the web inbox pipeline and annotate direct self messages as <code>(self)</code> in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.</li>
|
||||
<li>Webchat/stream finalization: persist streamed assistant text when final events omit <code>message</code>, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.</li>
|
||||
<li>Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)</li>
|
||||
<li>Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)</li>
|
||||
<li>Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)</li>
|
||||
<li>Feishu/Streaming block fallback: preserve markdown block stream text as final streaming-card content when final payload text is missing, while still suppressing non-card internal block chunk delivery. (#30663)</li>
|
||||
<li>Feishu/Bitable API errors: unify Feishu Bitable tool error handling with structured <code>LarkApiError</code> responses and consistent API/context attribution across wiki/base metadata, field, and record operations. (#31450)</li>
|
||||
<li>Feishu/Missing-scope grant URL fix: rewrite known invalid scope aliases (<code>contact:contact.base:readonly</code>) to valid scope names in permission grant links, so remediation URLs open with correct Feishu consent scopes. (#31943)</li>
|
||||
<li>BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound <code>message_id</code> selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.</li>
|
||||
<li>WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.</li>
|
||||
<li>Feishu/default account resolution: always honor explicit <code>channels.feishu.defaultAccount</code> during outbound account selection (including top-level-credential setups where the preferred id is not present in <code>accounts</code>), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (<code>contact:contact.base:readonly</code>) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)</li>
|
||||
<li>Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.</li>
|
||||
<li>Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)</li>
|
||||
<li>Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.</li>
|
||||
<li>Browser/Extension re-announce reliability: keep relay state in <code>connecting</code> when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.</li>
|
||||
<li>Browser/Act request compatibility: accept legacy flattened <code>action="act"</code> params (<code>kind/ref/text/...</code>) in addition to <code>request={...}</code> so browser act calls no longer fail with <code>request required</code>. (#15120) Thanks @vincentkoc.</li>
|
||||
<li>OpenRouter/x-ai compatibility: skip <code>reasoning.effort</code> injection for <code>x-ai/*</code> models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.</li>
|
||||
<li>Models/openai-completions developer-role compatibility: force <code>supportsDeveloperRole=false</code> for non-native endpoints, treat unparseable <code>baseUrl</code> values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.</li>
|
||||
<li>Browser/Profile attach-only override: support <code>browser.profiles.<name>.attachOnly</code> (fallback to global <code>browser.attachOnly</code>) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.</li>
|
||||
<li>Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file <code>starttime</code> with <code>/proc/<pid>/stat</code> starttime, so stale <code>.jsonl.lock</code> files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.</li>
|
||||
<li>Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel <code>resolveDefaultTo</code> fallback) when <code>delivery.to</code> is omitted. (#32364) Thanks @hclsys.</li>
|
||||
<li>OpenAI media capabilities: include <code>audio</code> in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.</li>
|
||||
<li>Browser/Managed tab cap: limit loopback managed <code>openclaw</code> page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.</li>
|
||||
<li>Docker/Image health checks: add Dockerfile <code>HEALTHCHECK</code> that probes gateway <code>GET /healthz</code> so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.</li>
|
||||
<li>Gateway/Node dangerous-command parity: include <code>sms.send</code> in default onboarding node <code>denyCommands</code>, share onboarding deny defaults with the gateway dangerous-command source of truth, and include <code>sms.send</code> in phone-control <code>/phone arm writes</code> handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.</li>
|
||||
<li>Pairing/AllowFrom account fallback: handle omitted <code>accountId</code> values in <code>readChannelAllowFromStore</code> and <code>readChannelAllowFromStoreSync</code> as <code>default</code>, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.</li>
|
||||
<li>Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.</li>
|
||||
<li>Browser/CDP proxy bypass: force direct loopback agent paths and scoped <code>NO_PROXY</code> expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Sessions/idle reset correctness: preserve existing <code>updatedAt</code> during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.</li>
|
||||
<li>Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing <code>starttime</code> when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.</li>
|
||||
<li>Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (<code>mtimeMs</code> + <code>sizeBytes</code>), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.</li>
|
||||
<li>Agents/Subagents <code>sessions_spawn</code>: reject malformed <code>agentId</code> inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.</li>
|
||||
<li>CLI/installer Node preflight: enforce Node.js <code>v22.12+</code> consistently in both <code>openclaw.mjs</code> runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.</li>
|
||||
<li>Web UI/config form: support SecretInput string-or-secret-ref unions in map <code>additionalProperties</code>, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.</li>
|
||||
<li>Auto-reply/inline command cleanup: preserve newline structure when stripping inline <code>/status</code> and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.</li>
|
||||
<li>Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like <code>source</code>/<code>provider</code>), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.</li>
|
||||
<li>Hooks/runtime stability: keep the internal hook handler registry on a <code>globalThis</code> singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.</li>
|
||||
<li>Hooks/after_tool_call: include embedded session context (<code>sessionKey</code>, <code>agentId</code>) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.</li>
|
||||
<li>Hooks/tool-call correlation: include <code>runId</code> and <code>toolCallId</code> in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in <code>before_tool_call</code> and <code>after_tool_call</code>. (#32360) Thanks @vincentkoc.</li>
|
||||
<li>Plugins/install diagnostics: reject legacy plugin package shapes without <code>openclaw.extensions</code> and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Hooks/plugin context parity: ensure <code>llm_input</code> hooks in embedded attempts receive the same <code>trigger</code> and <code>channelId</code>-aware <code>hookCtx</code> used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.</li>
|
||||
<li>Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (<code>pnpm</code>, <code>bun</code>) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.</li>
|
||||
<li>Cron/session reaper reliability: move cron session reaper sweeps into <code>onTimer</code> <code>finally</code> and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.</li>
|
||||
<li>Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so <code>HEARTBEAT_OK</code> noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.</li>
|
||||
<li>Authentication: classify <code>permission_error</code> as <code>auth_permanent</code> for profile fallback. (#31324) Thanks @Sid-Qin.</li>
|
||||
<li>Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (<code>newText</code> present and <code>oldText</code> absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.</li>
|
||||
<li>Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example <code>diffs</code> -> bundled <code>@openclaw/diffs</code>), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.</li>
|
||||
<li>Web UI/inline code copy fidelity: disable forced mid-token wraps on inline <code><code></code> spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.</li>
|
||||
<li>Restart sentinel formatting: avoid duplicate <code>Reason:</code> lines when restart message text already matches <code>stats.reason</code>, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.</li>
|
||||
<li>Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.</li>
|
||||
<li>Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.</li>
|
||||
<li>Failover/error classification: treat HTTP <code>529</code> (provider overloaded, common with Anthropic-compatible APIs) as <code>rate_limit</code> so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.</li>
|
||||
<li>Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.</li>
|
||||
<li>Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.</li>
|
||||
<li>Secrets/exec resolver timeout defaults: use provider <code>timeoutMs</code> as the default inactivity (<code>noOutputTimeoutMs</code>) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.</li>
|
||||
<li>Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.</li>
|
||||
<li>Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing <code>HEARTBEAT_OK</code> from being delivered to users. (#32131) Thanks @adhishthite.</li>
|
||||
<li>Cron/store migration: normalize legacy cron jobs with string <code>schedule</code> and top-level <code>command</code>/<code>timeout</code> fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.</li>
|
||||
<li>Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.</li>
|
||||
<li>Tests/Subagent announce: set <code>OPENCLAW_TEST_FAST=1</code> before importing <code>subagent-announce</code> format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.2/OpenClaw-2026.3.2.zip" length="23181513" type="application/octet-stream" sparkle:edSignature="THMgkcoMgz2vv5zse3Po3K7l3Or2RhBKurXZIi8iYVXN76yJy1YXAY6kXi6ovD+dbYn68JKYDIKA1Ya78bO7BQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.3.1</title>
|
||||
<pubDate>Mon, 02 Mar 2026 04:40:59 +0000</pubDate>
|
||||
@@ -140,175 +359,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.1/OpenClaw-2026.3.1.zip" length="12804155" type="application/octet-stream" sparkle:edSignature="TF1otD4Vk3pG0iViX7mvix5DQEgAsk4JkSFvH7opjf9aawV16f29SUa2wRmiCFU6HEgyNgnGI/078O+A27eXCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.15</title>
|
||||
<pubDate>Mon, 16 Feb 2026 05:04:34 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>202602150</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.15</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.15</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.</li>
|
||||
<li>Plugins: expose <code>llm_input</code> and <code>llm_output</code> hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.</li>
|
||||
<li>Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set <code>agents.defaults.subagents.maxSpawnDepth: 2</code> to allow sub-agents to spawn their own children. Includes <code>maxChildrenPerAgent</code> limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.</li>
|
||||
<li>Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.</li>
|
||||
<li>Cron/Gateway: add finished-run webhook delivery toggle (<code>notify</code>) and dedicated webhook auth token support (<code>cron.webhookToken</code>) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.</li>
|
||||
<li>Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.</li>
|
||||
<li>Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.</li>
|
||||
<li>Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.</li>
|
||||
<li>Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.</li>
|
||||
<li>Gateway/Security: redact sensitive session/path details from <code>status</code> responses for non-admin clients; full details remain available to <code>operator.admin</code>. (#8590) Thanks @fr33d3m0n.</li>
|
||||
<li>Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (<code>allowInsecureAuth</code> / <code>dangerouslyDisableDeviceAuth</code>) when device identity is unavailable, preventing false <code>missing scope</code> failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.</li>
|
||||
<li>LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.</li>
|
||||
<li>Skills/Security: restrict <code>download</code> installer <code>targetDir</code> to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.</li>
|
||||
<li>Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.</li>
|
||||
<li>Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.</li>
|
||||
<li>Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving <code>passwordFile</code> path exemptions, preventing accidental redaction of non-secret config values like <code>maxTokens</code> and IRC password-file paths. (#16042) Thanks @akramcodez.</li>
|
||||
<li>Dev tooling: harden git <code>pre-commit</code> hook against option injection from malicious filenames (for example <code>--force</code>), preventing accidental staging of ignored files. Thanks @mrthankyou.</li>
|
||||
<li>Gateway/Agent: reject malformed <code>agent:</code>-prefixed session keys (for example, <code>agent:main</code>) in <code>agent</code> and <code>agent.identity.get</code> instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.</li>
|
||||
<li>Gateway/Chat: harden <code>chat.send</code> inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.</li>
|
||||
<li>Gateway/Send: return an actionable error when <code>send</code> targets internal-only <code>webchat</code>, guiding callers to use <code>chat.send</code> or a deliverable channel. (#15703) Thanks @rodrigouroz.</li>
|
||||
<li>Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing <code>script-src 'self'</code>. Thanks @Adam55A-code.</li>
|
||||
<li>Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.</li>
|
||||
<li>Agents/Sandbox: clarify system prompt path guidance so sandbox <code>bash/exec</code> uses container paths (for example <code>/workspace</code>) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.</li>
|
||||
<li>Agents/Context: apply configured model <code>contextWindow</code> overrides after provider discovery so <code>lookupContextTokens()</code> honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.</li>
|
||||
<li>Agents/Context: derive <code>lookupContextTokens()</code> from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.</li>
|
||||
<li>Agents/OpenAI: force <code>store=true</code> for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.</li>
|
||||
<li>Memory/FTS: make <code>buildFtsQuery</code> Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.</li>
|
||||
<li>Auto-reply/Compaction: resolve <code>memory/YYYY-MM-DD.md</code> placeholders with timezone-aware runtime dates and append a <code>Current time:</code> line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.</li>
|
||||
<li>Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.</li>
|
||||
<li>Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.</li>
|
||||
<li>Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.</li>
|
||||
<li>Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Subagents/Models: preserve <code>agents.defaults.model.fallbacks</code> when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.</li>
|
||||
<li>Telegram: omit <code>message_thread_id</code> for DM sends/draft previews and keep forum-topic handling (<code>id=1</code> general omitted, non-general kept), preventing DM failures with <code>400 Bad Request: message thread not found</code>. (#10942) Thanks @garnetlyx.</li>
|
||||
<li>Telegram: replace inbound <code><media:audio></code> placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.</li>
|
||||
<li>Telegram: retry inbound media <code>getFile</code> calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.</li>
|
||||
<li>Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.</li>
|
||||
<li>Discord: preserve channel session continuity when runtime payloads omit <code>message.channelId</code> by falling back to event/raw <code>channel_id</code> values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as <code>sessionKey=unknown</code>. (#17622) Thanks @shakkernerd.</li>
|
||||
<li>Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with <code>_2</code> suffixes. (#17365) Thanks @seewhyme.</li>
|
||||
<li>Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.</li>
|
||||
<li>Web UI/Agents: hide <code>BOOTSTRAP.md</code> in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.</li>
|
||||
<li>Auto-reply/WhatsApp/TUI/Web: when a final assistant message is <code>NO_REPLY</code> and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show <code>NO_REPLY</code> placeholders. (#7010) Thanks @Morrowind-Xie.</li>
|
||||
<li>Cron: infer <code>payload.kind="agentTurn"</code> for model-only <code>cron.update</code> payload patches, so partial agent-turn updates do not fail validation when <code>kind</code> is omitted. (#15664) Thanks @rodrigouroz.</li>
|
||||
<li>TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.</li>
|
||||
<li>TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.</li>
|
||||
<li>TUI: suppress false <code>(no output)</code> placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.</li>
|
||||
<li>TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.</li>
|
||||
<li>CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.15/OpenClaw-2026.2.15.zip" length="22896513" type="application/octet-stream" sparkle:edSignature="MLGsd2NeHXFRH1Or0bFQnAjqfuuJDuhl1mvKFIqTQcRvwbeyvOyyLXrqSbmaOgJR3wBQBKLs6jYQ9dQ/3R8RCg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.26</title>
|
||||
<pubDate>Thu, 26 Feb 2026 23:37:15 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>202602260</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.26</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.26</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Highlight: External Secrets Management introduces a full <code>openclaw secrets</code> workflow (<code>audit</code>, <code>configure</code>, <code>apply</code>, <code>reload</code>) with runtime snapshot activation, strict <code>secrets apply</code> target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant.</li>
|
||||
<li>ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with <code>acp</code> spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.</li>
|
||||
<li>Agents/Routing CLI: add <code>openclaw agents bindings</code>, <code>openclaw agents bind</code>, and <code>openclaw agents unbind</code> for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in <code>openclaw channels add</code>. (#27195) thanks @gumadeiras.</li>
|
||||
<li>Codex/WebSocket transport: make <code>openai-codex</code> WebSocket-first by default (<code>transport: "auto"</code> with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection.</li>
|
||||
<li>Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional <code>configureInteractive</code> and <code>configureWhenConfigured</code> hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.</li>
|
||||
<li>Android/Nodes: add Android <code>device</code> capability plus <code>device.status</code> and <code>device.info</code> node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus.</li>
|
||||
<li>Android/Nodes: add <code>notifications.list</code> support on Android nodes and expose <code>nodes notifications_list</code> in agent tooling for listing active device notifications. (#27344) thanks @obviyus.</li>
|
||||
<li>Docs/Contributing: add Nimrod Gutman to the maintainer roster in <code>CONTRIBUTING.md</code>. (#27840) Thanks @ngutman.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Telegram/DM allowlist runtime inheritance: enforce <code>dmPolicy: "allowlist"</code> <code>allowFrom</code> requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align <code>openclaw doctor</code> checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Delivery queue/recovery backoff: prevent retry starvation by persisting <code>lastAttemptAt</code> on failed sends and deferring recovery retries until each entry's <code>lastAttemptAt + backoff</code> window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.</li>
|
||||
<li>Google Chat/Lifecycle: keep Google Chat <code>startAccount</code> pending until abort in webhook mode so startup is no longer interpreted as immediate exit, preventing auto-restart loops and webhook-target churn. (#27384) thanks @junsuwhy.</li>
|
||||
<li>Temp dirs/Linux umask: force <code>0700</code> permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so <code>umask 0002</code> installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky.</li>
|
||||
<li>Nextcloud Talk/Lifecycle: keep <code>startAccount</code> pending until abort and stop the webhook monitor on shutdown, preventing <code>EADDRINUSE</code> restart loops when the gateway manages account lifecycle. (#27897)</li>
|
||||
<li>Microsoft Teams/File uploads: acknowledge <code>fileConsent/invoke</code> immediately (<code>invokeResponse</code> before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011.</li>
|
||||
<li>Queue/Drain/Cron reliability: harden lane draining with guaranteed <code>draining</code> flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add <code>/stop</code> queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron <code>agentTurn</code> outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427)</li>
|
||||
<li>Typing/Main reply pipeline: always mark dispatch idle in <code>agent-runner</code> finalization so typing cleanup runs even when dispatcher <code>onIdle</code> does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin.</li>
|
||||
<li>Typing/TTL safety net: add max-duration guardrails to shared typing callbacks so stuck lifecycle edges auto-stop typing indicators even when explicit idle/cleanup signals are missed. (#27428) Thanks @Crpdim.</li>
|
||||
<li>Typing/Cross-channel leakage: unify run-scoped typing suppression for cross-channel/internal-webchat routes, preserve current inbound origin as embedded run message channel context, harden shared typing keepalive with consecutive-failure circuit breaker edge-case handling, and enforce dispatcher completion/idle waits in extension dispatcher callsites (Feishu, Matrix, Mattermost, MSTeams) so typing indicators always clean up on success/error paths. Related: #27647, #27493, #27598. Supersedes/replaces draft PRs: #27640, #27593, #27540.</li>
|
||||
<li>Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded <code>sendChatAction</code> retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Telegram/Webhook startup: clarify webhook config guidance, allow <code>channels.telegram.webhookPort: 0</code> for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo.</li>
|
||||
<li>Browser/Chrome extension handshake: bind relay WS message handling before <code>onopen</code> and add non-blocking <code>connect.challenge</code> response handling for gateway-style handshake frames, avoiding stuck <code>…</code> badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)</li>
|
||||
<li>Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688)</li>
|
||||
<li>Browser/Fill relay + CLI parity: accept <code>act.fill</code> fields without explicit <code>type</code> by defaulting missing/empty <code>type</code> to <code>text</code> in both browser relay route parsing and <code>openclaw browser fill</code> CLI field parsing, so relay calls no longer fail when the model omits field type metadata. Landed from contributor PR #27662 by @Uface11. (#27296) Thanks @Uface11.</li>
|
||||
<li>Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker.</li>
|
||||
<li>Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single <code>mac-*</code> candidate is selected, default to the first connected candidate instead of failing with <code>node required</code> for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.</li>
|
||||
<li>TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674)</li>
|
||||
<li>Hooks/Internal <code>message:sent</code>: forward <code>sessionKey</code> on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal <code>message:sent</code> hooks consistently dispatch with session context, including <code>openclaw agent --deliver</code> runs resumed via <code>--session-id</code> (without explicit <code>--session-key</code>). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.</li>
|
||||
<li>Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602)</li>
|
||||
<li>BlueBubbles/SSRF: auto-allowlist the configured <code>serverUrl</code> hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting.</li>
|
||||
<li>Agents/Compaction + onboarding safety: prevent destructive double-compaction by stripping stale assistant usage around compaction boundaries, skipping post-compaction custom metadata writes in the same attempt, and cancelling safeguard compaction when there are no real conversation messages to summarize; harden workspace/bootstrap detection for memory-backed workspaces; and change <code>openclaw onboard --reset</code> default scope to <code>config+creds+sessions</code> (workspace deletion now requires <code>--reset-scope full</code>). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.</li>
|
||||
<li>NO_REPLY suppression: suppress <code>NO_REPLY</code> before Slack API send and in sub-agent announce completion flow so sentinel text no longer leaks into user channels. Landed from contributor PRs #27529 (by @Sid-Qin) and #27535 (rewritten minimal landing by maintainers). (#27387, #27531)</li>
|
||||
<li>Matrix/Group sender identity: preserve sender labels in Matrix group inbound prompt text (<code>BodyForAgent</code>) for both channel and threaded messages, and align group envelopes with shared inbound sender-prefix formatting so first-person requests resolve against the current sender. (#27401) thanks @koushikxd.</li>
|
||||
<li>Auto-reply/Streaming: suppress only exact <code>NO_REPLY</code> final replies while still filtering streaming partial sentinel fragments (<code>NO_</code>, <code>NO_RE</code>, <code>HEARTBEAT_...</code>) so substantive replies ending with <code>NO_REPLY</code> are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.</li>
|
||||
<li>Auto-reply/Inbound metadata: add a readable <code>timestamp</code> field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy.</li>
|
||||
<li>Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding <code>triggerTyping()</code> with <code>runComplete</code>, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Typing/Dispatch idle: force typing cleanup when <code>markDispatchIdle</code> never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493)</li>
|
||||
<li>Telegram/Inline buttons: allow callback-query button handling in groups (including <code>/models</code> follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.</li>
|
||||
<li>Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example <code>no</code> before <code>no problem</code>). (#27449) Thanks @emanuelst for the original fix direction in #19673.</li>
|
||||
<li>Browser/Extension relay CORS: handle <code>/json*</code> <code>OPTIONS</code> preflight before auth checks, allow Chrome extension origins, and return extension-origin CORS headers on relay HTTP responses so extension token validation no longer fails cross-origin. Landed from contributor PR #23962 by @miloudbelarebia. (#23842)</li>
|
||||
<li>Browser/Extension relay auth: allow <code>?token=</code> query-param auth on relay <code>/json*</code> endpoints (consistent with relay WebSocket auth) so curl/devtools-style <code>/json/version</code> and <code>/json/list</code> probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)</li>
|
||||
<li>Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay <code>stop()</code> before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.</li>
|
||||
<li>Browser/Extension relay reconnect resilience: keep CDP clients alive across brief MV3 extension disconnect windows, wait briefly for extension reconnect before failing in-flight CDP commands, and only tear down relay target/client state after reconnect grace expires. Landed from contributor PR #27617 by @davidemanuelDEV.</li>
|
||||
<li>Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted <code>%</code> paths return <code>400</code> instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.</li>
|
||||
<li>Feishu/Inbound message metadata: include inbound <code>message_id</code> in <code>BodyForAgent</code> on a dedicated metadata line so agents can reliably correlate and act on media/message operations that require message IDs, with regression coverage. (#27253) thanks @xss925175263.</li>
|
||||
<li>Feishu/Doc tools: route <code>feishu_doc</code> and <code>feishu_app_scopes</code> through the active agent account context (with explicit <code>accountId</code> override support) so multi-account agents no longer default to the first configured app, with regression coverage for context routing and explicit override behavior. (#27338) thanks @AaronL725.</li>
|
||||
<li>LINE/Inline directives auth: gate directive parsing (<code>/model</code>, <code>/think</code>, <code>/verbose</code>, <code>/reasoning</code>, <code>/queue</code>) on resolved authorization (<code>command.isAuthorizedSender</code>) so <code>commands.allowFrom</code>-authorized LINE senders are not silently stripped when raw <code>CommandAuthorized</code> is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240)</li>
|
||||
<li>Onboarding/Gateway: seed default Control UI <code>allowedOrigins</code> for non-loopback binds during onboarding (<code>localhost</code>/<code>127.0.0.1</code> plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.</li>
|
||||
<li>Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during <code>pnpm install</code>, reuse existing gateway token during <code>docker-setup.sh</code> reruns so <code>.env</code> stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego.</li>
|
||||
<li>CLI/Gateway <code>--force</code> in non-root Docker: recover from <code>lsof</code> permission failures (<code>EACCES</code>/<code>EPERM</code>) by falling back to <code>fuser</code> kill + probe-based port checks, so <code>openclaw gateway --force</code> works for default container <code>node</code> user flows. (#27941)</li>
|
||||
<li>Gateway/Bind visibility: emit a startup warning when binding to non-loopback addresses so operators get explicit exposure guidance in runtime logs. (#25397) thanks @let5sne.</li>
|
||||
<li>Sessions cleanup/Doctor: add <code>openclaw sessions cleanup --fix-missing</code> to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422)</li>
|
||||
<li>Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so <code>openclaw doctor</code> no longer reports false-positive transcript-missing warnings for <code>*:slash:*</code> keys. (#27375) thanks @gumadeiras.</li>
|
||||
<li>CLI/Gateway status: force local <code>gateway status</code> probe host to <code>127.0.0.1</code> for <code>bind=lan</code> so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.</li>
|
||||
<li>CLI/Gateway auth: align <code>gateway run --auth</code> parsing/help text with supported gateway auth modes by accepting <code>none</code> and <code>trusted-proxy</code> (in addition to <code>token</code>/<code>password</code>) for CLI overrides. (#27469) thanks @s1korrrr.</li>
|
||||
<li>CLI/Daemon status TLS probe: use <code>wss://</code> and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so <code>openclaw daemon status</code> works with <code>gateway.bind=lan</code> + <code>gateway.tls.enabled=true</code>. (#24234) thanks @liuy.</li>
|
||||
<li>Podman/Default bind: change <code>run-openclaw-podman.sh</code> default gateway bind from <code>lan</code> to <code>loopback</code> and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla.</li>
|
||||
<li>Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent <code>KeepAlive=true</code> semantics, and harden restart sequencing to <code>print -> bootout -> wait old pid exit -> bootstrap -> kickstart</code>. (#27276) thanks @frankekn.</li>
|
||||
<li>Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before <code>/restart</code> launchctl/systemctl triggers, and set LaunchAgent <code>ThrottleInterval=60</code> to bound launchd retry storms during lock-release races. Landed from contributor PRs #27655 (@taw0002), #27448 (@Sid-Qin), and #27650 (@kevinWangSheng). (#27605, #27590, #26904, #26736)</li>
|
||||
<li>Models/MiniMax auth header defaults: set <code>authHeader: true</code> for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (<code>minimax</code>, <code>minimax-portal</code>) provider templates so first requests no longer fail with MiniMax <code>401 authentication_error</code> due to missing <code>Authorization</code> header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)</li>
|
||||
<li>Auth/Auth profiles: normalize <code>auth-profiles.json</code> alias fields (<code>mode -> type</code>, <code>apiKey -> key</code>) before credential validation so entries copied from <code>openclaw.json</code> auth examples are no longer silently dropped. (#26950) thanks @byungsker.</li>
|
||||
<li>Models/Profile suffix parsing: centralize trailing <code>@profile</code> parsing and only treat <code>@</code> as a profile separator when it appears after the final <code>/</code>, preserving model IDs like <code>openai/@cf/...</code> and <code>openrouter/@preset/...</code> across <code>/model</code> directive parsing and allowlist model resolution, with regression coverage.</li>
|
||||
<li>Models/OpenAI Codex config schema parity: accept <code>openai-codex-responses</code> in the config model API schema and TypeScript <code>ModelApi</code> union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli.</li>
|
||||
<li>Agents/Models config: preserve agent-level provider <code>apiKey</code> and <code>baseUrl</code> during merge-mode <code>models.json</code> updates when agent values are present. (#27293) thanks @Sid-Qin.</li>
|
||||
<li>Azure OpenAI Responses: force <code>store=true</code> for <code>azure-openai-responses</code> direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)</li>
|
||||
<li>Security/Node exec approvals: require structured <code>commandArgv</code> approvals for <code>host=node</code>, enforce versioned <code>systemRunBindingV1</code> matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add <code>GIT_EXTERNAL_DIFF</code> to blocked host env keys. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Plugin channel HTTP auth: normalize protected <code>/api/channels</code> path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed <code>%</code>-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Security/Gateway node pairing: pin paired-device <code>platform</code>/<code>deviceFamily</code> metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (<code>2026.2.26</code>). Thanks @76embiid21 for reporting.</li>
|
||||
<li>Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only <code>apply_patch</code> writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Config includes: harden <code>$include</code> file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Security/Node exec approvals hardening: freeze immutable approval-time execution plans (<code>argv</code>/<code>cwd</code>/<code>agentId</code>/<code>sessionKey</code>) via <code>system.run.prepare</code>, enforce those canonical plan values during approval forwarding/execution, and reject mutable parent-symlink cwd paths during approval-plan building to prevent approval bypass via symlink rebind. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned <code>i-twilio-idempotency-token</code> trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.</li>
|
||||
<li>Security/Pairing multi-account isolation: enforce account-scoped pairing allowlists and pending-request storage across core + extension message channels while preserving channel-scoped defaults for the default account. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting and @gumadeiras for implementation.</li>
|
||||
<li>Config/Plugins entries: treat unknown <code>plugins.entries.*</code> ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455)</li>
|
||||
<li>Telegram native commands: degrade command registration on <code>BOT_COMMANDS_TOO_MUCH</code> by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)</li>
|
||||
<li>Web tools/Proxy: route <code>web_search</code> provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and <code>web_fetch</code> through a shared proxy-aware SSRF guard path so gateway installs behind <code>HTTP_PROXY</code>/<code>HTTPS_PROXY</code>/<code>ALL_PROXY</code> no longer fail with transport <code>fetch failed</code> errors. (#27430) thanks @kevinWangSheng.</li>
|
||||
<li>Android/Node invoke: remove native gateway WebSocket <code>Origin</code> header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.</li>
|
||||
<li>Gateway shared-auth scopes: preserve requested operator scopes for shared-token clients when device identity is unavailable, instead of clearing scopes during auth handling. Landed from contributor PR #27498 by @kevinWangSheng. (#27494)</li>
|
||||
<li>Cron/Hooks isolated routing: preserve canonical <code>agent:*</code> session keys in isolated runs so already-qualified keys are not double-prefixed (for example <code>agent:main:main</code> no longer becomes <code>agent:main:agent:main:main</code>). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)</li>
|
||||
<li>Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into <code>channels.<channel>.accounts.default</code> before writing the new account so the original account keeps working without duplicated account values at channel root; <code>openclaw doctor --fix</code> now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.</li>
|
||||
<li>iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman.</li>
|
||||
<li>CI/Windows: shard the Windows <code>checks-windows</code> test lane into two matrix jobs and honor explicit shard index overrides in <code>scripts/test-parallel.mjs</code> to reduce CI critical-path wall time. (#27234) Thanks @joshavant.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.26/OpenClaw-2026.2.26.zip" length="12796628" type="application/octet-stream" sparkle:edSignature="qqVJfkQS9Q4LCTlGtOyXzORWZWWnOkWyiJ6DVX27oPF8aeUlUyfHrmb51sFiNjSuCJC2xmJW1Mi1CAHl/I1pCw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -33,10 +33,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executor
|
||||
@@ -101,7 +98,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(params) ?: 1600
|
||||
@@ -167,7 +164,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(params) ?: true
|
||||
@@ -293,20 +290,8 @@ class CameraCaptureManager(private val context: Context) {
|
||||
return rotated
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
|
||||
private fun parseFacing(params: JsonObject?): String? {
|
||||
val value = readPrimitive(params, "facing")?.contentOrNull?.trim()?.lowercase() ?: return null
|
||||
val value = parseJsonString(params, "facing")?.trim()?.lowercase() ?: return null
|
||||
return when (value) {
|
||||
"front", "back" -> value
|
||||
else -> null
|
||||
@@ -314,31 +299,21 @@ class CameraCaptureManager(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun parseQuality(params: JsonObject?): Double? =
|
||||
readPrimitive(params, "quality")?.contentOrNull?.toDoubleOrNull()
|
||||
parseJsonDouble(params, "quality")
|
||||
|
||||
private fun parseMaxWidth(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "maxWidth")
|
||||
?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
parseJsonInt(params, "maxWidth")
|
||||
?.takeIf { it > 0 }
|
||||
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
parseJsonInt(params, "durationMs")
|
||||
|
||||
private fun parseDeviceId(params: JsonObject?): String? =
|
||||
readPrimitive(params, "deviceId")
|
||||
?.contentOrNull
|
||||
parseJsonString(params, "deviceId")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
|
||||
|
||||
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
|
||||
|
||||
|
||||
@@ -44,6 +44,14 @@ class CanvasController {
|
||||
return (q * 100.0).toInt().coerceIn(1, 100)
|
||||
}
|
||||
|
||||
private fun Bitmap.scaleForMaxWidth(maxWidth: Int?): Bitmap {
|
||||
if (maxWidth == null || maxWidth <= 0 || width <= maxWidth) {
|
||||
return this
|
||||
}
|
||||
val scaledHeight = (height.toDouble() * (maxWidth.toDouble() / width.toDouble())).toInt().coerceAtLeast(1)
|
||||
return scale(maxWidth, scaledHeight)
|
||||
}
|
||||
|
||||
fun attach(webView: WebView) {
|
||||
this.webView = webView
|
||||
reload()
|
||||
@@ -148,13 +156,7 @@ class CanvasController {
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||
bmp.scale(maxWidth, h)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
@@ -165,13 +167,7 @@ class CanvasController {
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||
bmp.scale(maxWidth, h)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
val (compressFormat, compressQuality) =
|
||||
|
||||
@@ -248,30 +248,37 @@ private object SystemContactsDataSource : ContactsDataSource {
|
||||
}
|
||||
|
||||
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
resolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.CommonDataKinds.Phone.CONTACT_ID}=?",
|
||||
arrayOf(contactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
while (cursor.moveToNext()) {
|
||||
val value = cursor.getString(0)?.trim().orEmpty()
|
||||
if (value.isNotEmpty()) out += value
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
return queryContactValues(
|
||||
resolver = resolver,
|
||||
contentUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
valueColumn = ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
contactIdColumn = ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
|
||||
contactId = contactId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadEmails(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS)
|
||||
return queryContactValues(
|
||||
resolver = resolver,
|
||||
contentUri = ContactsContract.CommonDataKinds.Email.CONTENT_URI,
|
||||
valueColumn = ContactsContract.CommonDataKinds.Email.ADDRESS,
|
||||
contactIdColumn = ContactsContract.CommonDataKinds.Email.CONTACT_ID,
|
||||
contactId = contactId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun queryContactValues(
|
||||
resolver: ContentResolver,
|
||||
contentUri: android.net.Uri,
|
||||
valueColumn: String,
|
||||
contactIdColumn: String,
|
||||
contactId: Long,
|
||||
): List<String> {
|
||||
val projection = arrayOf(valueColumn)
|
||||
resolver.query(
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
|
||||
contentUri,
|
||||
projection,
|
||||
"${ContactsContract.CommonDataKinds.Email.CONTACT_ID}=?",
|
||||
"$contactIdColumn=?",
|
||||
arrayOf(contactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
@@ -33,6 +34,21 @@ data class DeviceNotificationEntry(
|
||||
val isClearable: Boolean,
|
||||
)
|
||||
|
||||
internal fun DeviceNotificationEntry.toJsonObject(): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("key", JsonPrimitive(key))
|
||||
put("packageName", JsonPrimitive(packageName))
|
||||
put("postTimeMs", JsonPrimitive(postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(isOngoing))
|
||||
put("isClearable", JsonPrimitive(isClearable))
|
||||
title?.let { put("title", JsonPrimitive(it)) }
|
||||
text?.let { put("text", JsonPrimitive(it)) }
|
||||
subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
category?.let { put("category", JsonPrimitive(it)) }
|
||||
channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
data class DeviceNotificationSnapshot(
|
||||
val enabled: Boolean,
|
||||
val connected: Boolean,
|
||||
|
||||
@@ -10,7 +10,6 @@ import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawPhotosCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSystemCommand
|
||||
@@ -146,7 +145,9 @@ class InvokeDispatcher(
|
||||
OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson)
|
||||
|
||||
// Photos command
|
||||
OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(paramsJson)
|
||||
ai.openclaw.android.protocol.OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(
|
||||
paramsJson,
|
||||
)
|
||||
|
||||
// Contacts command
|
||||
OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleContactsSearch(paramsJson)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
|
||||
|
||||
@@ -21,6 +23,35 @@ fun String.toJsonString(): String {
|
||||
|
||||
fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun readJsonPrimitive(params: JsonObject?, key: String): JsonPrimitive? = params?.get(key) as? JsonPrimitive
|
||||
|
||||
fun parseJsonInt(params: JsonObject?, key: String): Int? =
|
||||
readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
|
||||
|
||||
fun parseJsonDouble(params: JsonObject?, key: String): Double? =
|
||||
readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
fun parseJsonString(params: JsonObject?, key: String): String? =
|
||||
readJsonPrimitive(params, key)?.contentOrNull
|
||||
|
||||
fun parseJsonBooleanFlag(params: JsonObject?, key: String): Boolean? {
|
||||
val value = readJsonPrimitive(params, key)?.contentOrNull?.trim()?.lowercase() ?: return null
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
|
||||
@@ -131,20 +131,7 @@ class NotificationsHandler private constructor(
|
||||
put(
|
||||
"notifications",
|
||||
JsonArray(
|
||||
snapshot.notifications.map { entry ->
|
||||
buildJsonObject {
|
||||
put("key", JsonPrimitive(entry.key))
|
||||
put("packageName", JsonPrimitive(entry.packageName))
|
||||
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(entry.isOngoing))
|
||||
put("isClearable", JsonPrimitive(entry.isClearable))
|
||||
entry.title?.let { put("title", JsonPrimitive(it)) }
|
||||
entry.text?.let { put("text", JsonPrimitive(it)) }
|
||||
entry.subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
entry.category?.let { put("category", JsonPrimitive(it)) }
|
||||
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}
|
||||
},
|
||||
snapshot.notifications.map { entry -> entry.toJsonObject() },
|
||||
),
|
||||
)
|
||||
}.toString()
|
||||
|
||||
@@ -10,10 +10,7 @@ import ai.openclaw.android.ScreenCaptureRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -39,7 +36,7 @@ class ScreenRecordManager(private val context: Context) {
|
||||
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
|
||||
)
|
||||
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000)
|
||||
val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0)
|
||||
val fpsInt = fps.roundToInt().coerceIn(1, 60)
|
||||
@@ -146,38 +143,19 @@ class ScreenRecordManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
parseJsonInt(params, "durationMs")
|
||||
|
||||
private fun parseFps(params: JsonObject?): Double? =
|
||||
readPrimitive(params, "fps")?.contentOrNull?.toDoubleOrNull()
|
||||
parseJsonDouble(params, "fps")
|
||||
|
||||
private fun parseScreenIndex(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "screenIndex")?.contentOrNull?.toIntOrNull()
|
||||
parseJsonInt(params, "screenIndex")
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
|
||||
|
||||
private fun parseString(params: JsonObject?, key: String): String? =
|
||||
readPrimitive(params, key)?.contentOrNull
|
||||
parseJsonString(params, key)
|
||||
|
||||
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
|
||||
val pixels = width.toLong() * height.toLong()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package ai.openclaw.android.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal data class Base64ImageState(
|
||||
val image: ImageBitmap?,
|
||||
val failed: Boolean,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun rememberBase64ImageState(base64: String): Base64ImageState {
|
||||
var image by remember(base64) { mutableStateOf<ImageBitmap?>(null) }
|
||||
var failed by remember(base64) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(base64) {
|
||||
failed = false
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image == null) failed = true
|
||||
}
|
||||
|
||||
return Base64ImageState(image = image, failed = failed)
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package ai.openclaw.android.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -20,15 +18,10 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -47,8 +40,6 @@ import ai.openclaw.android.ui.mobileCaption1
|
||||
import ai.openclaw.android.ui.mobileCodeBg
|
||||
import ai.openclaw.android.ui.mobileCodeText
|
||||
import ai.openclaw.android.ui.mobileTextSecondary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.commonmark.Extension
|
||||
import org.commonmark.ext.autolink.AutolinkExtension
|
||||
import org.commonmark.ext.gfm.strikethrough.Strikethrough
|
||||
@@ -555,23 +546,8 @@ private data class ParsedDataImage(
|
||||
|
||||
@Composable
|
||||
private fun InlineBase64Image(base64: String, mimeType: String?) {
|
||||
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||
var failed by remember(base64) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(base64) {
|
||||
failed = false
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image == null) failed = true
|
||||
}
|
||||
val imageState = rememberBase64ImageState(base64)
|
||||
val image = imageState.image
|
||||
|
||||
if (image != null) {
|
||||
Image(
|
||||
@@ -580,7 +556,7 @@ private fun InlineBase64Image(base64: String, mimeType: String?) {
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else if (failed) {
|
||||
} else if (imageState.failed) {
|
||||
Text(
|
||||
text = "Image unavailable",
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package ai.openclaw.android.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -16,16 +14,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -51,8 +44,6 @@ import ai.openclaw.android.ui.mobileTextSecondary
|
||||
import ai.openclaw.android.ui.mobileWarning
|
||||
import ai.openclaw.android.ui.mobileWarningSoft
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private data class ChatBubbleStyle(
|
||||
val alignEnd: Boolean,
|
||||
@@ -241,23 +232,8 @@ private fun roleLabel(role: String): String {
|
||||
|
||||
@Composable
|
||||
private fun ChatBase64Image(base64: String, mimeType: String?) {
|
||||
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||
var failed by remember(base64) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(base64) {
|
||||
failed = false
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image == null) failed = true
|
||||
}
|
||||
val imageState = rememberBase64ImageState(base64)
|
||||
val image = imageState.image
|
||||
|
||||
if (image != null) {
|
||||
Surface(
|
||||
@@ -273,7 +249,7 @@ private fun ChatBase64Image(base64: String, mimeType: String?) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else if (failed) {
|
||||
} else if (imageState.failed) {
|
||||
Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ package ai.openclaw.android.gateway
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Response
|
||||
@@ -27,6 +29,10 @@ import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private const val TEST_TIMEOUT_MS = 8_000L
|
||||
private const val CONNECT_CHALLENGE_FRAME =
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
|
||||
|
||||
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
private val tokens = mutableMapOf<String, String>()
|
||||
|
||||
@@ -37,530 +43,301 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
}
|
||||
}
|
||||
|
||||
private data class NodeHarness(
|
||||
val session: GatewaySession,
|
||||
val sessionJob: Job,
|
||||
)
|
||||
|
||||
private data class InvokeScenarioResult(
|
||||
val request: GatewaySession.InvokeRequest,
|
||||
val resultParams: JsonObject,
|
||||
)
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GatewaySessionInvokeTest {
|
||||
@Test
|
||||
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val handshakeOrigin = AtomicReference<String?>(null)
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
handshakeOrigin.compareAndSet(null, request.getHeader("Origin"))
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
val result =
|
||||
runInvokeScenario(
|
||||
invokeEventFrame =
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
|
||||
onHandshake = { request -> handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) },
|
||||
) {
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-1", req.id)
|
||||
assertEquals("node-1", req.nodeId)
|
||||
assertEquals("debug.ping", req.command)
|
||||
assertEquals("""{"ping":"pong"}""", req.paramsJson)
|
||||
assertNull(handshakeOrigin.get())
|
||||
assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
true,
|
||||
resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
assertEquals("invoke-1", result.request.id)
|
||||
assertEquals("node-1", result.request.nodeId)
|
||||
assertEquals("debug.ping", result.request.command)
|
||||
assertEquals("""{"ping":"pong"}""", result.request.paramsJson)
|
||||
assertNull(handshakeOrigin.get())
|
||||
assertEquals("invoke-1", result.resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-1", result.resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
true,
|
||||
result.resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
val result =
|
||||
runInvokeScenario(
|
||||
invokeEventFrame =
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
|
||||
) {
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-2", req.id)
|
||||
assertEquals("node-2", req.nodeId)
|
||||
assertEquals("debug.raw", req.command)
|
||||
assertEquals("""{"raw":true}""", req.paramsJson)
|
||||
assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
assertEquals("invoke-2", result.request.id)
|
||||
assertEquals("node-2", result.request.nodeId)
|
||||
assertEquals("debug.raw", result.request.command)
|
||||
assertEquals("""{"raw":true}""", result.request.paramsJson)
|
||||
assertEquals("invoke-2", result.resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-2", result.resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
val result =
|
||||
runInvokeScenario(
|
||||
invokeEventFrame =
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
|
||||
) {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
"CAMERA_PERMISSION_REQUIRED",
|
||||
resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"grant Camera permission",
|
||||
resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
assertEquals("invoke-3", result.resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-3", result.resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(false, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
"CAMERA_PERMISSION_REQUIRED",
|
||||
result.resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"grant Camera permission",
|
||||
result.resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val json = testJson()
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val refreshRequestParams = CompletableDeferred<String?>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasHostUrl":"http://127.0.0.1/__openclaw__/cap/old-cap","snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
}
|
||||
"node.canvas.capability.refresh" -> {
|
||||
if (!refreshRequestParams.isCompleted) {
|
||||
refreshRequestParams.complete(frame["params"]?.toString())
|
||||
}
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
val server =
|
||||
startGatewayServer(json) { webSocket, id, method, frame ->
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(connectResponseFrame(id, canvasHostUrl = "http://127.0.0.1/__openclaw__/cap/old-cap"))
|
||||
}
|
||||
start()
|
||||
"node.canvas.capability.refresh" -> {
|
||||
if (!refreshRequestParams.isCompleted) {
|
||||
refreshRequestParams.complete(frame["params"]?.toString())
|
||||
}
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
|
||||
)
|
||||
val harness =
|
||||
createNodeHarness(
|
||||
connected = connected,
|
||||
lastDisconnect = lastDisconnect,
|
||||
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
connectNodeSession(harness.session, server.port)
|
||||
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val refreshed = session.refreshNodeCanvasCapability(timeoutMs = 8_000)
|
||||
val refreshParamsJson = withTimeout(8_000) { refreshRequestParams.await() }
|
||||
val refreshed = harness.session.refreshNodeCanvasCapability(timeoutMs = TEST_TIMEOUT_MS)
|
||||
val refreshParamsJson = withTimeout(TEST_TIMEOUT_MS) { refreshRequestParams.await() }
|
||||
|
||||
assertEquals(true, refreshed)
|
||||
assertEquals("{}", refreshParamsJson)
|
||||
assertEquals(
|
||||
"http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
|
||||
session.currentCanvasHostUrl(),
|
||||
harness.session.currentCanvasHostUrl(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
shutdownHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
private fun testJson(): Json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun createNodeHarness(
|
||||
connected: CompletableDeferred<Unit>,
|
||||
lastDisconnect: AtomicReference<String>,
|
||||
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
|
||||
): NodeHarness {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = InMemoryDeviceAuthStore(),
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = onInvoke,
|
||||
)
|
||||
|
||||
return NodeHarness(session = session, sessionJob = sessionJob)
|
||||
}
|
||||
|
||||
private suspend fun connectNodeSession(session: GatewaySession, port: Int) {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|$port",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun awaitConnectedOrThrow(
|
||||
connected: CompletableDeferred<Unit>,
|
||||
lastDisconnect: AtomicReference<String>,
|
||||
server: MockWebServer,
|
||||
) {
|
||||
val connectedWithinTimeout =
|
||||
withTimeoutOrNull(TEST_TIMEOUT_MS) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun shutdownHarness(harness: NodeHarness, server: MockWebServer) {
|
||||
harness.session.disconnect()
|
||||
harness.sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private suspend fun runInvokeScenario(
|
||||
invokeEventFrame: String,
|
||||
onHandshake: ((RecordedRequest) -> Unit)? = null,
|
||||
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
|
||||
): InvokeScenarioResult {
|
||||
val json = testJson()
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
startGatewayServer(
|
||||
json = json,
|
||||
onHandshake = onHandshake,
|
||||
) { webSocket, id, method, frame ->
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(connectResponseFrame(id))
|
||||
webSocket.send(invokeEventFrame)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
val harness =
|
||||
createNodeHarness(
|
||||
connected = connected,
|
||||
lastDisconnect = lastDisconnect,
|
||||
) { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
onInvoke(req)
|
||||
}
|
||||
|
||||
try {
|
||||
connectNodeSession(harness.session, server.port)
|
||||
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||
val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(TEST_TIMEOUT_MS) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
return InvokeScenarioResult(request = request, resultParams = resultParams)
|
||||
} finally {
|
||||
shutdownHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectResponseFrame(id: String, canvasHostUrl: String? = null): String {
|
||||
val canvas = canvasHostUrl?.let { "\"canvasHostUrl\":\"$it\"," } ?: ""
|
||||
return """{"type":"res","id":"$id","ok":true,"payload":{$canvas"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
|
||||
}
|
||||
|
||||
private fun startGatewayServer(
|
||||
json: Json,
|
||||
onHandshake: ((RecordedRequest) -> Unit)? = null,
|
||||
onRequestFrame: (webSocket: WebSocket, id: String, method: String, frame: JsonObject) -> Unit,
|
||||
): MockWebServer =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
onHandshake?.invoke(request)
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(CONNECT_CHALLENGE_FRAME)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
onRequestFrame(webSocket, id, method, frame)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,8 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CalendarHandlerTest {
|
||||
class CalendarHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@Test
|
||||
fun handleCalendarEvents_requiresPermission() {
|
||||
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false))
|
||||
@@ -83,8 +79,6 @@ class CalendarHandlerTest {
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CALENDAR_NOT_FOUND", result.error?.code)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeCalendarDataSource(
|
||||
|
||||
@@ -9,12 +9,8 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ContactsHandlerTest {
|
||||
class ContactsHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@Test
|
||||
fun handleContactsSearch_requiresReadPermission() {
|
||||
val handler = ContactsHandler.forTesting(appContext(), FakeContactsDataSource(canRead = false))
|
||||
@@ -92,8 +88,6 @@ class ContactsHandlerTest {
|
||||
assertEquals("Grace Hopper", contact.getValue("displayName").jsonPrimitive.content)
|
||||
assertEquals(1, source.addCalls)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeContactsDataSource(
|
||||
|
||||
@@ -16,144 +16,106 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeCommandRegistryTest {
|
||||
private val coreCapabilities =
|
||||
setOf(
|
||||
OpenClawCapability.Canvas.rawValue,
|
||||
OpenClawCapability.Screen.rawValue,
|
||||
OpenClawCapability.Device.rawValue,
|
||||
OpenClawCapability.Notifications.rawValue,
|
||||
OpenClawCapability.System.rawValue,
|
||||
OpenClawCapability.AppUpdate.rawValue,
|
||||
OpenClawCapability.Photos.rawValue,
|
||||
OpenClawCapability.Contacts.rawValue,
|
||||
OpenClawCapability.Calendar.rawValue,
|
||||
)
|
||||
|
||||
private val optionalCapabilities =
|
||||
setOf(
|
||||
OpenClawCapability.Camera.rawValue,
|
||||
OpenClawCapability.Location.rawValue,
|
||||
OpenClawCapability.Sms.rawValue,
|
||||
OpenClawCapability.VoiceWake.rawValue,
|
||||
OpenClawCapability.Motion.rawValue,
|
||||
)
|
||||
|
||||
private val coreCommands =
|
||||
setOf(
|
||||
OpenClawDeviceCommand.Status.rawValue,
|
||||
OpenClawDeviceCommand.Info.rawValue,
|
||||
OpenClawDeviceCommand.Permissions.rawValue,
|
||||
OpenClawDeviceCommand.Health.rawValue,
|
||||
OpenClawNotificationsCommand.List.rawValue,
|
||||
OpenClawNotificationsCommand.Actions.rawValue,
|
||||
OpenClawSystemCommand.Notify.rawValue,
|
||||
OpenClawPhotosCommand.Latest.rawValue,
|
||||
OpenClawContactsCommand.Search.rawValue,
|
||||
OpenClawContactsCommand.Add.rawValue,
|
||||
OpenClawCalendarCommand.Events.rawValue,
|
||||
OpenClawCalendarCommand.Add.rawValue,
|
||||
"app.update",
|
||||
)
|
||||
|
||||
private val optionalCommands =
|
||||
setOf(
|
||||
OpenClawCameraCommand.Snap.rawValue,
|
||||
OpenClawCameraCommand.Clip.rawValue,
|
||||
OpenClawCameraCommand.List.rawValue,
|
||||
OpenClawLocationCommand.Get.rawValue,
|
||||
OpenClawMotionCommand.Activity.rawValue,
|
||||
OpenClawMotionCommand.Pedometer.rawValue,
|
||||
OpenClawSmsCommand.Send.rawValue,
|
||||
)
|
||||
|
||||
private val debugCommands = setOf("debug.logs", "debug.ed25519")
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_respectsFeatureAvailability() {
|
||||
val capabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = false,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags())
|
||||
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Location.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Motion.rawValue))
|
||||
assertContainsAll(capabilities, coreCapabilities)
|
||||
assertMissingAll(capabilities, optionalCapabilities)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_includesFeatureCapabilitiesWhenEnabled() {
|
||||
val capabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
NodeRuntimeFlags(
|
||||
defaultFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
voiceWakeEnabled = true,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Location.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Motion.rawValue))
|
||||
assertContainsAll(capabilities, coreCapabilities + optionalCapabilities)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_respectsFeatureAvailability() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = false,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags())
|
||||
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
|
||||
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertFalse(commands.contains("debug.logs"))
|
||||
assertFalse(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
assertContainsAll(commands, coreCommands)
|
||||
assertMissingAll(commands, optionalCommands + debugCommands)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
NodeRuntimeFlags(
|
||||
defaultFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = true,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
|
||||
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertTrue(commands.contains("debug.logs"))
|
||||
assertTrue(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
assertContainsAll(commands, coreCommands + optionalCommands + debugCommands)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -174,4 +136,31 @@ class InvokeCommandRegistryTest {
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
}
|
||||
|
||||
private fun defaultFlags(
|
||||
cameraEnabled: Boolean = false,
|
||||
locationEnabled: Boolean = false,
|
||||
smsAvailable: Boolean = false,
|
||||
voiceWakeEnabled: Boolean = false,
|
||||
motionActivityAvailable: Boolean = false,
|
||||
motionPedometerAvailable: Boolean = false,
|
||||
debugBuild: Boolean = false,
|
||||
): NodeRuntimeFlags =
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = cameraEnabled,
|
||||
locationEnabled = locationEnabled,
|
||||
smsAvailable = smsAvailable,
|
||||
voiceWakeEnabled = voiceWakeEnabled,
|
||||
motionActivityAvailable = motionActivityAvailable,
|
||||
motionPedometerAvailable = motionPedometerAvailable,
|
||||
debugBuild = debugBuild,
|
||||
)
|
||||
|
||||
private fun assertContainsAll(actual: List<String>, expected: Set<String>) {
|
||||
expected.forEach { value -> assertTrue(actual.contains(value)) }
|
||||
}
|
||||
|
||||
private fun assertMissingAll(actual: List<String>, forbidden: Set<String>) {
|
||||
forbidden.forEach { value -> assertFalse(actual.contains(value)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,8 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MotionHandlerTest {
|
||||
class MotionHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@Test
|
||||
fun handleMotionActivity_requiresPermission() =
|
||||
runTest {
|
||||
@@ -86,8 +82,6 @@ class MotionHandlerTest {
|
||||
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
|
||||
assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeMotionDataSource(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
abstract class NodeHandlerRobolectricTest {
|
||||
protected fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
@@ -10,12 +10,8 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PhotosHandlerTest {
|
||||
class PhotosHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@Test
|
||||
fun handlePhotosLatest_requiresPermission() {
|
||||
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = false))
|
||||
@@ -63,8 +59,6 @@ class PhotosHandlerTest {
|
||||
assertEquals("jpeg", first.getValue("format").jsonPrimitive.content)
|
||||
assertEquals(640, first.getValue("width").jsonPrimitive.int)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakePhotosDataSource(
|
||||
|
||||
@@ -52,46 +52,27 @@ actor CameraController {
|
||||
|
||||
try await self.ensureAccess(for: .video)
|
||||
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .photo
|
||||
|
||||
guard let device = Self.pickCamera(facing: facing, deviceId: params.deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
guard session.canAddInput(input) else {
|
||||
throw CameraError.captureFailed("Failed to add camera input")
|
||||
}
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCapturePhotoOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
throw CameraError.captureFailed("Failed to add photo output")
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.maxPhotoQualityPrioritization = .quality
|
||||
let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
|
||||
preferFrontCamera: facing == .front,
|
||||
deviceId: params.deviceId,
|
||||
pickCamera: { preferFrontCamera, deviceId in
|
||||
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
||||
},
|
||||
cameraUnavailableError: CameraError.cameraUnavailable,
|
||||
mapSetupError: { setupError in
|
||||
CameraError.captureFailed(setupError.localizedDescription)
|
||||
})
|
||||
let session = prepared.session
|
||||
let output = prepared.output
|
||||
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
await CameraCapturePipelineSupport.warmUpCaptureSession()
|
||||
await Self.sleepDelayMs(delayMs)
|
||||
|
||||
let settings: AVCapturePhotoSettings = {
|
||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||
return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
|
||||
}
|
||||
return AVCapturePhotoSettings()
|
||||
}()
|
||||
settings.photoQualityPrioritization = .quality
|
||||
|
||||
var delegate: PhotoCaptureDelegate?
|
||||
let rawData: Data = try await withCheckedThrowingContinuation { cont in
|
||||
let d = PhotoCaptureDelegate(cont)
|
||||
delegate = d
|
||||
output.capturePhoto(with: settings, delegate: d)
|
||||
let rawData = try await CameraCapturePipelineSupport.capturePhotoData(output: output) { continuation in
|
||||
PhotoCaptureDelegate(continuation)
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
let res = try PhotoCapture.transcodeJPEGForGateway(
|
||||
rawData: rawData,
|
||||
@@ -121,63 +102,37 @@ actor CameraController {
|
||||
try await self.ensureAccess(for: .audio)
|
||||
}
|
||||
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .high
|
||||
|
||||
guard let camera = Self.pickCamera(facing: facing, deviceId: params.deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
let cameraInput = try AVCaptureDeviceInput(device: camera)
|
||||
guard session.canAddInput(cameraInput) else {
|
||||
throw CameraError.captureFailed("Failed to add camera input")
|
||||
}
|
||||
session.addInput(cameraInput)
|
||||
|
||||
if includeAudio {
|
||||
guard let mic = AVCaptureDevice.default(for: .audio) else {
|
||||
throw CameraError.microphoneUnavailable
|
||||
}
|
||||
let micInput = try AVCaptureDeviceInput(device: mic)
|
||||
if session.canAddInput(micInput) {
|
||||
session.addInput(micInput)
|
||||
} else {
|
||||
throw CameraError.captureFailed("Failed to add microphone input")
|
||||
}
|
||||
}
|
||||
|
||||
let output = AVCaptureMovieFileOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
throw CameraError.captureFailed("Failed to add movie output")
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
|
||||
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
|
||||
let movURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
|
||||
let mp4URL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
|
||||
|
||||
defer {
|
||||
try? FileManager().removeItem(at: movURL)
|
||||
try? FileManager().removeItem(at: mp4URL)
|
||||
}
|
||||
|
||||
var delegate: MovieFileDelegate?
|
||||
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
|
||||
let d = MovieFileDelegate(cont)
|
||||
delegate = d
|
||||
output.startRecording(to: movURL, recordingDelegate: d)
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
// Transcode .mov -> .mp4 for easier downstream handling.
|
||||
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
|
||||
|
||||
let data = try Data(contentsOf: mp4URL)
|
||||
let data = try await CameraCapturePipelineSupport.withWarmMovieSession(
|
||||
preferFrontCamera: facing == .front,
|
||||
deviceId: params.deviceId,
|
||||
includeAudio: includeAudio,
|
||||
durationMs: durationMs,
|
||||
pickCamera: { preferFrontCamera, deviceId in
|
||||
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
||||
},
|
||||
cameraUnavailableError: CameraError.cameraUnavailable,
|
||||
mapSetupError: Self.mapMovieSetupError,
|
||||
operation: { output in
|
||||
var delegate: MovieFileDelegate?
|
||||
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
|
||||
let d = MovieFileDelegate(cont)
|
||||
delegate = d
|
||||
output.startRecording(to: movURL, recordingDelegate: d)
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
// Transcode .mov -> .mp4 for easier downstream handling.
|
||||
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
|
||||
return try Data(contentsOf: mp4URL)
|
||||
})
|
||||
return (
|
||||
format: format.rawValue,
|
||||
base64: data.base64EncodedString(),
|
||||
@@ -196,22 +151,7 @@ actor CameraController {
|
||||
}
|
||||
|
||||
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .notDetermined:
|
||||
let ok = await withCheckedContinuation(isolation: nil) { cont in
|
||||
AVCaptureDevice.requestAccess(for: mediaType) { granted in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
@unknown default:
|
||||
if !(await CameraAuthorization.isAuthorized(for: mediaType)) {
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
}
|
||||
}
|
||||
@@ -233,12 +173,15 @@ actor CameraController {
|
||||
return AVCaptureDevice.default(for: .video)
|
||||
}
|
||||
|
||||
private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
|
||||
CameraCapturePipelineSupport.mapMovieSetupError(
|
||||
setupError,
|
||||
microphoneUnavailableError: .microphoneUnavailable,
|
||||
captureFailed: { .captureFailed($0) })
|
||||
}
|
||||
|
||||
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
|
||||
switch position {
|
||||
case .front: "front"
|
||||
case .back: "back"
|
||||
default: "unspecified"
|
||||
}
|
||||
CameraCapturePipelineSupport.positionLabel(position)
|
||||
}
|
||||
|
||||
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
|
||||
@@ -307,11 +250,6 @@ actor CameraController {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func warmUpCaptureSession() async {
|
||||
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
|
||||
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
|
||||
}
|
||||
|
||||
private nonisolated static func sleepDelayMs(_ delayMs: Int) async {
|
||||
guard delayMs > 0 else { return }
|
||||
let maxDelayMs = 10 * 1000
|
||||
|
||||
@@ -15,14 +15,7 @@ final class ContactsService: ContactsServicing {
|
||||
}
|
||||
|
||||
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload {
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
let store = try await Self.authorizedStore()
|
||||
|
||||
let limit = max(1, min(params.limit ?? 25, 200))
|
||||
|
||||
@@ -47,14 +40,7 @@ final class ContactsService: ContactsServicing {
|
||||
}
|
||||
|
||||
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload {
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
let store = try await Self.authorizedStore()
|
||||
|
||||
let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -127,6 +113,18 @@ final class ContactsService: ContactsServicing {
|
||||
}
|
||||
}
|
||||
|
||||
private static func authorizedStore() async throws -> CNContactStore {
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
|
||||
(values ?? [])
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
|
||||
@@ -53,23 +53,17 @@ final class GatewayDiscoveryModel {
|
||||
self.appendDebugLog("start()")
|
||||
|
||||
for domain in OpenClawBonjour.gatewayServiceDomains {
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let browser = NWBrowser(
|
||||
for: .bonjour(type: OpenClawBonjour.gatewayServiceType, domain: domain),
|
||||
using: params)
|
||||
|
||||
browser.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||
serviceType: OpenClawBonjour.gatewayServiceType,
|
||||
domain: domain,
|
||||
queueLabelPrefix: "ai.openclaw.ios.gateway-discovery",
|
||||
onState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state
|
||||
self.updateStatusText()
|
||||
self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))")
|
||||
}
|
||||
}
|
||||
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
Task { @MainActor in
|
||||
},
|
||||
onResults: { [weak self] results in
|
||||
guard let self else { return }
|
||||
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
|
||||
switch result.endpoint {
|
||||
@@ -98,13 +92,10 @@ final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.browsers[domain] = browser
|
||||
browser.start(queue: DispatchQueue(label: "ai.openclaw.ios.gateway-discovery.\(domain)"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
// NetService-based resolver for Bonjour services.
|
||||
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
|
||||
@@ -20,8 +21,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
}
|
||||
|
||||
func start(timeout: TimeInterval = 2.0) {
|
||||
self.service.schedule(in: .main, forMode: .common)
|
||||
self.service.resolve(withTimeout: timeout)
|
||||
BonjourServiceResolverSupport.start(self.service, timeout: timeout)
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
@@ -47,9 +47,6 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
}
|
||||
|
||||
private static func normalizeHost(_ raw: String?) -> String? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty { return nil }
|
||||
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
|
||||
BonjourServiceResolverSupport.normalizeHost(raw)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import CoreLocation
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||
enum Error: Swift.Error {
|
||||
case timeout
|
||||
case unavailable
|
||||
@@ -17,21 +17,18 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
|
||||
private var isMonitoringSignificantChanges = false
|
||||
|
||||
var locationManager: CLLocationManager {
|
||||
self.manager
|
||||
}
|
||||
|
||||
var locationRequestContinuation: CheckedContinuation<CLLocation, Swift.Error>? {
|
||||
get { self.locationContinuation }
|
||||
set { self.locationContinuation = newValue }
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
self.manager.delegate = self
|
||||
self.manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
}
|
||||
|
||||
func authorizationStatus() -> CLAuthorizationStatus {
|
||||
self.manager.authorizationStatus
|
||||
}
|
||||
|
||||
func accuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
if #available(iOS 14.0, *) {
|
||||
return self.manager.accuracyAuthorization
|
||||
}
|
||||
return .fullAccuracy
|
||||
self.configureLocationManager()
|
||||
}
|
||||
|
||||
func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus {
|
||||
@@ -62,26 +59,16 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
let now = Date()
|
||||
if let maxAgeMs,
|
||||
let cached = self.manager.location,
|
||||
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
|
||||
{
|
||||
return cached
|
||||
}
|
||||
|
||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||
let timeout = max(0, timeoutMs ?? 10000)
|
||||
return try await self.withTimeout(timeoutMs: timeout) {
|
||||
try await self.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocation() async throws -> CLLocation {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.locationContinuation = cont
|
||||
self.manager.requestLocation()
|
||||
}
|
||||
_ = params
|
||||
return try await LocationCurrentRequest.resolve(
|
||||
manager: self.manager,
|
||||
desiredAccuracy: desiredAccuracy,
|
||||
maxAgeMs: maxAgeMs,
|
||||
timeoutMs: timeoutMs,
|
||||
request: { try await self.requestLocationOnce() },
|
||||
withTimeout: { timeoutMs, operation in
|
||||
try await self.withTimeout(timeoutMs: timeoutMs, operation: operation)
|
||||
})
|
||||
}
|
||||
|
||||
private func awaitAuthorizationChange() async -> CLAuthorizationStatus {
|
||||
@@ -97,24 +84,13 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
|
||||
}
|
||||
|
||||
private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy {
|
||||
switch accuracy {
|
||||
case .coarse:
|
||||
kCLLocationAccuracyKilometer
|
||||
case .balanced:
|
||||
kCLLocationAccuracyHundredMeters
|
||||
case .precise:
|
||||
kCLLocationAccuracyBest
|
||||
}
|
||||
}
|
||||
|
||||
func startLocationUpdates(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
|
||||
{
|
||||
self.stopLocationUpdates()
|
||||
|
||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||
self.manager.desiredAccuracy = LocationCurrentRequest.accuracyValue(desiredAccuracy)
|
||||
self.manager.pausesLocationUpdatesAutomatically = true
|
||||
self.manager.allowsBackgroundLocationUpdates = true
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OpenClawKit
|
||||
import os
|
||||
|
||||
extension NodeAppModel {
|
||||
@@ -11,24 +12,12 @@ extension NodeAppModel {
|
||||
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
||||
if let host = base.host, Self.isLoopbackHost(host) {
|
||||
if let host = base.host, LoopbackHost.isLoopback(host) {
|
||||
return nil
|
||||
}
|
||||
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"
|
||||
}
|
||||
|
||||
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized.isEmpty { return true }
|
||||
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
|
||||
return true
|
||||
}
|
||||
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func showA2UIOnConnectIfNeeded() async {
|
||||
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
|
||||
await MainActor.run {
|
||||
|
||||
@@ -41,15 +41,17 @@ private struct AutoDetectStep: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Connection status") {
|
||||
ConnectionStatusBox(
|
||||
statusLines: self.connectionStatusLines(),
|
||||
secondaryLine: self.connectStatusText)
|
||||
}
|
||||
gatewayConnectionStatusSection(
|
||||
appModel: self.appModel,
|
||||
gatewayController: self.gatewayController,
|
||||
secondaryLine: self.connectStatusText)
|
||||
|
||||
Section {
|
||||
Button("Retry") {
|
||||
self.resetConnectionState()
|
||||
resetGatewayConnectionState(
|
||||
appModel: self.appModel,
|
||||
connectStatusText: &self.connectStatusText,
|
||||
connectingGatewayID: &self.connectingGatewayID)
|
||||
self.triggerAutoConnect()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
@@ -94,15 +96,6 @@ private struct AutoDetectStep: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func connectionStatusLines() -> [String] {
|
||||
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
|
||||
}
|
||||
|
||||
private func resetConnectionState() {
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectStatusText = nil
|
||||
self.connectingGatewayID = nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManualEntryStep: View {
|
||||
@@ -162,11 +155,10 @@ private struct ManualEntryStep: View {
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section("Connection status") {
|
||||
ConnectionStatusBox(
|
||||
statusLines: self.connectionStatusLines(),
|
||||
secondaryLine: self.connectStatusText)
|
||||
}
|
||||
gatewayConnectionStatusSection(
|
||||
appModel: self.appModel,
|
||||
gatewayController: self.gatewayController,
|
||||
secondaryLine: self.connectStatusText)
|
||||
|
||||
Section {
|
||||
Button {
|
||||
@@ -185,7 +177,10 @@ private struct ManualEntryStep: View {
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
|
||||
Button("Retry") {
|
||||
self.resetConnectionState()
|
||||
resetGatewayConnectionState(
|
||||
appModel: self.appModel,
|
||||
connectStatusText: &self.connectStatusText,
|
||||
connectingGatewayID: &self.connectingGatewayID)
|
||||
self.resetManualForm()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
@@ -237,16 +232,6 @@ private struct ManualEntryStep: View {
|
||||
return Int(trimmed.filter { $0.isNumber })
|
||||
}
|
||||
|
||||
private func connectionStatusLines() -> [String] {
|
||||
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
|
||||
}
|
||||
|
||||
private func resetConnectionState() {
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectStatusText = nil
|
||||
self.connectingGatewayID = nil
|
||||
}
|
||||
|
||||
private func resetManualForm() {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
@@ -317,6 +302,41 @@ private struct ManualEntryStep: View {
|
||||
// (GatewaySetupCode) decode raw setup codes.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func gatewayConnectionStatusLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController) -> [String]
|
||||
{
|
||||
ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resetGatewayConnectionState(
|
||||
appModel: NodeAppModel,
|
||||
connectStatusText: inout String?,
|
||||
connectingGatewayID: inout String?)
|
||||
{
|
||||
appModel.disconnectGateway()
|
||||
connectStatusText = nil
|
||||
connectingGatewayID = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ViewBuilder
|
||||
private func gatewayConnectionStatusSection(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController,
|
||||
secondaryLine: String?) -> some View
|
||||
{
|
||||
Section("Connection status") {
|
||||
ConnectionStatusBox(
|
||||
statusLines: gatewayConnectionStatusLines(
|
||||
appModel: appModel,
|
||||
gatewayController: gatewayController),
|
||||
secondaryLine: secondaryLine)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectionStatusBox: View {
|
||||
let statusLines: [String]
|
||||
let secondaryLine: String?
|
||||
|
||||
@@ -489,21 +489,7 @@ struct OnboardingWizardView: View {
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
Toggle("Use TLS", isOn: self.$manualTLS)
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
|
||||
self.manualConnectButton
|
||||
} header: {
|
||||
Text("Developer Local")
|
||||
} footer: {
|
||||
@@ -631,24 +617,27 @@ struct OnboardingWizardView: View {
|
||||
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
|
||||
self.manualConnectButton
|
||||
}
|
||||
}
|
||||
|
||||
private var manualConnectButton: some View {
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
|
||||
}
|
||||
|
||||
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
|
||||
self.manualHost = link.host
|
||||
self.manualPort = link.port
|
||||
|
||||
@@ -456,11 +456,7 @@ enum WatchPromptNotificationBridge {
|
||||
) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
center.add(request) { error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: ())
|
||||
}
|
||||
ThrowingContinuationSupport.resumeVoid(continuation, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,20 +177,7 @@ struct RootCanvas: View {
|
||||
}
|
||||
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
if self.appModel.gatewayServerName != nil { return .connected }
|
||||
|
||||
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
return .connecting
|
||||
}
|
||||
|
||||
if text.localizedCaseInsensitiveContains("error") {
|
||||
return .error
|
||||
}
|
||||
|
||||
return .disconnected
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
}
|
||||
|
||||
private func updateIdleTimer() {
|
||||
@@ -343,82 +330,18 @@ private struct CanvasContent: View {
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Gateway",
|
||||
.gatewayActionsDialog(
|
||||
isPresented: self.$showGatewayActions,
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectGateway()
|
||||
}
|
||||
Button("Open Settings") {
|
||||
self.openSettings()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Disconnect from the gateway?")
|
||||
}
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.openSettings() })
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
|
||||
if self.appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
}
|
||||
|
||||
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
switch cameraHUDKind {
|
||||
case .photo:
|
||||
systemImage = "camera.fill"
|
||||
tint = nil
|
||||
case .recording:
|
||||
systemImage = "video.fill"
|
||||
tint = .red
|
||||
case .success:
|
||||
systemImage = "checkmark.circle.fill"
|
||||
tint = .green
|
||||
case .error:
|
||||
systemImage = "exclamationmark.triangle.fill"
|
||||
tint = .red
|
||||
}
|
||||
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||
}
|
||||
|
||||
if self.voiceWakeEnabled {
|
||||
let voiceStatus = self.appModel.voiceWake.statusText
|
||||
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if self.appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
StatusActivityBuilder.build(
|
||||
appModel: self.appModel,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
cameraHUDText: self.cameraHUDText,
|
||||
cameraHUDKind: self.cameraHUDKind)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,38 +70,14 @@ struct RootTabs: View {
|
||||
self.toastDismissTask?.cancel()
|
||||
self.toastDismissTask = nil
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Gateway",
|
||||
.gatewayActionsDialog(
|
||||
isPresented: self.$showGatewayActions,
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectGateway()
|
||||
}
|
||||
Button("Open Settings") {
|
||||
self.selectedTab = 2
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Disconnect from the gateway?")
|
||||
}
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.selectedTab = 2 })
|
||||
}
|
||||
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
if self.appModel.gatewayServerName != nil { return .connected }
|
||||
|
||||
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
return .connecting
|
||||
}
|
||||
|
||||
if text.localizedCaseInsensitiveContains("error") {
|
||||
return .error
|
||||
}
|
||||
|
||||
return .disconnected
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
|
||||
@@ -35,7 +35,7 @@ final class ScreenController {
|
||||
if let url = URL(string: trimmed),
|
||||
!url.isFileURL,
|
||||
let host = url.host,
|
||||
Self.isLoopbackHost(host)
|
||||
LoopbackHost.isLoopback(host)
|
||||
{
|
||||
// Never try to load loopback URLs from a remote gateway.
|
||||
self.showDefaultCanvas()
|
||||
@@ -87,25 +87,11 @@ final class ScreenController {
|
||||
|
||||
func applyDebugStatusIfNeeded() {
|
||||
guard let webView = self.activeWebView else { return }
|
||||
let enabled = self.debugStatusEnabled
|
||||
let title = self.debugStatusTitle
|
||||
let subtitle = self.debugStatusSubtitle
|
||||
let js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__openclaw;
|
||||
if (!api) return;
|
||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
|
||||
}
|
||||
if (!\(enabled ? "true" : "false")) return;
|
||||
if (typeof api.setStatus === 'function') {
|
||||
api.setStatus(\(Self.jsValue(title)), \(Self.jsValue(subtitle)));
|
||||
}
|
||||
} catch (_) {}
|
||||
})()
|
||||
"""
|
||||
webView.evaluateJavaScript(js) { _, _ in }
|
||||
WebViewJavaScriptSupport.applyDebugStatus(
|
||||
webView: webView,
|
||||
enabled: self.debugStatusEnabled,
|
||||
title: self.debugStatusTitle,
|
||||
subtitle: self.debugStatusSubtitle)
|
||||
}
|
||||
|
||||
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
|
||||
@@ -137,46 +123,11 @@ final class ScreenController {
|
||||
NSLocalizedDescriptionKey: "web view unavailable",
|
||||
])
|
||||
}
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
webView.evaluateJavaScript(javaScript) { result, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if let result {
|
||||
cont.resume(returning: String(describing: result))
|
||||
} else {
|
||||
cont.resume(returning: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
return try await WebViewJavaScriptSupport.evaluateToString(webView: webView, javaScript: javaScript)
|
||||
}
|
||||
|
||||
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
|
||||
let config = WKSnapshotConfiguration()
|
||||
if let maxWidth {
|
||||
config.snapshotWidth = NSNumber(value: Double(maxWidth))
|
||||
}
|
||||
guard let webView = self.activeWebView else {
|
||||
throw NSError(domain: "Screen", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "web view unavailable",
|
||||
])
|
||||
}
|
||||
let image: UIImage = try await withCheckedThrowingContinuation { cont in
|
||||
webView.takeSnapshot(with: config) { image, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let image else {
|
||||
cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot failed",
|
||||
]))
|
||||
return
|
||||
}
|
||||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
let image = try await self.snapshotImage(maxWidth: maxWidth)
|
||||
guard let data = image.pngData() else {
|
||||
throw NSError(domain: "Screen", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot encode failed",
|
||||
@@ -190,30 +141,7 @@ final class ScreenController {
|
||||
format: OpenClawCanvasSnapshotFormat,
|
||||
quality: Double? = nil) async throws -> String
|
||||
{
|
||||
let config = WKSnapshotConfiguration()
|
||||
if let maxWidth {
|
||||
config.snapshotWidth = NSNumber(value: Double(maxWidth))
|
||||
}
|
||||
guard let webView = self.activeWebView else {
|
||||
throw NSError(domain: "Screen", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "web view unavailable",
|
||||
])
|
||||
}
|
||||
let image: UIImage = try await withCheckedThrowingContinuation { cont in
|
||||
webView.takeSnapshot(with: config) { image, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let image else {
|
||||
cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot failed",
|
||||
]))
|
||||
return
|
||||
}
|
||||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
let image = try await self.snapshotImage(maxWidth: maxWidth)
|
||||
|
||||
let data: Data?
|
||||
switch format {
|
||||
@@ -231,6 +159,34 @@ final class ScreenController {
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
private func snapshotImage(maxWidth: CGFloat?) async throws -> UIImage {
|
||||
let config = WKSnapshotConfiguration()
|
||||
if let maxWidth {
|
||||
config.snapshotWidth = NSNumber(value: Double(maxWidth))
|
||||
}
|
||||
guard let webView = self.activeWebView else {
|
||||
throw NSError(domain: "Screen", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "web view unavailable",
|
||||
])
|
||||
}
|
||||
let image: UIImage = try await withCheckedThrowingContinuation { cont in
|
||||
webView.takeSnapshot(with: config) { image, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let image else {
|
||||
cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot failed",
|
||||
]))
|
||||
return
|
||||
}
|
||||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
func attachWebView(_ webView: WKWebView) {
|
||||
self.activeWebView = webView
|
||||
self.reload()
|
||||
@@ -258,17 +214,6 @@ final class ScreenController {
|
||||
ext: "html",
|
||||
subdirectory: "CanvasScaffold")
|
||||
|
||||
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized.isEmpty { return true }
|
||||
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
|
||||
return true
|
||||
}
|
||||
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
|
||||
guard url.isFileURL else { return false }
|
||||
let std = url.standardizedFileURL
|
||||
@@ -290,59 +235,8 @@ final class ScreenController {
|
||||
scrollView.bounces = allowScroll
|
||||
}
|
||||
|
||||
private static func jsValue(_ value: String?) -> String {
|
||||
guard let value else { return "null" }
|
||||
if let data = try? JSONSerialization.data(withJSONObject: [value]),
|
||||
let encoded = String(data: data, encoding: .utf8),
|
||||
encoded.count >= 2
|
||||
{
|
||||
return String(encoded.dropFirst().dropLast())
|
||||
}
|
||||
return "null"
|
||||
}
|
||||
|
||||
func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
}
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" { return true }
|
||||
if host.hasSuffix(".local") { return true }
|
||||
if host.hasSuffix(".ts.net") { return true }
|
||||
if host.hasSuffix(".tailscale.net") { return true }
|
||||
// Allow MagicDNS / LAN hostnames like "peters-mac-studio-1".
|
||||
if !host.contains("."), !host.contains(":") { return true }
|
||||
if let ipv4 = Self.parseIPv4(host) {
|
||||
return Self.isLocalNetworkIPv4(ipv4)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 4 else { return nil }
|
||||
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
|
||||
guard bytes.count == 4 else { return nil }
|
||||
return (bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
}
|
||||
|
||||
private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
let (a, b, _, _) = ip
|
||||
// 10.0.0.0/8
|
||||
if a == 10 { return true }
|
||||
// 172.16.0.0/12
|
||||
if a == 172, (16...31).contains(Int(b)) { return true }
|
||||
// 192.168.0.0/16
|
||||
if a == 192, b == 168 { return true }
|
||||
// 127.0.0.0/8
|
||||
if a == 127 { return true }
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if a == 169, b == 254 { return true }
|
||||
// Tailscale: 100.64.0.0/10
|
||||
if a == 100, (64...127).contains(Int(b)) { return true }
|
||||
return false
|
||||
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
|
||||
}
|
||||
|
||||
nonisolated static func parseA2UIActionBody(_ body: Any) -> [String: Any]? {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AVFoundation
|
||||
import OpenClawKit
|
||||
import ReplayKit
|
||||
|
||||
final class ScreenRecordService: @unchecked Sendable {
|
||||
@@ -84,8 +85,8 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
throw ScreenRecordError.invalidScreenIndex(idx)
|
||||
}
|
||||
|
||||
let durationMs = Self.clampDurationMs(durationMs)
|
||||
let fps = Self.clampFps(fps)
|
||||
let durationMs = CaptureRateLimits.clampDurationMs(durationMs)
|
||||
let fps = CaptureRateLimits.clampFps(fps, maxFps: 30)
|
||||
let fpsInt = Int32(fps.rounded())
|
||||
let fpsValue = Double(fpsInt)
|
||||
let includeAudio = includeAudio ?? true
|
||||
@@ -319,16 +320,6 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
||||
let v = ms ?? 10000
|
||||
return min(60000, max(250, v))
|
||||
}
|
||||
|
||||
private nonisolated static func clampFps(_ fps: Double?) -> Double {
|
||||
let v = fps ?? 10
|
||||
if !v.isFinite { return 10 }
|
||||
return min(30, max(1, v))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -350,11 +341,11 @@ private func stopReplayKitCapture(_ completion: @escaping @Sendable (Error?) ->
|
||||
#if DEBUG
|
||||
extension ScreenRecordService {
|
||||
nonisolated static func _test_clampDurationMs(_ ms: Int?) -> Int {
|
||||
self.clampDurationMs(ms)
|
||||
CaptureRateLimits.clampDurationMs(ms)
|
||||
}
|
||||
|
||||
nonisolated static func _test_clampFps(_ fps: Double?) -> Double {
|
||||
self.clampFps(fps)
|
||||
CaptureRateLimits.clampFps(fps, maxFps: 30)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
25
apps/ios/Sources/Status/GatewayActionsDialog.swift
Normal file
25
apps/ios/Sources/Status/GatewayActionsDialog.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func gatewayActionsDialog(
|
||||
isPresented: Binding<Bool>,
|
||||
onDisconnect: @escaping () -> Void,
|
||||
onOpenSettings: @escaping () -> Void) -> some View
|
||||
{
|
||||
self.confirmationDialog(
|
||||
"Gateway",
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button("Disconnect", role: .destructive) {
|
||||
onDisconnect()
|
||||
}
|
||||
Button("Open Settings") {
|
||||
onOpenSettings()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Disconnect from the gateway?")
|
||||
}
|
||||
}
|
||||
}
|
||||
21
apps/ios/Sources/Status/GatewayStatusBuilder.swift
Normal file
21
apps/ios/Sources/Status/GatewayStatusBuilder.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayStatusBuilder {
|
||||
@MainActor
|
||||
static func build(appModel: NodeAppModel) -> StatusPill.GatewayState {
|
||||
if appModel.gatewayServerName != nil { return .connected }
|
||||
|
||||
let text = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
return .connecting
|
||||
}
|
||||
|
||||
if text.localizedCaseInsensitiveContains("error") {
|
||||
return .error
|
||||
}
|
||||
|
||||
return .disconnected
|
||||
}
|
||||
}
|
||||
39
apps/ios/Sources/Status/StatusGlassCard.swift
Normal file
39
apps/ios/Sources/Status/StatusGlassCard.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
private struct StatusGlassCardModifier: ViewModifier {
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
let brighten: Bool
|
||||
let verticalPadding: CGFloat
|
||||
let horizontalPadding: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.padding(.vertical, self.verticalPadding)
|
||||
.padding(.horizontal, self.horizontalPadding)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func statusGlassCard(brighten: Bool, verticalPadding: CGFloat, horizontalPadding: CGFloat = 12) -> some View {
|
||||
self.modifier(
|
||||
StatusGlassCardModifier(
|
||||
brighten: brighten,
|
||||
verticalPadding: verticalPadding,
|
||||
horizontalPadding: horizontalPadding
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import SwiftUI
|
||||
struct StatusPill: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
enum GatewayState: Equatable {
|
||||
case connected
|
||||
@@ -86,20 +85,7 @@ struct StatusPill: View {
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Connection Status")
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceWakeToast: View {
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var command: String
|
||||
var brighten: Bool = false
|
||||
|
||||
@@ -18,20 +16,7 @@ struct VoiceWakeToast: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 10)
|
||||
.accessibilityLabel("Voice Wake triggered")
|
||||
.accessibilityValue("Command: \(self.command)")
|
||||
}
|
||||
|
||||
@@ -216,22 +216,7 @@ final class VoiceWakeManager: NSObject {
|
||||
self.isEnabled = false
|
||||
self.isListening = false
|
||||
self.statusText = "Off"
|
||||
|
||||
self.tapDrainTask?.cancel()
|
||||
self.tapDrainTask = nil
|
||||
self.tapQueue?.clear()
|
||||
self.tapQueue = nil
|
||||
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
self.recognitionRequest = nil
|
||||
|
||||
if self.audioEngine.isRunning {
|
||||
self.audioEngine.stop()
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
}
|
||||
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
self.tearDownRecognitionPipeline()
|
||||
}
|
||||
|
||||
/// Temporarily releases the microphone so other subsystems (e.g. camera video capture) can record audio.
|
||||
@@ -241,22 +226,7 @@ final class VoiceWakeManager: NSObject {
|
||||
|
||||
self.isListening = false
|
||||
self.statusText = "Paused"
|
||||
|
||||
self.tapDrainTask?.cancel()
|
||||
self.tapDrainTask = nil
|
||||
self.tapQueue?.clear()
|
||||
self.tapQueue = nil
|
||||
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
self.recognitionRequest = nil
|
||||
|
||||
if self.audioEngine.isRunning {
|
||||
self.audioEngine.stop()
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
}
|
||||
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
self.tearDownRecognitionPipeline()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -310,6 +280,24 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func tearDownRecognitionPipeline() {
|
||||
self.tapDrainTask?.cancel()
|
||||
self.tapDrainTask = nil
|
||||
self.tapQueue?.clear()
|
||||
self.tapQueue = nil
|
||||
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
self.recognitionRequest = nil
|
||||
|
||||
if self.audioEngine.isRunning {
|
||||
self.audioEngine.stop()
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
}
|
||||
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
|
||||
{ [weak self] result, error in
|
||||
let transcript = result?.bestTranscription.formattedString
|
||||
@@ -404,16 +392,10 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
|
||||
private nonisolated static func microphonePermissionMessage(kind: String) -> String {
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .undetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .granted:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
let status = AVAudioApplication.shared.recordPermission
|
||||
return self.deniedByDefaultPermissionMessage(
|
||||
kind: kind,
|
||||
isUndetermined: status == .undetermined)
|
||||
}
|
||||
|
||||
private nonisolated static func requestSpeechPermission() async -> Bool {
|
||||
@@ -459,22 +441,6 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private static func permissionMessage(
|
||||
kind: String,
|
||||
status: AVAudioSession.RecordPermission) -> String
|
||||
{
|
||||
switch status {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .undetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .granted:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
private static func permissionMessage(
|
||||
kind: String,
|
||||
status: SFSpeechRecognizerAuthorizationStatus) -> String
|
||||
@@ -492,6 +458,13 @@ final class VoiceWakeManager: NSObject {
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func deniedByDefaultPermissionMessage(kind: String, isUndetermined: Bool) -> String {
|
||||
if isUndetermined {
|
||||
return "\(kind) permission not granted"
|
||||
}
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -2,6 +2,36 @@ import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
private func setupCode(from payload: String) -> String {
|
||||
Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private func agentAction(
|
||||
message: String,
|
||||
sessionKey: String? = nil,
|
||||
thinking: String? = nil,
|
||||
deliver: Bool = false,
|
||||
to: String? = nil,
|
||||
channel: String? = nil,
|
||||
timeoutSeconds: Int? = nil,
|
||||
key: String? = nil) -> DeepLinkRoute
|
||||
{
|
||||
.agent(
|
||||
.init(
|
||||
message: message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
channel: channel,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
key: key))
|
||||
}
|
||||
|
||||
@Suite struct DeepLinkParserTests {
|
||||
@Test func parseRejectsUnknownHost() {
|
||||
let url = URL(string: "openclaw://nope?message=hi")!
|
||||
@@ -10,15 +40,7 @@ import Testing
|
||||
|
||||
@Test func parseHostIsCaseInsensitive() {
|
||||
let url = URL(string: "openclaw://AGENT?message=Hello")!
|
||||
#expect(DeepLinkParser.parse(url) == .agent(.init(
|
||||
message: "Hello",
|
||||
sessionKey: nil,
|
||||
thinking: nil,
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: nil,
|
||||
timeoutSeconds: nil,
|
||||
key: nil)))
|
||||
#expect(DeepLinkParser.parse(url) == agentAction(message: "Hello"))
|
||||
}
|
||||
|
||||
@Test func parseRejectsNonOpenClawScheme() {
|
||||
@@ -34,47 +56,29 @@ import Testing
|
||||
@Test func parseAgentLinkParsesCommonFields() {
|
||||
let url =
|
||||
URL(string: "openclaw://agent?message=Hello&deliver=1&sessionKey=node-test&thinking=low&timeoutSeconds=30")!
|
||||
#expect(
|
||||
DeepLinkParser.parse(url) == .agent(
|
||||
.init(
|
||||
message: "Hello",
|
||||
sessionKey: "node-test",
|
||||
thinking: "low",
|
||||
deliver: true,
|
||||
to: nil,
|
||||
channel: nil,
|
||||
timeoutSeconds: 30,
|
||||
key: nil)))
|
||||
#expect(DeepLinkParser.parse(url) == agentAction(
|
||||
message: "Hello",
|
||||
sessionKey: "node-test",
|
||||
thinking: "low",
|
||||
deliver: true,
|
||||
timeoutSeconds: 30))
|
||||
}
|
||||
|
||||
@Test func parseAgentLinkParsesTargetRoutingFields() {
|
||||
let url =
|
||||
URL(
|
||||
string: "openclaw://agent?message=Hello%20World&deliver=1&to=%2B15551234567&channel=whatsapp&key=secret")!
|
||||
#expect(
|
||||
DeepLinkParser.parse(url) == .agent(
|
||||
.init(
|
||||
message: "Hello World",
|
||||
sessionKey: nil,
|
||||
thinking: nil,
|
||||
deliver: true,
|
||||
to: "+15551234567",
|
||||
channel: "whatsapp",
|
||||
timeoutSeconds: nil,
|
||||
key: "secret")))
|
||||
#expect(DeepLinkParser.parse(url) == agentAction(
|
||||
message: "Hello World",
|
||||
deliver: true,
|
||||
to: "+15551234567",
|
||||
channel: "whatsapp",
|
||||
key: "secret"))
|
||||
}
|
||||
|
||||
@Test func parseRejectsNegativeTimeoutSeconds() {
|
||||
let url = URL(string: "openclaw://agent?message=Hello&timeoutSeconds=-1")!
|
||||
#expect(DeepLinkParser.parse(url) == .agent(.init(
|
||||
message: "Hello",
|
||||
sessionKey: nil,
|
||||
thinking: nil,
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: nil,
|
||||
timeoutSeconds: nil,
|
||||
key: nil)))
|
||||
#expect(DeepLinkParser.parse(url) == agentAction(message: "Hello"))
|
||||
}
|
||||
|
||||
@Test func parseGatewayLinkParsesCommonFields() {
|
||||
@@ -99,13 +103,7 @@ import Testing
|
||||
|
||||
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
|
||||
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
|
||||
#expect(link == .init(
|
||||
host: "gateway.example.com",
|
||||
@@ -121,13 +119,7 @@ import Testing
|
||||
|
||||
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
|
||||
let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
|
||||
#expect(link == .init(
|
||||
host: "gateway.example.com",
|
||||
@@ -139,37 +131,19 @@ import Testing
|
||||
|
||||
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() {
|
||||
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
#expect(link == nil)
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() {
|
||||
let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
#expect(link == nil)
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeAllowsLoopbackWs() {
|
||||
let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
|
||||
#expect(link == .init(
|
||||
host: "127.0.0.1",
|
||||
|
||||
@@ -4,31 +4,6 @@ import Testing
|
||||
import UIKit
|
||||
@testable import OpenClaw
|
||||
|
||||
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in updates.keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (key, value) in snapshot {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionControllerTests {
|
||||
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
@@ -5,6 +5,32 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionSecurityTests {
|
||||
private func makeController() -> GatewayConnectionController {
|
||||
GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
|
||||
}
|
||||
|
||||
private func makeDiscoveredGateway(
|
||||
stableID: String,
|
||||
lanHost: String?,
|
||||
tailnetDns: String?,
|
||||
gatewayPort: Int?,
|
||||
fingerprint: String?) -> GatewayDiscoveryModel.DiscoveredGateway
|
||||
{
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
return GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: lanHost,
|
||||
tailnetDns: tailnetDns,
|
||||
gatewayPort: gatewayPort,
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: fingerprint,
|
||||
cliPath: nil)
|
||||
}
|
||||
|
||||
private func clearTLSFingerprint(stableID: String) {
|
||||
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
|
||||
suite.removeObject(forKey: "gateway.tls.\(stableID)")
|
||||
@@ -17,22 +43,13 @@ import Testing
|
||||
|
||||
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
|
||||
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
let gateway = makeDiscoveredGateway(
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: "evil.example.com",
|
||||
tailnetDns: "evil.example.com",
|
||||
gatewayPort: 12345,
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: "22",
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
fingerprint: "22")
|
||||
let controller = makeController()
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
#expect(params?.expectedFingerprint == "11")
|
||||
@@ -44,22 +61,13 @@ import Testing
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
let gateway = makeDiscoveredGateway(
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
gatewayPort: nil,
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: "22",
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
fingerprint: "22")
|
||||
let controller = makeController()
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
#expect(params?.expectedFingerprint == nil)
|
||||
@@ -82,22 +90,13 @@ import Testing
|
||||
defaults.removeObject(forKey: "gateway.preferredStableID")
|
||||
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
|
||||
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
let gateway = makeDiscoveredGateway(
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: "test.local",
|
||||
tailnetDns: nil,
|
||||
gatewayPort: 18789,
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
fingerprint: nil)
|
||||
let controller = makeController()
|
||||
controller._test_setGateways([gateway])
|
||||
controller._test_triggerAutoConnect()
|
||||
|
||||
@@ -105,8 +104,7 @@ import Testing
|
||||
}
|
||||
|
||||
@Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let controller = makeController()
|
||||
|
||||
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
|
||||
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == true)
|
||||
@@ -121,8 +119,7 @@ import Testing
|
||||
}
|
||||
|
||||
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let controller = makeController()
|
||||
|
||||
#expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789)
|
||||
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443)
|
||||
|
||||
@@ -14,6 +14,19 @@ private let instanceIdEntry = KeychainEntry(service: nodeService, account: "inst
|
||||
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
||||
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
||||
private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme")
|
||||
private let bootstrapDefaultsKeys = [
|
||||
"node.instanceId",
|
||||
"gateway.preferredStableID",
|
||||
"gateway.lastDiscoveredStableID",
|
||||
]
|
||||
private let bootstrapKeychainEntries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
|
||||
private let lastGatewayDefaultsKeys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
|
||||
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -61,142 +74,112 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
applyKeychain(snapshot)
|
||||
}
|
||||
|
||||
private func withBootstrapSnapshots(_ body: () -> Void) {
|
||||
let defaultsSnapshot = snapshotDefaults(bootstrapDefaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(bootstrapKeychainEntries)
|
||||
defer {
|
||||
restoreDefaults(defaultsSnapshot)
|
||||
restoreKeychain(keychainSnapshot)
|
||||
}
|
||||
body()
|
||||
}
|
||||
|
||||
private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
|
||||
let snapshot = snapshotDefaults(lastGatewayDefaultsKeys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
body()
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct GatewaySettingsStoreTests {
|
||||
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
|
||||
let defaultsKeys = [
|
||||
"node.instanceId",
|
||||
"gateway.preferredStableID",
|
||||
"gateway.lastDiscoveredStableID",
|
||||
]
|
||||
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
|
||||
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(entries)
|
||||
defer {
|
||||
restoreDefaults(defaultsSnapshot)
|
||||
restoreKeychain(keychainSnapshot)
|
||||
withBootstrapSnapshots {
|
||||
applyDefaults([
|
||||
"node.instanceId": "node-test",
|
||||
"gateway.preferredStableID": "preferred-test",
|
||||
"gateway.lastDiscoveredStableID": "last-test",
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: nil,
|
||||
preferredGatewayEntry: nil,
|
||||
lastGatewayEntry: nil,
|
||||
])
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
|
||||
}
|
||||
|
||||
applyDefaults([
|
||||
"node.instanceId": "node-test",
|
||||
"gateway.preferredStableID": "preferred-test",
|
||||
"gateway.lastDiscoveredStableID": "last-test",
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: nil,
|
||||
preferredGatewayEntry: nil,
|
||||
lastGatewayEntry: nil,
|
||||
])
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
|
||||
}
|
||||
|
||||
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
|
||||
let defaultsKeys = [
|
||||
"node.instanceId",
|
||||
"gateway.preferredStableID",
|
||||
"gateway.lastDiscoveredStableID",
|
||||
]
|
||||
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
|
||||
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(entries)
|
||||
defer {
|
||||
restoreDefaults(defaultsSnapshot)
|
||||
restoreKeychain(keychainSnapshot)
|
||||
withBootstrapSnapshots {
|
||||
applyDefaults([
|
||||
"node.instanceId": nil,
|
||||
"gateway.preferredStableID": nil,
|
||||
"gateway.lastDiscoveredStableID": nil,
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: "node-from-keychain",
|
||||
preferredGatewayEntry: "preferred-from-keychain",
|
||||
lastGatewayEntry: "last-from-keychain",
|
||||
])
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
|
||||
}
|
||||
|
||||
applyDefaults([
|
||||
"node.instanceId": nil,
|
||||
"gateway.preferredStableID": nil,
|
||||
"gateway.lastDiscoveredStableID": nil,
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: "node-from-keychain",
|
||||
preferredGatewayEntry: "preferred-from-keychain",
|
||||
lastGatewayEntry: "last-from-keychain",
|
||||
])
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
|
||||
}
|
||||
|
||||
@Test func lastGateway_manualRoundTrip() {
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|example.com|443")
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|example.com|443")
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() {
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
// Simulate a prior manual record that included host/port.
|
||||
applyDefaults([
|
||||
"gateway.last.host": "10.0.0.99",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": true,
|
||||
"gateway.last.stableID": "manual|10.0.0.99|18789",
|
||||
"gateway.last.kind": "manual",
|
||||
])
|
||||
|
||||
// Simulate a prior manual record that included host/port.
|
||||
applyDefaults([
|
||||
"gateway.last.host": "10.0.0.99",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": true,
|
||||
"gateway.last.stableID": "manual|10.0.0.99|18789",
|
||||
"gateway.last.kind": "manual",
|
||||
])
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
#expect(defaults.object(forKey: "gateway.last.port") == nil)
|
||||
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
#expect(defaults.object(forKey: "gateway.last.port") == nil)
|
||||
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func lastGateway_backCompat_manualLoadsWhenKindMissing() {
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
applyDefaults([
|
||||
"gateway.last.kind": nil,
|
||||
"gateway.last.host": "example.org",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": false,
|
||||
"gateway.last.stableID": "manual|example.org|18789",
|
||||
])
|
||||
|
||||
applyDefaults([
|
||||
"gateway.last.kind": nil,
|
||||
"gateway.last.host": "example.org",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": false,
|
||||
"gateway.last.stableID": "manual|example.org|18789",
|
||||
])
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func talkProviderApiKey_genericRoundTrip() {
|
||||
|
||||
@@ -4,31 +4,6 @@ import Testing
|
||||
import UIKit
|
||||
@testable import OpenClaw
|
||||
|
||||
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in updates.keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (key, value) in snapshot {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
|
||||
private func makeAgentDeepLinkURL(
|
||||
message: String,
|
||||
deliver: Bool = false,
|
||||
|
||||
26
apps/ios/Tests/TestDefaultsSupport.swift
Normal file
26
apps/ios/Tests/TestDefaultsSupport.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in updates.keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (key, value) in snapshot {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
@@ -3,6 +3,19 @@ import SwabbleKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
private let openclawTranscript = "hey openclaw do thing"
|
||||
|
||||
private func openclawSegments(postTriggerStart: TimeInterval) -> [WakeWordSegment] {
|
||||
makeSegments(
|
||||
transcript: openclawTranscript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("openclaw", 0.2, 0.1),
|
||||
("do", postTriggerStart, 0.1),
|
||||
("thing", postTriggerStart + 0.2, 0.1),
|
||||
])
|
||||
}
|
||||
|
||||
@Suite struct VoiceWakeManagerExtractCommandTests {
|
||||
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
|
||||
let transcript = "hello world"
|
||||
@@ -13,17 +26,9 @@ import Testing
|
||||
}
|
||||
|
||||
@Test func extractCommandTrimsTokensAndResult() {
|
||||
let transcript = "hey openclaw do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("openclaw", 0.2, 0.1),
|
||||
("do", 0.9, 0.1),
|
||||
("thing", 1.1, 0.1),
|
||||
])
|
||||
let segments = openclawSegments(postTriggerStart: 0.9)
|
||||
let cmd = VoiceWakeManager.extractCommand(
|
||||
from: transcript,
|
||||
from: openclawTranscript,
|
||||
segments: segments,
|
||||
triggers: [" openclaw "],
|
||||
minPostTriggerGap: 0.3)
|
||||
@@ -31,17 +36,9 @@ import Testing
|
||||
}
|
||||
|
||||
@Test func extractCommandReturnsNilWhenGapTooShort() {
|
||||
let transcript = "hey openclaw do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("openclaw", 0.2, 0.1),
|
||||
("do", 0.35, 0.1),
|
||||
("thing", 0.5, 0.1),
|
||||
])
|
||||
let segments = openclawSegments(postTriggerStart: 0.35)
|
||||
let cmd = VoiceWakeManager.extractCommand(
|
||||
from: transcript,
|
||||
from: openclawTranscript,
|
||||
segments: segments,
|
||||
triggers: ["openclaw"],
|
||||
minPostTriggerGap: 0.3)
|
||||
@@ -57,17 +54,9 @@ import Testing
|
||||
}
|
||||
|
||||
@Test func extractCommandIgnoresEmptyTriggers() {
|
||||
let transcript = "hey openclaw do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("openclaw", 0.2, 0.1),
|
||||
("do", 0.9, 0.1),
|
||||
("thing", 1.1, 0.1),
|
||||
])
|
||||
let segments = openclawSegments(postTriggerStart: 0.9)
|
||||
let cmd = VoiceWakeManager.extractCommand(
|
||||
from: transcript,
|
||||
from: openclawTranscript,
|
||||
segments: segments,
|
||||
triggers: ["", " ", "openclaw"],
|
||||
minPostTriggerGap: 0.3)
|
||||
|
||||
30
apps/macos/Sources/OpenClaw/AgentWorkspaceConfig.swift
Normal file
30
apps/macos/Sources/OpenClaw/AgentWorkspaceConfig.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
enum AgentWorkspaceConfig {
|
||||
static func workspace(from root: [String: Any]) -> String? {
|
||||
let agents = root["agents"] as? [String: Any]
|
||||
let defaults = agents?["defaults"] as? [String: Any]
|
||||
return defaults?["workspace"] as? String
|
||||
}
|
||||
|
||||
static func setWorkspace(in root: inout [String: Any], workspace: String?) {
|
||||
var agents = root["agents"] as? [String: Any] ?? [:]
|
||||
var defaults = agents["defaults"] as? [String: Any] ?? [:]
|
||||
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
defaults.removeValue(forKey: "workspace")
|
||||
} else {
|
||||
defaults["workspace"] = trimmed
|
||||
}
|
||||
if defaults.isEmpty {
|
||||
agents.removeValue(forKey: "defaults")
|
||||
} else {
|
||||
agents["defaults"] = defaults
|
||||
}
|
||||
if agents.isEmpty {
|
||||
root.removeValue(forKey: "agents")
|
||||
} else {
|
||||
root["agents"] = agents
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,7 @@ final class AudioInputDeviceObserver {
|
||||
private var defaultInputListener: AudioObjectPropertyListenerBlock?
|
||||
|
||||
static func defaultInputDeviceUID() -> String? {
|
||||
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var deviceID = AudioObjectID(0)
|
||||
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||
let status = AudioObjectGetPropertyData(
|
||||
systemObject,
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&size,
|
||||
&deviceID)
|
||||
guard status == noErr, deviceID != 0 else { return nil }
|
||||
guard let deviceID = self.defaultInputDeviceID() else { return nil }
|
||||
return self.deviceUID(for: deviceID)
|
||||
}
|
||||
|
||||
@@ -63,6 +49,15 @@ final class AudioInputDeviceObserver {
|
||||
}
|
||||
|
||||
static func defaultInputDeviceSummary() -> String {
|
||||
guard let deviceID = self.defaultInputDeviceID() else {
|
||||
return "defaultInput=unknown"
|
||||
}
|
||||
let uid = self.deviceUID(for: deviceID) ?? "unknown"
|
||||
let name = self.deviceName(for: deviceID) ?? "unknown"
|
||||
return "defaultInput=\(name) (\(uid))"
|
||||
}
|
||||
|
||||
private static func defaultInputDeviceID() -> AudioObjectID? {
|
||||
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
@@ -77,12 +72,8 @@ final class AudioInputDeviceObserver {
|
||||
nil,
|
||||
&size,
|
||||
&deviceID)
|
||||
guard status == noErr, deviceID != 0 else {
|
||||
return "defaultInput=unknown"
|
||||
}
|
||||
let uid = self.deviceUID(for: deviceID) ?? "unknown"
|
||||
let name = self.deviceName(for: deviceID) ?? "unknown"
|
||||
return "defaultInput=\(name) (\(uid))"
|
||||
guard status == noErr, deviceID != 0 else { return nil }
|
||||
return deviceID
|
||||
}
|
||||
|
||||
func start(onChange: @escaping @Sendable () -> Void) {
|
||||
|
||||
@@ -64,45 +64,33 @@ actor CameraCaptureService {
|
||||
|
||||
try await self.ensureAccess(for: .video)
|
||||
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .photo
|
||||
|
||||
guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
guard session.canAddInput(input) else {
|
||||
throw CameraError.captureFailed("Failed to add camera input")
|
||||
}
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCapturePhotoOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
throw CameraError.captureFailed("Failed to add photo output")
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.maxPhotoQualityPrioritization = .quality
|
||||
let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
|
||||
preferFrontCamera: facing == .front,
|
||||
deviceId: deviceId,
|
||||
pickCamera: { preferFrontCamera, deviceId in
|
||||
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
||||
},
|
||||
cameraUnavailableError: CameraError.cameraUnavailable,
|
||||
mapSetupError: { setupError in
|
||||
CameraError.captureFailed(setupError.localizedDescription)
|
||||
})
|
||||
let session = prepared.session
|
||||
let device = prepared.device
|
||||
let output = prepared.output
|
||||
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
await CameraCapturePipelineSupport.warmUpCaptureSession()
|
||||
await self.waitForExposureAndWhiteBalance(device: device)
|
||||
await self.sleepDelayMs(delayMs)
|
||||
|
||||
let settings: AVCapturePhotoSettings = {
|
||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||
return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
|
||||
}
|
||||
return AVCapturePhotoSettings()
|
||||
}()
|
||||
settings.photoQualityPrioritization = .quality
|
||||
|
||||
var delegate: PhotoCaptureDelegate?
|
||||
let rawData: Data = try await withCheckedThrowingContinuation { cont in
|
||||
let d = PhotoCaptureDelegate(cont)
|
||||
delegate = d
|
||||
output.capturePhoto(with: settings, delegate: d)
|
||||
let rawData: Data = try await withCheckedThrowingContinuation { continuation in
|
||||
let captureDelegate = PhotoCaptureDelegate(continuation)
|
||||
delegate = captureDelegate
|
||||
output.capturePhoto(
|
||||
with: CameraCapturePipelineSupport.makePhotoSettings(output: output),
|
||||
delegate: captureDelegate)
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
@@ -135,39 +123,19 @@ actor CameraCaptureService {
|
||||
try await self.ensureAccess(for: .audio)
|
||||
}
|
||||
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .high
|
||||
|
||||
guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
let cameraInput = try AVCaptureDeviceInput(device: camera)
|
||||
guard session.canAddInput(cameraInput) else {
|
||||
throw CameraError.captureFailed("Failed to add camera input")
|
||||
}
|
||||
session.addInput(cameraInput)
|
||||
|
||||
if includeAudio {
|
||||
guard let mic = AVCaptureDevice.default(for: .audio) else {
|
||||
throw CameraError.microphoneUnavailable
|
||||
}
|
||||
let micInput = try AVCaptureDeviceInput(device: mic)
|
||||
guard session.canAddInput(micInput) else {
|
||||
throw CameraError.captureFailed("Failed to add microphone input")
|
||||
}
|
||||
session.addInput(micInput)
|
||||
}
|
||||
|
||||
let output = AVCaptureMovieFileOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
throw CameraError.captureFailed("Failed to add movie output")
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
|
||||
|
||||
session.startRunning()
|
||||
let prepared = try await CameraCapturePipelineSupport.prepareWarmMovieSession(
|
||||
preferFrontCamera: facing == .front,
|
||||
deviceId: deviceId,
|
||||
includeAudio: includeAudio,
|
||||
durationMs: durationMs,
|
||||
pickCamera: { preferFrontCamera, deviceId in
|
||||
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
||||
},
|
||||
cameraUnavailableError: CameraError.cameraUnavailable,
|
||||
mapSetupError: Self.mapMovieSetupError)
|
||||
let session = prepared.session
|
||||
let output = prepared.output
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
|
||||
let tmpMovURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
|
||||
@@ -180,7 +148,6 @@ actor CameraCaptureService {
|
||||
return FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
|
||||
}()
|
||||
|
||||
// Ensure we don't fail exporting due to an existing file.
|
||||
try? FileManager().removeItem(at: outputURL)
|
||||
|
||||
@@ -192,28 +159,12 @@ actor CameraCaptureService {
|
||||
output.startRecording(to: tmpMovURL, recordingDelegate: d)
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
|
||||
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
|
||||
}
|
||||
|
||||
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .notDetermined:
|
||||
let ok = await withCheckedContinuation(isolation: nil) { cont in
|
||||
AVCaptureDevice.requestAccess(for: mediaType) { granted in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
@unknown default:
|
||||
if await !(CameraAuthorization.isAuthorized(for: mediaType)) {
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
}
|
||||
}
|
||||
@@ -278,6 +229,13 @@ actor CameraCaptureService {
|
||||
return min(60000, max(250, v))
|
||||
}
|
||||
|
||||
private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
|
||||
CameraCapturePipelineSupport.mapMovieSetupError(
|
||||
setupError,
|
||||
microphoneUnavailableError: .microphoneUnavailable,
|
||||
captureFailed: { .captureFailed($0) })
|
||||
}
|
||||
|
||||
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
||||
let asset = AVURLAsset(url: inputURL)
|
||||
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
|
||||
@@ -315,11 +273,6 @@ actor CameraCaptureService {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func warmUpCaptureSession() async {
|
||||
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
|
||||
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
|
||||
}
|
||||
|
||||
private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async {
|
||||
let stepNs: UInt64 = 50_000_000
|
||||
let maxSteps = 30 // ~1.5s
|
||||
@@ -338,11 +291,7 @@ actor CameraCaptureService {
|
||||
}
|
||||
|
||||
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
|
||||
switch position {
|
||||
case .front: "front"
|
||||
case .back: "back"
|
||||
default: "unspecified"
|
||||
}
|
||||
CameraCapturePipelineSupport.positionLabel(position)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,40 +109,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
}
|
||||
|
||||
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
}
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" { return true }
|
||||
if host.hasSuffix(".local") { return true }
|
||||
if host.hasSuffix(".ts.net") { return true }
|
||||
if host.hasSuffix(".tailscale.net") { return true }
|
||||
if !host.contains("."), !host.contains(":") { return true }
|
||||
if let ipv4 = Self.parseIPv4(host) {
|
||||
return Self.isLocalNetworkIPv4(ipv4)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 4 else { return nil }
|
||||
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
|
||||
guard bytes.count == 4 else { return nil }
|
||||
return (bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
}
|
||||
|
||||
static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
let (a, b, _, _) = ip
|
||||
if a == 10 { return true }
|
||||
if a == 172, (16...31).contains(Int(b)) { return true }
|
||||
if a == 192, b == 168 { return true }
|
||||
if a == 127 { return true }
|
||||
if a == 169, b == 254 { return true }
|
||||
if a == 100, (64...127).contains(Int(b)) { return true }
|
||||
return false
|
||||
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
|
||||
}
|
||||
|
||||
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
final class CanvasFileWatcher: @unchecked Sendable {
|
||||
private let watcher: CoalescingFSEventsWatcher
|
||||
final class CanvasFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
|
||||
let watcher: SimpleFileWatcher
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.watcher = CoalescingFSEventsWatcher(
|
||||
self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
|
||||
paths: [url.path],
|
||||
queueLabel: "ai.openclaw.canvaswatcher",
|
||||
onChange: onChange)
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stop()
|
||||
}
|
||||
|
||||
func start() {
|
||||
self.watcher.start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.watcher.stop()
|
||||
onChange: onChange))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,22 @@ extension CanvasWindowController {
|
||||
}
|
||||
|
||||
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||
CanvasA2UIActionMessageHandler.parseIPv4(host)
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 4 else { return nil }
|
||||
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
|
||||
guard bytes.count == 4 else { return nil }
|
||||
return (bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
}
|
||||
|
||||
static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip)
|
||||
let (a, b, _, _) = ip
|
||||
if a == 10 { return true }
|
||||
if a == 172, (16...31).contains(Int(b)) { return true }
|
||||
if a == 192, b == 168 { return true }
|
||||
if a == 127 { return true }
|
||||
if a == 169, b == 254 { return true }
|
||||
if a == 100, (64...127).contains(Int(b)) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
|
||||
@@ -274,25 +274,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
func applyDebugStatusIfNeeded() {
|
||||
let enabled = self.debugStatusEnabled
|
||||
let title = Self.jsOptionalStringLiteral(self.debugStatusTitle)
|
||||
let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle)
|
||||
let js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__openclaw;
|
||||
if (!api) return;
|
||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
|
||||
}
|
||||
if (!\(enabled ? "true" : "false")) return;
|
||||
if (typeof api.setStatus === 'function') {
|
||||
api.setStatus(\(title), \(subtitle));
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
"""
|
||||
self.webView.evaluateJavaScript(js) { _, _ in }
|
||||
WebViewJavaScriptSupport.applyDebugStatus(
|
||||
webView: self.webView,
|
||||
enabled: self.debugStatusEnabled,
|
||||
title: self.debugStatusTitle,
|
||||
subtitle: self.debugStatusSubtitle)
|
||||
}
|
||||
|
||||
private func loadFile(_ url: URL) {
|
||||
@@ -302,19 +288,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
func eval(javaScript: String) async throws -> String {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.webView.evaluateJavaScript(javaScript) { result, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if let result {
|
||||
cont.resume(returning: String(describing: result))
|
||||
} else {
|
||||
cont.resume(returning: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
try await WebViewJavaScriptSupport.evaluateToString(webView: self.webView, javaScript: javaScript)
|
||||
}
|
||||
|
||||
func snapshot(to outPath: String?) async throws -> String {
|
||||
|
||||
@@ -9,6 +9,90 @@ extension ChannelsSettings {
|
||||
self.store.snapshot?.decodeChannel(id, as: type)
|
||||
}
|
||||
|
||||
private func configuredChannelTint(configured: Bool, running: Bool, hasError: Bool, probeOk: Bool?) -> Color {
|
||||
if !configured { return .secondary }
|
||||
if hasError { return .orange }
|
||||
if probeOk == false { return .orange }
|
||||
if running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
private func configuredChannelSummary(configured: Bool, running: Bool) -> String {
|
||||
if !configured { return "Not configured" }
|
||||
if running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
private func appendProbeDetails(
|
||||
lines: inout [String],
|
||||
probeOk: Bool?,
|
||||
probeStatus: Int?,
|
||||
probeElapsedMs: Double?,
|
||||
probeVersion: String? = nil,
|
||||
probeError: String? = nil,
|
||||
lastProbeAtMs: Double?,
|
||||
lastError: String?)
|
||||
{
|
||||
if let probeOk {
|
||||
if probeOk {
|
||||
if let version = probeVersion, !version.isEmpty {
|
||||
lines.append("Version \(version)")
|
||||
}
|
||||
if let elapsed = probeElapsedMs {
|
||||
lines.append("Probe \(Int(elapsed))ms")
|
||||
}
|
||||
} else if let probeError, !probeError.isEmpty {
|
||||
lines.append("Probe error: \(probeError)")
|
||||
} else {
|
||||
let code = probeStatus.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
if let last = self.date(fromMs: lastProbeAtMs) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let lastError, !lastError.isEmpty {
|
||||
lines.append("Error: \(lastError)")
|
||||
}
|
||||
}
|
||||
|
||||
private func finishDetails(
|
||||
lines: inout [String],
|
||||
probeOk: Bool?,
|
||||
probeStatus: Int?,
|
||||
probeElapsedMs: Double?,
|
||||
probeVersion: String? = nil,
|
||||
probeError: String? = nil,
|
||||
lastProbeAtMs: Double?,
|
||||
lastError: String?) -> String?
|
||||
{
|
||||
self.appendProbeDetails(
|
||||
lines: &lines,
|
||||
probeOk: probeOk,
|
||||
probeStatus: probeStatus,
|
||||
probeElapsedMs: probeElapsedMs,
|
||||
probeVersion: probeVersion,
|
||||
probeError: probeError,
|
||||
lastProbeAtMs: lastProbeAtMs,
|
||||
lastError: lastError)
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private func finishProbeDetails(
|
||||
lines: inout [String],
|
||||
probe: (ok: Bool?, status: Int?, elapsedMs: Double?),
|
||||
lastProbeAtMs: Double?,
|
||||
lastError: String?) -> String?
|
||||
{
|
||||
self.finishDetails(
|
||||
lines: &lines,
|
||||
probeOk: probe.ok,
|
||||
probeStatus: probe.status,
|
||||
probeElapsedMs: probe.elapsedMs,
|
||||
lastProbeAtMs: lastProbeAtMs,
|
||||
lastError: lastError)
|
||||
}
|
||||
|
||||
var whatsAppTint: Color {
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return .secondary }
|
||||
@@ -23,51 +107,51 @@ extension ChannelsSettings {
|
||||
var telegramTint: Color {
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
return self.configuredChannelTint(
|
||||
configured: status.configured,
|
||||
running: status.running,
|
||||
hasError: status.lastError != nil,
|
||||
probeOk: status.probe?.ok)
|
||||
}
|
||||
|
||||
var discordTint: Color {
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
return self.configuredChannelTint(
|
||||
configured: status.configured,
|
||||
running: status.running,
|
||||
hasError: status.lastError != nil,
|
||||
probeOk: status.probe?.ok)
|
||||
}
|
||||
|
||||
var googlechatTint: Color {
|
||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
return self.configuredChannelTint(
|
||||
configured: status.configured,
|
||||
running: status.running,
|
||||
hasError: status.lastError != nil,
|
||||
probeOk: status.probe?.ok)
|
||||
}
|
||||
|
||||
var signalTint: Color {
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
return self.configuredChannelTint(
|
||||
configured: status.configured,
|
||||
running: status.running,
|
||||
hasError: status.lastError != nil,
|
||||
probeOk: status.probe?.ok)
|
||||
}
|
||||
|
||||
var imessageTint: Color {
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
return self.configuredChannelTint(
|
||||
configured: status.configured,
|
||||
running: status.running,
|
||||
hasError: status.lastError != nil,
|
||||
probeOk: status.probe?.ok)
|
||||
}
|
||||
|
||||
var whatsAppSummary: String {
|
||||
@@ -82,41 +166,31 @@ extension ChannelsSettings {
|
||||
var telegramSummary: String {
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||
}
|
||||
|
||||
var discordSummary: String {
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||
}
|
||||
|
||||
var googlechatSummary: String {
|
||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||
}
|
||||
|
||||
var signalSummary: String {
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||
}
|
||||
|
||||
var imessageSummary: String {
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||
}
|
||||
|
||||
var whatsAppDetails: String? {
|
||||
@@ -168,18 +242,15 @@ extension ChannelsSettings {
|
||||
if let url = probe.webhook?.url, !url.isEmpty {
|
||||
lines.append("Webhook: \(url)")
|
||||
}
|
||||
} else {
|
||||
let code = probe.status.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
return self.finishDetails(
|
||||
lines: &lines,
|
||||
probeOk: status.probe?.ok,
|
||||
probeStatus: status.probe?.status,
|
||||
probeElapsedMs: nil,
|
||||
lastProbeAtMs: status.lastProbeAt,
|
||||
lastError: status.lastError)
|
||||
}
|
||||
|
||||
var discordDetails: String? {
|
||||
@@ -189,26 +260,17 @@ extension ChannelsSettings {
|
||||
if let source = status.tokenSource {
|
||||
lines.append("Token source: \(source)")
|
||||
}
|
||||
if let probe = status.probe {
|
||||
if probe.ok {
|
||||
if let name = probe.bot?.username {
|
||||
lines.append("Bot: @\(name)")
|
||||
}
|
||||
if let elapsed = probe.elapsedMs {
|
||||
lines.append("Probe \(Int(elapsed))ms")
|
||||
}
|
||||
} else {
|
||||
let code = probe.status.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
if let name = status.probe?.bot?.username, !name.isEmpty {
|
||||
lines.append("Bot: @\(name)")
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
return self.finishProbeDetails(
|
||||
lines: &lines,
|
||||
probe: (
|
||||
ok: status.probe?.ok,
|
||||
status: status.probe?.status,
|
||||
elapsedMs: status.probe?.elapsedMs),
|
||||
lastProbeAtMs: status.lastProbeAt,
|
||||
lastError: status.lastError)
|
||||
}
|
||||
|
||||
var googlechatDetails: String? {
|
||||
@@ -223,23 +285,14 @@ extension ChannelsSettings {
|
||||
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
|
||||
lines.append("Audience: \(label)")
|
||||
}
|
||||
if let probe = status.probe {
|
||||
if probe.ok {
|
||||
if let elapsed = probe.elapsedMs {
|
||||
lines.append("Probe \(Int(elapsed))ms")
|
||||
}
|
||||
} else {
|
||||
let code = probe.status.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
return self.finishProbeDetails(
|
||||
lines: &lines,
|
||||
probe: (
|
||||
ok: status.probe?.ok,
|
||||
status: status.probe?.status,
|
||||
elapsedMs: status.probe?.elapsedMs),
|
||||
lastProbeAtMs: status.lastProbeAt,
|
||||
lastError: status.lastError)
|
||||
}
|
||||
|
||||
var signalDetails: String? {
|
||||
@@ -247,26 +300,14 @@ extension ChannelsSettings {
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
lines.append("Base URL: \(status.baseUrl)")
|
||||
if let probe = status.probe {
|
||||
if probe.ok {
|
||||
if let version = probe.version, !version.isEmpty {
|
||||
lines.append("Version \(version)")
|
||||
}
|
||||
if let elapsed = probe.elapsedMs {
|
||||
lines.append("Probe \(Int(elapsed))ms")
|
||||
}
|
||||
} else {
|
||||
let code = probe.status.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
return self.finishDetails(
|
||||
lines: &lines,
|
||||
probeOk: status.probe?.ok,
|
||||
probeStatus: status.probe?.status,
|
||||
probeElapsedMs: status.probe?.elapsedMs,
|
||||
probeVersion: status.probe?.version,
|
||||
lastProbeAtMs: status.lastProbeAt,
|
||||
lastError: status.lastError)
|
||||
}
|
||||
|
||||
var imessageDetails: String? {
|
||||
@@ -279,17 +320,14 @@ extension ChannelsSettings {
|
||||
if let dbPath = status.dbPath, !dbPath.isEmpty {
|
||||
lines.append("DB: \(dbPath)")
|
||||
}
|
||||
if let probe = status.probe, !probe.ok {
|
||||
let err = probe.error ?? "probe failed"
|
||||
lines.append("Probe error: \(err)")
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
return self.finishDetails(
|
||||
lines: &lines,
|
||||
probeOk: status.probe?.ok,
|
||||
probeStatus: nil,
|
||||
probeElapsedMs: nil,
|
||||
probeError: status.probe?.error,
|
||||
lastProbeAtMs: status.lastProbeAt,
|
||||
lastError: status.lastError)
|
||||
}
|
||||
|
||||
var orderedChannels: [ChannelItem] {
|
||||
|
||||
@@ -18,7 +18,7 @@ extension ChannelsSettings {
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
ScrollView {
|
||||
SettingsSidebarScroll {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
if !self.enabledChannels.isEmpty {
|
||||
self.sidebarSectionHeader("Configured")
|
||||
@@ -34,14 +34,7 @@ extension ChannelsSettings {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
private var detail: some View {
|
||||
|
||||
14
apps/macos/Sources/OpenClaw/ColorHexSupport.swift
Normal file
14
apps/macos/Sources/OpenClaw/ColorHexSupport.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
enum ColorHexSupport {
|
||||
static func color(fromHex raw: String?) -> Color? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||
let b = Double(value & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
final class ConfigFileWatcher: @unchecked Sendable {
|
||||
final class ConfigFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
|
||||
private let url: URL
|
||||
private let watchedDir: URL
|
||||
private let targetPath: String
|
||||
private let targetName: String
|
||||
private let watcher: CoalescingFSEventsWatcher
|
||||
let watcher: SimpleFileWatcher
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.url = url
|
||||
@@ -15,7 +15,7 @@ final class ConfigFileWatcher: @unchecked Sendable {
|
||||
let watchedDirPath = self.watchedDir.path
|
||||
let targetPath = self.targetPath
|
||||
let targetName = self.targetName
|
||||
self.watcher = CoalescingFSEventsWatcher(
|
||||
self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
|
||||
paths: [watchedDirPath],
|
||||
queueLabel: "ai.openclaw.configwatcher",
|
||||
shouldNotify: { _, eventPaths in
|
||||
@@ -28,18 +28,6 @@ final class ConfigFileWatcher: @unchecked Sendable {
|
||||
}
|
||||
return false
|
||||
},
|
||||
onChange: onChange)
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stop()
|
||||
}
|
||||
|
||||
func start() {
|
||||
self.watcher.start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.watcher.stop()
|
||||
onChange: onChange))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ extension ConfigSettings {
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
ScrollView {
|
||||
SettingsSidebarScroll {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
if self.sections.isEmpty {
|
||||
Text("No config sections available.")
|
||||
@@ -86,14 +86,7 @@ extension ConfigSettings {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
private var detail: some View {
|
||||
|
||||
@@ -6,10 +6,6 @@ struct ContextMenuCardView: View {
|
||||
private let rows: [SessionRow]
|
||||
private let statusText: String?
|
||||
private let isLoading: Bool
|
||||
private let paddingTop: CGFloat = 8
|
||||
private let paddingBottom: CGFloat = 8
|
||||
private let paddingTrailing: CGFloat = 10
|
||||
private let paddingLeading: CGFloat = 20
|
||||
private let barHeight: CGFloat = 3
|
||||
|
||||
init(
|
||||
@@ -23,45 +19,32 @@ struct ContextMenuCardView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Context")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let statusText {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.rows.isEmpty, !self.isLoading {
|
||||
Text("No active sessions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if self.rows.isEmpty, self.isLoading {
|
||||
ForEach(0..<2, id: \.self) { _ in
|
||||
self.placeholderRow
|
||||
}
|
||||
} else {
|
||||
ForEach(self.rows) { row in
|
||||
self.sessionRow(row)
|
||||
MenuHeaderCard(
|
||||
title: "Context",
|
||||
subtitle: self.subtitle,
|
||||
statusText: self.statusText,
|
||||
paddingBottom: 8)
|
||||
{
|
||||
if self.statusText == nil {
|
||||
if self.rows.isEmpty, !self.isLoading {
|
||||
Text("No active sessions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if self.rows.isEmpty, self.isLoading {
|
||||
ForEach(0..<2, id: \.self) { _ in
|
||||
self.placeholderRow
|
||||
}
|
||||
} else {
|
||||
ForEach(self.rows) { row in
|
||||
self.sessionRow(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, self.paddingTop)
|
||||
.padding(.bottom, self.paddingBottom)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
.padding(.trailing, self.paddingTrailing)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
||||
.transaction { txn in txn.animation = nil }
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
|
||||
@@ -336,16 +336,8 @@ final class ControlChannel {
|
||||
}
|
||||
|
||||
private func startEventStream() {
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
}
|
||||
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -258,14 +258,6 @@ extension CronJobEditor {
|
||||
}
|
||||
|
||||
func formatDuration(ms: Int) -> String {
|
||||
if ms < 1000 { return "\(ms)ms" }
|
||||
let s = Double(ms) / 1000.0
|
||||
if s < 60 { return "\(Int(round(s)))s" }
|
||||
let m = s / 60.0
|
||||
if m < 60 { return "\(Int(round(m)))m" }
|
||||
let h = m / 60.0
|
||||
if h < 48 { return "\(Int(round(h)))h" }
|
||||
let d = h / 24.0
|
||||
return "\(Int(round(d)))d"
|
||||
DurationFormattingSupport.conciseDuration(ms: ms)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ final class CronJobsStore {
|
||||
func start() {
|
||||
guard !self.isPreview else { return }
|
||||
guard self.eventTask == nil else { return }
|
||||
self.startGatewaySubscription()
|
||||
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
self.pollTask = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refreshJobs()
|
||||
@@ -142,20 +144,6 @@ final class CronJobsStore {
|
||||
|
||||
// MARK: - Gateway events
|
||||
|
||||
private func startGatewaySubscription() {
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
switch push {
|
||||
case let .event(evt) where evt.event == "cron":
|
||||
|
||||
@@ -31,15 +31,7 @@ extension CronSettings {
|
||||
}
|
||||
|
||||
func formatDuration(ms: Int) -> String {
|
||||
if ms < 1000 { return "\(ms)ms" }
|
||||
let s = Double(ms) / 1000.0
|
||||
if s < 60 { return "\(Int(round(s)))s" }
|
||||
let m = s / 60.0
|
||||
if m < 60 { return "\(Int(round(m)))m" }
|
||||
let h = m / 60.0
|
||||
if h < 48 { return "\(Int(round(h)))h" }
|
||||
let d = h / 24.0
|
||||
return "\(Int(round(d)))d"
|
||||
DurationFormattingSupport.conciseDuration(ms: ms)
|
||||
}
|
||||
|
||||
func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
|
||||
|
||||
@@ -17,9 +17,7 @@ final class DevicePairingApprovalPrompter {
|
||||
private var queue: [PendingRequest] = []
|
||||
var pendingCount: Int = 0
|
||||
var pendingRepairCount: Int = 0
|
||||
private var activeAlert: NSAlert?
|
||||
private var activeRequestId: String?
|
||||
private var alertHostWindow: NSWindow?
|
||||
private let alertState = PairingAlertState()
|
||||
private var resolvedByRequestId: Set<String> = []
|
||||
|
||||
private struct PairingList: Codable {
|
||||
@@ -55,48 +53,35 @@ final class DevicePairingApprovalPrompter {
|
||||
}
|
||||
}
|
||||
|
||||
private struct PairingResolvedEvent: Codable {
|
||||
let requestId: String
|
||||
let deviceId: String
|
||||
let decision: String
|
||||
let ts: Double
|
||||
}
|
||||
|
||||
private enum PairingResolution: String {
|
||||
case approved
|
||||
case rejected
|
||||
}
|
||||
private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
self.isStopping = false
|
||||
self.task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = try? await GatewayConnection.shared.refresh()
|
||||
await self.loadPendingRequestsFromGateway()
|
||||
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in self?.handle(push: push) }
|
||||
}
|
||||
}
|
||||
self.startPushTask()
|
||||
}
|
||||
|
||||
private func startPushTask() {
|
||||
PairingAlertSupport.startPairingPushTask(
|
||||
task: &self.task,
|
||||
isStopping: &self.isStopping,
|
||||
loadPending: self.loadPendingRequestsFromGateway,
|
||||
handlePush: self.handle(push:))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.isStopping = true
|
||||
self.endActiveAlert()
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
self.queue.removeAll(keepingCapacity: false)
|
||||
self.stopPushTask()
|
||||
self.updatePendingCounts()
|
||||
self.isPresenting = false
|
||||
self.activeRequestId = nil
|
||||
self.alertHostWindow?.orderOut(nil)
|
||||
self.alertHostWindow?.close()
|
||||
self.alertHostWindow = nil
|
||||
self.resolvedByRequestId.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private func stopPushTask() {
|
||||
PairingAlertSupport.stopPairingPrompter(
|
||||
isStopping: &self.isStopping,
|
||||
task: &self.task,
|
||||
queue: &self.queue,
|
||||
isPresenting: &self.isPresenting,
|
||||
state: self.alertState)
|
||||
}
|
||||
|
||||
private func loadPendingRequestsFromGateway() async {
|
||||
do {
|
||||
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
|
||||
@@ -127,44 +112,13 @@ final class DevicePairingApprovalPrompter {
|
||||
|
||||
private func presentAlert(for req: PendingRequest) {
|
||||
self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow device to connect?"
|
||||
alert.informativeText = Self.describe(req)
|
||||
alert.addButton(withTitle: "Later")
|
||||
alert.addButton(withTitle: "Approve")
|
||||
alert.addButton(withTitle: "Reject")
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||
alert.buttons[2].hasDestructiveAction = true
|
||||
}
|
||||
|
||||
self.activeAlert = alert
|
||||
self.activeRequestId = req.requestId
|
||||
let hostWindow = self.requireAlertHostWindow()
|
||||
|
||||
let sheetSize = alert.window.frame.size
|
||||
if let screen = hostWindow.screen ?? NSScreen.main {
|
||||
let bounds = screen.visibleFrame
|
||||
let x = bounds.midX - (sheetSize.width / 2)
|
||||
let sheetOriginY = bounds.midY - (sheetSize.height / 2)
|
||||
let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
|
||||
hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
|
||||
} else {
|
||||
hostWindow.center()
|
||||
}
|
||||
|
||||
hostWindow.makeKeyAndOrderFront(nil)
|
||||
alert.beginSheetModal(for: hostWindow) { [weak self] response in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeRequestId = nil
|
||||
self.activeAlert = nil
|
||||
await self.handleAlertResponse(response, request: req)
|
||||
hostWindow.orderOut(nil)
|
||||
}
|
||||
}
|
||||
PairingAlertSupport.presentPairingAlert(
|
||||
request: req,
|
||||
requestId: req.requestId,
|
||||
messageText: "Allow device to connect?",
|
||||
informativeText: Self.describe(req),
|
||||
state: self.alertState,
|
||||
onResponse: self.handleAlertResponse)
|
||||
}
|
||||
|
||||
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||
@@ -206,33 +160,27 @@ final class DevicePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
private func approve(requestId: String) async -> Bool {
|
||||
do {
|
||||
await PairingAlertSupport.approveRequest(
|
||||
requestId: requestId,
|
||||
kind: "device",
|
||||
logger: self.logger)
|
||||
{
|
||||
try await GatewayConnection.shared.devicePairApprove(requestId: requestId)
|
||||
self.logger.info("approved device pairing requestId=\(requestId, privacy: .public)")
|
||||
return true
|
||||
} catch {
|
||||
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
||||
self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func reject(requestId: String) async {
|
||||
do {
|
||||
await PairingAlertSupport.rejectRequest(
|
||||
requestId: requestId,
|
||||
kind: "device",
|
||||
logger: self.logger)
|
||||
{
|
||||
try await GatewayConnection.shared.devicePairReject(requestId: requestId)
|
||||
self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
||||
self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func endActiveAlert() {
|
||||
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
|
||||
}
|
||||
|
||||
private func requireAlertHostWindow() -> NSWindow {
|
||||
PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
|
||||
PairingAlertSupport.endActiveAlert(state: self.alertState)
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
@@ -269,9 +217,10 @@ final class DevicePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
private func handleResolved(_ resolved: PairingResolvedEvent) {
|
||||
let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution
|
||||
.approved : .rejected
|
||||
if let activeRequestId, activeRequestId == resolved.requestId {
|
||||
let resolution = resolved.decision == PairingAlertSupport.PairingResolution.approved.rawValue
|
||||
? PairingAlertSupport.PairingResolution.approved
|
||||
: PairingAlertSupport.PairingResolution.rejected
|
||||
if let activeRequestId = self.alertState.activeRequestId, activeRequestId == resolved.requestId {
|
||||
self.resolvedByRequestId.insert(resolved.requestId)
|
||||
self.endActiveAlert()
|
||||
let decision = resolution.rawValue
|
||||
|
||||
15
apps/macos/Sources/OpenClaw/DurationFormattingSupport.swift
Normal file
15
apps/macos/Sources/OpenClaw/DurationFormattingSupport.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
enum DurationFormattingSupport {
|
||||
static func conciseDuration(ms: Int) -> String {
|
||||
if ms < 1000 { return "\(ms)ms" }
|
||||
let s = Double(ms) / 1000.0
|
||||
if s < 60 { return "\(Int(round(s)))s" }
|
||||
let m = s / 60.0
|
||||
if m < 60 { return "\(Int(round(m)))m" }
|
||||
let h = m / 60.0
|
||||
if h < 48 { return "\(Int(round(h)))h" }
|
||||
let d = h / 24.0
|
||||
return "\(Int(round(d)))d"
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,13 @@ final class ExecApprovalsGatewayPrompter {
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
self.task = Task { [weak self] in
|
||||
SimpleTaskSupport.start(task: &self.task) { [weak self] in
|
||||
await self?.run()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
SimpleTaskSupport.stop(task: &self.task)
|
||||
}
|
||||
|
||||
private func run() async {
|
||||
|
||||
@@ -73,6 +73,22 @@ private struct ExecHostResponse: Codable {
|
||||
var error: ExecHostError?
|
||||
}
|
||||
|
||||
private func readLineFromHandle(_ handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||
var buffer = Data()
|
||||
while buffer.count < maxBytes {
|
||||
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||
if chunk.isEmpty { break }
|
||||
buffer.append(chunk)
|
||||
if buffer.contains(0x0A) { break }
|
||||
}
|
||||
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
return String(data: buffer, encoding: .utf8)
|
||||
}
|
||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
enum ExecApprovalsSocketClient {
|
||||
private struct TimeoutError: LocalizedError {
|
||||
var message: String
|
||||
@@ -159,28 +175,12 @@ enum ExecApprovalsSocketClient {
|
||||
payload.append(0x0A)
|
||||
try handle.write(contentsOf: payload)
|
||||
|
||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||
guard let line = try readLineFromHandle(handle, maxBytes: 256_000),
|
||||
let lineData = line.data(using: .utf8)
|
||||
else { return nil }
|
||||
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
|
||||
return response.decision
|
||||
}
|
||||
|
||||
private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||
var buffer = Data()
|
||||
while buffer.count < maxBytes {
|
||||
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||
if chunk.isEmpty { break }
|
||||
buffer.append(chunk)
|
||||
if buffer.contains(0x0A) { break }
|
||||
}
|
||||
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
return String(data: buffer, encoding: .utf8)
|
||||
}
|
||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -781,7 +781,7 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
|
||||
return
|
||||
}
|
||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||
guard let line = try readLineFromHandle(handle, maxBytes: 256_000),
|
||||
let data = line.data(using: .utf8)
|
||||
else {
|
||||
return
|
||||
@@ -815,22 +815,6 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||
var buffer = Data()
|
||||
while buffer.count < maxBytes {
|
||||
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||
if chunk.isEmpty { break }
|
||||
buffer.append(chunk)
|
||||
if buffer.contains(0x0A) { break }
|
||||
}
|
||||
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
return String(data: buffer, encoding: .utf8)
|
||||
}
|
||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func sendApprovalResponse(
|
||||
handle: FileHandle,
|
||||
id: String,
|
||||
|
||||
@@ -12,19 +12,6 @@ enum ExecCommandToken {
|
||||
enum ExecEnvInvocationUnwrapper {
|
||||
static let maxWrapperDepth = 4
|
||||
|
||||
private static let optionsWithValue = Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
])
|
||||
private static let flagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
|
||||
|
||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||
return token.range(of: pattern, options: .regularExpression) != nil
|
||||
@@ -55,11 +42,11 @@ enum ExecEnvInvocationUnwrapper {
|
||||
if token.hasPrefix("-"), token != "-" {
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if self.flagOptions.contains(flag) {
|
||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if self.optionsWithValue.contains(flag) {
|
||||
if ExecEnvOptions.withValue.contains(flag) {
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
}
|
||||
|
||||
29
apps/macos/Sources/OpenClaw/ExecEnvOptions.swift
Normal file
29
apps/macos/Sources/OpenClaw/ExecEnvOptions.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
enum ExecEnvOptions {
|
||||
static let withValue = Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
])
|
||||
|
||||
static let flagOnly = Set(["-i", "--ignore-environment", "-0", "--null"])
|
||||
|
||||
static let inlineValuePrefixes = [
|
||||
"-u",
|
||||
"-c",
|
||||
"-s",
|
||||
"--unset=",
|
||||
"--chdir=",
|
||||
"--split-string=",
|
||||
"--default-signal=",
|
||||
"--ignore-signal=",
|
||||
"--block-signal=",
|
||||
]
|
||||
}
|
||||
@@ -39,30 +39,6 @@ enum ExecSystemRunCommandValidator {
|
||||
private static let posixInlineCommandFlags = Set(["-lc", "-c", "--command"])
|
||||
private static let powershellInlineCommandFlags = Set(["-c", "-command", "--command"])
|
||||
|
||||
private static let envOptionsWithValue = Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
])
|
||||
private static let envFlagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
|
||||
private static let envInlineValuePrefixes = [
|
||||
"-u",
|
||||
"-c",
|
||||
"-s",
|
||||
"--unset=",
|
||||
"--chdir=",
|
||||
"--split-string=",
|
||||
"--default-signal=",
|
||||
"--ignore-signal=",
|
||||
"--block-signal=",
|
||||
]
|
||||
|
||||
private struct EnvUnwrapResult {
|
||||
let argv: [String]
|
||||
let usesModifiers: Bool
|
||||
@@ -113,7 +89,7 @@ enum ExecSystemRunCommandValidator {
|
||||
}
|
||||
|
||||
private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool {
|
||||
self.envInlineValuePrefixes.contains { lowerToken.hasPrefix($0) }
|
||||
ExecEnvOptions.inlineValuePrefixes.contains { lowerToken.hasPrefix($0) }
|
||||
}
|
||||
|
||||
private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? {
|
||||
@@ -148,12 +124,12 @@ enum ExecSystemRunCommandValidator {
|
||||
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if self.envFlagOptions.contains(flag) {
|
||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if self.envOptionsWithValue.contains(flag) {
|
||||
if ExecEnvOptions.withValue.contains(flag) {
|
||||
usesModifiers = true
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
@@ -301,10 +277,15 @@ enum ExecSystemRunCommandValidator {
|
||||
return current
|
||||
}
|
||||
|
||||
private static func resolveInlineCommandTokenIndex(
|
||||
private struct InlineCommandTokenMatch {
|
||||
var tokenIndex: Int
|
||||
var inlineCommand: String?
|
||||
}
|
||||
|
||||
private static func findInlineCommandTokenMatch(
|
||||
_ argv: [String],
|
||||
flags: Set<String>,
|
||||
allowCombinedC: Bool) -> Int?
|
||||
allowCombinedC: Bool) -> InlineCommandTokenMatch?
|
||||
{
|
||||
var idx = 1
|
||||
while idx < argv.count {
|
||||
@@ -318,21 +299,35 @@ enum ExecSystemRunCommandValidator {
|
||||
break
|
||||
}
|
||||
if flags.contains(lower) {
|
||||
return idx + 1 < argv.count ? idx + 1 : nil
|
||||
return InlineCommandTokenMatch(tokenIndex: idx, inlineCommand: nil)
|
||||
}
|
||||
if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) {
|
||||
let inline = String(token.dropFirst(inlineOffset))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !inline.isEmpty {
|
||||
return idx
|
||||
}
|
||||
return idx + 1 < argv.count ? idx + 1 : nil
|
||||
return InlineCommandTokenMatch(
|
||||
tokenIndex: idx,
|
||||
inlineCommand: inline.isEmpty ? nil : inline)
|
||||
}
|
||||
idx += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveInlineCommandTokenIndex(
|
||||
_ argv: [String],
|
||||
flags: Set<String>,
|
||||
allowCombinedC: Bool) -> Int?
|
||||
{
|
||||
guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
|
||||
return nil
|
||||
}
|
||||
if match.inlineCommand != nil {
|
||||
return match.tokenIndex
|
||||
}
|
||||
let nextIndex = match.tokenIndex + 1
|
||||
return nextIndex < argv.count ? nextIndex : nil
|
||||
}
|
||||
|
||||
private static func combinedCommandInlineOffset(_ token: String) -> Int? {
|
||||
let chars = Array(token.lowercased())
|
||||
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
|
||||
@@ -371,30 +366,14 @@ enum ExecSystemRunCommandValidator {
|
||||
flags: Set<String>,
|
||||
allowCombinedC: Bool) -> String?
|
||||
{
|
||||
var idx = 1
|
||||
while idx < argv.count {
|
||||
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
let lower = token.lowercased()
|
||||
if lower == "--" {
|
||||
break
|
||||
}
|
||||
if flags.contains(lower) {
|
||||
return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil)
|
||||
}
|
||||
if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) {
|
||||
let inline = String(token.dropFirst(inlineOffset))
|
||||
if let inlineValue = self.trimmedNonEmpty(inline) {
|
||||
return inlineValue
|
||||
}
|
||||
return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil)
|
||||
}
|
||||
idx += 1
|
||||
guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
if let inlineCommand = match.inlineCommand {
|
||||
return inlineCommand
|
||||
}
|
||||
let nextIndex = match.tokenIndex + 1
|
||||
return self.trimmedNonEmpty(nextIndex < argv.count ? argv[nextIndex] : nil)
|
||||
}
|
||||
|
||||
private static func extractCmdInlineCommand(_ argv: [String]) -> String? {
|
||||
|
||||
@@ -48,27 +48,11 @@ struct GatewayDiscoveryInlineList: View {
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if selected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
} else {
|
||||
Image(systemName: "arrow.right.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
SelectionStateIndicator(selected: selected)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.rowBackground(
|
||||
selected: selected,
|
||||
hovered: self.hoveredGatewayID == gateway.id)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
selected ? Color.accentColor.opacity(0.45) : Color.clear,
|
||||
lineWidth: 1))
|
||||
.openClawSelectableRowChrome(
|
||||
selected: selected,
|
||||
hovered: self.hoveredGatewayID == gateway.id)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -106,12 +90,6 @@ struct GatewayDiscoveryInlineList: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
||||
if selected { return Color.accentColor.opacity(0.12) }
|
||||
if hovered { return Color.secondary.opacity(0.08) }
|
||||
return Color.clear
|
||||
}
|
||||
|
||||
private func trimmed(_ value: String?) -> String {
|
||||
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import OpenClawDiscovery
|
||||
|
||||
@MainActor
|
||||
enum GatewayDiscoverySelectionSupport {
|
||||
static func applyRemoteSelection(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
state: AppState)
|
||||
{
|
||||
if state.remoteTransport == .direct {
|
||||
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
|
||||
} else {
|
||||
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
|
||||
}
|
||||
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port)
|
||||
} else {
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,21 +347,8 @@ actor GatewayEndpointStore {
|
||||
|
||||
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
|
||||
func ensureRemoteControlTunnel() async throws -> UInt16 {
|
||||
let mode = await self.deps.mode()
|
||||
guard mode == .remote else {
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
try await self.requireRemoteMode()
|
||||
if let url = try self.resolveDirectRemoteURL() {
|
||||
guard let port = GatewayRemoteConfig.defaultPort(for: url),
|
||||
let portInt = UInt16(exactly: port)
|
||||
else {
|
||||
@@ -425,22 +412,9 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
|
||||
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
|
||||
let mode = await self.deps.mode()
|
||||
guard mode == .remote else {
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
try await self.requireRemoteMode()
|
||||
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
if let url = try self.resolveDirectRemoteURL() {
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
self.cancelRemoteEnsure()
|
||||
@@ -491,6 +465,27 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
}
|
||||
|
||||
private func requireRemoteMode() async throws {
|
||||
guard await self.deps.mode() == .remote else {
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDirectRemoteURL() throws -> URL? {
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
guard GatewayRemoteConfig.resolveTransport(root: root) == .direct else { return nil }
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func removeSubscriber(_ id: UUID) {
|
||||
self.subscribers[id] = nil
|
||||
}
|
||||
|
||||
@@ -180,25 +180,11 @@ extension GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let start = trimmed.firstIndex(of: "{"),
|
||||
let end = trimmed.lastIndex(of: "}")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let jsonText = String(trimmed[start...end])
|
||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
return ParsedDaemonJson(text: jsonText, object: object)
|
||||
guard let parsed = JSONObjectExtractionSupport.extract(from: raw) else { return nil }
|
||||
return ParsedDaemonJson(text: parsed.text, object: parsed.object)
|
||||
}
|
||||
|
||||
private static func summarize(_ text: String) -> String? {
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let last = lines.last else { return nil }
|
||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||
TextSummarySupport.summarizeLastLine(text)
|
||||
}
|
||||
}
|
||||
|
||||
34
apps/macos/Sources/OpenClaw/GatewayPushSubscription.swift
Normal file
34
apps/macos/Sources/OpenClaw/GatewayPushSubscription.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import OpenClawKit
|
||||
|
||||
enum GatewayPushSubscription {
|
||||
@MainActor
|
||||
static func consume(
|
||||
bufferingNewest: Int? = nil,
|
||||
onPush: @escaping @MainActor (GatewayPush) -> Void) async
|
||||
{
|
||||
let stream: AsyncStream<GatewayPush> = if let bufferingNewest {
|
||||
await GatewayConnection.shared.subscribe(bufferingNewest: bufferingNewest)
|
||||
} else {
|
||||
await GatewayConnection.shared.subscribe()
|
||||
}
|
||||
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run {
|
||||
onPush(push)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func restartTask(
|
||||
task: inout Task<Void, Never>?,
|
||||
bufferingNewest: Int? = nil,
|
||||
onPush: @escaping @MainActor (GatewayPush) -> Void)
|
||||
{
|
||||
task?.cancel()
|
||||
task = Task {
|
||||
await self.consume(bufferingNewest: bufferingNewest, onPush: onPush)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,7 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OpenClawKit
|
||||
|
||||
enum GatewayRemoteConfig {
|
||||
private static func isLoopbackHost(_ rawHost: String) -> Bool {
|
||||
var host = rawHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
|
||||
if host.hasSuffix(".") {
|
||||
host.removeLast()
|
||||
}
|
||||
if let zoneIndex = host.firstIndex(of: "%") {
|
||||
host = String(host[..<zoneIndex])
|
||||
}
|
||||
if host.isEmpty {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" || host == "0.0.0.0" || host == "::" {
|
||||
return true
|
||||
}
|
||||
|
||||
if let ipv4 = IPv4Address(host) {
|
||||
return ipv4.rawValue.first == 127
|
||||
}
|
||||
if let ipv6 = IPv6Address(host) {
|
||||
let bytes = Array(ipv6.rawValue)
|
||||
let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1
|
||||
if isV6Loopback {
|
||||
return true
|
||||
}
|
||||
let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF
|
||||
return isMappedV4 && bytes[12] == 127
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
@@ -74,7 +40,7 @@ enum GatewayRemoteConfig {
|
||||
guard scheme == "ws" || scheme == "wss" else { return nil }
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !host.isEmpty else { return nil }
|
||||
if scheme == "ws", !self.isLoopbackHost(host) {
|
||||
if scheme == "ws", !LoopbackHost.isLoopbackHost(host) {
|
||||
return nil
|
||||
}
|
||||
if scheme == "ws", url.port == nil {
|
||||
|
||||
@@ -260,17 +260,7 @@ struct GeneralSettings: View {
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || !canTest)
|
||||
self.remoteTestButton(disabled: !canTest)
|
||||
}
|
||||
if let validationMessage {
|
||||
Text(validationMessage)
|
||||
@@ -290,18 +280,8 @@ struct GeneralSettings: View {
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
self.remoteTestButton(
|
||||
disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
Text(
|
||||
"Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.")
|
||||
@@ -311,6 +291,20 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func remoteTestButton(disabled: Bool) -> some View {
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || disabled)
|
||||
}
|
||||
|
||||
private var controlStatusLine: String {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: "Connected"
|
||||
@@ -672,19 +666,7 @@ extension GeneralSettings {
|
||||
|
||||
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
|
||||
|
||||
if self.state.remoteTransport == .direct {
|
||||
self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
|
||||
} else {
|
||||
self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
|
||||
}
|
||||
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port)
|
||||
} else {
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
}
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,17 +100,8 @@ final class HoverHUDController {
|
||||
return
|
||||
}
|
||||
|
||||
let target = window.frame.offsetBy(dx: 0, dy: 6)
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.14
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(target, display: true)
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
Task { @MainActor in
|
||||
window.orderOut(nil)
|
||||
self.model.isVisible = false
|
||||
}
|
||||
OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 0, offsetY: 6, duration: 0.14) {
|
||||
self.model.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,15 +131,7 @@ final class HoverHUDController {
|
||||
if !self.model.isVisible {
|
||||
self.model.isVisible = true
|
||||
let start = target.offsetBy(dx: 0, dy: 8)
|
||||
window.setFrame(start, display: true)
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(target, display: true)
|
||||
window.animator().alphaValue = 1
|
||||
}
|
||||
OverlayPanelFactory.animatePresent(window: window, from: start, to: target)
|
||||
} else {
|
||||
window.orderFrontRegardless()
|
||||
self.updateWindowFrame(animate: true)
|
||||
@@ -157,22 +140,10 @@ final class HoverHUDController {
|
||||
|
||||
private func ensureWindow() {
|
||||
if self.window != nil { return }
|
||||
let panel = NSPanel(
|
||||
let panel = OverlayPanelFactory.makePanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height),
|
||||
styleMask: [.nonactivatingPanel, .borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.level = .statusBar
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.isMovable = false
|
||||
panel.isFloatingPanel = true
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.titleVisibility = .hidden
|
||||
panel.titlebarAppearsTransparent = true
|
||||
level: .statusBar,
|
||||
hasShadow: true)
|
||||
|
||||
let host = NSHostingView(rootView: HoverHUDView(controller: self))
|
||||
host.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -201,17 +172,7 @@ final class HoverHUDController {
|
||||
}
|
||||
|
||||
private func updateWindowFrame(animate: Bool = false) {
|
||||
guard let window else { return }
|
||||
let frame = self.targetFrame()
|
||||
if animate {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.12
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(frame, display: true)
|
||||
}
|
||||
} else {
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
|
||||
}
|
||||
|
||||
private func installDismissMonitor() {
|
||||
@@ -231,10 +192,7 @@ final class HoverHUDController {
|
||||
}
|
||||
|
||||
private func removeDismissMonitor() {
|
||||
if let monitor = self.dismissMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
self.dismissMonitor = nil
|
||||
}
|
||||
OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,16 +43,8 @@ struct InstancesSettings: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if self.store.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button {
|
||||
Task { await self.store.refresh() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help("Refresh")
|
||||
SettingsRefreshButton(isLoading: self.store.isLoading) {
|
||||
Task { await self.store.refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +268,7 @@ struct InstancesSettings: View {
|
||||
}
|
||||
|
||||
private func platformIcon(_ raw: String) -> String {
|
||||
let (prefix, _) = self.parsePlatform(raw)
|
||||
let (prefix, _) = PlatformLabelFormatter.parse(raw)
|
||||
switch prefix {
|
||||
case "macos":
|
||||
return "laptopcomputer"
|
||||
@@ -294,31 +286,7 @@ struct InstancesSettings: View {
|
||||
}
|
||||
|
||||
private func prettyPlatform(_ raw: String) -> String? {
|
||||
let (prefix, version) = self.parsePlatform(raw)
|
||||
if prefix.isEmpty { return nil }
|
||||
let name: String = switch prefix {
|
||||
case "macos": "macOS"
|
||||
case "ios": "iOS"
|
||||
case "ipados": "iPadOS"
|
||||
case "tvos": "tvOS"
|
||||
case "watchos": "watchOS"
|
||||
default: prefix.prefix(1).uppercased() + prefix.dropFirst()
|
||||
}
|
||||
guard let version, !version.isEmpty else { return name }
|
||||
let parts = version.split(separator: ".").map(String.init)
|
||||
if parts.count >= 2 {
|
||||
return "\(name) \(parts[0]).\(parts[1])"
|
||||
}
|
||||
return "\(name) \(version)"
|
||||
}
|
||||
|
||||
private func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return ("", nil) }
|
||||
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
let prefix = parts.first?.lowercased() ?? ""
|
||||
let versionToken = parts.dropFirst().first
|
||||
return (prefix, versionToken)
|
||||
PlatformLabelFormatter.pretty(raw)
|
||||
}
|
||||
|
||||
private func presenceUpdateSourceShortText(_ reason: String) -> String? {
|
||||
@@ -450,8 +418,8 @@ extension InstancesSettings {
|
||||
_ = view.prettyPlatform("ipados 17.1")
|
||||
_ = view.prettyPlatform("linux")
|
||||
_ = view.prettyPlatform(" ")
|
||||
_ = view.parsePlatform("macOS 14.1")
|
||||
_ = view.parsePlatform(" ")
|
||||
_ = PlatformLabelFormatter.parse("macOS 14.1")
|
||||
_ = PlatformLabelFormatter.parse(" ")
|
||||
_ = view.presenceUpdateSourceShortText("self")
|
||||
_ = view.presenceUpdateSourceShortText("instances-refresh")
|
||||
_ = view.presenceUpdateSourceShortText("seq gap")
|
||||
|
||||
@@ -62,14 +62,11 @@ final class InstancesStore {
|
||||
self.startCount += 1
|
||||
guard self.startCount == 1 else { return }
|
||||
guard self.task == nil else { return }
|
||||
self.startGatewaySubscription()
|
||||
self.task = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||
await self.refresh()
|
||||
}
|
||||
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
SimpleTaskSupport.startDetachedLoop(task: &self.task, interval: self.interval) { [weak self] in
|
||||
await self?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,20 +81,6 @@ final class InstancesStore {
|
||||
self.eventTask = nil
|
||||
}
|
||||
|
||||
private func startGatewaySubscription() {
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
switch push {
|
||||
case let .event(evt) where evt.event == "presence":
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
enum JSONObjectExtractionSupport {
|
||||
static func extract(from raw: String) -> (text: String, object: [String: Any])? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let start = trimmed.firstIndex(of: "{"),
|
||||
let end = trimmed.lastIndex(of: "}")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let jsonText = String(trimmed[start...end])
|
||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
return (jsonText, object)
|
||||
}
|
||||
}
|
||||
@@ -98,23 +98,42 @@ extension Logger.Message.StringInterpolation {
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawOSLogHandler: LogHandler {
|
||||
private let osLogger: os.Logger
|
||||
var metadata: Logger.Metadata = [:]
|
||||
private func stringifyLogMetadataValue(_ value: Logger.Metadata.Value) -> String {
|
||||
switch value {
|
||||
case let .string(text):
|
||||
text
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { stringifyLogMetadataValue($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(stringifyLogMetadataValue($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
|
||||
private protocol AppLogLevelBackedHandler: LogHandler {
|
||||
var metadata: Logger.Metadata { get set }
|
||||
}
|
||||
|
||||
extension AppLogLevelBackedHandler {
|
||||
var logLevel: Logger.Level {
|
||||
get { AppLogSettings.logLevel() }
|
||||
set { AppLogSettings.setLogLevel(newValue) }
|
||||
}
|
||||
|
||||
init(subsystem: String, category: String) {
|
||||
self.osLogger = os.Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||
get { self.metadata[key] }
|
||||
set { self.metadata[key] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawOSLogHandler: AppLogLevelBackedHandler {
|
||||
private let osLogger: os.Logger
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
init(subsystem: String, category: String) {
|
||||
self.osLogger = os.Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
@@ -157,39 +176,16 @@ struct OpenClawOSLogHandler: LogHandler {
|
||||
guard !metadata.isEmpty else { return message.description }
|
||||
let meta = metadata
|
||||
.sorted(by: { $0.key < $1.key })
|
||||
.map { "\($0.key)=\(self.stringify($0.value))" }
|
||||
.map { "\($0.key)=\(stringifyLogMetadataValue($0.value))" }
|
||||
.joined(separator: " ")
|
||||
return "\(message.description) [\(meta)]"
|
||||
}
|
||||
|
||||
private static func stringify(_ value: Logger.Metadata.Value) -> String {
|
||||
switch value {
|
||||
case let .string(text):
|
||||
text
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawFileLogHandler: LogHandler {
|
||||
struct OpenClawFileLogHandler: AppLogLevelBackedHandler {
|
||||
let label: String
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
var logLevel: Logger.Level {
|
||||
get { AppLogSettings.logLevel() }
|
||||
set { AppLogSettings.setLogLevel(newValue) }
|
||||
}
|
||||
|
||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||
get { self.metadata[key] }
|
||||
set { self.metadata[key] = newValue }
|
||||
}
|
||||
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
@@ -212,21 +208,8 @@ struct OpenClawFileLogHandler: LogHandler {
|
||||
]
|
||||
let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
|
||||
for (key, value) in merged {
|
||||
fields["meta.\(key)"] = Self.stringify(value)
|
||||
fields["meta.\(key)"] = stringifyLogMetadataValue(value)
|
||||
}
|
||||
DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields)
|
||||
}
|
||||
|
||||
private static func stringify(_ value: Logger.Metadata.Value) -> String {
|
||||
switch value {
|
||||
case let .string(text):
|
||||
text
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,17 +228,7 @@ private final class StatusItemMouseHandlerView: NSView {
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let tracking {
|
||||
self.removeTrackingArea(tracking)
|
||||
}
|
||||
let options: NSTrackingArea.Options = [
|
||||
.mouseEnteredAndExited,
|
||||
.activeAlways,
|
||||
.inVisibleRect,
|
||||
]
|
||||
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.tracking = area
|
||||
TrackingAreaSupport.resetMouseTracking(on: self, tracking: &self.tracking, owner: self)
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user