mirror of https://github.com/openclaw/openclaw.git
Compare commits
823 Commits
f8c8845b77
...
4fcbf070b4
| Author | SHA1 | Date |
|---|---|---|
|
|
4fcbf070b4 | |
|
|
c4265a5f16 | |
|
|
26e0a3ee9a | |
|
|
5c5c64b612 | |
|
|
9d3e653ec9 | |
|
|
843e3c1efb | |
|
|
d7ac16788e | |
|
|
4bb8a65edd | |
|
|
9616d1e8ba | |
|
|
a2d73be3a4 | |
|
|
c33375f843 | |
|
|
d230bd9c38 | |
|
|
6a458ef29e | |
|
|
f77a684131 | |
|
|
8e04d1fe15 | |
|
|
3cbf932413 | |
|
|
d1e4ee03ff | |
|
|
8e4a1d87e2 | |
|
|
a97b9014a2 | |
|
|
8851d06429 | |
|
|
37c79f84ba | |
|
|
db20141993 | |
|
|
29fec8bb9f | |
|
|
8aaafa045a | |
|
|
ba6064cc22 | |
|
|
f00db91590 | |
|
|
e3b7ff2f1f | |
|
|
df3a247db2 | |
|
|
f4dbd78afd | |
|
|
946c24d674 | |
|
|
c57b750be4 | |
|
|
4c6a7f84a4 | |
|
|
774b40467b | |
|
|
f4aff83c51 | |
|
|
e5a42c0bec | |
|
|
92fc8065e9 | |
|
|
b5b589d99d | |
|
|
c1a0196826 | |
|
|
b202ac2ad1 | |
|
|
2806f2b878 | |
|
|
9e8df16732 | |
|
|
3928b4872a | |
|
|
8a607d7553 | |
|
|
3704293e6f | |
|
|
2f7e548a57 | |
|
|
b1d8737017 | |
|
|
39b4185d0b | |
|
|
173fe3cb54 | |
|
|
92834c8440 | |
|
|
39377b7a20 | |
|
|
cbec476b6b | |
|
|
432ea11248 | |
|
|
e81442ac80 | |
|
|
678ea77dcf | |
|
|
747609d7d5 | |
|
|
b49e1386d0 | |
|
|
bb06dc7cc9 | |
|
|
d33f3f843a | |
|
|
8db6fcca77 | |
|
|
ac29edf6c3 | |
|
|
e490f450f3 | |
|
|
9bffa3422c | |
|
|
c6e32835d4 | |
|
|
d9bc1920ed | |
|
|
c30cabcca4 | |
|
|
0e893347f6 | |
|
|
d039add663 | |
|
|
133cce23ce | |
|
|
d9c285e930 | |
|
|
62afc4b514 | |
|
|
9aac55d306 | |
|
|
b5ba2101c7 | |
|
|
c08317203d | |
|
|
5c9fae5adc | |
|
|
00891dee90 | |
|
|
61a7f2e7c3 | |
|
|
02a86da23a | |
|
|
2eea93982f | |
|
|
78d2bfc4d8 | |
|
|
2fad7b823e | |
|
|
0ee11d3321 | |
|
|
40c81e9cd3 | |
|
|
64e6df7eea | |
|
|
c79c4ffbfb | |
|
|
439c21e078 | |
|
|
5682ec37fa | |
|
|
e5bca0832f | |
|
|
8746362f5e | |
|
|
16505718e8 | |
|
|
0ce23dc62d | |
|
|
4540c6b3bc | |
|
|
7764f717e9 | |
|
|
0c926a2c5e | |
|
|
17cb60080a | |
|
|
61bf7b8536 | |
|
|
dd6ecd5bfa | |
|
|
105dcd69e7 | |
|
|
e403ed6546 | |
|
|
c1c74f9952 | |
|
|
dac220bd88 | |
|
|
2f5d3b6574 | |
|
|
49a2ff7d01 | |
|
|
be8fc3399e | |
|
|
6e251dcf68 | |
|
|
e7d9648fba | |
|
|
61d171ab0b | |
|
|
32dcae9d01 | |
|
|
2ae8837987 | |
|
|
f6e5b6758e | |
|
|
a6bdf2dfd0 | |
|
|
aa0cb4ef01 | |
|
|
81ecae9d7a | |
|
|
5fc43ff0ec | |
|
|
bc3319207c | |
|
|
94a292686c | |
|
|
4f3ed8f4ab | |
|
|
ad65778818 | |
|
|
7e41ba4cbb | |
|
|
2ce6b77205 | |
|
|
b6d1d0d72d | |
|
|
1f9cc647f8 | |
|
|
f4fef64fc1 | |
|
|
f251e7e2c2 | |
|
|
70459e7fec | |
|
|
a142853032 | |
|
|
a4a5fdcd98 | |
|
|
f1d9fcd407 | |
|
|
3fb629219e | |
|
|
5c40c1c78a | |
|
|
b857a8d8bc | |
|
|
f4a2bbe0c9 | |
|
|
2659fc6c97 | |
|
|
df765f602b | |
|
|
8bc163d15f | |
|
|
eee5d7c6b0 | |
|
|
01674c575e | |
|
|
bed661609e | |
|
|
66e02b296f | |
|
|
c5d905871f | |
|
|
6720bf5be0 | |
|
|
3bc9d9177d | |
|
|
6ad675c1e9 | |
|
|
95b4132674 | |
|
|
e474ac882e | |
|
|
0e6f150c3b | |
|
|
dfcc2fae9f | |
|
|
f0179d3b4a | |
|
|
8622395c8b | |
|
|
7aedb6d442 | |
|
|
013ad58f3c | |
|
|
6a61d5504c | |
|
|
1ac4bac8b1 | |
|
|
6decaebcf2 | |
|
|
c3e78908c7 | |
|
|
97dc493e2a | |
|
|
e885f1999f | |
|
|
74e50d3be3 | |
|
|
55ebdce9c3 | |
|
|
38b09866b8 | |
|
|
8410d5a050 | |
|
|
bcbfbb831e | |
|
|
27e863ce40 | |
|
|
10afde99c1 | |
|
|
5c73ed62d5 | |
|
|
d925b0113f | |
|
|
965bdb2d2d | |
|
|
200625b340 | |
|
|
17bd36bf4d | |
|
|
66cb015bb4 | |
|
|
8b82a0124d | |
|
|
9cfc2d4618 | |
|
|
757077d028 | |
|
|
42d6e35cb4 | |
|
|
d9a604f15f | |
|
|
231589ef66 | |
|
|
258945d4d0 | |
|
|
0acd1f63fc | |
|
|
b61bc4948e | |
|
|
91d9573b55 | |
|
|
c0831927b0 | |
|
|
f4094ab19e | |
|
|
d886ca6474 | |
|
|
5b53481d1d | |
|
|
5197171d7a | |
|
|
66de7311c7 | |
|
|
1ec6b012f8 | |
|
|
7285e04ead | |
|
|
d4b193b581 | |
|
|
fb93acb046 | |
|
|
88de4769de | |
|
|
6e3f0f9fcb | |
|
|
0db62fc6c5 | |
|
|
414e9c87cb | |
|
|
997256d370 | |
|
|
d7637d3a19 | |
|
|
4e055d8df2 | |
|
|
d1fda7b8f2 | |
|
|
f7f5c24786 | |
|
|
827b166bbc | |
|
|
d55fa78e40 | |
|
|
e8a80cfbd8 | |
|
|
487e188112 | |
|
|
81ea997d40 | |
|
|
66aabf5eaa | |
|
|
3850ea1e0f | |
|
|
8de2f7339c | |
|
|
2bfe188510 | |
|
|
e5fe818a74 | |
|
|
19edeb1aeb | |
|
|
e3637253ef | |
|
|
604203c179 | |
|
|
5ef458ca56 | |
|
|
40ab39b5ea | |
|
|
89e52d6178 | |
|
|
2351caa9cf | |
|
|
0146345b88 | |
|
|
25f458a907 | |
|
|
1aca4c7b87 | |
|
|
cbd264f33d | |
|
|
8dab4a48c4 | |
|
|
4d523f4e19 | |
|
|
91f725a998 | |
|
|
9050aa9efd | |
|
|
a23a23ba69 | |
|
|
9fbb7eb2e1 | |
|
|
70d6217dbe | |
|
|
e794417623 | |
|
|
17eaa59a7a | |
|
|
958a2f31da | |
|
|
983fecc106 | |
|
|
2083b0581d | |
|
|
576134ec73 | |
|
|
4eb279036a | |
|
|
9984e83d1e | |
|
|
7621589ba2 | |
|
|
482fdd8c05 | |
|
|
226c1be964 | |
|
|
701bed85f8 | |
|
|
a6385091e0 | |
|
|
dcbc574a27 | |
|
|
4523260dda | |
|
|
727fc79ed2 | |
|
|
4dbab064f0 | |
|
|
767609532f | |
|
|
f806b07208 | |
|
|
97aa786dd5 | |
|
|
614844c9fe | |
|
|
5eaa14687f | |
|
|
944a2c93e3 | |
|
|
42f9737e59 | |
|
|
1886fe5fd9 | |
|
|
8225b9edbb | |
|
|
b64466953a | |
|
|
b72ac7936a | |
|
|
320de5ecdd | |
|
|
5f87b1eba5 | |
|
|
49cbcea429 | |
|
|
2d39c50ee6 | |
|
|
f8efa30305 | |
|
|
54999be326 | |
|
|
c90b10b02f | |
|
|
68a507ab31 | |
|
|
6e7e82e5e7 | |
|
|
d07c6c0bc6 | |
|
|
ed14682d63 | |
|
|
1243927cfb | |
|
|
fbdea7f3ba | |
|
|
d78b7b3dcf | |
|
|
903cb0679d | |
|
|
91b9c47dad | |
|
|
34a552383f | |
|
|
2da384e110 | |
|
|
ba1d7b272a | |
|
|
e91a5c72de | |
|
|
4fe59edd84 | |
|
|
26578a18c8 | |
|
|
2d1134be23 | |
|
|
6d06c582e3 | |
|
|
9442260a20 | |
|
|
a0fb5c7c41 | |
|
|
403e35e6b0 | |
|
|
0a50eb0343 | |
|
|
a9194f7a67 | |
|
|
3920c444cb | |
|
|
56798bd811 | |
|
|
285b50c549 | |
|
|
6ad2f793af | |
|
|
70489cbed0 | |
|
|
766f13d37a | |
|
|
3c70e50af5 | |
|
|
7a53eb7ea8 | |
|
|
060f3e5f9a | |
|
|
0e8672af87 | |
|
|
4f1195f5ab | |
|
|
6ae66a8cbc | |
|
|
6e32daa4da | |
|
|
e268e7a726 | |
|
|
d4f36fe0be | |
|
|
60f2aba40d | |
|
|
7709e4a219 | |
|
|
2235511849 | |
|
|
2d0b9ee53c | |
|
|
816ffb9379 | |
|
|
b7ca9082ef | |
|
|
4357cf4e37 | |
|
|
fa05947225 | |
|
|
71a3dd80e7 | |
|
|
699ac5ab12 | |
|
|
2e409da274 | |
|
|
a5a2e487c7 | |
|
|
4c77c3a7bb | |
|
|
e8c300c353 | |
|
|
8240fc519a | |
|
|
fffe587e27 | |
|
|
3b9989bd90 | |
|
|
1ae2163413 | |
|
|
98716bc0d7 | |
|
|
f8b13e5b70 | |
|
|
47a15d7a9a | |
|
|
369032c256 | |
|
|
4d16d1390a | |
|
|
50c4e89aeb | |
|
|
a54bf71b4c | |
|
|
ff6636ed5b | |
|
|
bff340c1ca | |
|
|
0da9a25818 | |
|
|
a56e620777 | |
|
|
a474a9c45d | |
|
|
b6c297af8c | |
|
|
4df8722edf | |
|
|
0f8531dea6 | |
|
|
9b0e333f2c | |
|
|
d7aa3cc1c3 | |
|
|
66979bcc2f | |
|
|
46d4fe2fa1 | |
|
|
0201f3ff7b | |
|
|
fd5243c27e | |
|
|
fd340a88d6 | |
|
|
6a44ca9f76 | |
|
|
a7c293b8ef | |
|
|
6cabcf3fd2 | |
|
|
f15abb657a | |
|
|
58a51e2746 | |
|
|
801113b46a | |
|
|
f8ee528174 | |
|
|
809785dcd7 | |
|
|
aed626ed96 | |
|
|
ee80b4be69 | |
|
|
3eb039c554 | |
|
|
cad1c95405 | |
|
|
8cd48c2896 | |
|
|
c59ae1527c | |
|
|
1b91fa9358 | |
|
|
97ce1503fd | |
|
|
301594b448 | |
|
|
0f9e16ca46 | |
|
|
da51e40638 | |
|
|
bd758bb438 | |
|
|
aaea0b2f28 | |
|
|
07b3f5233e | |
|
|
91c94c8b95 | |
|
|
b9e5f23914 | |
|
|
36e9a811cc | |
|
|
7b70fa26e6 | |
|
|
bbb52087ed | |
|
|
a5671ea3d8 | |
|
|
22e976574c | |
|
|
ccd763aef7 | |
|
|
b4719455bc | |
|
|
1d99401b8b | |
|
|
41fa63a49e | |
|
|
088d6432a4 | |
|
|
f7b9cfebe1 | |
|
|
07900303f4 | |
|
|
86caf454f4 | |
|
|
9b24f890b2 | |
|
|
c5dc61e795 | |
|
|
017c0dce32 | |
|
|
0229246f3b | |
|
|
fd58268f04 | |
|
|
a4a7958678 | |
|
|
2ebc7e3ded | |
|
|
40b0cbd713 | |
|
|
8ca510a669 | |
|
|
b213348665 | |
|
|
4d1fcc1df2 | |
|
|
1ea5bba848 | |
|
|
5af8322ff5 | |
|
|
7ca8804a33 | |
|
|
a7e5925ec1 | |
|
|
9a14696f30 | |
|
|
854df8352c | |
|
|
b5eb329f94 | |
|
|
2cf6e2e4f6 | |
|
|
1dc8e17371 | |
|
|
407d0d296d | |
|
|
a57c590a71 | |
|
|
868fd32ee7 | |
|
|
63802c1112 | |
|
|
1803d16d5c | |
|
|
aaeb348bb7 | |
|
|
ae1a1fccfe | |
|
|
e82ba71911 | |
|
|
7e49e98f79 | |
|
|
1ef0aa443b | |
|
|
f59b2b1db3 | |
|
|
d0337a18b6 | |
|
|
a66a0852bb | |
|
|
65f92fd839 | |
|
|
fac754041c | |
|
|
0826feb94d | |
|
|
56e5b8b9e8 | |
|
|
c04ea0eac5 | |
|
|
cb99a23d84 | |
|
|
fb4aa7eaba | |
|
|
2fe4c4f8e5 | |
|
|
6a9e141c7a | |
|
|
b7ff8256ef | |
|
|
ccced29b46 | |
|
|
592d93211f | |
|
|
25e900f64a | |
|
|
a9d8518e7c | |
|
|
110eeec5b8 | |
|
|
0530d1c530 | |
|
|
f2300f4522 | |
|
|
b23bfef8cc | |
|
|
5b51d92f3e | |
|
|
d964c15040 | |
|
|
8896a477df | |
|
|
168394980f | |
|
|
f0d0ad39c4 | |
|
|
58baf22230 | |
|
|
b9f0effd55 | |
|
|
853999fd7f | |
|
|
f5b9095108 | |
|
|
158d970e2b | |
|
|
52900b48ad | |
|
|
4de268587c | |
|
|
e665888a45 | |
|
|
fbcea506ba | |
|
|
daca6c9df2 | |
|
|
9b590c9f67 | |
|
|
ae5563dd18 | |
|
|
2d7a061161 | |
|
|
e7863d7fdd | |
|
|
c659f6c959 | |
|
|
eea41f308e | |
|
|
dd54b6f4c7 | |
|
|
73c2edbc0c | |
|
|
fa04e62201 | |
|
|
a6375a2094 | |
|
|
7235ee55c6 | |
|
|
29bc011ec7 | |
|
|
ed3dd6a1a0 | |
|
|
84a50acb55 | |
|
|
ef15600b3e | |
|
|
8f852ef82f | |
|
|
a2fcaf9774 | |
|
|
f06ae90884 | |
|
|
25eb3d5209 | |
|
|
95f8b91c8a | |
|
|
7eb38e8f7b | |
|
|
a879ad7547 | |
|
|
4ec0a120df | |
|
|
ba34266e89 | |
|
|
83571fdb93 | |
|
|
fa1ce9fd19 | |
|
|
7119ab1d98 | |
|
|
e4924a0134 | |
|
|
77d2f9a354 | |
|
|
467a7bae3f | |
|
|
0f637b5e30 | |
|
|
9b6790e3a6 | |
|
|
94531fa237 | |
|
|
d9fb1e0e45 | |
|
|
1301462a1b | |
|
|
4269ea4e8d | |
|
|
71639d1744 | |
|
|
12432ca138 | |
|
|
d4d0091760 | |
|
|
9ecd1898d0 | |
|
|
3ffb9f19cb | |
|
|
d347a4426d | |
|
|
aa551e5a9c | |
|
|
65cf2cea9d | |
|
|
67f7d1e65f | |
|
|
c8898034f9 | |
|
|
b5010719d6 | |
|
|
d5d2fe1b0e | |
|
|
de9ea76b6c | |
|
|
0159269a51 | |
|
|
4674fbf923 | |
|
|
6ecc184637 | |
|
|
e64cc907ff | |
|
|
0574ac23d0 | |
|
|
d7f9035e80 | |
|
|
7fd21b6bc6 | |
|
|
d3f46fa7fa | |
|
|
eca22c0cc7 | |
|
|
89e0e80db3 | |
|
|
8ddb531346 | |
|
|
143ae5a5b0 | |
|
|
6756e376f3 | |
|
|
867dc6a185 | |
|
|
a8508f2b31 | |
|
|
534e4b1418 | |
|
|
1cea43d349 | |
|
|
6d0e4c76ac | |
|
|
0836bf844b | |
|
|
cdde51c608 | |
|
|
56299effe9 | |
|
|
4ecdd7907a | |
|
|
4fd8b98b10 | |
|
|
80569babd3 | |
|
|
fe55622205 | |
|
|
2f82ade66f | |
|
|
3a59d40109 | |
|
|
783d320547 | |
|
|
330631a0eb | |
|
|
5a9d3abc10 | |
|
|
e56e0cc913 | |
|
|
090c0c4b5d | |
|
|
0c79c86b40 | |
|
|
42ccee658d | |
|
|
e8addf2ac2 | |
|
|
256c91ca6d | |
|
|
651ccf9901 | |
|
|
28b0d8e8bd | |
|
|
3e6c8376fb | |
|
|
d062252522 | |
|
|
8c7bdbe4d1 | |
|
|
c2a9c5699d | |
|
|
c355b8a671 | |
|
|
9c08312121 | |
|
|
a976cc2e95 | |
|
|
1f4b8c4eea | |
|
|
9da06d918f | |
|
|
9044a10c5f | |
|
|
a9fd34058f | |
|
|
b84c7037de | |
|
|
cfc9a21957 | |
|
|
4d686b47f0 | |
|
|
fc140bb02b | |
|
|
ffee3dfef0 | |
|
|
d537904abb | |
|
|
6b49a604b4 | |
|
|
cd72fa6e77 | |
|
|
9747da8682 | |
|
|
377b42c92b | |
|
|
e1fedd4388 | |
|
|
0643c0d15a | |
|
|
bde038527c | |
|
|
8b05cd4074 | |
|
|
5f0e97b22a | |
|
|
8dcee1f6b2 | |
|
|
75c7c169e1 | |
|
|
5c07207dd1 | |
|
|
8c21284c1c | |
|
|
bf631b5872 | |
|
|
eec1b3a512 | |
|
|
9dafcd417d | |
|
|
e762a57d62 | |
|
|
ec31948bcc | |
|
|
ba2d57d024 | |
|
|
48853f875b | |
|
|
28a49aaa34 | |
|
|
e2fa47f5f2 | |
|
|
f568bd23d8 | |
|
|
3957f29e2f | |
|
|
fca6b57037 | |
|
|
7fe5cd26b5 | |
|
|
b7afc7bf40 | |
|
|
9666188da8 | |
|
|
d291148e93 | |
|
|
2192bb7eb5 | |
|
|
8dd454530d | |
|
|
341d3e3493 | |
|
|
35cf3d0ce5 | |
|
|
e7fb2fea5c | |
|
|
784020f71e | |
|
|
1ea97fddd7 | |
|
|
5225667e25 | |
|
|
fff514c7f2 | |
|
|
8473a29da7 | |
|
|
c74e5210f6 | |
|
|
92dbb59b79 | |
|
|
e08dc6f0af | |
|
|
7d69579634 | |
|
|
4e05357c45 | |
|
|
95818a7c32 | |
|
|
1a5a3fecf3 | |
|
|
d53d4dc22f | |
|
|
ea82458290 | |
|
|
806e3c12dc | |
|
|
420b0672e4 | |
|
|
2dd180472f | |
|
|
e731974da1 | |
|
|
df2bda63c6 | |
|
|
e6a26e82ca | |
|
|
d904f37f1c | |
|
|
da1ec45505 | |
|
|
5a255809b9 | |
|
|
5cc751386d | |
|
|
855748a1a2 | |
|
|
cba07a400a | |
|
|
feba7ea8fd | |
|
|
3a21f8b1e3 | |
|
|
1fe261f92f | |
|
|
f201bad372 | |
|
|
2cd1a4b8dd | |
|
|
c61f3f4ede | |
|
|
0625547800 | |
|
|
ad52724d9a | |
|
|
0652b885df | |
|
|
14c052a256 | |
|
|
95ed44ce71 | |
|
|
5067d06f55 | |
|
|
ae7121d534 | |
|
|
a5f0f66427 | |
|
|
44e1c6cc21 | |
|
|
8ddaca1763 | |
|
|
fd656ed3b0 | |
|
|
94e748086c | |
|
|
985be2a864 | |
|
|
4ec0fcf1b6 | |
|
|
5f34391f75 | |
|
|
60dc46ad10 | |
|
|
b1b6c7a982 | |
|
|
9780e999e9 | |
|
|
44cd3674dd | |
|
|
07e5fc19bd | |
|
|
c2096897bb | |
|
|
d2a36d0a98 | |
|
|
f95c09b6f2 | |
|
|
1a319b7847 | |
|
|
fdbfdec341 | |
|
|
c3fadff0ce | |
|
|
dbef3dfef0 | |
|
|
593964560b | |
|
|
9c52e1b7de | |
|
|
146cba46ca | |
|
|
bf6da81028 | |
|
|
7771444725 | |
|
|
ddfa6e66c8 | |
|
|
6d159a45a8 | |
|
|
7c95a25df7 | |
|
|
7c58de294e | |
|
|
1fefd4e67f | |
|
|
60d308cff0 | |
|
|
d17490ff54 | |
|
|
a423b1d936 | |
|
|
3e77263b4c | |
|
|
c84c76ee66 | |
|
|
ba9fb4d994 | |
|
|
549cb65ba4 | |
|
|
c351b49aed | |
|
|
b8a2b1b5cc | |
|
|
cda4e904cd | |
|
|
413c8d189c | |
|
|
0386dcb63f | |
|
|
12cbaddade | |
|
|
eb32f42b53 | |
|
|
a9b5fe4099 | |
|
|
bb84e5e82e | |
|
|
cda9eacada | |
|
|
7817eb0117 | |
|
|
3442acbae1 | |
|
|
cab2f891e7 | |
|
|
d2bebfb253 | |
|
|
47b0ee36ff | |
|
|
4a6020c574 | |
|
|
cf43951abc | |
|
|
348f8e8f28 | |
|
|
0ece3834f8 | |
|
|
03d076283c | |
|
|
4f78d8542d | |
|
|
9270c03665 | |
|
|
54728c60d5 | |
|
|
da2f85ae2b | |
|
|
09fd72bc5b | |
|
|
dfcbfcfcc9 | |
|
|
5189ba851c | |
|
|
5024fd0908 | |
|
|
f155d8febc | |
|
|
b4a3e5324b | |
|
|
e6213b2fc7 | |
|
|
6bbf2d486c | |
|
|
96c48f5566 | |
|
|
e928f55537 | |
|
|
2622b05c0b | |
|
|
0f48556241 | |
|
|
6a545c04eb | |
|
|
e895d4d1a8 | |
|
|
3e9243817e | |
|
|
d4d7174773 | |
|
|
cf39c03801 | |
|
|
30dbd1a598 | |
|
|
5ba1bfdb7b | |
|
|
9e28f5aac2 | |
|
|
690f7bba97 | |
|
|
5dd9389c25 | |
|
|
8f86cb92ac | |
|
|
ec2663ee5d | |
|
|
a6b4294bfd | |
|
|
6cb8729952 | |
|
|
51fe0bf663 | |
|
|
0db1c31103 | |
|
|
1bf56e711a | |
|
|
897910ea4f | |
|
|
7eeefb3813 | |
|
|
389de66b25 | |
|
|
03c2814124 | |
|
|
565dc0d17b | |
|
|
e003038261 | |
|
|
05a1b0c3ae | |
|
|
41c9e3ead0 | |
|
|
99b274592d | |
|
|
a3ece09d19 | |
|
|
6a1ba52ad5 | |
|
|
07dacec904 | |
|
|
6cc86ad211 | |
|
|
2f58647033 | |
|
|
7cb6553ce8 | |
|
|
198c2482ee | |
|
|
6464149031 | |
|
|
88b87d893d | |
|
|
b5349f7563 | |
|
|
8633d2e0a9 | |
|
|
b697c05354 | |
|
|
3bf3ebf514 | |
|
|
31c8bb9167 | |
|
|
db9c755045 | |
|
|
06bdfc403e | |
|
|
fc408bba37 | |
|
|
5b63f6486f | |
|
|
0bf930bdc7 | |
|
|
6a9285d1f5 | |
|
|
54998a1042 | |
|
|
c1b3a49320 | |
|
|
5ea03efe92 | |
|
|
84a2a289e6 | |
|
|
9c343fb3db | |
|
|
8cef6f2120 | |
|
|
f0a266cb86 | |
|
|
bc9a9cf972 | |
|
|
cc3846d1b5 | |
|
|
f5ab0c1d32 | |
|
|
cc5168b5c3 | |
|
|
1d300c416d | |
|
|
fbc06f1926 | |
|
|
e1b9250dea | |
|
|
5aa79f1ba4 | |
|
|
f3d4bb4103 | |
|
|
3e8d9bc6ea | |
|
|
431463dec2 | |
|
|
584e3c2916 | |
|
|
29b9e21b7b | |
|
|
2920d61f18 | |
|
|
4ed3b62f01 | |
|
|
bec76be592 | |
|
|
8f4e77e72f | |
|
|
572df97179 | |
|
|
91d4f5cd2f | |
|
|
987c254eea | |
|
|
1f85c9af68 | |
|
|
e25fa446e8 | |
|
|
91f1894372 | |
|
|
981062a94e | |
|
|
a68caaf719 | |
|
|
118abfbdb7 | |
|
|
87c447ed46 | |
|
|
f5b006f6a1 | |
|
|
2d32cf2839 | |
|
|
4aec20d365 | |
|
|
9b5000057e | |
|
|
7778627b71 | |
|
|
ee1d4eb29d | |
|
|
644fb76960 | |
|
|
8de94abfbc | |
|
|
4a00cefe63 | |
|
|
369430f9ab | |
|
|
6a812b621d | |
|
|
e358d57fb5 | |
|
|
a14a32695d | |
|
|
49f3fbf726 | |
|
|
acfb95e2c6 | |
|
|
e351a86290 | |
|
|
3ccf5f9dc8 | |
|
|
592dd35ce9 | |
|
|
b6b5e5caac | |
|
|
fb40b09157 | |
|
|
6b04ab1e35 | |
|
|
e94ac57f80 | |
|
|
7b8e48ffb6 | |
|
|
1ff8de3a8a | |
|
|
a4525b721e | |
|
|
6b07604d64 | |
|
|
ef8cc3d0fb | |
|
|
966653e174 | |
|
|
41718404a1 | |
|
|
261a40dae1 | |
|
|
3f37afd18c | |
|
|
501837058c | |
|
|
a37e25fa21 | |
|
|
f4ed317083 | |
|
|
202765c810 | |
|
|
394fd87c2c | |
|
|
55e79adf69 | |
|
|
ca414735b9 | |
|
|
72b6a11a83 | |
|
|
80e7da92ce | |
|
|
0a3b9a9a09 | |
|
|
3cf06f7939 | |
|
|
be8d51c301 | |
|
|
2f03de029c | |
|
|
af4731aa5f | |
|
|
e9b1e856a0 | |
|
|
496176d738 | |
|
|
61429230b2 | |
|
|
4e68684bd2 | |
|
|
45721d5dec | |
|
|
b934cb49c7 | |
|
|
b72c87712d |
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
description: Update Clawdbot from upstream when branch has diverged (ahead/behind)
|
||||
description: Update OpenClaw from upstream when branch has diverged (ahead/behind)
|
||||
---
|
||||
|
||||
# Clawdbot Upstream Sync Workflow
|
||||
# OpenClaw Upstream Sync Workflow
|
||||
|
||||
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
|
||||
|
||||
|
|
@ -132,16 +132,16 @@ pnpm mac:package
|
|||
|
||||
```bash
|
||||
# Kill running app
|
||||
pkill -x "Clawdbot" || true
|
||||
pkill -x "OpenClaw" || true
|
||||
|
||||
# Move old version
|
||||
mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app
|
||||
mv /Applications/OpenClaw.app /tmp/OpenClaw-backup.app
|
||||
|
||||
# Install new build
|
||||
cp -R dist/Clawdbot.app /Applications/
|
||||
cp -R dist/OpenClaw.app /Applications/
|
||||
|
||||
# Launch
|
||||
open /Applications/Clawdbot.app
|
||||
open /Applications/OpenClaw.app
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -235,7 +235,7 @@ If upstream introduced new model configurations:
|
|||
# Check for OpenRouter API key requirements
|
||||
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
|
||||
|
||||
# Update clawdbot.json with fallback chains
|
||||
# Update openclaw.json with fallback chains
|
||||
# Add model fallback configurations as needed
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
.git
|
||||
.worktrees
|
||||
|
||||
# Sensitive files – docker-setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN
|
||||
# into the project root; keep it out of the build context.
|
||||
.env
|
||||
.env.*
|
||||
|
||||
.bun-cache
|
||||
.bun
|
||||
.tmp
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
# Protect the ownership rules themselves.
|
||||
/.github/CODEOWNERS @steipete
|
||||
|
||||
# WARNING: GitHub CODEOWNERS uses last-match-wins semantics.
|
||||
# If you add overlapping rules below the secops block, include @openclaw/secops
|
||||
# on those entries too or you can silently remove required secops review.
|
||||
# Security-sensitive code, config, and docs require secops review.
|
||||
/SECURITY.md @openclaw/secops
|
||||
/.github/dependabot.yml @openclaw/secops
|
||||
/.github/codeql/ @openclaw/secops
|
||||
/.github/workflows/codeql.yml @openclaw/secops
|
||||
/src/security/ @openclaw/secops
|
||||
/src/secrets/ @openclaw/secops
|
||||
/src/config/*secret*.ts @openclaw/secops
|
||||
/src/config/**/*secret*.ts @openclaw/secops
|
||||
/src/gateway/*auth*.ts @openclaw/secops
|
||||
/src/gateway/**/*auth*.ts @openclaw/secops
|
||||
/src/gateway/*secret*.ts @openclaw/secops
|
||||
/src/gateway/**/*secret*.ts @openclaw/secops
|
||||
/src/gateway/security-path*.ts @openclaw/secops
|
||||
/src/gateway/resolve-configured-secret-input-string*.ts @openclaw/secops
|
||||
/src/gateway/protocol/**/*secret*.ts @openclaw/secops
|
||||
/src/gateway/server-methods/secrets*.ts @openclaw/secops
|
||||
/src/agents/*auth*.ts @openclaw/secops
|
||||
/src/agents/**/*auth*.ts @openclaw/secops
|
||||
/src/agents/auth-profiles*.ts @openclaw/secops
|
||||
/src/agents/auth-health*.ts @openclaw/secops
|
||||
/src/agents/auth-profiles/ @openclaw/secops
|
||||
/src/agents/sandbox.ts @openclaw/secops
|
||||
/src/agents/sandbox-*.ts @openclaw/secops
|
||||
/src/agents/sandbox/ @openclaw/secops
|
||||
/src/infra/secret-file*.ts @openclaw/secops
|
||||
/src/cron/stagger.ts @openclaw/secops
|
||||
/src/cron/service/jobs.ts @openclaw/secops
|
||||
/docs/security/ @openclaw/secops
|
||||
/docs/gateway/authentication.md @openclaw/secops
|
||||
/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @openclaw/secops
|
||||
/docs/gateway/sandboxing.md @openclaw/secops
|
||||
/docs/gateway/secrets-plan-contract.md @openclaw/secops
|
||||
/docs/gateway/secrets.md @openclaw/secops
|
||||
/docs/gateway/security/ @openclaw/secops
|
||||
/docs/cli/approvals.md @openclaw/secops
|
||||
/docs/cli/sandbox.md @openclaw/secops
|
||||
/docs/cli/security.md @openclaw/secops
|
||||
/docs/cli/secrets.md @openclaw/secops
|
||||
/docs/reference/secretref-credential-surface.md @openclaw/secops
|
||||
/docs/reference/secretref-user-supplied-credentials-matrix.json @openclaw/secops
|
||||
|
||||
# Release workflow and its supporting release-path checks.
|
||||
/.github/workflows/openclaw-npm-release.yml @openclaw/openclaw-release-managers
|
||||
/docs/reference/RELEASING.md @openclaw/openclaw-release-managers
|
||||
/scripts/openclaw-npm-publish.sh @openclaw/openclaw-release-managers
|
||||
/scripts/openclaw-npm-release-check.ts @openclaw/openclaw-release-managers
|
||||
/scripts/release-check.ts @openclaw/openclaw-release-managers
|
||||
|
|
@ -49,7 +49,7 @@ runs:
|
|||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
check-latest: false
|
||||
|
|
@ -63,7 +63,7 @@ runs:
|
|||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
uses: oven-sh/setup-bun@v2.1.3
|
||||
with:
|
||||
bun-version: "1.3.9"
|
||||
|
||||
|
|
|
|||
|
|
@ -61,14 +61,14 @@ runs:
|
|||
- name: Restore pnpm store cache (exact key only)
|
||||
# PRs that request sticky disks still need a safe cache restore path.
|
||||
if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys != 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
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' || github.event_name == 'pull_request') && inputs.use-restore-keys == 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
"channel: discord":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/discord/**"
|
||||
- "extensions/discord/**"
|
||||
- "docs/channels/discord.md"
|
||||
"channel: irc":
|
||||
|
|
@ -28,7 +27,6 @@
|
|||
"channel: imessage":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/imessage/**"
|
||||
- "extensions/imessage/**"
|
||||
- "docs/channels/imessage.md"
|
||||
"channel: line":
|
||||
|
|
@ -64,19 +62,16 @@
|
|||
"channel: signal":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/signal/**"
|
||||
- "extensions/signal/**"
|
||||
- "docs/channels/signal.md"
|
||||
"channel: slack":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/slack/**"
|
||||
- "extensions/slack/**"
|
||||
- "docs/channels/slack.md"
|
||||
"channel: telegram":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/telegram/**"
|
||||
- "extensions/telegram/**"
|
||||
- "docs/channels/telegram.md"
|
||||
"channel: tlon":
|
||||
|
|
@ -96,7 +91,6 @@
|
|||
"channel: whatsapp-web":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/web/**"
|
||||
- "extensions/whatsapp/**"
|
||||
- "docs/channels/whatsapp.md"
|
||||
"channel: zalo":
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ on:
|
|||
types: [opened, edited, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; no untrusted checkout or code execution
|
||||
types: [labeled]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
|
|
@ -17,20 +20,20 @@ jobs:
|
|||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Handle labeled items
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ on:
|
|||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
|
||||
|
|
@ -19,7 +22,7 @@ jobs:
|
|||
docs_changed: ${{ steps.check.outputs.docs_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
|
|
@ -35,9 +38,8 @@ jobs:
|
|||
id: check
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
# Detect which heavy areas are touched so PRs can skip unrelated expensive jobs.
|
||||
# Push to main keeps broad coverage, but this job still needs to run so
|
||||
# downstream jobs that list it in `needs` are not skipped.
|
||||
# Detect which heavy areas are touched so CI can skip unrelated expensive jobs.
|
||||
# Fail-safe: if detection fails, downstream jobs run.
|
||||
changed-scope:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
|
|
@ -50,7 +52,7 @@ jobs:
|
|||
run_windows: ${{ steps.scope.outputs.run_windows }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
|
|
@ -79,11 +81,11 @@ jobs:
|
|||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
build-artifacts:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
|
|
@ -98,13 +100,13 @@ jobs:
|
|||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
|
@ -117,7 +119,7 @@ jobs:
|
|||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
|
|
@ -125,10 +127,10 @@ jobs:
|
|||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
|
@ -138,7 +140,7 @@ jobs:
|
|||
|
||||
checks:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
@ -146,10 +148,20 @@ jobs:
|
|||
include:
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 1
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 2
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: extensions
|
||||
command: pnpm test:extensions
|
||||
- runtime: node
|
||||
task: channels
|
||||
command: pnpm test:channels
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
|
|
@ -157,44 +169,51 @@ jobs:
|
|||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
|
||||
steps:
|
||||
- name: Skip bun lane on push
|
||||
if: github.event_name == 'push' && matrix.runtime == 'bun'
|
||||
run: echo "Skipping bun test lane on push events."
|
||||
- name: Skip bun lane on pull requests
|
||||
if: github.event_name == 'pull_request' && matrix.runtime == 'bun'
|
||||
run: echo "Skipping Bun compatibility lane on pull requests."
|
||||
|
||||
- name: Checkout
|
||||
if: github.event_name != 'push' || matrix.runtime != 'bun'
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name != 'pull_request' || matrix.runtime != 'bun'
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'push'
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "${{ matrix.runtime == 'bun' }}"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Configure Node test resources
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
if: (github.event_name != 'pull_request' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
env:
|
||||
SHARD_COUNT: ${{ matrix.shard_count || '' }}
|
||||
SHARD_INDEX: ${{ matrix.shard_index || '' }}
|
||||
run: |
|
||||
# `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
|
||||
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
|
||||
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
|
||||
if [ -n "$SHARD_COUNT" ] && [ -n "$SHARD_INDEX" ]; then
|
||||
echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'push'
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "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')
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
|
|
@ -202,7 +221,7 @@ jobs:
|
|||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Check types and lint and oxfmt
|
||||
run: pnpm check
|
||||
|
|
@ -220,7 +239,7 @@ jobs:
|
|||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
|
|
@ -228,7 +247,7 @@ jobs:
|
|||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Check docs
|
||||
run: pnpm check:docs
|
||||
|
|
@ -236,11 +255,11 @@ jobs:
|
|||
compat-node22:
|
||||
name: "compat-node22"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
|
|
@ -250,7 +269,7 @@ jobs:
|
|||
node-version: "22.x"
|
||||
cache-key-suffix: "node22"
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Configure Node 22 test resources
|
||||
run: |
|
||||
|
|
@ -269,16 +288,16 @@ jobs:
|
|||
|
||||
skills-python:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true' || needs.changed-scope.outputs.run_skills_python == 'true')
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_skills_python == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
|
@ -297,7 +316,7 @@ jobs:
|
|||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
|
|
@ -316,7 +335,7 @@ jobs:
|
|||
|
||||
- name: Setup Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
|
@ -326,7 +345,7 @@ jobs:
|
|||
.github/workflows/ci.yml
|
||||
|
||||
- name: Restore pre-commit cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
|
|
@ -362,7 +381,7 @@ jobs:
|
|||
|
||||
checks-windows:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_windows == 'true')
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_windows == 'true'
|
||||
runs-on: blacksmith-32vcpu-windows-2025
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
|
|
@ -409,7 +428,7 @@ jobs:
|
|||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
|
|
@ -433,7 +452,7 @@ jobs:
|
|||
}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.x
|
||||
check-latest: false
|
||||
|
|
@ -495,7 +514,7 @@ jobs:
|
|||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
|
|
@ -531,7 +550,7 @@ jobs:
|
|||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Cache SwiftPM
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/Library/Caches/org.swift.swiftpm
|
||||
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
|
||||
|
|
@ -567,7 +586,7 @@ jobs:
|
|||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
|
|
@ -724,7 +743,7 @@ jobs:
|
|||
|
||||
android:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_android == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
@ -736,31 +755,45 @@ jobs:
|
|||
command: ./gradlew --no-daemon :app:assembleDebug
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
# setup-android's sdkmanager currently crashes on JDK 21 in CI.
|
||||
# Keep sdkmanager on the stable JDK path for Linux CI runners.
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
accept-android-sdk-licenses: false
|
||||
- name: Setup Android SDK cmdline-tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ANDROID_SDK_ROOT="$HOME/.android-sdk"
|
||||
CMDLINE_TOOLS_VERSION="12266719"
|
||||
ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
|
||||
URL="https://dl.google.com/android/repository/${ARCHIVE}"
|
||||
|
||||
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||
curl -fsSL "$URL" -o "/tmp/${ARCHIVE}"
|
||||
rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||
unzip -q "/tmp/${ARCHIVE}" -d "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
|
||||
echo "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
|
||||
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
gradle-version: 8.11.1
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null
|
||||
sdkmanager --install \
|
||||
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
|
||||
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ concurrency:
|
|||
group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
|
@ -67,7 +70,7 @@ jobs:
|
|||
config_file: ""
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
|
|
@ -76,17 +79,17 @@ jobs:
|
|||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Setup Python
|
||||
if: matrix.needs_python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup Java
|
||||
if: matrix.needs_java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
|
|
|||
|
|
@ -12,19 +12,65 @@ on:
|
|||
- "**/*.mdx"
|
||||
- ".agents/**"
|
||||
- "skills/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing release tag to backfill (for example v2026.3.13)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: docker-release-${{ github.workflow }}-${{ github.ref }}
|
||||
group: docker-release-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
validate_manual_backfill:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then
|
||||
echo "Invalid release tag: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout selected tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
approve_manual_backfill:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
needs: validate_manual_backfill
|
||||
# WARNING: KEEP MANUAL BACKFILLS GATED BY THE docker-release ENVIRONMENT.
|
||||
runs-on: ubuntu-24.04
|
||||
environment: docker-release
|
||||
steps:
|
||||
- name: Approve Docker backfill
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: echo "Approved Docker backfill for $RELEASE_TAG"
|
||||
|
||||
# KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS.
|
||||
# DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS.
|
||||
# Build amd64 images (default + slim share the build stage cache)
|
||||
build-amd64:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs: [approve_manual_backfill]
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
|
||||
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
|
@ -33,13 +79,16 @@ jobs:
|
|||
slim-digest: ${{ steps.build-slim.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
|
|
@ -50,21 +99,22 @@ jobs:
|
|||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-amd64")
|
||||
slim_tags+=("${IMAGE}:main-slim-amd64")
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}-amd64")
|
||||
slim_tags+=("${IMAGE}:${version}-slim-amd64")
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No amd64 tags resolved for ref ${GITHUB_REF}"
|
||||
echo "::error::No amd64 tags resolved for ref ${SOURCE_REF}"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
|
|
@ -81,19 +131,22 @@ jobs:
|
|||
- name: Resolve OCI labels (amd64)
|
||||
id: labels
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_SHA}"
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
source_sha="$(git rev-parse HEAD)"
|
||||
version="${source_sha}"
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
version="main"
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
fi
|
||||
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
{
|
||||
echo "value<<EOF"
|
||||
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
echo "org.opencontainers.image.revision=${source_sha}"
|
||||
echo "org.opencontainers.image.version=${version}"
|
||||
echo "org.opencontainers.image.created=${created}"
|
||||
echo "EOF"
|
||||
|
|
@ -101,7 +154,8 @@ jobs:
|
|||
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
|
|
@ -112,7 +166,8 @@ jobs:
|
|||
|
||||
- name: Build and push amd64 slim image
|
||||
id: build-slim
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
|
|
@ -125,7 +180,10 @@ jobs:
|
|||
|
||||
# Build arm64 images (default + slim share the build stage cache)
|
||||
build-arm64:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
needs: [approve_manual_backfill]
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
|
||||
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
|
@ -134,13 +192,16 @@ jobs:
|
|||
slim-digest: ${{ steps.build-slim.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
|
|
@ -151,21 +212,22 @@ jobs:
|
|||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-arm64")
|
||||
slim_tags+=("${IMAGE}:main-slim-arm64")
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}-arm64")
|
||||
slim_tags+=("${IMAGE}:${version}-slim-arm64")
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No arm64 tags resolved for ref ${GITHUB_REF}"
|
||||
echo "::error::No arm64 tags resolved for ref ${SOURCE_REF}"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
|
|
@ -182,19 +244,22 @@ jobs:
|
|||
- name: Resolve OCI labels (arm64)
|
||||
id: labels
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_SHA}"
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
source_sha="$(git rev-parse HEAD)"
|
||||
version="${source_sha}"
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
version="main"
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
fi
|
||||
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
{
|
||||
echo "value<<EOF"
|
||||
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
echo "org.opencontainers.image.revision=${source_sha}"
|
||||
echo "org.opencontainers.image.version=${version}"
|
||||
echo "org.opencontainers.image.created=${created}"
|
||||
echo "EOF"
|
||||
|
|
@ -202,7 +267,8 @@ jobs:
|
|||
|
||||
- name: Build and push arm64 image
|
||||
id: build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
|
|
@ -213,7 +279,8 @@ jobs:
|
|||
|
||||
- name: Build and push arm64 slim image
|
||||
id: build-slim
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
|
|
@ -226,17 +293,22 @@ jobs:
|
|||
|
||||
# Create multi-platform manifests
|
||||
create-manifest:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs: [approve_manual_backfill, build-amd64, build-arm64]
|
||||
if: ${{ always() && needs.build-amd64.result == 'success' && needs.build-arm64.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
|
||||
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs: [build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
|
|
@ -247,25 +319,28 @@ jobs:
|
|||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
IS_MANUAL_BACKFILL: ${{ github.event_name == 'workflow_dispatch' && '1' || '0' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main")
|
||||
slim_tags+=("${IMAGE}:main-slim")
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}")
|
||||
slim_tags+=("${IMAGE}:${version}-slim")
|
||||
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
|
||||
# Manual backfills should only republish the requested version tags.
|
||||
if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
|
||||
tags+=("${IMAGE}:latest")
|
||||
slim_tags+=("${IMAGE}:slim")
|
||||
fi
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No manifest tags resolved for ref ${GITHUB_REF}"
|
||||
echo "::error::No manifest tags resolved for ref ${SOURCE_REF}"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ concurrency:
|
|||
group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
docs-scope:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
|
|
@ -17,7 +20,7 @@ jobs:
|
|||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
|
|
@ -38,10 +41,10 @@ jobs:
|
|||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# Blacksmith can fall back to the local docker driver, which rejects gha
|
||||
# cache export/import. Keep smoke builds driver-agnostic.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: Labeler
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned triage workflow; no untrusted checkout or PR code execution
|
||||
types: [opened, synchronize, reopened]
|
||||
issues:
|
||||
types: [opened]
|
||||
|
|
@ -16,6 +16,9 @@ on:
|
|||
required: false
|
||||
default: "50"
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
|
|
@ -25,25 +28,25 @@ jobs:
|
|||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
sync-labels: true
|
||||
- name: Apply PR size label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
|
|
@ -132,7 +135,7 @@ jobs:
|
|||
labels: [targetSizeLabel],
|
||||
});
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
|
|
@ -203,7 +206,7 @@ jobs:
|
|||
// });
|
||||
// }
|
||||
- name: Apply too-many-prs label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
|
|
@ -381,20 +384,20 @@ jobs:
|
|||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Backfill PR labels
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
|
|
@ -629,20 +632,20 @@ jobs:
|
|||
issues: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -4,26 +4,149 @@ on:
|
|||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish (for example v2026.3.14, v2026.3.14-beta.1, or fallback v2026.3.14-1)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: openclaw-npm-release-${{ github.ref }}
|
||||
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
|
||||
jobs:
|
||||
preview_openclaw_npm:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Print release plan
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
|
||||
TAG_KIND="fallback correction"
|
||||
else
|
||||
TAG_KIND="standard"
|
||||
fi
|
||||
echo "Release plan for ${RELEASE_TAG}:"
|
||||
echo "Resolved release SHA: ${RELEASE_SHA}"
|
||||
echo "Resolved package version: ${PACKAGE_VERSION}"
|
||||
echo "Resolved tag kind: ${TAG_KIND}"
|
||||
if [[ "${TAG_KIND}" == "fallback correction" ]]; then
|
||||
echo "Correction tag note: npm version remains ${PACKAGE_VERSION}"
|
||||
fi
|
||||
echo "Would run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main"
|
||||
echo "Would run with env: RELEASE_SHA=${RELEASE_SHA} RELEASE_TAG=${RELEASE_TAG} RELEASE_MAIN_REF=origin/main pnpm release:openclaw:npm:check"
|
||||
echo "Would run: npm view openclaw@${PACKAGE_VERSION} version"
|
||||
echo "Would run: pnpm check"
|
||||
echo "Would run: pnpm build"
|
||||
echo "Would run: pnpm release:check"
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
|
||||
# Fetch the full main ref so merge-base ancestry checks keep working
|
||||
# for older tagged commits that are still contained in main.
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
IS_CORRECTION_TAG=0
|
||||
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
|
||||
IS_CORRECTION_TAG=1
|
||||
fi
|
||||
|
||||
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error."
|
||||
exit 0
|
||||
fi
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
|
||||
echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}"
|
||||
else
|
||||
echo "Previewing openclaw@${PACKAGE_VERSION}"
|
||||
fi
|
||||
|
||||
- name: Check
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm check
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm build
|
||||
|
||||
- name: Verify release contents
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm release:check
|
||||
|
||||
- name: Preview publish command
|
||||
run: bash scripts/openclaw-npm-publish.sh --dry-run
|
||||
|
||||
publish_openclaw_npm:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# npm trusted publishing + provenance requires a GitHub-hosted runner.
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Validate tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
|
||||
echo "Invalid release tag format: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
|
|
@ -36,11 +159,12 @@ jobs:
|
|||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_SHA: ${{ github.sha }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
|
||||
# Fetch the full main ref so merge-base ancestry checks keep working
|
||||
# for older tagged commits that are still contained in main.
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
|
@ -68,12 +192,4 @@ jobs:
|
|||
run: pnpm release:check
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if [[ "$PACKAGE_VERSION" == *-beta.* ]]; then
|
||||
npm publish --access public --tag beta --provenance
|
||||
else
|
||||
npm publish --access public --provenance
|
||||
fi
|
||||
run: bash scripts/openclaw-npm-publish.sh --publish
|
||||
|
|
|
|||
|
|
@ -17,17 +17,20 @@ concurrency:
|
|||
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
sandbox-common-smoke:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build minimal sandbox base (USER sandbox)
|
||||
shell: bash
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ on:
|
|||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
|
|
@ -14,13 +17,13 @@ jobs:
|
|||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token-fallback
|
||||
continue-on-error: true
|
||||
with:
|
||||
|
|
@ -29,7 +32,7 @@ jobs:
|
|||
- name: Mark stale issues and pull requests (primary)
|
||||
id: stale-primary
|
||||
continue-on-error: true
|
||||
uses: actions/stale@v9
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
|
|
@ -62,7 +65,7 @@ jobs:
|
|||
- name: Check stale state cache
|
||||
id: stale-state
|
||||
if: always()
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token-fallback.outputs.token || steps.app-token.outputs.token }}
|
||||
script: |
|
||||
|
|
@ -85,7 +88,7 @@ jobs:
|
|||
}
|
||||
- name: Mark stale issues and pull requests (fallback)
|
||||
if: (steps.stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != ''
|
||||
uses: actions/stale@v9
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
|
|
@ -121,13 +124,13 @@ jobs:
|
|||
issues: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Lock closed issues after 48h of no comments
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -4,17 +4,22 @@ on:
|
|||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Fail on tabs in workflow files
|
||||
run: |
|
||||
|
|
@ -42,10 +47,11 @@ jobs:
|
|||
PY
|
||||
|
||||
actionlint:
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
|
|
@ -65,3 +71,19 @@ jobs:
|
|||
|
||||
- name: Disallow direct inputs interpolation in composite run blocks
|
||||
run: python3 scripts/check-composite-action-input-interpolation.py
|
||||
|
||||
config-docs-drift:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Check config docs drift statefile
|
||||
run: pnpm config:docs:check
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
node_modules
|
||||
**/node_modules/
|
||||
.env
|
||||
docker-compose.override.yml
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
|
|
@ -128,6 +129,7 @@ docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md
|
|||
.gitignore
|
||||
test/config-form.analyze.telegram.test.ts
|
||||
ui/src/ui/theme-variants.browser.test.ts
|
||||
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||
ui/src/ui/__screenshots__
|
||||
ui/src/ui/views/__screenshots__
|
||||
ui/.vitest-attachments
|
||||
docs/superpowers
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"gitignore": true,
|
||||
"noSymlinks": true,
|
||||
"ignore": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"dist/**",
|
||||
"**/.git/**",
|
||||
"**/coverage/**",
|
||||
"**/build/**",
|
||||
"**/.build/**",
|
||||
"**/.artifacts/**",
|
||||
"docs/zh-CN/**",
|
||||
"**/CHANGELOG.md"
|
||||
]
|
||||
}
|
||||
|
|
@ -12314,14 +12314,14 @@
|
|||
"filename": "src/config/schema.help.ts",
|
||||
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
|
||||
"is_verified": false,
|
||||
"line_number": 653
|
||||
"line_number": 657
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/schema.help.ts",
|
||||
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
|
||||
"is_verified": false,
|
||||
"line_number": 686
|
||||
"line_number": 690
|
||||
}
|
||||
],
|
||||
"src/config/schema.irc.ts": [
|
||||
|
|
@ -12360,14 +12360,14 @@
|
|||
"filename": "src/config/schema.labels.ts",
|
||||
"hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439",
|
||||
"is_verified": false,
|
||||
"line_number": 217
|
||||
"line_number": 219
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/schema.labels.ts",
|
||||
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
|
||||
"is_verified": false,
|
||||
"line_number": 326
|
||||
"line_number": 328
|
||||
}
|
||||
],
|
||||
"src/config/slack-http-config.test.ts": [
|
||||
|
|
|
|||
40
AGENTS.md
40
AGENTS.md
|
|
@ -9,6 +9,7 @@
|
|||
- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers.
|
||||
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
|
||||
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
|
||||
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
|
||||
|
||||
## Auto-close labels (issues and PRs)
|
||||
|
||||
|
|
@ -132,6 +133,7 @@
|
|||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
|
||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||
|
|
@ -201,6 +203,44 @@
|
|||
## Agent-Specific Notes
|
||||
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Parallels macOS retests: use the snapshot most closely named like `macOS 26.3.1 fresh` when the user asks for a clean/fresh macOS rerun; avoid older Tahoe snapshots unless explicitly requested.
|
||||
- Parallels beta smoke: use `--target-package-spec openclaw@<beta-version>` for the beta artifact, and pin the stable side with both `--install-version <stable-version>` and `--latest-version <stable-version>` for upgrade runs. npm dist-tags can move mid-run.
|
||||
- Parallels beta smoke, Windows nuance: old stable `2026.3.12` still prints the Unicode Windows onboarding banner, so mojibake during the stable precheck log is expected there. Judge the beta package by the post-upgrade lane.
|
||||
- Parallels macOS smoke playbook:
|
||||
- `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`.
|
||||
- Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed.
|
||||
- Preferred automation entrypoint: `pnpm test:parallels:macos`. It restores the snapshot most closely matching `macOS 26.3.1 fresh`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
||||
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
|
||||
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
|
||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-smoke.*`.
|
||||
- All-OS parallel runs should share the host `dist` build via `/tmp/openclaw-parallels-build.lock` instead of rebuilding three times.
|
||||
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
|
||||
- Fresh host-served tgz install: restore fresh snapshot, install tgz as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
|
||||
- For `openclaw onboard --non-interactive --secret-input-mode ref --install-daemon`, expect env-backed auth-profile refs (for example `OPENAI_API_KEY`) to be copied into the service env at install time; this path was fixed and should stay green.
|
||||
- Don’t run local + gateway agent turns in parallel on the same fresh workspace/session; they can collide on the session lock. Run sequentially.
|
||||
- Root-installed tarball smoke on Tahoe can still log plugin blocks for world-writable `extensions/*` under `/opt/homebrew/lib/node_modules/openclaw`; treat that as separate from onboarding/gateway health unless the task is plugin loading.
|
||||
- Parallels Windows smoke playbook:
|
||||
- Preferred automation entrypoint: `pnpm test:parallels:windows`. It restores the snapshot most closely matching `pre-openclaw-native-e2e-2026-03-12`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
||||
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
|
||||
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
|
||||
- Always use `prlctl exec --current-user` for Windows guest runs; plain `prlctl exec` lands in `NT AUTHORITY\SYSTEM` and does not match the real desktop-user install path.
|
||||
- Prefer explicit `npm.cmd` / `openclaw.cmd`. Bare `npm` / `openclaw` in PowerShell can hit the `.ps1` shim and fail under restrictive execution policy.
|
||||
- Use PowerShell only as the transport (`powershell.exe -NoProfile -ExecutionPolicy Bypass`) and call the `.cmd` shims explicitly from inside it.
|
||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-windows.*`.
|
||||
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
|
||||
- Keep Windows onboarding/status text ASCII-clean in logs. Fancy punctuation in banners shows up as mojibake through the current guest PowerShell capture path.
|
||||
- Parallels Linux smoke playbook:
|
||||
- Preferred automation entrypoint: `pnpm test:parallels:linux`. It restores the snapshot most closely matching `fresh` on `Ubuntu 24.04.3 ARM64`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
||||
- Use plain `prlctl exec` on this snapshot. `--current-user` is not the right transport there.
|
||||
- Fresh snapshot reality: `curl` is missing and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates` before testing installer paths.
|
||||
- Fresh `main` tgz smoke on Linux still needs the latest-release installer first, because this snapshot has no Node/npm before bootstrap. The harness does stable bootstrap first, then overlays current `main`.
|
||||
- This snapshot does not have a usable `systemd --user` session. Treat managed daemon install as unsupported here; use `--skip-health`, then verify with direct `openclaw gateway run --bind loopback --port 18789 --force`.
|
||||
- Env-backed auth refs are still fine, but any direct shell launch (`openclaw gateway run`, `openclaw agent --local`, Linux `gateway status --deep` against that direct run) must inherit the referenced env vars in the same shell.
|
||||
- `prlctl exec` reaps detached Linux child processes on this snapshot, so a background `openclaw gateway run` launched from automation is not a trustworthy smoke path. The harness verifies installer + `agent --local`; do direct gateway checks only from an interactive guest shell when needed.
|
||||
- When you do run Linux gateway checks manually from an interactive guest shell, use `openclaw gateway status --deep --require-rpc` so an RPC miss is a hard failure.
|
||||
- Prefer direct argv guest commands for fetch/install steps (`curl`, `npm install -g`, `openclaw ...`) over nested `bash -lc` quoting; Linux guest quoting through Parallels was the flaky part.
|
||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-linux.*`.
|
||||
- Current expected outcome on Linux smoke: fresh + upgrade should pass installer and `agent --local`; gateway remains `skipped-no-detached-linux-gateway` on this snapshot and should not be treated as a regression by itself.
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`).
|
||||
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
||||
|
|
|
|||
128
CHANGELOG.md
128
CHANGELOG.md
|
|
@ -6,19 +6,122 @@ Docs: https://docs.openclaw.ai
|
|||
|
||||
### Changes
|
||||
|
||||
- Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus.
|
||||
- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei.
|
||||
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
|
||||
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
||||
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
|
||||
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior.
|
||||
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
|
||||
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups.
|
||||
- Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.
|
||||
- Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.
|
||||
- Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.
|
||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
|
||||
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
|
||||
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
|
||||
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. Thanks @vincentkoc.
|
||||
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
||||
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
|
||||
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
|
||||
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
|
||||
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
|
||||
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
||||
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
||||
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
|
||||
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
|
||||
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
|
||||
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
|
||||
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
|
||||
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus.
|
||||
- iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show `/pair qr` instructions on the connect step. (#45054) Thanks @ngutman.
|
||||
- Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for `chrome://inspect/#remote-debugging` enablement and direct backlinks to Chrome’s own setup guides.
|
||||
- Browser/agents: add built-in `profile="user"` for the logged-in host browser and `profile="chrome-relay"` for the extension relay, so agent browser calls can prefer the real signed-in browser without the extra `browserSession` selector.
|
||||
- Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc.
|
||||
- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei.
|
||||
- Dependencies/pi: bump `@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, and `@mariozechner/pi-tui` to `0.58.0`.
|
||||
- Cron/sessions: add `sessionTarget: "current"` and `session:<id>` support so cron jobs can bind to the creating session or a persistent named session instead of only `main` or `isolated`. Thanks @kkhomej33-netizen and @ImLukeF.
|
||||
- Telegram/message send: add `--force-document` so Telegram image and GIF sends can upload as documents without compression. (#45111) Thanks @thepagent.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.
|
||||
- Gateway/client requests: reject unanswered gateway RPC calls after a bounded timeout and clear their pending state, so stalled connections no longer leak hanging `GatewayClient.request()` promises indefinitely.
|
||||
- Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.
|
||||
- Ollama/reasoning visibility: stop promoting native `thinking` and `reasoning` fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang.
|
||||
- Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus.
|
||||
- Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0.
|
||||
- Browser/existing-session: accept text-only `list_pages` and `new_page` responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata.
|
||||
- Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark.
|
||||
- Gateway/session reset: preserve `lastAccountId` and `lastThreadId` across gateway session resets so replies keep routing back to the same account and thread after `/reset`. (#44773) Thanks @Lanfei.
|
||||
- Agents/memory bootstrap: load only one root memory file, preferring `MEMORY.md` and using `memory.md` as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei.
|
||||
- macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots.
|
||||
- Gateway/status: add `openclaw gateway status --require-rpc` and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green.
|
||||
- macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered `system.run` requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens.
|
||||
- Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.
|
||||
- Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images.
|
||||
- Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups.
|
||||
- Windows/gateway stop: resolve Startup-folder fallback listeners from the installed `gateway.cmd` port, so `openclaw gateway stop` now actually kills fallback-launched gateway processes before restart.
|
||||
- Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`.
|
||||
- Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale `device signature expired` fallback noise before succeeding.
|
||||
- Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.
|
||||
- Slack/probe: keep `auth.test()` bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss.
|
||||
- Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes.
|
||||
- Dashboard/chat UI: restore the `chat-new-messages` class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han.
|
||||
- Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom.
|
||||
- macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance.
|
||||
- Discord/allowlists: honor raw `guild_id` when hydrated guild objects are missing so allowlisted channels and threads like `#maintainers` no longer get false-dropped before channel allowlist checks.
|
||||
- macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo.
|
||||
- Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu.
|
||||
- Models/google-vertex Gemini flash-lite normalization: apply existing bare-ID preview normalization to `google-vertex` model refs and provider configs so `google-vertex/gemini-3.1-flash-lite` resolves as `gemini-3.1-flash-lite-preview`. (#42435) thanks @scoootscooob.
|
||||
- iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua.
|
||||
- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
|
||||
- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
|
||||
- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization.
|
||||
- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates.
|
||||
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
|
||||
- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants.
|
||||
- Security/exec approvals: unwrap `env` dispatch wrappers inside shell-segment allowlist resolution on macOS so `env FOO=bar /path/to/bin` resolves against the effective executable instead of the wrapper token.
|
||||
- Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued `$(` substitutions fail closed instead of slipping past command-substitution checks.
|
||||
- Security/exec approvals: bind macOS skill auto-allow trust to both executable name and resolved path so same-basename binaries no longer inherit trust from unrelated skill bins.
|
||||
- Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.
|
||||
- Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc.
|
||||
- Agents/OpenAI-compatible compat overrides: respect explicit user `models[].compat` opt-ins for non-native `openai-completions` endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference.
|
||||
- Agents/Azure OpenAI startup prompts: rephrase the built-in `/new`, `/reset`, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97.
|
||||
- Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.
|
||||
- Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello.
|
||||
- Agents/tool warnings: distinguish gated core tools like `apply_patch` from plugin-only unknown entries in `tools.profile` warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin.
|
||||
- Config/validation: accept documented `agents.list[].params` per-agent overrides in strict config validation so `openclaw config validate` no longer rejects runtime-supported `cacheRetention`, `temperature`, and `maxTokens` settings. (#41171) Thanks @atian8179.
|
||||
- Config/web fetch: restore runtime validation for documented `tools.web.fetch.readability` and `tools.web.fetch.firecrawl` settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec.
|
||||
- Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone.
|
||||
- Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh.
|
||||
- Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.
|
||||
- Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic.
|
||||
- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix
|
||||
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931)
|
||||
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
|
||||
- Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy.
|
||||
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
|
|
@ -31,6 +134,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi
|
||||
- Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff
|
||||
- Slack/agent replies: support `channelData.slack.blocks` in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.
|
||||
- Slack/interactive replies: add opt-in Slack button and select reply directives behind `channels.slack.capabilities.interactiveReplies`, disabled by default unless explicitly enabled. (#44607) Thanks @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
|
@ -87,13 +191,16 @@ Docs: https://docs.openclaw.ai
|
|||
- Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman.
|
||||
- Context engine/session routing: forward optional `sessionKey` through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman.
|
||||
- Agents/failover: classify z.ai `network_error` stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev.
|
||||
- Config/Anthropic startup: inline Anthropic alias normalization during config load so gateway startup no longer crashes on dated Anthropic model refs like `anthropic/claude-sonnet-4-20250514`. (#45520) Thanks @BunsDev.
|
||||
- Memory/session sync: add mode-aware post-compaction session reindexing with `agents.defaults.compaction.postIndexSync` plus `agents.defaults.memorySearch.sync.sessions.postCompactionForce`, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz.
|
||||
- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
|
||||
- Telegram/native command sync: suppress expected `BOT_COMMANDS_TOO_MUCH` retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures.
|
||||
- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
|
||||
- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras.
|
||||
- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
|
||||
- Browser/existing-session: stop reporting fake CDP ports/URLs for live attached Chrome sessions, render `transport: chrome-mcp` in CLI/status output instead of `port: 0`, and keep timeout diagnostics transport-aware when no direct CDP URL exists.
|
||||
- Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write.
|
||||
- Feishu/event dedupe: keep early duplicate suppression aligned with the shared Feishu message-id contract and release the pre-queue dedupe marker after failed dispatch so retried events can recover instead of being dropped until the short TTL expires. (#43762) Thanks @yunweibang.
|
||||
- Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when `hooks.allowedAgentIds` leaves hook routing unrestricted.
|
||||
- Agents/compaction: skip the post-compaction `cache-ttl` marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.
|
||||
- Native chat/macOS: add `/new`, `/reset`, and `/clear` reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639.
|
||||
|
|
@ -104,6 +211,8 @@ Docs: https://docs.openclaw.ai
|
|||
- Delivery/dedupe: trim completed direct-cron delivery cache correctly and keep mirrored transcript dedupe active even when transcript files contain malformed lines. (#44666) thanks @frankekn.
|
||||
- CLI/thinking help: add the missing `xhigh` level hints to `openclaw cron add`, `openclaw cron edit`, and `openclaw agent` so the help text matches the levels already accepted at runtime. (#44819) Thanks @kiki830621.
|
||||
- Agents/Anthropic replay: drop replayed assistant thinking blocks for native Anthropic and Bedrock Claude providers so persisted follow-up turns no longer fail on stored thinking blocks. (#44843) Thanks @jmcte.
|
||||
- Docs/Brave pricing: escape literal dollar signs in Brave Search cost text so the docs render the free credit and per-request pricing correctly. (#44989) Thanks @keelanfh.
|
||||
- Feishu/file uploads: preserve literal UTF-8 filenames in `im.file.create` so Chinese and other non-ASCII filenames no longer appear percent-encoded in chat. (#34262) Thanks @fabiaodemianyang and @KangShuaiFu.
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
|
|
@ -244,6 +353,8 @@ Docs: https://docs.openclaw.ai
|
|||
- Agents/failover: classify ZenMux quota-refresh `402` responses as `rate_limit` so model fallback retries continue instead of stopping on a temporary subscription window. (#43917) thanks @bwjoke.
|
||||
- Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode.
|
||||
- Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows.
|
||||
- macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint.
|
||||
- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
|
|
@ -322,6 +433,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
|
||||
- Agents/embedded runner: carry provider-observed overflow token counts into compaction so overflow retries and diagnostics use the rejected live prompt size instead of only transcript estimates. (#40357) thanks @rabsef-bicrym.
|
||||
- Agents/compaction transcript updates: emit a transcript-update event immediately after successful embedded compaction so downstream listeners observe the post-compact transcript without waiting for a later write. (#25558) thanks @rodrigouroz.
|
||||
- Agents/sessions_spawn: use the target agent workspace for cross-agent spawned runs instead of inheriting the caller workspace, so child sessions load the correct workspace-scoped instructions and persona files. (#40176) Thanks @moshehbenavraham.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
|
|
@ -3246,7 +3358,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
|
||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998)
|
||||
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (#45459) Thanks @LyttonFeng and @vincentkoc.
|
||||
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ Welcome to the lobster tank! 🦞
|
|||
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
|
||||
- GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
|
||||
|
||||
- **Radek Sienkiewicz** - Control UI + WebChat correctness
|
||||
- **Radek Sienkiewicz** - Docs, Control UI
|
||||
- GitHub [@velvet-shark](https://github.com/velvet-shark) · X: [@velvet_shark](https://twitter.com/velvet_shark)
|
||||
|
||||
- **Muhammed Mukhthar** - Mattermost, CLI
|
||||
|
|
@ -76,6 +76,9 @@ Welcome to the lobster tank! 🦞
|
|||
- **Tengji (George) Zhang** - Chinese model APIs, cloud, pi
|
||||
- GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z)
|
||||
|
||||
- **Andrew (Bubbles) Demczuk** - Agents/Gateway/TTS/VTT
|
||||
- GitHub: [@ademczuk](https://github.com/ademczuk) · X: [@ademczuk](https://x.com/ademczuk)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
|
|
@ -93,6 +96,7 @@ Welcome to the lobster tank! 🦞
|
|||
- Reply to or resolve bot review conversations you addressed before asking for review again
|
||||
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
|
||||
- Use American English spelling and grammar in code, comments, docs, and UI strings
|
||||
- Do not edit files covered by `CODEOWNERS` security ownership unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted review surfaces, not opportunistic cleanup targets.
|
||||
|
||||
## Review Conversations Are Author-Owned
|
||||
|
||||
|
|
|
|||
|
|
@ -132,8 +132,9 @@ WORKDIR /app
|
|||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
procps hostname curl git openssl
|
||||
procps hostname curl git lsof openssl
|
||||
|
||||
RUN chown node:node /app
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get upgrade -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get upgrade -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin
|
|||
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get upgrade -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends ${PACKAGES}
|
||||
|
||||
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.svg" alt="OpenClaw" width="500">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -101,25 +101,19 @@ public enum WakeWordGate {
|
|||
}
|
||||
|
||||
public static func commandText(
|
||||
transcript: String,
|
||||
transcript _: String,
|
||||
segments: [WakeWordSegment],
|
||||
triggerEndTime: TimeInterval)
|
||||
-> String {
|
||||
let threshold = triggerEndTime + 0.001
|
||||
var commandWords: [String] = []
|
||||
commandWords.reserveCapacity(segments.count)
|
||||
for segment in segments where segment.start >= threshold {
|
||||
if normalizeToken(segment.text).isEmpty { continue }
|
||||
if let range = segment.range {
|
||||
let slice = transcript[range.lowerBound...]
|
||||
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
}
|
||||
break
|
||||
let normalized = normalizeToken(segment.text)
|
||||
if normalized.isEmpty { continue }
|
||||
commandWords.append(segment.text)
|
||||
}
|
||||
|
||||
let text = segments
|
||||
.filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty }
|
||||
.map(\.text)
|
||||
.joined(separator: " ")
|
||||
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
return commandWords.joined(separator: " ").trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
}
|
||||
|
||||
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,25 @@ import Testing
|
|||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
||||
#expect(match?.command == "do it")
|
||||
}
|
||||
|
||||
@Test func commandTextHandlesForeignRangeIndices() {
|
||||
let transcript = "hey clawd do thing"
|
||||
let other = "do thing"
|
||||
let foreignRange = other.range(of: "do")
|
||||
let segments = [
|
||||
WakeWordSegment(text: "hey", start: 0.0, duration: 0.1, range: transcript.range(of: "hey")),
|
||||
WakeWordSegment(text: "clawd", start: 0.2, duration: 0.1, range: transcript.range(of: "clawd")),
|
||||
WakeWordSegment(text: "do", start: 0.9, duration: 0.1, range: foreignRange),
|
||||
WakeWordSegment(text: "thing", start: 1.1, duration: 0.1, range: nil),
|
||||
]
|
||||
|
||||
let command = WakeWordGate.commandText(
|
||||
transcript: transcript,
|
||||
segments: segments,
|
||||
triggerEndTime: 0.3)
|
||||
|
||||
#expect(command == "do thing")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSegments(
|
||||
|
|
|
|||
438
appcast.xml
438
appcast.xml
|
|
@ -2,6 +2,82 @@
|
|||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.3.13</title>
|
||||
<pubDate>Sat, 14 Mar 2026 05:19:48 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026031390</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.13</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.13</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus.</li>
|
||||
<li>iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show <code>/pair qr</code> instructions on the connect step. (#45054) Thanks @ngutman.</li>
|
||||
<li>Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for <code>chrome://inspect/#remote-debugging</code> enablement and direct backlinks to Chrome’s own setup guides.</li>
|
||||
<li>Browser/agents: add built-in <code>profile="user"</code> for the logged-in host browser and <code>profile="chrome-relay"</code> for the extension relay, so agent browser calls can prefer the real signed-in browser without the extra <code>browserSession</code> selector.</li>
|
||||
<li>Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc.</li>
|
||||
<li>Docker/timezone override: add <code>OPENCLAW_TZ</code> so <code>docker-setup.sh</code> can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei.</li>
|
||||
<li>Dependencies/pi: bump <code>@mariozechner/pi-agent-core</code>, <code>@mariozechner/pi-ai</code>, <code>@mariozechner/pi-coding-agent</code>, and <code>@mariozechner/pi-tui</code> to <code>0.58.0</code>.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.</li>
|
||||
<li>Gateway/client requests: reject unanswered gateway RPC calls after a bounded timeout and clear their pending state, so stalled connections no longer leak hanging <code>GatewayClient.request()</code> promises indefinitely.</li>
|
||||
<li>Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.</li>
|
||||
<li>Ollama/reasoning visibility: stop promoting native <code>thinking</code> and <code>reasoning</code> fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang.</li>
|
||||
<li>Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus.</li>
|
||||
<li>Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0.</li>
|
||||
<li>Browser/existing-session: accept text-only <code>list_pages</code> and <code>new_page</code> responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata.</li>
|
||||
<li>Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark.</li>
|
||||
<li>Gateway/session reset: preserve <code>lastAccountId</code> and <code>lastThreadId</code> across gateway session resets so replies keep routing back to the same account and thread after <code>/reset</code>. (#44773) Thanks @Lanfei.</li>
|
||||
<li>macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so <code>openclaw onboard --install-daemon</code> no longer false-fails on slower Macs and fresh VM snapshots.</li>
|
||||
<li>Gateway/status: add <code>openclaw gateway status --require-rpc</code> and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green.</li>
|
||||
<li>macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered <code>system.run</code> requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens.</li>
|
||||
<li>Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.</li>
|
||||
<li>Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images.</li>
|
||||
<li>Windows/gateway install: bound <code>schtasks</code> calls and fall back to the Startup-folder login item when task creation hangs, so native <code>openclaw gateway install</code> fails fast instead of wedging forever on broken Scheduled Task setups.</li>
|
||||
<li>Windows/gateway stop: resolve Startup-folder fallback listeners from the installed <code>gateway.cmd</code> port, so <code>openclaw gateway stop</code> now actually kills fallback-launched gateway processes before restart.</li>
|
||||
<li>Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in <code>gateway status --json</code> instead of falling back to <code>gateway port unknown</code>.</li>
|
||||
<li>Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale <code>device signature expired</code> fallback noise before succeeding.</li>
|
||||
<li>Discord/gateway startup: treat plain-text and transient <code>/gateway/bot</code> metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.</li>
|
||||
<li>Slack/probe: keep <code>auth.test()</code> bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss.</li>
|
||||
<li>Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes.</li>
|
||||
<li>Dashboard/chat UI: restore the <code>chat-new-messages</code> class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han.</li>
|
||||
<li>Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom.</li>
|
||||
<li>macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance.</li>
|
||||
<li>Discord/allowlists: honor raw <code>guild_id</code> when hydrated guild objects are missing so allowlisted channels and threads like <code>#maintainers</code> no longer get false-dropped before channel allowlist checks.</li>
|
||||
<li>macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo.</li>
|
||||
<li>Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu.</li>
|
||||
<li>Models/google-vertex Gemini flash-lite normalization: apply existing bare-ID preview normalization to <code>google-vertex</code> model refs and provider configs so <code>google-vertex/gemini-3.1-flash-lite</code> resolves as <code>gemini-3.1-flash-lite-preview</code>. (#42435) thanks @scoootscooob.</li>
|
||||
<li>iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua.</li>
|
||||
<li>Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.</li>
|
||||
<li>Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.</li>
|
||||
<li>Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed <code>EXTERNAL_UNTRUSTED_CONTENT</code> markers fall back to the existing hardening path instead of bypassing marker normalization.</li>
|
||||
<li>Security/exec approvals: unwrap more <code>pnpm</code> runtime forms during approval binding, including <code>pnpm --reporter ... exec</code> and direct <code>pnpm node</code> file runs, with matching regression coverage and docs updates.</li>
|
||||
<li>Security/exec approvals: fail closed for Perl <code>-M</code> and <code>-I</code> approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.</li>
|
||||
<li>Security/exec approvals: recognize PowerShell <code>-File</code> and <code>-f</code> wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing <code>-Command</code> variants.</li>
|
||||
<li>Security/exec approvals: unwrap <code>env</code> dispatch wrappers inside shell-segment allowlist resolution on macOS so <code>env FOO=bar /path/to/bin</code> resolves against the effective executable instead of the wrapper token.</li>
|
||||
<li>Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued <code>$(</code> substitutions fail closed instead of slipping past command-substitution checks.</li>
|
||||
<li>Security/exec approvals: bind macOS skill auto-allow trust to both executable name and resolved path so same-basename binaries no longer inherit trust from unrelated skill bins.</li>
|
||||
<li>Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.</li>
|
||||
<li>Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc.</li>
|
||||
<li>Agents/OpenAI-compatible compat overrides: respect explicit user <code>models[].compat</code> opt-ins for non-native <code>openai-completions</code> endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference.</li>
|
||||
<li>Agents/Azure OpenAI startup prompts: rephrase the built-in <code>/new</code>, <code>/reset</code>, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97.</li>
|
||||
<li>Agents/memory bootstrap: load only one root memory file, preferring <code>MEMORY.md</code> and using <code>memory.md</code> as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei.</li>
|
||||
<li>Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.</li>
|
||||
<li>Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello.</li>
|
||||
<li>Agents/tool warnings: distinguish gated core tools like <code>apply_patch</code> from plugin-only unknown entries in <code>tools.profile</code> warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin.</li>
|
||||
<li>Config/validation: accept documented <code>agents.list[].params</code> per-agent overrides in strict config validation so <code>openclaw config validate</code> no longer rejects runtime-supported <code>cacheRetention</code>, <code>temperature</code>, and <code>maxTokens</code> settings. (#41171) Thanks @atian8179.</li>
|
||||
<li>Config/web fetch: restore runtime validation for documented <code>tools.web.fetch.readability</code> and <code>tools.web.fetch.firecrawl</code> settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec.</li>
|
||||
<li>Signal/config validation: add <code>channels.signal.groups</code> schema support so per-group <code>requireMention</code>, <code>tools</code>, and <code>toolsBySender</code> overrides no longer get rejected during config validation. (#27199) Thanks @unisone.</li>
|
||||
<li>Config/discovery: accept <code>discovery.wideArea.domain</code> in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh.</li>
|
||||
<li>Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.</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.13/OpenClaw-2026.3.13.zip" length="23640917" type="application/octet-stream" sparkle:edSignature="Me63UHSpFLocTo5Lt7Iqsl0Hq61y3jTcZ9DUkiFl9xQvTE0+ORuqRMFWqPgYwfaKMgcgQmUbrV/uFzEoTIRHBA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.3.12</title>
|
||||
<pubDate>Fri, 13 Mar 2026 04:25:50 +0000</pubDate>
|
||||
|
|
@ -168,367 +244,5 @@
|
|||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.8-beta.1/OpenClaw-2026.3.8-beta.1.zip" length="23407015" type="application/octet-stream" sparkle:edSignature="KCqhSmu4b0tHf55RqcQOHorsc55CgBI5BUmK/NTizxNq04INn/7QvsamHYQou9DbB2IW6B2nawBC4nn4au5yDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.3.7</title>
|
||||
<pubDate>Sun, 08 Mar 2026 04:42:35 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026030790</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.7</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.7</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Agents/context engine plugin interface: add <code>ContextEngine</code> plugin slot with full lifecycle hooks (<code>bootstrap</code>, <code>ingest</code>, <code>assemble</code>, <code>compact</code>, <code>afterTurn</code>, <code>prepareSubagentSpawn</code>, <code>onSubagentEnded</code>), slot-based registry with config-driven resolution, <code>LegacyContextEngine</code> wrapper preserving existing compaction behavior, scoped subagent runtime for plugin runtimes via <code>AsyncLocalStorage</code>, and <code>sessions.get</code> gateway method. Enables plugins like <code>lossless-claw</code> to provide alternative context management strategies without modifying core compaction logic. Zero behavior change when no context engine plugin is configured. (#22201) thanks @jalehman.</li>
|
||||
<li>ACP/persistent channel bindings: add durable Discord channel and Telegram topic binding storage, routing resolution, and CLI/docs support so ACP thread targets survive restarts and can be managed consistently. (#34873) Thanks @dutifulbob.</li>
|
||||
<li>Telegram/ACP topic bindings: accept Telegram Mac Unicode dash option prefixes in <code>/acp spawn</code>, support Telegram topic thread binding (<code>--thread here|auto</code>), route bound-topic follow-ups to ACP sessions, add actionable Telegram approval buttons with prefixed approval-id resolution, and pin successful bind confirmations in-topic. (#36683) Thanks @huntharo.</li>
|
||||
<li>Telegram/topic agent routing: support per-topic <code>agentId</code> overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin.</li>
|
||||
<li>Web UI/i18n: add Spanish (<code>es</code>) locale support in the Control UI, including locale detection, lazy loading, and language picker labels across supported locales. (#35038) Thanks @DaoPromociones.</li>
|
||||
<li>Onboarding/web search: add provider selection step and full provider list in configure wizard, with SecretRef ref-mode support during onboarding. (#34009) Thanks @kesku and @thewilloftheshadow.</li>
|
||||
<li>Tools/Web search: switch Perplexity provider to Search API with structured results plus new language/region/time filters. (#33822) Thanks @kesku.</li>
|
||||
<li>Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant.</li>
|
||||
<li>Docker/Podman extension dependency baking: add <code>OPENCLAW_EXTENSIONS</code> so container builds can preinstall selected bundled extension npm dependencies into the image for faster and more reproducible startup in container deployments. (#32223) Thanks @sallyom.</li>
|
||||
<li>Plugins/before_prompt_build system-context fields: add <code>prependSystemContext</code> and <code>appendSystemContext</code> so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin.</li>
|
||||
<li>Plugins/hook policy: add <code>plugins.entries.<id>.hooks.allowPromptInjection</code>, validate unknown typed hook names at runtime, and preserve legacy <code>before_agent_start</code> model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras.</li>
|
||||
<li>Hooks/Compaction lifecycle: emit <code>session:compact:before</code> and <code>session:compact:after</code> internal events plus plugin compaction callbacks with session/count metadata, so automations can react to compaction runs consistently. (#16788) thanks @vincentkoc.</li>
|
||||
<li>Agents/compaction post-context configurability: add <code>agents.defaults.compaction.postCompactionSections</code> so deployments can choose which <code>AGENTS.md</code> sections are re-injected after compaction, while preserving legacy fallback behavior when the documented default pair is configured in any order. (#34556) thanks @efe-arv.</li>
|
||||
<li>TTS/OpenAI-compatible endpoints: add <code>messages.tts.openai.baseUrl</code> config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42.</li>
|
||||
<li>Slack/DM typing feedback: add <code>channels.slack.typingReaction</code> so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat.</li>
|
||||
<li>Discord/allowBots mention gating: add <code>allowBots: "mentions"</code> to only accept bot-authored messages that mention the bot. Thanks @thewilloftheshadow.</li>
|
||||
<li>Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr.</li>
|
||||
<li>Cron/job snapshot persistence: skip backup during normalization persistence in <code>ensureLoaded</code> so <code>jobs.json.bak</code> keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline.</li>
|
||||
<li>CLI: make read-only SecretRef status flows degrade safely (#37023) thanks @joshavant.</li>
|
||||
<li>Tools/Diffs guidance: restore a short system-prompt hint for enabled diffs while keeping the detailed instructions in the companion skill, so diffs usage guidance stays out of user-prompt space. (#36904) thanks @gumadeiras.</li>
|
||||
<li>Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet.</li>
|
||||
<li>Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind.</li>
|
||||
<li>Config/Compaction safeguard tuning: expose <code>agents.defaults.compaction.recentTurnsPreserve</code> and quality-guard retry knobs through the validated config surface and embedded-runner wiring, with regression coverage for real config loading and schema metadata. (#25557) thanks @rodrigouroz.</li>
|
||||
<li>iOS/App Store Connect release prep: align iOS bundle identifiers under <code>ai.openclaw.client</code>, refresh Watch app icons, add Fastlane metadata/screenshot automation, and support Keychain-backed ASC auth for uploads. (#38936) Thanks @ngutman.</li>
|
||||
<li>Mattermost/model picker: add Telegram-style interactive provider/model browsing for <code>/oc_model</code> and <code>/oc_models</code>, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.</li>
|
||||
<li>Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add <code>OPENCLAW_VARIANT=slim</code> build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.</li>
|
||||
<li>Google/Gemini 3.1 Flash-Lite: add first-class <code>google/gemini-3.1-flash-lite-preview</code> support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Gateway auth now requires explicit <code>gateway.auth.mode</code> when both <code>gateway.auth.token</code> and <code>gateway.auth.password</code> are configured (including SecretRefs). Set <code>gateway.auth.mode</code> to <code>token</code> or <code>password</code> before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Models/MiniMax: stop advertising removed <code>MiniMax-M2.5-Lightning</code> in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as <code>MiniMax-M2.5-highspeed</code>.</li>
|
||||
<li>Security/Config: fail closed when <code>loadConfig()</code> hits validation or read errors so invalid configs cannot silently fall back to permissive runtime defaults. (#9040) Thanks @joetomasone.</li>
|
||||
<li>Memory/Hybrid search: preserve negative FTS5 BM25 relevance ordering in <code>bm25RankToScore()</code> so stronger keyword matches rank above weaker ones instead of collapsing or reversing scores. (#33757) Thanks @lsdcc01.</li>
|
||||
<li>LINE/<code>requireMention</code> group gating: align inbound and reply-stage LINE group policy resolution across raw, <code>group:</code>, and <code>room:</code> keys (including account-scoped group config), preserve plugin-backed reply-stage fallback behavior, and add regression coverage for prefixed-only group/room config plus reply-stage policy resolution. (#35847) Thanks @kirisame-wang.</li>
|
||||
<li>Onboarding/local setup: default unset local <code>tools.profile</code> to <code>coding</code> instead of <code>messaging</code>, restoring file/runtime tools for fresh local installs while preserving explicit user-set profiles. (from #38241, overlap with #34958) Thanks @cgdusek.</li>
|
||||
<li>Gateway/Telegram stale-socket restart guard: only apply stale-socket restarts to channels that publish event-liveness timestamps, preventing Telegram providers from being misclassified as stale solely due to long uptime and avoiding restart/pairing storms after upgrade. (openclaw#38464)</li>
|
||||
<li>Onboarding/headless Linux daemon probe hardening: treat <code>systemctl --user is-enabled</code> probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web.</li>
|
||||
<li>Memory/QMD mcporter Windows spawn hardening: when <code>mcporter.cmd</code> launch fails with <code>spawn EINVAL</code>, retry via bare <code>mcporter</code> shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i.</li>
|
||||
<li>Tools/web_search Brave language-code validation: align <code>search_lang</code> handling with Brave-supported codes (including <code>zh-hans</code>, <code>zh-hant</code>, <code>en-gb</code>, and <code>pt-br</code>), map common alias inputs (<code>zh</code>, <code>ja</code>) to valid Brave values, and reject unsupported codes before upstream requests to prevent 422 failures. (#37260) Thanks @heyanming.</li>
|
||||
<li>Models/openai-completions streaming compatibility: force <code>compat.supportsUsageInStreaming=false</code> for non-native OpenAI-compatible endpoints during model normalization, preventing usage-only stream chunks from triggering <code>choices[0]</code> parser crashes in provider streams. (#8714) Thanks @nonanon1.</li>
|
||||
<li>Tools/xAI native web-search collision guard: drop OpenClaw <code>web_search</code> from tool registration when routing to xAI/Grok model providers (including OpenRouter <code>x-ai/*</code>) to avoid duplicate tool-name request failures against provider-native <code>web_search</code>. (#14749) Thanks @realsamrat.</li>
|
||||
<li>TUI/token copy-safety rendering: treat long credential-like mixed alphanumeric tokens (including quoted forms) as copy-sensitive in render sanitization so formatter hard-wrap guards no longer inject visible spaces into auth-style values before display. (#26710) Thanks @jasonthane.</li>
|
||||
<li>WhatsApp/self-chat response prefix fallback: stop forcing <code>"[openclaw]"</code> as the implicit outbound response prefix when no identity name or response prefix is configured, so blank/default prefix settings no longer inject branding text unexpectedly in self-chat flows. (#27962) Thanks @ecanmor.</li>
|
||||
<li>Memory/QMD search result decoding: accept <code>qmd search</code> hits that only include <code>file</code> URIs (for example <code>qmd://collection/path.md</code>) without <code>docid</code>, resolve them through managed collection roots, and keep multi-collection results keyed by file fallback so valid QMD hits no longer collapse to empty <code>memory_search</code> output. (#28181) Thanks @0x76696265.</li>
|
||||
<li>Memory/QMD collection-name conflict recovery: when <code>qmd collection add</code> fails because another collection already occupies the same <code>path + pattern</code>, detect the conflicting collection from <code>collection list</code>, remove it, and retry add so agent-scoped managed collections are created deterministically instead of being silently skipped; also add warning-only fallback when qmd metadata is unavailable to avoid destructive guesses. (#25496) Thanks @Ramsbaby.</li>
|
||||
<li>Slack/app_mention race dedupe: when <code>app_mention</code> dispatch wins while same-<code>ts</code> <code>message</code> prepare is still in-flight, suppress the later message dispatch so near-simultaneous Slack deliveries do not produce duplicate replies; keep single-retry behavior and add regression coverage for both dropped and successful message-prepare outcomes. (#37033) Thanks @Takhoffman.</li>
|
||||
<li>Gateway/chat streaming tool-boundary text retention: merge assistant delta segments into per-run chat buffers so pre-tool text is preserved in live chat deltas/finals when providers emit post-tool assistant segments as non-prefix snapshots. (#36957) Thanks @Datyedyeguy.</li>
|
||||
<li>TUI/model indicator freshness: prevent stale session snapshots from overwriting freshly patched model selection (and reset per-session freshness when switching session keys) so <code>/model</code> updates reflect immediately instead of lagging by one or more commands. (#21255) Thanks @kowza.</li>
|
||||
<li>TUI/final-error rendering fallback: when a chat <code>final</code> event has no renderable assistant content but includes envelope <code>errorMessage</code>, render the formatted error text instead of collapsing to <code>"(no output)"</code>, preserving actionable failure context in-session. (#14687) Thanks @Mquarmoc.</li>
|
||||
<li>TUI/session-key alias event matching: treat chat events whose session keys are canonical aliases (for example <code>agent:<id>:main</code> vs <code>main</code>) as the same session while preserving cross-agent isolation, so assistant replies no longer disappear or surface in another terminal window due to strict key-form mismatch. (#33937) Thanks @yjh1412.</li>
|
||||
<li>OpenAI Codex OAuth/login parity: keep <code>openclaw models auth login --provider openai-codex</code> on the built-in path even without provider plugins, preserve Pi-generated authorize URLs without local scope rewriting, and stop validating successful Codex sign-ins against the public OpenAI Responses API after callback. (#37558; follow-up to #36660 and #24720) Thanks @driesvints, @Skippy-Gunboat, and @obviyus.</li>
|
||||
<li>Agents/config schema lookup: add <code>gateway</code> tool action <code>config.schema.lookup</code> so agents can inspect one config path at a time before edits without loading the full schema into prompt context. (#37266) Thanks @gumadeiras.</li>
|
||||
<li>Onboarding/API key input hardening: strip non-Latin1 Unicode artifacts from normalized secret input (while preserving Latin-1 content and internal spaces) so malformed copied API keys cannot trigger HTTP header <code>ByteString</code> construction crashes; adds regression coverage for shared normalization and MiniMax auth header usage. (#24496) Thanks @fa6maalassaf.</li>
|
||||
<li>Kimi Coding/Anthropic tools compatibility: normalize <code>anthropic-messages</code> tool payloads to OpenAI-style <code>tools[].function</code> + compatible <code>tool_choice</code> when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.</li>
|
||||
<li>Heartbeat/workspace-path guardrails: append explicit workspace <code>HEARTBEAT.md</code> path guidance (and <code>docs/heartbeat.md</code> avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.</li>
|
||||
<li>Subagents/kill-complete announce race: when a late <code>subagent-complete</code> lifecycle event arrives after an earlier kill marker, clear stale kill suppression/cleanup flags and re-run announce cleanup so finished runs no longer get silently swallowed. (#37024) Thanks @cmfinlan.</li>
|
||||
<li>Agents/tool-result cleanup timeout hardening: on embedded runner teardown idle timeouts, clear pending tool-call state without persisting synthetic <code>missing tool result</code> entries, preventing timeout cleanups from poisoning follow-up turns; adds regression coverage for timeout clear-vs-flush behavior. (#37081) Thanks @Coyote-Den.</li>
|
||||
<li>Agents/openai-completions stream timeout hardening: ensure runtime undici global dispatchers use extended streaming body/header timeouts (including env-proxy dispatcher mode) before embedded runs, reducing forced mid-stream <code>terminated</code> failures on long generations; adds regression coverage for dispatcher selection and idempotent reconfiguration. (#9708) Thanks @scottchguard.</li>
|
||||
<li>Agents/fallback cooldown probe execution: thread explicit rate-limit cooldown probe intent from model fallback into embedded runner auth-profile selection so same-provider fallback attempts can actually run when all profiles are cooldowned for <code>rate_limit</code> (instead of failing pre-run as <code>No available auth profile</code>), while preserving default cooldown skip behavior and adding regression tests at both fallback and runner layers. (#13623) Thanks @asfura.</li>
|
||||
<li>Cron/OpenAI Codex OAuth refresh hardening: when <code>openai-codex</code> token refresh fails specifically on account-id extraction, reuse the cached access token instead of failing the run immediately, with regression coverage to keep non-Codex and unrelated refresh failures unchanged. (#36604) Thanks @laulopezreal.</li>
|
||||
<li>TUI/session isolation for <code>/new</code>: make <code>/new</code> allocate a unique <code>tui-<uuid></code> session key instead of resetting the shared agent session, so multiple TUI clients on the same agent stop receiving each other’s replies; also sanitize <code>/new</code> and <code>/reset</code> failure text before rendering in-terminal. Landed from contributor PR #39238 by @widingmarcus-cyber. Thanks @widingmarcus-cyber.</li>
|
||||
<li>Synology Chat/rate-limit env parsing: honor <code>SYNOLOGY_RATE_LIMIT=0</code> as an explicit value while still falling back to the default limit for malformed env values instead of partially parsing them. Landed from contributor PR #39197 by @scoootscooob. Thanks @scoootscooob.</li>
|
||||
<li>Voice-call/OpenAI Realtime STT config defaults: honor explicit <code>vadThreshold: 0</code> and <code>silenceDurationMs: 0</code> instead of silently replacing them with defaults. Landed from contributor PR #39196 by @scoootscooob. Thanks @scoootscooob.</li>
|
||||
<li>Voice-call/OpenAI TTS speed config: honor explicit <code>speed: 0</code> instead of silently replacing it with the default speed. Landed from contributor PR #39318 by @ql-wade. Thanks @ql-wade.</li>
|
||||
<li>launchd/runtime PID parsing: reject <code>pid <= 0</code> from <code>launchctl print</code> so the daemon state parser no longer treats kernel/non-running sentinel values as real process IDs. Landed from contributor PR #39281 by @mvanhorn. Thanks @mvanhorn.</li>
|
||||
<li>Cron/file permission hardening: enforce owner-only (<code>0600</code>) cron store/backup/run-log files and harden cron store + run-log directories to <code>0700</code>, including pre-existing directories from older installs. (#36078) Thanks @aerelune.</li>
|
||||
<li>Gateway/remote WS break-glass hostname support: honor <code>OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1</code> for <code>ws://</code> hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn.</li>
|
||||
<li>Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second <code>resolveAgentRoute</code> stalls in large binding configurations. (#36915) Thanks @songchenghao.</li>
|
||||
<li>Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during <code>sessions.reset</code>/<code>sessions.delete</code> runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693.</li>
|
||||
<li>Plugin/hook install rollback hardening: stage installs under the canonical install base, validate and run dependency installs before publish, and restore updates by rename instead of deleting the target path, reducing partial-replace and symlink-rebind risk during install failures.</li>
|
||||
<li>Slack/local file upload allowlist parity: propagate <code>mediaLocalRoots</code> through the Slack send action pipeline so workspace-rooted attachments pass <code>assertLocalMediaAllowed</code> checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin.</li>
|
||||
<li>Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin.</li>
|
||||
<li>Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent <code>RangeError: Invalid string length</code> on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888.</li>
|
||||
<li>iMessage/cron completion announces: strip leaked inline reply tags (for example <code>[[reply_to:6100]]</code>) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc.</li>
|
||||
<li>Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt.</li>
|
||||
<li>Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker.</li>
|
||||
<li>Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example <code>@Bot/model</code> and <code>@Bot /reset</code>) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Control UI/auth token separation: keep the shared gateway token in browser auth validation while reserving cached device tokens for signed device payloads, preventing false <code>device token mismatch</code> disconnects after restart/rotation. Landed from contributor PR #37382 by @FradSer. Thanks @FradSer.</li>
|
||||
<li>Gateway/browser auth reconnect hardening: stop counting missing token/password submissions as auth rate-limit failures, and stop auto-reconnecting Control UI clients on non-recoverable auth errors so misconfigured browser tabs no longer lock out healthy sessions. Landed from contributor PR #38725 by @ademczuk. Thanks @ademczuk.</li>
|
||||
<li>Gateway/service token drift repair: stop persisting shared auth tokens into installed gateway service units, flag stale embedded service tokens for reinstall, and treat tokenless service env as canonical so token rotation/reboot flows stay aligned with config/env resolution. Landed from contributor PR #28428 by @l0cka. Thanks @l0cka.</li>
|
||||
<li>Control UI/agents-page selection: keep the edited agent selected after saving agent config changes and reloading the agents list, so <code>/agents</code> no longer snaps back to the default agent. Landed from contributor PR #39301 by @MumuTW. Thanks @MumuTW.</li>
|
||||
<li>Gateway/auth follow-up hardening: preserve systemd <code>EnvironmentFile=</code> precedence/source provenance in daemon audits and doctor repairs, block shared-password override flows from piggybacking cached device tokens, and fail closed when config-first gateway SecretRefs cannot resolve. Follow-up to #39241.</li>
|
||||
<li>Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing <code>thinking</code>/<code>text</code> strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin.</li>
|
||||
<li>Agents/transcript policy: set <code>preserveSignatures</code> to Anthropic-only handling in <code>resolveTranscriptPolicy</code> so Anthropic thinking signatures are preserved while non-Anthropic providers remain unchanged. (#32813) thanks @Sid-Qin.</li>
|
||||
<li>Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok <code>Invalid arguments</code> failures. (openclaw#35355) thanks @Sid-Qin.</li>
|
||||
<li>Skills/native command deduplication: centralize skill command dedupe by canonical <code>skillName</code> in <code>listSkillCommandsForAgents</code> so duplicate suffixed variants (for example <code>_2</code>) are no longer surfaced across interfaces outside Discord. (#27521) thanks @shivama205.</li>
|
||||
<li>Agents/xAI tool-call argument decoding: decode HTML-entity encoded xAI/Grok tool-call argument values (<code>&</code>, <code>"</code>, <code><</code>, <code>></code>, numeric entities) before tool execution so commands with shell operators and quotes no longer fail with parse errors. (#35276) Thanks @Sid-Qin.</li>
|
||||
<li>Linux/WSL2 daemon install hardening: add regression coverage for WSL environment detection, WSL-specific systemd guidance, and <code>systemctl --user is-enabled</code> failure paths so WSL2/headless onboarding keeps treating bus-unavailable probes as non-fatal while preserving real permission errors. Related: #36495. Thanks @vincentkoc.</li>
|
||||
<li>Linux/systemd status and degraded-session handling: treat degraded-but-reachable <code>systemctl --user status</code> results as available, preserve early errors for truly unavailable user-bus cases, and report externally managed running services as running instead of <code>not installed</code>. Thanks @vincentkoc.</li>
|
||||
<li>Agents/thinking-tag promotion hardening: guard <code>promoteThinkingTagsToBlocks</code> against malformed assistant content entries (<code>null</code>/<code>undefined</code>) before <code>block.type</code> reads so malformed provider payloads no longer crash session processing while preserving pass-through behavior. (#35143) thanks @Sid-Qin.</li>
|
||||
<li>Gateway/Control UI version reporting: align runtime and browser client version metadata to avoid <code>dev</code> placeholders, wait for bootstrap version before first UI websocket connect, and only forward bootstrap <code>serverVersion</code> to same-origin gateway targets to prevent cross-target version leakage. (from #35230, #30928, #33928) Thanks @Sid-Qin, @joelnishanth, and @MoerAI.</li>
|
||||
<li>Control UI/markdown parser crash fallback: catch <code>marked.parse()</code> failures and fall back to escaped plain-text <code><pre></code> rendering so malformed recursive markdown no longer crashes Control UI session rendering on load. (#36445) Thanks @BinHPdev.</li>
|
||||
<li>Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev.</li>
|
||||
<li>Web UI/config form: treat <code>additionalProperties: true</code> object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai.</li>
|
||||
<li>Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread <code>message.reply</code> routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.</li>
|
||||
<li>Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so <code>requireMention</code> checks compare against current bot identity instead of stale config names, fixing missed <code>@bot</code> handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Security/dependency audit: patch transitive Hono vulnerabilities by pinning <code>hono</code> to <code>4.12.5</code> and <code>@hono/node-server</code> to <code>1.19.10</code> in production resolution paths. Thanks @shakkernerd.</li>
|
||||
<li>Security/dependency audit: bump <code>tar</code> to <code>7.5.10</code> (from <code>7.5.9</code>) to address the high-severity hardlink path traversal advisory (<code>GHSA-qffp-2rhf-9h96</code>). Thanks @shakkernerd.</li>
|
||||
<li>Cron/announce delivery robustness: bypass pending-descendant announce guards for cron completion sends, ensure named-agent announce routes have outbound session entries, and fall back to direct delivery only when an announce send was actually attempted and failed. (from #35185, #32443, #34987) Thanks @Sid-Qin, @scoootscooob, and @bmendonca3.</li>
|
||||
<li>Cron/announce best-effort fallback: run direct outbound fallback after attempted announce failures even when delivery is configured as best-effort, so Telegram cron sends are not left as attempted-but-undelivered after <code>cron announce delivery failed</code> warnings.</li>
|
||||
<li>Auto-reply/system events: restore runtime system events to the message timeline (<code>System:</code> lines), preserve think-hint parsing with prepended events, and carry events into deferred followup/collect/steer-backlog prompts to keep cache behavior stable without dropping queued metadata. (#34794) Thanks @anisoptera.</li>
|
||||
<li>Security/audit account handling: avoid prototype-chain account IDs in audit validation by using own-property checks for <code>accounts</code>. (#34982) Thanks @HOYALIM.</li>
|
||||
<li>Cron/restart catch-up semantics: replay interrupted recurring jobs and missed immediate cron slots on startup without replaying interrupted one-shot jobs, with guarded missed-slot probing to avoid malformed-schedule startup aborts and duplicate-trigger drift after restart. (from #34466, #34896, #34625, #33206) Thanks @dunamismax, @dsantoreis, @Octane0411, and @Sid-Qin.</li>
|
||||
<li>Venice/provider onboarding hardening: align per-model Venice completion-token limits with discovery metadata, clamp untrusted discovery values to safe bounds, sync the static Venice fallback catalog with current live model metadata, and disable tool wiring for Venice models that do not support function calling so default Venice setups no longer fail with <code>max_completion_tokens</code> or unsupported-tools 400s. Fixes #38168. Thanks @Sid-Qin, @powermaster888 and @vincentkoc.</li>
|
||||
<li>Agents/session usage tracking: preserve accumulated usage metadata on embedded Pi runner error exits so failed turns still update session <code>totalTokens</code> from real usage instead of stale prior values. (#34275) thanks @RealKai42.</li>
|
||||
<li>Slack/reaction thread context routing: carry Slack native DM channel IDs through inbound context and threading tool resolution so reaction targets resolve consistently for DM <code>To=user:*</code> sessions (including <code>toolContext.currentChannelId</code> fallback behavior). (from #34831; overlaps #34440, #34502, #34483, #32754) Thanks @dunamismax.</li>
|
||||
<li>Subagents/announce completion scoping: scope nested direct-child completion aggregation to the current requester run window, harden frozen completion capture for deterministic descendant synthesis, and route completion announce delivery through parent-agent announce turns with provenance-aware internal events. (#35080) Thanks @tyler6204.</li>
|
||||
<li>Nodes/system.run approval hardening: use explicit argv-mutation signaling when regenerating prepared <code>rawCommand</code>, and cover the <code>system.run.prepare -> system.run</code> handoff so direct PATH-based <code>nodes.run</code> commands no longer fail with <code>rawCommand does not match command</code>. (#33137) thanks @Sid-Qin.</li>
|
||||
<li>Models/custom provider headers: propagate <code>models.providers.<name>.headers</code> across inline, fallback, and registry-found model resolution so header-authenticated proxies consistently receive configured request headers. (#27490) thanks @Sid-Qin.</li>
|
||||
<li>Ollama/remote provider auth fallback: synthesize a local runtime auth key for explicitly configured <code>models.providers.ollama</code> entries that omit <code>apiKey</code>, so remote Ollama endpoints run without requiring manual dummy-key setup while preserving env/profile/config key precedence and missing-config failures. (#11283) Thanks @cpreecs.</li>
|
||||
<li>Ollama/custom provider headers: forward resolved model headers into native Ollama stream requests so header-authenticated Ollama proxies receive configured request headers. (#24337) thanks @echoVic.</li>
|
||||
<li>Ollama/compaction and summarization: register custom <code>api: "ollama"</code> handling for compaction, branch-style internal summarization, and TTS text summarization on current <code>main</code>, so native Ollama models no longer fail with <code>No API provider registered for api: ollama</code> outside the main run loop. Thanks @JaviLib.</li>
|
||||
<li>Daemon/systemd install robustness: treat <code>systemctl --user is-enabled</code> exit-code-4 <code>not-found</code> responses as not-enabled by combining stderr/stdout detail parsing, so Ubuntu fresh installs no longer fail with <code>systemctl is-enabled unavailable</code>. (#33634) Thanks @Yuandiaodiaodiao.</li>
|
||||
<li>Slack/system-event session routing: resolve reaction/member/pin/interaction system-event session keys through channel/account bindings (with sender-aware DM routing) so inbound Slack events target the correct agent session in multi-account setups instead of defaulting to <code>agent:main</code>. (#34045) Thanks @paulomcg, @daht-mad and @vincentkoc.</li>
|
||||
<li>Slack/native streaming markdown conversion: stop pre-normalizing text passed to Slack native <code>markdown_text</code> in streaming start/append/stop paths to prevent Markdown style corruption from double conversion. (#34931)</li>
|
||||
<li>Gateway/HTTP tools invoke media compatibility: preserve raw media payload access for direct <code>/tools/invoke</code> clients by allowing media <code>nodes</code> invoke commands only in HTTP tool context, while keeping agent-context media invoke blocking to prevent base64 prompt bloat. (#34365) Thanks @obviyus.</li>
|
||||
<li>Security/archive ZIP hardening: extract ZIP entries via same-directory temp files plus atomic rename, then re-open and reject post-rename hardlink alias races outside the destination root.</li>
|
||||
<li>Agents/Nodes media outputs: add dedicated <code>photos_latest</code> action handling, block media-returning <code>nodes invoke</code> commands, keep metadata-only <code>camera.list</code> invoke allowed, and normalize empty <code>photos_latest</code> results to a consistent response shape to prevent base64 context bloat. (#34332) Thanks @obviyus.</li>
|
||||
<li>TUI/session-key canonicalization: normalize <code>openclaw tui --session</code> values to lowercase so uppercase session names no longer drop real-time streaming updates due to gateway/TUI key mismatches. (#33866, #34013) thanks @lynnzc.</li>
|
||||
<li>iMessage/echo loop hardening: strip leaked assistant-internal scaffolding from outbound iMessage replies, drop reflected assistant-content messages before they re-enter inbound processing, extend echo-cache text retention for delayed reflections, and suppress repeated loop traffic before it amplifies into queue overflow. (#33295) Thanks @joelnishanth.</li>
|
||||
<li>Skills/workspace boundary hardening: reject workspace and extra-dir skill roots or <code>SKILL.md</code> files whose realpath escapes the configured source root, and skip syncing those escaped skills into sandbox workspaces.</li>
|
||||
<li>Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant.</li>
|
||||
<li>gateway: harden shared auth resolution across systemd, discord, and node host (#39241) Thanks @joshavant.</li>
|
||||
<li>Secrets/models.json persistence hardening: keep SecretRef-managed api keys + headers from persisting in generated models.json, expand audit/apply coverage, and harden marker handling/serialization. (#38955) Thanks @joshavant.</li>
|
||||
<li>Sessions/subagent attachments: remove <code>attachments[].content.maxLength</code> from <code>sessions_spawn</code> schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera.</li>
|
||||
<li>Runtime/tool-state stability: recover from dangling Anthropic <code>tool_use</code> after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr.</li>
|
||||
<li>ACP/Discord startup hardening: clean up stuck ACP worker children on gateway restart, unbind stale ACP thread bindings during Discord startup reconciliation, and add per-thread listener watchdog timeouts so wedged turns cannot block later messages. (#33699) Thanks @dutifulbob.</li>
|
||||
<li>Extensions/media local-root propagation: consistently forward <code>mediaLocalRoots</code> through extension <code>sendMedia</code> adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3.</li>
|
||||
<li>Gateway/plugin HTTP auth hardening: require gateway auth when any overlapping matched route needs it, block mixed-auth fallthrough at dispatch, and reject mixed-auth exact/prefix route overlaps during plugin registration.</li>
|
||||
<li>Feishu/video media send contract: keep mp4-like outbound payloads on <code>msg_type: "media"</code> (including reply and reply-in-thread paths) so videos render as media instead of degrading to file-link behavior, while preserving existing non-video file subtype handling. (from #33720, #33808, #33678) Thanks @polooooo, @dingjianrui, and @kevinWangSheng.</li>
|
||||
<li>Gateway/security default response headers: add <code>Permissions-Policy: camera=(), microphone=(), geolocation=()</code> to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan.</li>
|
||||
<li>Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into <code>openclaw/plugin-sdk/core</code> and <code>openclaw/plugin-sdk/telegram</code>, and preserve <code>api.runtime</code> reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy.</li>
|
||||
<li>Plugins/startup performance: reduce bursty plugin discovery/manifest overhead with short in-process caches, skip importing bundled memory plugins that are disabled by slot selection, and speed legacy root <code>openclaw/plugin-sdk</code> compatibility via runtime root-alias routing while preserving backward compatibility. Thanks @gumadeiras.</li>
|
||||
<li>Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras.</li>
|
||||
<li>Gateway/password CLI hardening: add <code>openclaw gateway run --password-file</code>, warn when inline <code>--password</code> is used because it can leak via process listings, and document env/file-backed password input as the preferred startup path. Fixes #27948. Thanks @vibewrk and @vincentkoc.</li>
|
||||
<li>Config/heartbeat legacy-path handling: auto-migrate top-level <code>heartbeat</code> into <code>agents.defaults.heartbeat</code> (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan.</li>
|
||||
<li>Plugins/SDK subpath parity: expand plugin SDK subpaths across bundled channels/extensions (Discord, Slack, Signal, iMessage, WhatsApp, LINE, and bundled companion plugins), with build/export/type/runtime wiring so scoped imports resolve consistently in source and dist while preserving compatibility. (#33737) thanks @gumadeiras.</li>
|
||||
<li>Google/Gemini Flash model selection: switch built-in <code>gemini-flash</code> defaults and docs/examples from the nonexistent <code>google/gemini-3.1-flash-preview</code> ID to the working <code>google/gemini-3-flash-preview</code>, while normalizing legacy OpenClaw config that still uses the old Flash 3.1 alias.</li>
|
||||
<li>Plugins/bundled scoped-import migration: migrate bundled plugins from monolithic <code>openclaw/plugin-sdk</code> imports to scoped subpaths (or <code>openclaw/plugin-sdk/core</code>) across registration and startup-sensitive runtime files, add CI/release guardrails to prevent regressions, and keep root <code>openclaw/plugin-sdk</code> support for external/community plugins. Thanks @gumadeiras.</li>
|
||||
<li>Routing/session duplicate suppression synthesis: align shared session delivery-context inheritance, channel-paired route-field merges, and reply-surface target matching so dmScope=main turns avoid cross-surface duplicate replies while thread-aware forwarding keeps intended routing semantics. (from #33629, #26889, #17337, #33250) Thanks @Yuandiaodiaodiao, @kevinwildenradt, @Glucksberg, and @bmendonca3.</li>
|
||||
<li>Routing/legacy session route inheritance: preserve external route metadata inheritance for legacy channel session keys (<code>agent:<agent>:<channel>:<peer></code> and <code>...:thread:<id></code>) so <code>chat.send</code> does not incorrectly fall back to webchat when valid delivery context exists. Follow-up to #33786.</li>
|
||||
<li>Routing/legacy route guard tightening: require legacy session-key channel hints to match the saved delivery channel before inheriting external routing metadata, preventing custom namespaced keys like <code>agent:<agent>:work:<ticket></code> from inheriting stale non-webchat routes.</li>
|
||||
<li>Gateway/internal client routing continuity: prevent webchat/TUI/UI turns from inheriting stale external reply routes by requiring explicit <code>deliver: true</code> for external delivery, keeping main-session external inheritance scoped to non-Webchat/UI clients, and honoring configured <code>session.mainKey</code> when identifying main-session continuity. (from #35321, #34635, #35356) Thanks @alexyyyander and @Octane0411.</li>
|
||||
<li>Security/auth labels: remove token and API-key snippets from user-facing auth status labels so <code>/status</code> and <code>/models</code> do not expose credential fragments. (#33262) thanks @cu1ch3n.</li>
|
||||
<li>Models/MiniMax portal vision routing: add <code>MiniMax-VL-01</code> to the <code>minimax-portal</code> provider, route portal image understanding through the MiniMax VLM endpoint, and align media auto-selection plus Telegram sticker description with the shared portal image provider path. (#33953) Thanks @tars90percent.</li>
|
||||
<li>Auth/credential semantics: align profile eligibility + probe diagnostics with SecretRef/expiry rules and harden browser download atomic writes. (#33733) thanks @joshavant.</li>
|
||||
<li>Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown <code>gateway.nodes.denyCommands</code> entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot.</li>
|
||||
<li>Agents/overload failover handling: classify overloaded provider failures separately from rate limits/status timeouts, add short overload backoff before retry/failover, record overloaded prompt/assistant failures as transient auth-profile cooldowns (with probeable same-provider fallback) instead of treating them like persistent auth/billing failures, and keep one-shot cron retry classification aligned so overloaded fallback summaries still count as transient retries.</li>
|
||||
<li>Docs/security hardening guidance: document Docker <code>DOCKER-USER</code> + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan.</li>
|
||||
<li>Docs/security threat-model links: replace relative <code>.md</code> links with Mintlify-compatible root-relative routes in security docs to prevent broken internal navigation. (#27698) thanks @clawdoo.</li>
|
||||
<li>Plugins/Update integrity drift: avoid false integrity drift prompts when updating npm-installed plugins from unpinned specs, while keeping drift checks for exact pinned versions. (#37179) Thanks @vincentkoc.</li>
|
||||
<li>iOS/Voice timing safety: guard system speech start/finish callbacks to the active utterance to avoid misattributed start events during rapid stop/restart cycles. (#33304) thanks @mbelinky; original implementation direction by @ngutman.</li>
|
||||
<li>Gateway/chat.send command scopes: require <code>operator.admin</code> for persistent <code>/config set|unset</code> writes routed through gateway chat clients while keeping <code>/config show</code> available to normal write-scoped operator clients, preserving messaging-channel config command behavior without widening RPC write scope into admin config mutation. Thanks @tdjackey for reporting.</li>
|
||||
<li>iOS/Talk incremental speech pacing: allow long punctuation-free assistant chunks to start speaking at safe whitespace boundaries so voice responses begin sooner instead of waiting for terminal punctuation. (#33305) thanks @mbelinky; original implementation by @ngutman.</li>
|
||||
<li>iOS/Watch reply reliability: make watch session activation waiters robust under concurrent requests so status/send calls no longer hang intermittently, and align delegate callbacks with Swift 6 actor safety. (#33306) thanks @mbelinky; original implementation by @Rocuts.</li>
|
||||
<li>Docs/tool-loop detection config keys: align <code>docs/tools/loop-detection.md</code> examples and field names with the current <code>tools.loopDetection</code> schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.</li>
|
||||
<li>Gateway/session agent discovery: include disk-scanned agent IDs in <code>listConfiguredAgentIds</code> even when <code>agents.list</code> is configured, so disk-only/ACP agent sessions remain visible in gateway session aggregation and listings. (#32831) thanks @Sid-Qin.</li>
|
||||
<li>Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/Agent-scoped media roots: pass <code>mediaLocalRoots</code> through Discord monitor reply delivery (message + component interaction paths) so local media attachments honor per-agent workspace roots instead of falling back to default global roots. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/slash command handling: intercept text-based slash commands in channels, register plugin commands as native, and send fallback acknowledgments for empty slash runs so interactions do not hang. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/thread session lifecycle: reset thread-scoped sessions when a thread is archived so reopening a thread starts fresh without deleting transcript history. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.</li>
|
||||
<li>ACP/sandbox spawn parity: block <code>/acp spawn</code> from sandboxed requester sessions with the same host-runtime guard already enforced for <code>sessions_spawn({ runtime: "acp" })</code>, preserving non-sandbox ACP flows while closing the command-path policy gap. Thanks @patte.</li>
|
||||
<li>Discord/config SecretRef typing: align Discord account token config typing with SecretInput so SecretRef tokens typecheck. (#32490) Thanks @scoootscooob.</li>
|
||||
<li>Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/voice decoder fallback: drop the native Opus dependency and use opusscript for voice decoding to avoid native-opus installs. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/auto presence health signal: add runtime availability-driven presence updates plus connected-state reporting to improve health monitoring and operator visibility. (#33277) Thanks @thewilloftheshadow.</li>
|
||||
<li>HEIC image inputs: accept HEIC/HEIF <code>input_image</code> sources in Gateway HTTP APIs, normalize them to JPEG before provider delivery, and document the expanded default MIME allowlist. Thanks @vincentkoc.</li>
|
||||
<li>Gateway/HEIC input follow-up: keep non-HEIC <code>input_image</code> MIME handling unchanged, make HEIC tests hermetic, and enforce chat-completions <code>maxTotalImageBytes</code> against post-normalization image payload size. Thanks @vincentkoc.</li>
|
||||
<li>Telegram/draft-stream boundary stability: materialize DM draft previews at assistant-message/tool boundaries, serialize lane-boundary callbacks before final delivery, and scope preview cleanup to the active preview so multi-step Telegram streams no longer lose, overwrite, or leave stale preview bubbles. (#33842) Thanks @ngutman.</li>
|
||||
<li>Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.</li>
|
||||
<li>Telegram/DM draft final delivery: materialize text-only <code>sendMessageDraft</code> previews into one permanent final message and skip duplicate final payload sends, while preserving fallback behavior when materialization fails. (#34318) Thanks @Brotherinlaw-13.</li>
|
||||
<li>Telegram/DM draft duplicate display: clear stale DM draft previews after materializing the real final message, including threadless fallback when DM topic lookup fails, so partial streaming no longer briefly shows duplicate replies. (#36746) Thanks @joelnishanth.</li>
|
||||
<li>Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress <code>NO_REPLY</code> lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus.</li>
|
||||
<li>Telegram/native commands <code>commands.allowFrom</code> precedence: make native Telegram commands honor <code>commands.allowFrom</code> as the command-specific authorization source, including group chats, instead of falling back to channel sender allowlists. (#28216) Thanks @toolsbybuddy and @vincentkoc.</li>
|
||||
<li>Telegram/<code>groupAllowFrom</code> sender-ID validation: restore sender-only runtime validation so negative chat/group IDs remain invalid entries instead of appearing accepted while still being unable to authorize group access. (#37134) Thanks @qiuyuemartin-max and @vincentkoc.</li>
|
||||
<li>Telegram/native group command auth: authorize native commands in groups and forum topics against <code>groupAllowFrom</code> and per-group/topic sender overrides, while keeping auth rejection replies in the originating topic thread. (#39267) Thanks @edwluo.</li>
|
||||
<li>Telegram/named-account DMs: restore non-default-account DM routing when a named Telegram account falls back to the default agent by keeping groups fail-closed but deriving a per-account session key for DMs, including identity-link canonicalization and regression coverage for account isolation. (from #32426; fixes #32351) Thanks @chengzhichao-xydt.</li>
|
||||
<li>Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/mention handling: add id-based mention formatting + cached rewrites, resolve inbound mentions to display names, and add optional ignoreOtherMentions gating (excluding @everyone/@here). (#33224) Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/media SSRF allowlist: allow Discord CDN hostnames (including wildcard domains) in inbound media SSRF policy to prevent proxy/VPN fake-ip blocks. (#33275) Thanks @thewilloftheshadow.</li>
|
||||
<li>Telegram/device pairing notifications: auto-arm one-shot notify on <code>/pair qr</code>, auto-ping on new pairing requests, and add manual fallback via <code>/pair approve latest</code> if the ping does not arrive. (#33299) thanks @mbelinky.</li>
|
||||
<li>Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf</li>
|
||||
<li>macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (<code>wss://<peer>.ts.net</code>) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.</li>
|
||||
<li>iOS/Gateway keychain hardening: move gateway metadata and TLS fingerprints to device keychain storage with safer migration behavior and rollback-safe writes to reduce credential loss risk during upgrades. (#33029) thanks @mbelinky.</li>
|
||||
<li>iOS/Concurrency stability: replace risky shared-state access in camera and gateway connection paths with lock-protected access patterns to reduce crash risk under load. (#33241) thanks @mbelinky.</li>
|
||||
<li>iOS/Security guardrails: limit production API-key sourcing to app config and make deep-link confirmation prompts safer by coalescing queued requests instead of silently dropping them. (#33031) thanks @mbelinky.</li>
|
||||
<li>iOS/TTS playback fallback: keep voice playback resilient by switching from PCM to MP3 when provider format support is unavailable, while avoiding sticky fallback on generic local playback errors. (#33032) thanks @mbelinky.</li>
|
||||
<li>Plugin outbound/text-only adapter compatibility: allow direct-delivery channel plugins that only implement <code>sendText</code> (without <code>sendMedia</code>) to remain outbound-capable, gracefully fall back to text delivery for media payloads when <code>sendMedia</code> is absent, and fail explicitly for media-only payloads with no text fallback. (#32788) thanks @liuxiaopai-ai.</li>
|
||||
<li>Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add <code>openclaw doctor</code> warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.</li>
|
||||
<li>Telegram/plugin outbound hook parity: run <code>message_sending</code> + <code>message_sent</code> in Telegram reply delivery, include reply-path hook metadata (<code>mediaUrls</code>, <code>threadId</code>), and report <code>message_sent.success=false</code> when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee.</li>
|
||||
<li>CLI/Coding-agent reliability: switch default <code>claude-cli</code> non-interactive args to <code>--permission-mode bypassPermissions</code>, auto-normalize legacy <code>--dangerously-skip-permissions</code> backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. (#28610, #31149, #34055). Thanks @niceysam, @cryptomaltese and @vincentkoc.</li>
|
||||
<li>Gateway/OpenAI chat completions: parse active-turn <code>image_url</code> content parts (including parameterized data URIs and guarded URL sources), forward them as multimodal <code>images</code>, accept image-only user turns, enforce per-request image-part/byte budgets, default URL-based image fetches to disabled unless explicitly enabled by config, and redact image base64 data in cache-trace/provider payload diagnostics. (#17685) Thanks @vincentkoc</li>
|
||||
<li>ACP/ACPX session bootstrap: retry with <code>sessions new</code> when <code>sessions ensure</code> returns no session identifiers so ACP spawns avoid <code>NO_SESSION</code>/<code>ACP_TURN_FAILED</code> failures on affected agents. (#28786, #31338, #34055). Thanks @Sid-Qin and @vincentkoc.</li>
|
||||
<li>ACP/sessions_spawn parent stream visibility: add <code>streamTo: "parent"</code> for <code>runtime: "acp"</code> to forward initial child-run progress/no-output/completion updates back into the requester session as system events (instead of direct child delivery), and emit a tail-able session-scoped relay log (<code><sessionId>.acp-stream.jsonl</code>, returned as <code>streamLogPath</code> when available), improving orchestrator visibility for blocked or long-running harness turns. (#34310, #29909; reopened from #34055). Thanks @vincentkoc.</li>
|
||||
<li>Agents/bootstrap truncation warning handling: unify bootstrap budget/truncation analysis across embedded + CLI runtime, <code>/context</code>, and <code>openclaw doctor</code>; add <code>agents.defaults.bootstrapPromptTruncationWarning</code> (<code>off|once|always</code>, default <code>once</code>) and persist warning-signature metadata so truncation warnings are consistent and deduped across turns. (#32769) Thanks @gumadeiras.</li>
|
||||
<li>Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras.</li>
|
||||
<li>Agents/Session startup date grounding: substitute <code>YYYY-MM-DD</code> placeholders in startup/post-compaction AGENTS context and append runtime current-time lines for <code>/new</code> and <code>/reset</code> prompts so daily-memory references resolve correctly. (#32381) Thanks @chengzhichao-xydt.</li>
|
||||
<li>Agents/Compaction template heading alignment: update AGENTS template section names to <code>Session Startup</code>/<code>Red Lines</code> and keep legacy <code>Every Session</code>/<code>Safety</code> fallback extraction so post-compaction context remains intact across template versions. (#25098) thanks @echoVic.</li>
|
||||
<li>Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone.</li>
|
||||
<li>Agents/Compaction safeguard structure hardening: require exact fallback summary headings, sanitize untrusted compaction instruction text before prompt embedding, and keep structured sections when preserving all turns. (#25555) thanks @rodrigouroz.</li>
|
||||
<li>Gateway/status self version reporting: make Gateway self version in <code>openclaw status</code> prefer runtime <code>VERSION</code> (while preserving explicit <code>OPENCLAW_VERSION</code> override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai.</li>
|
||||
<li>Memory/QMD index isolation: set <code>QMD_CONFIG_DIR</code> alongside <code>XDG_CONFIG_HOME</code> so QMD config state stays per-agent despite upstream XDG handling bugs, preventing cross-agent collection indexing and excess disk/CPU usage. (#27028) thanks @HenryLoenwind.</li>
|
||||
<li>Memory/QMD collection safety: stop destructive collection rebinds when QMD <code>collection list</code> only reports names without path metadata, preventing <code>memory search</code> from dropping existing collections if re-add fails. (#36870) Thanks @Adnannnnnnna.</li>
|
||||
<li>Memory/QMD duplicate-document recovery: detect <code>UNIQUE constraint failed: documents.collection, documents.path</code> update failures, rebuild managed collections once, and retry update so periodic QMD syncs recover instead of failing every run; includes regression coverage to avoid over-matching unrelated unique constraints. (#27649) Thanks @MiscMich.</li>
|
||||
<li>Memory/local embedding initialization hardening: add regression coverage for transient initialization retry and mixed <code>embedQuery</code> + <code>embedBatch</code> concurrent startup to lock single-flight initialization behavior. (#15639) thanks @SubtleSpark.</li>
|
||||
<li>CLI/Coding-agent reliability: switch default <code>claude-cli</code> non-interactive args to <code>--permission-mode bypassPermissions</code>, auto-normalize legacy <code>--dangerously-skip-permissions</code> backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. Related to #28261. Landed from contributor PRs #28610 and #31149. Thanks @niceysam, @cryptomaltese and @vincentkoc.</li>
|
||||
<li>ACP/ACPX session bootstrap: retry with <code>sessions new</code> when <code>sessions ensure</code> returns no session identifiers so ACP spawns avoid <code>NO_SESSION</code>/<code>ACP_TURN_FAILED</code> failures on affected agents. Related to #28786. Landed from contributor PR #31338. Thanks @Sid-Qin and @vincentkoc.</li>
|
||||
<li>LINE/auth boundary hardening synthesis: enforce strict LINE webhook authn/z boundary semantics across pairing-store account scoping, DM/group allowlist separation, fail-closed webhook auth/runtime behavior, and replay/duplication controls (including in-flight replay reservation and post-success dedupe marking). (from #26701, #26683, #25978, #17593, #16619, #31990, #26047, #30584, #18777) Thanks @bmendonca3, @davidahmann, @harshang03, @haosenwang1018, @liuxiaopai-ai, @coygeek, and @Takhoffman.</li>
|
||||
<li>LINE/media download synthesis: fix file-media download handling and M4A audio classification across overlapping LINE regressions. (from #26386, #27761, #27787, #29509, #29755, #29776, #29785, #32240) Thanks @kevinWangSheng, @loiie45e, @carrotRakko, @Sid-Qin, @codeafridi, and @bmendonca3.</li>
|
||||
<li>LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman.</li>
|
||||
<li>LINE/status/config/webhook synthesis: fix status false positives from snapshot/config state and accept LINE webhook HEAD probes for compatibility. (from #10487, #25726, #27537, #27908, #31387) Thanks @BlueBirdBack, @stakeswky, @loiie45e, @puritysb, and @mcaxtr.</li>
|
||||
<li>LINE cleanup/test follow-ups: fold cleanup/test learnings into the synthesis review path while keeping runtime changes focused on regression fixes. (from #17630, #17289) Thanks @Clawborn and @davidahmann.</li>
|
||||
<li>Mattermost/interactive buttons: add interactive button send/callback support with directory-based channel/user target resolution, and harden callbacks via account-scoped HMAC verification plus sender-scoped DM routing. (#19957) thanks @tonydehnke.</li>
|
||||
<li>Feishu/groupPolicy legacy alias compatibility: treat legacy <code>groupPolicy: "allowall"</code> as <code>open</code> in both schema parsing and runtime policy checks so intended open-group configs no longer silently drop group messages when <code>groupAllowFrom</code> is empty. (from #36358) Thanks @Sid-Qin.</li>
|
||||
<li>Mattermost/plugin SDK import policy: replace remaining monolithic <code>openclaw/plugin-sdk</code> imports in Mattermost mention-gating paths/tests with scoped subpaths (<code>openclaw/plugin-sdk/compat</code> and <code>openclaw/plugin-sdk/mattermost</code>) so <code>pnpm check</code> passes <code>lint:plugins:no-monolithic-plugin-sdk-entry-imports</code> on baseline. (#36480) Thanks @Takhoffman.</li>
|
||||
<li>Telegram/polls: add Telegram poll action support to channel action discovery and tool/CLI poll flows, with multi-account discoverability gated to accounts that can actually execute polls (<code>sendMessage</code> + <code>poll</code>). (#36547) thanks @gumadeiras.</li>
|
||||
<li>Agents/failover cooldown classification: stop treating generic <code>cooling down</code> text as provider <code>rate_limit</code> so healthy models no longer show false global cooldown/rate-limit warnings while explicit <code>model_cooldown</code> markers still trigger failover. (#32972) thanks @stakeswky.</li>
|
||||
<li>Agents/failover service-unavailable handling: stop treating bare proxy/CDN <code>service unavailable</code> errors as provider overload while keeping them retryable via the timeout/failover path, so transient outages no longer show false rate-limit warnings or block fallback. (#36646) thanks @jnMetaCode.</li>
|
||||
<li>Plugins/HTTP route migration diagnostics: rewrite legacy <code>api.registerHttpHandler(...)</code> loader failures into actionable migration guidance so doctor/plugin diagnostics point operators to <code>api.registerHttpRoute(...)</code> or <code>registerPluginHttpRoute(...)</code>. (#36794) Thanks @vincentkoc</li>
|
||||
<li>Doctor/Heartbeat upgrade diagnostics: warn when heartbeat delivery is configured with an implicit <code>directPolicy</code> so upgrades pin direct/DM behavior explicitly instead of relying on the current default. (#36789) Thanks @vincentkoc.</li>
|
||||
<li>Agents/current-time UTC anchor: append a machine-readable UTC suffix alongside local <code>Current time:</code> lines in shared cron-style prompt contexts so agents can compare UTC-stamped workspace timestamps without doing timezone math. (#32423) thanks @jriff.</li>
|
||||
<li>Ollama/local model handling: preserve explicit lower <code>contextWindow</code> / <code>maxTokens</code> overrides during merge refresh, and keep native Ollama streamed replies from surfacing fallback <code>thinking</code> / <code>reasoning</code> text once real content starts streaming. (#39292) Thanks @vincentkoc.</li>
|
||||
<li>TUI/webchat command-owner scope alignment: treat internal-channel gateway sessions with <code>operator.admin</code> as owner-authorized in command auth, restoring cron/gateway/connector tool access for affected TUI/webchat sessions while keeping external channels on identity-based owner checks. (from #35666, #35673, #35704) Thanks @Naylenv, @Octane0411, and @Sid-Qin.</li>
|
||||
<li>Discord/inbound timeout isolation: separate inbound worker timeout tracking from listener timeout budgets so queued Discord replies are no longer dropped when listener watchdog windows expire mid-run. (#36602) Thanks @dutifulbob.</li>
|
||||
<li>Memory/doctor SecretRef handling: treat SecretRef-backed memory-search API keys as configured, and fail embedding setup with explicit unresolved-secret errors instead of crashing. (#36835) Thanks @joshavant.</li>
|
||||
<li>Memory/flush default prompt: ban timestamped variant filenames during default memory flush runs so durable notes stay in the canonical daily <code>memory/YYYY-MM-DD.md</code> file. (#34951) thanks @zerone0x.</li>
|
||||
<li>Agents/reply delivery timing: flush embedded Pi block replies before waiting on compaction retries so already-generated assistant replies reach channels before compaction wait completes. (#35489) thanks @Sid-Qin.</li>
|
||||
<li>Agents/gateway config guidance: stop exposing <code>config.schema</code> through the agent <code>gateway</code> tool, remove prompt/docs guidance that told agents to call it, and keep agents on <code>config.get</code> plus <code>config.patch</code>/<code>config.apply</code> for config changes. (#7382) thanks @kakuteki.</li>
|
||||
<li>Provider/KiloCode: Keep duplicate models after malformed discovery rows, and strip legacy <code>reasoning_effort</code> when proxy reasoning injection is skipped. (#32352) Thanks @pandemicsyn and @vincentkoc.</li>
|
||||
<li>Agents/failover: classify periodic provider limit exhaustion text (for example <code>Weekly/Monthly Limit Exhausted</code>) as <code>rate_limit</code> while keeping explicit <code>402 Payment Required</code> variants in billing, so failover continues without misclassifying billing-wrapped quota errors. (#33813) thanks @zhouhe-xydt.</li>
|
||||
<li>Mattermost/interactive button callbacks: allow external callback base URLs and stop requiring loopback-origin requests so button clicks work when Mattermost reaches the gateway over Tailscale, LAN, or a reverse proxy. (#37543) thanks @mukhtharcm.</li>
|
||||
<li>Gateway/chat.send route inheritance: keep explicit external delivery for channel-scoped sessions while preventing shared-main and other channel-agnostic webchat sessions from inheriting stale external routes, so Control UI replies stay on webchat without breaking selected channel-target sessions. (#34669) Thanks @vincentkoc.</li>
|
||||
<li>Telegram/Discord media upload caps: make outbound uploads honor channel <code>mediaMaxMb</code> config, raise Telegram's default media cap to 100MB, and remove MIME fallback limits that kept some Telegram uploads at 16MB. Thanks @vincentkoc.</li>
|
||||
<li>Skills/nano-banana-pro resolution override: respect explicit <code>--resolution</code> values during image editing and only auto-detect output size from input images when the flag is omitted. (#36880) Thanks @shuofengzhang and @vincentkoc.</li>
|
||||
<li>Skills/openai-image-gen CLI validation: validate <code>--background</code> and <code>--style</code> inputs early, normalize supported values, and warn when those flags are ignored for incompatible models. (#36762) Thanks @shuofengzhang and @vincentkoc.</li>
|
||||
<li>Skills/openai-image-gen output formats: validate <code>--output-format</code> values early, normalize aliases like <code>jpg -> jpeg</code>, and warn when the flag is ignored for incompatible models. (#36648) Thanks @shuofengzhang and @vincentkoc.</li>
|
||||
<li>ACP/skill env isolation: strip skill-injected API keys from ACP harness child-process environments so tools like Codex CLI keep their own auth flow instead of inheriting billed provider keys from active skills. (#36316) Thanks @taw0002 and @vincentkoc.</li>
|
||||
<li>WhatsApp media upload caps: make outbound media sends and auto-replies honor <code>channels.whatsapp.mediaMaxMb</code> with per-account overrides so inbound and outbound limits use the same channel config. Thanks @vincentkoc.</li>
|
||||
<li>Windows/Plugin install: when OpenClaw runs on Windows via Bun and <code>npm-cli.js</code> is not colocated with the runtime binary, fall back to <code>npm.cmd</code>/<code>npx.cmd</code> through the existing <code>cmd.exe</code> wrapper so <code>openclaw plugins install</code> no longer fails with <code>spawn EINVAL</code>. (#38056) Thanks @0xlin2023.</li>
|
||||
<li>Telegram/send retry classification: retry grammY <code>Network request ... failed after N attempts</code> envelopes in send flows without reclassifying plain <code>Network request ... failed!</code> wrappers as transient, restoring the intended retry path while keeping broad send-context message matching tight. (#38056) Thanks @0xlin2023.</li>
|
||||
<li>Gateway/probes: keep <code>/health</code>, <code>/healthz</code>, <code>/ready</code>, and <code>/readyz</code> reachable when the Control UI is mounted at <code>/</code>, preserve plugin-owned route precedence on those paths, and make <code>/ready</code> and <code>/readyz</code> report channel-backed readiness with startup grace plus <code>503</code> on disconnected managed channels, while <code>/health</code> and <code>/healthz</code> stay shallow liveness probes. (#18446) Thanks @vibecodooor, @mahsumaktas, and @vincentkoc.</li>
|
||||
<li>Feishu/media downloads: drop invalid timeout fields from SDK method calls now that client-level <code>httpTimeoutMs</code> applies to requests. (#38267) Thanks @ant1eicher and @thewilloftheshadow.</li>
|
||||
<li>PI embedded runner/Feishu docs: propagate sender identity into embedded attempts so Feishu doc auto-grant restores requester access for embedded-runner executions. (#32915) thanks @cszhouwei.</li>
|
||||
<li>Agents/usage normalization: normalize missing or partial assistant usage snapshots before compaction accounting so <code>openclaw agent --json</code> no longer crashes when provider payloads omit <code>totalTokens</code> or related usage fields. (#34977) thanks @sp-hk2ldn.</li>
|
||||
<li>Venice/default model refresh: switch the built-in Venice default to <code>kimi-k2-5</code>, update onboarding aliasing, and refresh Venice provider docs/recommendations to match the current private and anonymized catalog. (from #12964) Fixes #20156. Thanks @sabrinaaquino and @vincentkoc.</li>
|
||||
<li>Agents/skill API write pacing: add a global prompt guardrail that treats skill-driven external API writes as rate-limited by default, so runners prefer batched writes, avoid tight request loops, and respect <code>429</code>/<code>Retry-After</code>. Thanks @vincentkoc.</li>
|
||||
<li>Google Chat/multi-account webhook auth fallback: when <code>channels.googlechat.accounts.default</code> carries shared webhook audience/path settings (for example after config normalization), inherit those defaults for named accounts while preserving top-level and per-account overrides, so inbound webhook verification no longer fails silently for named accounts missing duplicated audience fields. Fixes #38369.</li>
|
||||
<li>Models/tool probing: raise the tool-capability probe budget from 32 to 256 tokens so reasoning models that spend tokens on thinking before returning a required tool call are less likely to be misclassified as not supporting tools. (#7521) Thanks @jakobdylanc.</li>
|
||||
<li>Gateway/transient network classification: treat wrapped <code>...: fetch failed</code> transport messages as transient while avoiding broad matches like <code>Web fetch failed (404): ...</code>, preventing Discord reconnect wrappers from crashing the gateway without suppressing non-network tool failures. (#38530) Thanks @xinhuagu.</li>
|
||||
<li>ACP/console silent reply suppression: filter ACP <code>NO_REPLY</code> lead fragments and silent-only finals before <code>openclaw agent</code> logging/delivery so console-backed ACP sessions no longer leak <code>NO</code>/<code>NO_REPLY</code> placeholders. (#38436) Thanks @ql-wade.</li>
|
||||
<li>Feishu/reply delivery reliability: disable block streaming in Feishu reply options so plain-text auto-render replies are no longer silently dropped before final delivery. (#38258) Thanks @xinhuagu.</li>
|
||||
<li>Agents/reply MEDIA delivery: normalize local assistant <code>MEDIA:</code> paths before block/final delivery, keep media dedupe aligned with message-tool sends, and contain malformed media normalization failures so generated files send reliably instead of falling back to empty responses. (#38572) Thanks @obviyus.</li>
|
||||
<li>Sessions/bootstrap cache rollover invalidation: clear cached workspace bootstrap snapshots whenever an existing <code>sessionKey</code> rolls to a new <code>sessionId</code> across auto-reply, command, and isolated cron session resolvers, so <code>AGENTS.md</code>/<code>MEMORY.md</code>/<code>USER.md</code> updates are reloaded after daily, idle, or forced session resets instead of staying stale until gateway restart. (#38494) Thanks @LivingInDrm.</li>
|
||||
<li>Gateway/Telegram polling health monitor: skip stale-socket restarts for Telegram long-polling channels and thread channel identity through shared health evaluation so polling connections are not restarted on the WebSocket stale-socket heuristic. (#38395) Thanks @ql-wade and @Takhoffman.</li>
|
||||
<li>Daemon/systemd fresh-install probe: check for OpenClaw's managed user unit before running <code>systemctl --user is-enabled</code>, so first-time Linux installs no longer fail on generic missing-unit probe errors. (#38819) Thanks @adaHubble.</li>
|
||||
<li>Gateway/container lifecycle: allow <code>openclaw gateway stop</code> to SIGTERM unmanaged gateway listeners and <code>openclaw gateway restart</code> to SIGUSR1 a single unmanaged listener when no service manager is installed, so container and supervisor-based deployments are no longer blocked by <code>service disabled</code> no-op responses. Fixes #36137. Thanks @vincentkoc.</li>
|
||||
<li>Gateway/Windows restart supervision: relaunch task-managed gateways through Scheduled Task with quoted helper-script command paths, distinguish restart-capable supervisors per platform, and stop orphaned Windows gateway children during self-restart. (#38825) Thanks @obviyus.</li>
|
||||
<li>Telegram/native topic command routing: resolve forum-topic native commands through the same conversation route as inbound messages so topic <code>agentId</code> overrides and bound topic sessions target the active session instead of the default topic-parent session. (#38871) Thanks @obviyus.</li>
|
||||
<li>Markdown/assistant image hardening: flatten remote markdown images to plain text across the Control UI, exported HTML, and shared Swift chat while keeping inline <code>data:image/...</code> markdown renderable, so model output no longer triggers automatic remote image fetches. (#38895) Thanks @obviyus.</li>
|
||||
<li>Config/compaction safeguard settings: regression-test <code>agents.defaults.compaction.recentTurnsPreserve</code> through <code>loadConfig()</code> and cover the new help metadata entry so the exposed preserve knob stays wired through schema validation and config UX. (#25557) thanks @rodrigouroz.</li>
|
||||
<li>iOS/Quick Setup presentation: skip automatic Quick Setup when a gateway is already configured (active connect config, last-known connection, preferred gateway, or manual host), so reconnecting installs no longer get prompted to connect again. (#38964) Thanks @ngutman.</li>
|
||||
<li>CLI/Docs memory help accuracy: clarify <code>openclaw memory status --deep</code> behavior and align memory command examples/docs with the current search options. (#31803) Thanks @JasonOA888 and @Avi974.</li>
|
||||
<li>Auto-reply/allowlist store account scoping: keep <code>/allowlist ... --store</code> writes scoped to the selected account and clear legacy unscoped entries when removing default-account store access, preventing cross-account default allowlist bleed-through from legacy pairing-store reads. Thanks @tdjackey for reporting and @vincentkoc for the fix.</li>
|
||||
<li>Security/Nostr: harden profile mutation/import loopback guards by failing closed on non-loopback forwarded client headers (<code>x-forwarded-for</code> / <code>x-real-ip</code>) and rejecting <code>sec-fetch-site: cross-site</code>; adds regression coverage for proxy-forwarded and browser cross-site mutation attempts.</li>
|
||||
<li>CLI/bootstrap Node version hint maintenance: replace hardcoded nvm <code>22</code> instructions in <code>openclaw.mjs</code> with <code>MIN_NODE_MAJOR</code> interpolation so future minimum-Node bumps keep startup guidance in sync automatically. (#39056) Thanks @onstash.</li>
|
||||
<li>Discord/native slash command auth: honor <code>commands.allowFrom.discord</code> (and <code>commands.allowFrom["*"]</code>) in guild slash-command pre-dispatch authorization so allowlisted senders are no longer incorrectly rejected as unauthorized. (#38794) Thanks @jskoiz and @thewilloftheshadow.</li>
|
||||
<li>Outbound/message target normalization: ignore empty legacy <code>to</code>/<code>channelId</code> fields when explicit <code>target</code> is provided so valid target-based sends no longer fail legacy-param validation; includes regression coverage. (#38944) Thanks @Narcooo.</li>
|
||||
<li>Models/auth token prompts: guard cancelled manual token prompts so <code>Symbol(clack:cancel)</code> values cannot be persisted into auth profiles; adds regression coverage for cancelled <code>models auth paste-token</code>. (#38951) Thanks @MumuTW.</li>
|
||||
<li>Gateway/loopback announce URLs: treat <code>http://</code> and <code>https://</code> aliases with the same loopback/private-network policy as websocket URLs so loopback cron announce delivery no longer fails secure URL validation. (#39064) Thanks @Narcooo.</li>
|
||||
<li>Models/default provider fallback: when the hardcoded default provider is removed from <code>models.providers</code>, resolve defaults from configured providers instead of reporting stale removed-provider defaults in status output. (#38947) Thanks @davidemanuelDEV.</li>
|
||||
<li>Agents/cache-trace stability: guard stable stringify against circular references in trace payloads so near-limit payloads no longer crash with <code>Maximum call stack size exceeded</code>; adds regression coverage. (#38935) Thanks @MumuTW.</li>
|
||||
<li>Extensions/diffs CI stability: add <code>headers</code> to the <code>localReq</code> test helper in <code>extensions/diffs/index.test.ts</code> so forwarding-hint checks no longer crash with <code>req.headers</code> undefined. (supersedes #39063) Thanks @Shennng.</li>
|
||||
<li>Agents/compaction thresholding: apply <code>agents.defaults.contextTokens</code> cap to the model passed into embedded run and <code>/compact</code> session creation so auto-compaction thresholds use the effective context window, not native model max context. (#39099) Thanks @MumuTW.</li>
|
||||
<li>Models/merge mode provider precedence: when <code>models.mode: "merge"</code> is active and config explicitly sets a provider <code>baseUrl</code>, keep config as source of truth instead of preserving stale runtime <code>models.json</code> <code>baseUrl</code> values; includes normalized provider-key coverage. (#39103) Thanks @BigUncle.</li>
|
||||
<li>UI/Control chat tool streaming: render tool events live in webchat without requiring refresh by enabling <code>tool-events</code> capability, fixing stream/event correlation, and resetting/reloading stream state around tool results and terminal events. (#39104) Thanks @jakepresent.</li>
|
||||
<li>Models/provider apiKey persistence hardening: when a provider <code>apiKey</code> value equals a known provider env var value, persist the canonical env var name into <code>models.json</code> instead of resolved plaintext secrets. (#38889) Thanks @gambletan.</li>
|
||||
<li>Discord/model picker persistence check: add a short post-dispatch settle delay before reading back session model state so picker confirmations stop reporting false mismatch warnings after successful model switches. (#39105) Thanks @akropp.</li>
|
||||
<li>Agents/OpenAI WS compat store flag: omit <code>store</code> from <code>response.create</code> payloads when model compat sets <code>supportsStore: false</code>, preventing strict OpenAI-compatible providers from rejecting websocket requests with unknown-field errors. (#39113) Thanks @scoootscooob.</li>
|
||||
<li>Config/validation log sanitization: sanitize config-validation issue paths/messages before logging so control characters and ANSI escape sequences cannot inject misleading terminal output from crafted config content. (#39116) Thanks @powermaster888.</li>
|
||||
<li>Agents/compaction counter accuracy: count successful overflow-triggered auto-compactions (<code>willRetry=true</code>) in the compaction counter while still excluding aborted/no-result events, so <code>/status</code> reflects actual safeguard compaction activity. (#39123) Thanks @MumuTW.</li>
|
||||
<li>Gateway/chat delta ordering: flush buffered assistant deltas before emitting tool <code>start</code> events so pre-tool text is delivered to Control UI before tool cards, avoiding transient text/tool ordering artifacts in streaming. (#39128) Thanks @0xtangping.</li>
|
||||
<li>Voice-call plugin schema parity: add missing manifest <code>configSchema</code> fields (<code>webhookSecurity</code>, <code>streaming.preStartTimeoutMs|maxPendingConnections|maxPendingConnectionsPerIp|maxConnections</code>, <code>staleCallReaperSeconds</code>) so gateway AJV validation accepts already-supported runtime config instead of failing with <code>additionalProperties</code> errors. (#38892) Thanks @giumex.</li>
|
||||
<li>Agents/OpenAI WS reconnect retry accounting: avoid double retry scheduling when reconnect failures emit both <code>error</code> and <code>close</code>, so retry budgets track actual reconnect attempts instead of exhausting early. (#39133) Thanks @scoootscooob.</li>
|
||||
<li>Daemon/Windows schtasks runtime detection: use locale-invariant <code>Last Run Result</code> running codes (<code>0x41301</code>/<code>267009</code>) as the primary running signal so <code>openclaw node status</code> no longer misreports active tasks as stopped on non-English Windows locales. (#39076) Thanks @ademczuk.</li>
|
||||
<li>Usage/token count formatting: round near-million token counts to millions (<code>1.0m</code>) instead of <code>1000k</code>, with explicit boundary coverage for <code>999_499</code> and <code>999_500</code>. (#39129) Thanks @CurryMessi.</li>
|
||||
<li>Gateway/session bootstrap cache invalidation ordering: clear bootstrap snapshots only after active embedded-run shutdown wait completes, preventing dying runs from repopulating stale cache between <code>/new</code>/<code>sessions.reset</code> turns. (#38873) Thanks @MumuTW.</li>
|
||||
<li>Browser/dispatcher error clarity: preserve dispatcher-side failure context in browser fetch errors while still appending operator guidance and explicit no-retry model hints, preventing misleading <code>"Can't reach service"</code> wrapping and avoiding LLM retry loops. (#39090) Thanks @NewdlDewdl.</li>
|
||||
<li>Telegram/polling offset safety: confirm persisted offsets before polling startup while validating stored <code>lastUpdateId</code> values as non-negative safe integers (with overflow guards) so malformed offset state cannot cause update skipping/dropping. (#39111) Thanks @MumuTW.</li>
|
||||
<li>Telegram/status SecretRef read-only resolution: resolve env-backed bot-token SecretRefs in config-only/status inspection while respecting provider source/defaults and env allowlists, so status no longer crashes or reports false-ready tokens for disallowed providers. (#39130) Thanks @neocody.</li>
|
||||
<li>Agents/OpenAI WS max-token zero forwarding: treat <code>maxTokens: 0</code> as an explicit value in websocket <code>response.create</code> payloads (instead of dropping it as falsy), with regression coverage for zero-token forwarding. (#39148) Thanks @scoootscooob.</li>
|
||||
<li>Podman/.env gateway bind precedence: evaluate <code>OPENCLAW_GATEWAY_BIND</code> after sourcing <code>.env</code> in <code>run-openclaw-podman.sh</code> so env-file overrides are honored. (#38785) Thanks @majinyu666.</li>
|
||||
<li>Models/default alias refresh: bump <code>gpt</code> to <code>openai/gpt-5.4</code> and Gemini defaults to <code>gemini-3.1</code> preview aliases (including normalization/default wiring) to track current model IDs. (#38638) Thanks @ademczuk.</li>
|
||||
<li>Config/env substitution degraded mode: convert missing <code>${VAR}</code> resolution in config reads from hard-fail to warning-backed degraded behavior, while preventing unresolved placeholders from being accepted as gateway credentials. (#39050) Thanks @akz142857.</li>
|
||||
<li>Discord inbound listener non-blocking dispatch: make <code>MESSAGE_CREATE</code> listener handoff asynchronous (no per-listener queue blocking), so long runs no longer stall unrelated incoming events. (#39154) Thanks @yaseenkadlemakki.</li>
|
||||
<li>Daemon/Windows PATH freeze fix: stop persisting install-time <code>PATH</code> snapshots into Scheduled Task scripts so runtime tool lookup follows current host PATH updates; also refresh local TUI history on silent local finals. (#39139) Thanks @Narcooo.</li>
|
||||
<li>Gateway/systemd service restart hardening: clear stale gateway listeners by explicit run-port before service bind, add restart stale-pid port-override support, tune systemd start/stop/exit handling, and disable detached child mode only in service-managed runtime so cgroup stop semantics clean up descendants reliably. (#38463) Thanks @spirittechie.</li>
|
||||
<li>Discord/plugin native command aliases: let plugins declare provider-specific slash names so native Discord registration can avoid built-in command collisions; the bundled Talk voice plugin now uses <code>/talkvoice</code> natively on Discord while keeping text <code>/voice</code>.</li>
|
||||
<li>Daemon/Windows schtasks status normalization: derive runtime state from locale-neutral numeric <code>Last Run Result</code> codes only (without language string matching) and surface unknown when numeric result data is unavailable, preventing locale-specific misclassification drift. (#39153) Thanks @scoootscooob.</li>
|
||||
<li>Telegram/polling conflict recovery: reset the polling <code>webhookCleared</code> latch on <code>getUpdates</code> 409 conflicts so webhook cleanup re-runs on restart cycles and polling avoids infinite conflict loops. (#39205) Thanks @amittell.</li>
|
||||
<li>Heartbeat/requests-in-flight scheduling: stop advancing <code>nextDueMs</code> and avoid immediate <code>scheduleNext()</code> timer overrides on requests-in-flight skips, so wake-layer retry cooldowns are honored and heartbeat cadence no longer drifts under sustained contention. (#39182) Thanks @MumuTW.</li>
|
||||
<li>Memory/SQLite contention resilience: re-apply <code>PRAGMA busy_timeout</code> on every sync-store and QMD connection open so process restarts/reopens no longer revert to immediate <code>SQLITE_BUSY</code> failures under lock contention. (#39183) Thanks @MumuTW.</li>
|
||||
<li>Gateway/webchat route safety: block webchat/control-ui clients from inheriting stored external delivery routes on channel-scoped sessions (while preserving route inheritance for UI/TUI clients), preventing cross-channel leakage from scoped chats. (#39175) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Telegram error-surface resilience: return a user-visible fallback reply when dispatch/debounce processing fails instead of going silent, while preserving draft-stream cleanup and best-effort thread-scoped fallback delivery. (#39209) Thanks @riftzen-bit.</li>
|
||||
<li>Gateway/password auth startup diagnostics: detect unresolved provider-reference objects in <code>gateway.auth.password</code> and fail with a specific bootstrap-secrets error message instead of generic misconfiguration output. (#39230) Thanks @ademczuk.</li>
|
||||
<li>Agents/OpenAI-responses compatibility: strip unsupported <code>store</code> payload fields when <code>supportsStore=false</code> (including OpenAI-compatible non-OpenAI providers) while preserving server-compaction payload behavior. (#39219) Thanks @ademczuk.</li>
|
||||
<li>Agents/model fallback visibility: warn when configured model IDs cannot be resolved and fallback is applied, with log-safe sanitization of model text to prevent control-sequence injection in warning output. (#39215) Thanks @ademczuk.</li>
|
||||
<li>Outbound delivery replay safety: use two-phase delivery ACK markers (<code>.json</code> -> <code>.delivered</code> -> unlink) and startup marker cleanup so crash windows between send and cleanup do not replay already-delivered messages. (#38668) Thanks @Gundam98.</li>
|
||||
<li>Nodes/system.run approval binding: carry prepared approval plans through gateway forwarding and bind interpreter-style script operands across approval to execution, so post-approval script rewrites are denied while unchanged approved script runs keep working. Thanks @tdjackey for reporting.</li>
|
||||
<li>Nodes/system.run PowerShell wrapper parsing: treat <code>pwsh</code>/<code>powershell</code> <code>-EncodedCommand</code> forms as shell-wrapper payloads so allowlist mode still requires approval instead of falling back to plain argv analysis. Thanks @tdjackey for reporting.</li>
|
||||
<li>Control UI/auth error reporting: map generic browser <code>Fetch failed</code> websocket close errors back to actionable gateway auth messages (<code>gateway token mismatch</code>, <code>authentication failed</code>, <code>retry later</code>) so dashboard disconnects stop hiding credential problems. Landed from contributor PR #28608 by @KimGLee. Thanks @KimGLee.</li>
|
||||
<li>Media/mime unknown-kind handling: return <code>undefined</code> (not <code>"unknown"</code>) for missing/unrecognized MIME kinds and use document-size fallback caps for unknown remote media, preventing phantom <code><media:unknown></code> Signal events from being treated as real messages. (#39199) Thanks @nicolasgrasset.</li>
|
||||
<li>Nodes/system.run allow-always persistence: honor shell comment semantics during allowlist analysis so <code>#</code>-tailed payloads that never execute are not persisted as trusted follow-up commands. Thanks @tdjackey for reporting.</li>
|
||||
<li>Signal/inbound attachment fan-in: forward all successfully fetched inbound attachments through <code>MediaPaths</code>/<code>MediaUrls</code>/<code>MediaTypes</code> (instead of only the first), and improve multi-attachment placeholder summaries in mention-gated pending history. (#39212) Thanks @joeykrug.</li>
|
||||
<li>Nodes/system.run dispatch-wrapper boundary: keep shell-wrapper approval classification active at the depth boundary so <code>env</code> wrapper stacks cannot reach <code>/bin/sh -c</code> execution without the expected approval gate. Thanks @tdjackey for reporting.</li>
|
||||
<li>Docker/token persistence on reconfigure: reuse the existing <code>.env</code> gateway token during <code>docker-setup.sh</code> reruns and align compose token env defaults, so Docker installs stop silently rotating tokens and breaking existing dashboard sessions. Landed from contributor PR #33097 by @chengzhichao-xydt. Thanks @chengzhichao-xydt.</li>
|
||||
<li>Agents/strict OpenAI turn ordering: apply assistant-first transcript bootstrap sanitization to strict OpenAI-compatible providers (for example vLLM/Gemma via <code>openai-completions</code>) without adding Google-specific session markers, preventing assistant-first history rejections. (#39252) Thanks @scoootscooob.</li>
|
||||
<li>Discord/exec approvals gateway auth: pass resolved shared gateway credentials into the Discord exec-approvals gateway client so token-auth installs stop failing approvals with <code>gateway token mismatch</code>. Related to #38179. Thanks @0riginal-claw for the adjacent PR #35147 investigation.</li>
|
||||
<li>Subagents/workspace inheritance: propagate parent workspace directory to spawned subagent runs so child sessions reliably inherit workspace-scoped instructions (<code>AGENTS.md</code>, <code>SOUL.md</code>, etc.) without exposing workspace override through tool-call arguments. (#39247) Thanks @jasonQin6.</li>
|
||||
<li>Exec approvals/gateway-node policy: honor explicit <code>ask=off</code> from <code>exec-approvals.json</code> even when runtime defaults are stricter, so trusted full/off setups stop re-prompting on gateway and node exec paths. Landed from contributor PR #26789 by @pandego. Thanks @pandego.</li>
|
||||
<li>Exec approvals/config fallback: inherit <code>ask</code> from <code>exec-approvals.json</code> when <code>tools.exec.ask</code> is unset, so local full/off defaults no longer fall back to <code>on-miss</code> for exec tool and <code>nodes run</code>. Landed from contributor PR #29187 by @Bartok9. Thanks @Bartok9.</li>
|
||||
<li>Exec approvals/allow-always shell scripts: persist and match script paths for wrapper invocations like <code>bash scripts/foo.sh</code> while still blocking <code>-c</code>/<code>-s</code> wrapper bypasses. Landed from contributor PR #35137 by @yuweuii. Thanks @yuweuii.</li>
|
||||
<li>Queue/followup dedupe across drain restarts: dedupe queued redelivery <code>message_id</code> values after queue recreation so busy-session followups no longer duplicate on replayed inbound events. Landed from contributor PR #33168 by @rylena. Thanks @rylena.</li>
|
||||
<li>Telegram/preview-final edit idempotence: treat <code>message is not modified</code> errors during preview finalization as delivered so partial-stream final replies do not fall back to duplicate sends. Landed from contributor PR #34983 by @HOYALIM. Thanks @HOYALIM.</li>
|
||||
<li>Telegram/DM streaming transport parity: use message preview transport for all DM streaming lanes so final delivery can edit the active preview instead of sending duplicate finals. Landed from contributor PR #38906 by @gambletan. Thanks @gambletan.</li>
|
||||
<li>Telegram/DM draft streaming restoration: restore native <code>sendMessageDraft</code> preview transport for DM answer streaming while keeping reasoning on message transport, with regression coverage to keep draft finalization from sending duplicate finals. (#39398) Thanks @obviyus.</li>
|
||||
<li>Telegram/send retry safety: retry non-idempotent send paths only for pre-connect failures and make custom retry predicates strict, preventing ambiguous reconnect retries from sending duplicate messages. Landed from contributor PR #34238 by @hal-crackbot. Thanks @hal-crackbot.</li>
|
||||
<li>ACP/run spawn delivery bootstrap: stop reusing requester inline delivery targets for one-shot <code>mode: "run"</code> ACP spawns, so fresh run-mode workers bootstrap in isolation instead of inheriting thread-bound session delivery behavior. (#39014) Thanks @lidamao633.</li>
|
||||
<li>Discord/DM session-key normalization: rewrite legacy <code>discord:dm:*</code> and phantom direct-message <code>discord:channel:<user></code> session keys to <code>discord:direct:*</code> when the sender matches, so multi-agent Discord DMs stop falling into empty channel-shaped sessions and resume replying correctly.</li>
|
||||
<li>Discord/native slash session fallback: treat empty configured bound-session keys as missing so <code>/status</code> and other native commands fall back to the routed slash session and routed channel session instead of blanking Discord session keys in normal channel bindings.</li>
|
||||
<li>Agents/tool-call dispatch normalization: normalize provider-prefixed tool names before dispatch across <code>toolCall</code>, <code>toolUse</code>, and <code>functionCall</code> blocks, while preserving multi-segment tool suffixes when stripping provider wrappers so malformed-but-recoverable tool names no longer fail with <code>Tool not found</code>. (#39328) Thanks @vincentkoc.</li>
|
||||
<li>Agents/parallel tool-call compatibility: honor <code>parallel_tool_calls</code> / <code>parallelToolCalls</code> extra params only for <code>openai-completions</code> and <code>openai-responses</code> payloads, preserve higher-precedence alias overrides across config and runtime layers, and ignore invalid non-boolean values so single-tool-call providers like NVIDIA-hosted Kimi stop failing on forced parallel tool-call payloads. (#37048) Thanks @vincentkoc.</li>
|
||||
<li>Config/invalid-load fail-closed: stop converting <code>INVALID_CONFIG</code> into an empty runtime config, keep valid settings available only through explicit best-effort diagnostic reads, and route read-only CLI diagnostics through that path so unknown keys no longer silently drop security-sensitive config. (#28140) Thanks @bobsahur-robot and @vincentkoc.</li>
|
||||
<li>Agents/codex-cli sandbox defaults: switch the built-in Codex backend from <code>read-only</code> to <code>workspace-write</code> so spawned coding runs can edit files out of the box. Landed from contributor PR #39336 by @0xtangping. Thanks @0xtangping.</li>
|
||||
<li>Gateway/health-monitor restart reason labeling: report <code>disconnected</code> instead of <code>stuck</code> for clean channel disconnect restarts, so operator logs distinguish socket drops from genuinely stuck channels. (#36436) Thanks @Sid-Qin.</li>
|
||||
<li>Control UI/agents-page overrides: auto-create minimal per-agent config entries when editing inherited agents, so model/tool/skill changes enable Save and inherited model fallbacks can be cleared by writing a primary-only override. Landed from contributor PR #39326 by @dunamismax. Thanks @dunamismax.</li>
|
||||
<li>Gateway/Telegram webhook-mode recovery: add <code>webhookCertPath</code> to re-upload self-signed certificates during webhook registration and skip stale-socket detection for webhook-mode channels, so Telegram webhook setups survive health-monitor restarts. Landed from contributor PR #39313 by @fellanH. Thanks @fellanH.</li>
|
||||
<li>Discord/config schema parity: add <code>channels.discord.agentComponents</code> to the strict Zod config schema so valid <code>agentComponents.enabled</code> settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow.</li>
|
||||
<li>ACPX/MCP session bootstrap: inject configured MCP servers into ACP <code>session/new</code> and <code>session/load</code> for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337. Thanks @goodspeed-apps.</li>
|
||||
<li>Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of <code>You</code>. (#39414) Thanks @obviyus.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.7/OpenClaw-2026.3.7.zip" length="23263833" type="application/octet-stream" sparkle:edSignature="SO0zedZMzrvSDltLkuaSVQTWFPPPe1iu/enS4TGGb5EGckhqRCmNJWMKNID5lKwFC8vefTbfG9JTlSrZedP4Bg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
|
|
@ -30,8 +30,12 @@ cd apps/android
|
|||
./gradlew :app:assembleDebug
|
||||
./gradlew :app:installDebug
|
||||
./gradlew :app:testDebugUnitTest
|
||||
cd ../..
|
||||
bun run android:bundle:release
|
||||
```
|
||||
|
||||
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds a signed release `.aab`.
|
||||
|
||||
## Kotlin Lint + Format
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
|
||||
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
|
||||
|
||||
val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() }
|
||||
|
|
@ -63,8 +65,8 @@ android {
|
|||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202603130
|
||||
versionName = "2026.3.13"
|
||||
versionCode = 2026031400
|
||||
versionName = "2026.3.14"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
|
@ -78,6 +80,9 @@ android {
|
|||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
ndk {
|
||||
debugSymbolLevel = "SYMBOL_TABLE"
|
||||
}
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
debug {
|
||||
|
|
@ -104,6 +109,10 @@ android {
|
|||
"/META-INF/LICENSE*.txt",
|
||||
"DebugProbesKt.bin",
|
||||
"kotlin-tooling-metadata.json",
|
||||
"org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties",
|
||||
"org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties",
|
||||
"org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties",
|
||||
"org/bouncycastle/x509/CertPathReviewerMessages*.properties",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -168,7 +177,6 @@ dependencies {
|
|||
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
|
||||
// R8 will tree-shake unused icons when minify is enabled on release builds.
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.7")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
|
|
@ -193,8 +201,7 @@ dependencies {
|
|||
implementation("androidx.camera:camera-camera2:1.5.2")
|
||||
implementation("androidx.camera:camera-lifecycle:1.5.2")
|
||||
implementation("androidx.camera:camera-video:1.5.2")
|
||||
implementation("androidx.camera:camera-view:1.5.2")
|
||||
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
|
||||
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")
|
||||
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation("dnsjava:dnsjava:3.6.4")
|
||||
|
|
@ -211,3 +218,45 @@ dependencies {
|
|||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
val stripReleaseDnsjavaServiceDescriptor =
|
||||
tasks.register("stripReleaseDnsjavaServiceDescriptor") {
|
||||
val mergedJar =
|
||||
layout.buildDirectory.file(
|
||||
"intermediates/merged_java_res/release/mergeReleaseJavaResource/base.jar",
|
||||
)
|
||||
|
||||
inputs.file(mergedJar)
|
||||
outputs.file(mergedJar)
|
||||
|
||||
doLast {
|
||||
val jarFile = mergedJar.get().asFile
|
||||
if (!jarFile.exists()) {
|
||||
return@doLast
|
||||
}
|
||||
|
||||
val unpackDir = temporaryDir.resolve("merged-java-res")
|
||||
delete(unpackDir)
|
||||
copy {
|
||||
from(zipTree(jarFile))
|
||||
into(unpackDir)
|
||||
exclude(dnsjavaInetAddressResolverService)
|
||||
}
|
||||
delete(jarFile)
|
||||
ant.invokeMethod(
|
||||
"zip",
|
||||
mapOf(
|
||||
"destfile" to jarFile.absolutePath,
|
||||
"basedir" to unpackDir.absolutePath,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == "stripReleaseDnsjavaServiceDescriptor" }.configureEach {
|
||||
dependsOn("mergeReleaseJavaResource")
|
||||
}
|
||||
|
||||
tasks.matching { it.name == "minifyReleaseWithR8" }.configureEach {
|
||||
dependsOn(stripReleaseDnsjavaServiceDescriptor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,6 @@
|
|||
# ── App classes ───────────────────────────────────────────────────
|
||||
-keep class ai.openclaw.app.** { *; }
|
||||
|
||||
# ── Bouncy Castle ─────────────────────────────────────────────────
|
||||
-keep class org.bouncycastle.** { *; }
|
||||
-dontwarn org.bouncycastle.**
|
||||
|
||||
# ── CameraX ───────────────────────────────────────────────────────
|
||||
-keep class androidx.camera.** { *; }
|
||||
|
||||
# ── kotlinx.serialization ────────────────────────────────────────
|
||||
-keep class kotlinx.serialization.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@kotlinx.serialization.Serializable *;
|
||||
}
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
|
||||
# ── OkHttp ────────────────────────────────────────────────────────
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.internal.platform.** { *; }
|
||||
|
||||
# ── Misc suppressions ────────────────────────────────────────────
|
||||
-dontwarn com.sun.jna.**
|
||||
-dontwarn javax.naming.**
|
||||
-dontwarn lombok.Generated
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
|
|
|
|||
|
|
@ -176,6 +176,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||
runtime.requestCanvasRehydrate(source = source, force = true)
|
||||
}
|
||||
|
||||
fun refreshHomeCanvasOverviewIfConnected() {
|
||||
runtime.refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String) {
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
|
@ -108,6 +110,10 @@ class NodeRuntime(context: Context) {
|
|||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val callLogHandler: CallLogHandler = CallLogHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val motionHandler: MotionHandler = MotionHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
|
@ -149,6 +155,7 @@ class NodeRuntime(context: Context) {
|
|||
smsHandler = smsHandlerImpl,
|
||||
a2uiHandler = a2uiHandler,
|
||||
debugHandler = debugHandler,
|
||||
callLogHandler = callLogHandler,
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
|
|
@ -210,7 +217,8 @@ class NodeRuntime(context: Context) {
|
|||
private val _isForeground = MutableStateFlow(true)
|
||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
private var lastAutoA2uiUrl: String? = null
|
||||
private var gatewayDefaultAgentId: String? = null
|
||||
private var gatewayAgents: List<GatewayAgentSummary> = emptyList()
|
||||
private var didAutoRequestCanvasRehydrate = false
|
||||
private val canvasRehydrateSeq = AtomicLong(0)
|
||||
private var operatorConnected = false
|
||||
|
|
@ -232,7 +240,7 @@ class NodeRuntime(context: Context) {
|
|||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
refreshBrandingFromGateway()
|
||||
refreshHomeCanvasOverviewIfConnected()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
}
|
||||
|
|
@ -270,7 +278,7 @@ class NodeRuntime(context: Context) {
|
|||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
updateStatus()
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
showLocalCanvasOnConnect()
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
_nodeConnected.value = false
|
||||
|
|
@ -396,6 +404,7 @@ class NodeRuntime(context: Context) {
|
|||
_mainSessionKey.value = trimmed
|
||||
talkMode.setMainSessionKey(trimmed)
|
||||
chat.applyMainSessionKey(trimmed)
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
private fun updateStatus() {
|
||||
|
|
@ -415,6 +424,7 @@ class NodeRuntime(context: Context) {
|
|||
operator.isNotBlank() && operator != "Offline" -> operator
|
||||
else -> node
|
||||
}
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
private fun resolveMainSessionKey(): String {
|
||||
|
|
@ -422,23 +432,31 @@ class NodeRuntime(context: Context) {
|
|||
return if (trimmed.isEmpty()) "main" else trimmed
|
||||
}
|
||||
|
||||
private fun maybeNavigateToA2uiOnConnect() {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: return
|
||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||
if (current.isEmpty() || current == lastAutoA2uiUrl) {
|
||||
lastAutoA2uiUrl = a2uiUrl
|
||||
canvas.navigate(a2uiUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLocalCanvasOnDisconnect() {
|
||||
lastAutoA2uiUrl = null
|
||||
private fun showLocalCanvasOnConnect() {
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
canvas.navigate("")
|
||||
}
|
||||
|
||||
private fun showLocalCanvasOnDisconnect() {
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
canvas.navigate("")
|
||||
}
|
||||
|
||||
fun refreshHomeCanvasOverviewIfConnected() {
|
||||
if (!operatorConnected) {
|
||||
updateHomeCanvasState()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
refreshBrandingFromGateway()
|
||||
refreshAgentsFromGateway()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) {
|
||||
scope.launch {
|
||||
if (!_nodeConnected.value) {
|
||||
|
|
@ -602,6 +620,8 @@ class NodeRuntime(context: Context) {
|
|||
canvas.setDebugStatus(status, server ?: remote)
|
||||
}
|
||||
}
|
||||
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
|
|
@ -928,11 +948,177 @@ class NodeRuntime(context: Context) {
|
|||
|
||||
val parsed = parseHexColorArgb(raw)
|
||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||
updateHomeCanvasState()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshAgentsFromGateway() {
|
||||
if (!operatorConnected) return
|
||||
try {
|
||||
val res = operatorSession.request("agents.list", "{}")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return
|
||||
val defaultAgentId = root["defaultId"].asStringOrNull()?.trim().orEmpty()
|
||||
val mainKey = normalizeMainKey(root["mainKey"].asStringOrNull())
|
||||
val agents =
|
||||
(root["agents"] as? JsonArray)?.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
|
||||
if (id.isEmpty()) return@mapNotNull null
|
||||
val name = obj["name"].asStringOrNull()?.trim()
|
||||
val emoji = obj["identity"].asObjectOrNull()?.get("emoji").asStringOrNull()?.trim()
|
||||
GatewayAgentSummary(
|
||||
id = id,
|
||||
name = name?.takeIf { it.isNotEmpty() },
|
||||
emoji = emoji?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
gatewayDefaultAgentId = defaultAgentId.ifEmpty { null }
|
||||
gatewayAgents = agents
|
||||
applyMainSessionKey(mainKey)
|
||||
updateHomeCanvasState()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateHomeCanvasState() {
|
||||
val payload =
|
||||
try {
|
||||
json.encodeToString(makeHomeCanvasPayload())
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
canvas.updateHomeCanvasState(payload)
|
||||
}
|
||||
|
||||
private fun makeHomeCanvasPayload(): HomeCanvasPayload {
|
||||
val state = resolveHomeCanvasGatewayState()
|
||||
val gatewayName = normalized(_serverName.value)
|
||||
val gatewayAddress = normalized(_remoteAddress.value)
|
||||
val gatewayLabel = gatewayName ?: gatewayAddress ?: "Gateway"
|
||||
val activeAgentId = resolveActiveAgentId()
|
||||
val agents = homeCanvasAgents(activeAgentId)
|
||||
|
||||
return when (state) {
|
||||
HomeCanvasGatewayState.Connected ->
|
||||
HomeCanvasPayload(
|
||||
gatewayState = "connected",
|
||||
eyebrow = "Connected to $gatewayLabel",
|
||||
title = "Your agents are ready",
|
||||
subtitle =
|
||||
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
|
||||
gatewayLabel = gatewayLabel,
|
||||
activeAgentName = resolveActiveAgentName(activeAgentId),
|
||||
activeAgentBadge = agents.firstOrNull { it.isActive }?.badge ?: "OC",
|
||||
activeAgentCaption = "Selected on this phone",
|
||||
agentCount = agents.size,
|
||||
agents = agents.take(6),
|
||||
footer = "The overview refreshes on reconnect and when this screen opens.",
|
||||
)
|
||||
HomeCanvasGatewayState.Connecting ->
|
||||
HomeCanvasPayload(
|
||||
gatewayState = "connecting",
|
||||
eyebrow = "Reconnecting",
|
||||
title = "OpenClaw is syncing back up",
|
||||
subtitle =
|
||||
"The gateway session is coming back online. Agent shortcuts should settle automatically in a moment.",
|
||||
gatewayLabel = gatewayLabel,
|
||||
activeAgentName = resolveActiveAgentName(activeAgentId),
|
||||
activeAgentBadge = "OC",
|
||||
activeAgentCaption = "Gateway session in progress",
|
||||
agentCount = agents.size,
|
||||
agents = agents.take(4),
|
||||
footer = "If the gateway is reachable, reconnect should complete without intervention.",
|
||||
)
|
||||
HomeCanvasGatewayState.Error, HomeCanvasGatewayState.Offline ->
|
||||
HomeCanvasPayload(
|
||||
gatewayState = if (state == HomeCanvasGatewayState.Error) "error" else "offline",
|
||||
eyebrow = "Welcome to OpenClaw",
|
||||
title = "Your phone stays quiet until it is needed",
|
||||
subtitle =
|
||||
"Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.",
|
||||
gatewayLabel = gatewayLabel,
|
||||
activeAgentName = "Main",
|
||||
activeAgentBadge = "OC",
|
||||
activeAgentCaption = "Connect to load your agents",
|
||||
agentCount = agents.size,
|
||||
agents = agents.take(4),
|
||||
footer = "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveHomeCanvasGatewayState(): HomeCanvasGatewayState {
|
||||
val lower = _statusText.value.trim().lowercase()
|
||||
return when {
|
||||
_isConnected.value -> HomeCanvasGatewayState.Connected
|
||||
lower.contains("connecting") || lower.contains("reconnecting") -> HomeCanvasGatewayState.Connecting
|
||||
lower.contains("error") || lower.contains("failed") -> HomeCanvasGatewayState.Error
|
||||
else -> HomeCanvasGatewayState.Offline
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveActiveAgentId(): String {
|
||||
val mainKey = _mainSessionKey.value.trim()
|
||||
if (mainKey.startsWith("agent:")) {
|
||||
val agentId = mainKey.removePrefix("agent:").substringBefore(':').trim()
|
||||
if (agentId.isNotEmpty()) return agentId
|
||||
}
|
||||
return gatewayDefaultAgentId?.trim().orEmpty()
|
||||
}
|
||||
|
||||
private fun resolveActiveAgentName(activeAgentId: String): String {
|
||||
if (activeAgentId.isNotEmpty()) {
|
||||
gatewayAgents.firstOrNull { it.id == activeAgentId }?.let { agent ->
|
||||
return normalized(agent.name) ?: agent.id
|
||||
}
|
||||
return activeAgentId
|
||||
}
|
||||
return gatewayAgents.firstOrNull()?.let { normalized(it.name) ?: it.id } ?: "Main"
|
||||
}
|
||||
|
||||
private fun homeCanvasAgents(activeAgentId: String): List<HomeCanvasAgentCard> {
|
||||
val defaultAgentId = gatewayDefaultAgentId?.trim().orEmpty()
|
||||
return gatewayAgents
|
||||
.map { agent ->
|
||||
val isActive = activeAgentId.isNotEmpty() && agent.id == activeAgentId
|
||||
val isDefault = defaultAgentId.isNotEmpty() && agent.id == defaultAgentId
|
||||
HomeCanvasAgentCard(
|
||||
id = agent.id,
|
||||
name = normalized(agent.name) ?: agent.id,
|
||||
badge = homeCanvasBadge(agent),
|
||||
caption =
|
||||
when {
|
||||
isActive -> "Active on this phone"
|
||||
isDefault -> "Default agent"
|
||||
else -> "Ready"
|
||||
},
|
||||
isActive = isActive,
|
||||
)
|
||||
}.sortedWith(compareByDescending<HomeCanvasAgentCard> { it.isActive }.thenBy { it.name.lowercase() })
|
||||
}
|
||||
|
||||
private fun homeCanvasBadge(agent: GatewayAgentSummary): String {
|
||||
val emoji = normalized(agent.emoji)
|
||||
if (emoji != null) return emoji
|
||||
val initials =
|
||||
(normalized(agent.name) ?: agent.id)
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { token -> token.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
return if (initials.isNotEmpty()) initials else "OC"
|
||||
}
|
||||
|
||||
private fun normalized(value: String?): String? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
return trimmed.ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun triggerCameraFlash() {
|
||||
// Token is used as a pulse trigger; value doesn't matter as long as it changes.
|
||||
_cameraFlashToken.value = SystemClock.elapsedRealtimeNanos()
|
||||
|
|
@ -951,3 +1137,40 @@ class NodeRuntime(context: Context) {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
private enum class HomeCanvasGatewayState {
|
||||
Connected,
|
||||
Connecting,
|
||||
Error,
|
||||
Offline,
|
||||
}
|
||||
|
||||
private data class GatewayAgentSummary(
|
||||
val id: String,
|
||||
val name: String?,
|
||||
val emoji: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class HomeCanvasPayload(
|
||||
val gatewayState: String,
|
||||
val eyebrow: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val gatewayLabel: String,
|
||||
val activeAgentName: String,
|
||||
val activeAgentBadge: String,
|
||||
val activeAgentCaption: String,
|
||||
val agentCount: Int,
|
||||
val agents: List<HomeCanvasAgentCard>,
|
||||
val footer: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class HomeCanvasAgentCard(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val badge: String,
|
||||
val caption: String,
|
||||
val isActive: Boolean,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,247 @@
|
|||
package ai.openclaw.app.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.provider.CallLog
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_CALL_LOG_LIMIT = 25
|
||||
|
||||
internal data class CallLogRecord(
|
||||
val number: String?,
|
||||
val cachedName: String?,
|
||||
val date: Long,
|
||||
val duration: Long,
|
||||
val type: Int,
|
||||
)
|
||||
|
||||
internal data class CallLogSearchRequest(
|
||||
val limit: Int, // Number of records to return
|
||||
val offset: Int, // Offset value
|
||||
val cachedName: String?, // Search by contact name
|
||||
val number: String?, // Search by phone number
|
||||
val date: Long?, // Search by time (timestamp, deprecated, use dateStart/dateEnd)
|
||||
val dateStart: Long?, // Query start time (timestamp)
|
||||
val dateEnd: Long?, // Query end time (timestamp)
|
||||
val duration: Long?, // Search by duration (seconds)
|
||||
val type: Int?, // Search by call log type
|
||||
)
|
||||
|
||||
internal interface CallLogDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord>
|
||||
}
|
||||
|
||||
private object SystemCallLogDataSource : CallLogDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_CALL_LOG
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
|
||||
val resolver = context.contentResolver
|
||||
val projection = arrayOf(
|
||||
CallLog.Calls.NUMBER,
|
||||
CallLog.Calls.CACHED_NAME,
|
||||
CallLog.Calls.DATE,
|
||||
CallLog.Calls.DURATION,
|
||||
CallLog.Calls.TYPE,
|
||||
)
|
||||
|
||||
// Build selection and selectionArgs for filtering
|
||||
val selections = mutableListOf<String>()
|
||||
val selectionArgs = mutableListOf<String>()
|
||||
|
||||
request.cachedName?.let {
|
||||
selections.add("${CallLog.Calls.CACHED_NAME} LIKE ?")
|
||||
selectionArgs.add("%$it%")
|
||||
}
|
||||
|
||||
request.number?.let {
|
||||
selections.add("${CallLog.Calls.NUMBER} LIKE ?")
|
||||
selectionArgs.add("%$it%")
|
||||
}
|
||||
|
||||
// Support time range query
|
||||
if (request.dateStart != null && request.dateEnd != null) {
|
||||
selections.add("${CallLog.Calls.DATE} >= ? AND ${CallLog.Calls.DATE} <= ?")
|
||||
selectionArgs.add(request.dateStart.toString())
|
||||
selectionArgs.add(request.dateEnd.toString())
|
||||
} else if (request.dateStart != null) {
|
||||
selections.add("${CallLog.Calls.DATE} >= ?")
|
||||
selectionArgs.add(request.dateStart.toString())
|
||||
} else if (request.dateEnd != null) {
|
||||
selections.add("${CallLog.Calls.DATE} <= ?")
|
||||
selectionArgs.add(request.dateEnd.toString())
|
||||
} else if (request.date != null) {
|
||||
// Compatible with the old date parameter (exact match)
|
||||
selections.add("${CallLog.Calls.DATE} = ?")
|
||||
selectionArgs.add(request.date.toString())
|
||||
}
|
||||
|
||||
request.duration?.let {
|
||||
selections.add("${CallLog.Calls.DURATION} = ?")
|
||||
selectionArgs.add(it.toString())
|
||||
}
|
||||
|
||||
request.type?.let {
|
||||
selections.add("${CallLog.Calls.TYPE} = ?")
|
||||
selectionArgs.add(it.toString())
|
||||
}
|
||||
|
||||
val selection = if (selections.isNotEmpty()) selections.joinToString(" AND ") else null
|
||||
val selectionArgsArray = if (selectionArgs.isNotEmpty()) selectionArgs.toTypedArray() else null
|
||||
|
||||
val sortOrder = "${CallLog.Calls.DATE} DESC"
|
||||
|
||||
resolver.query(
|
||||
CallLog.Calls.CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgsArray,
|
||||
sortOrder,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
|
||||
val numberIndex = cursor.getColumnIndex(CallLog.Calls.NUMBER)
|
||||
val cachedNameIndex = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME)
|
||||
val dateIndex = cursor.getColumnIndex(CallLog.Calls.DATE)
|
||||
val durationIndex = cursor.getColumnIndex(CallLog.Calls.DURATION)
|
||||
val typeIndex = cursor.getColumnIndex(CallLog.Calls.TYPE)
|
||||
|
||||
// Skip offset rows
|
||||
if (request.offset > 0 && cursor.moveToPosition(request.offset - 1)) {
|
||||
// Successfully moved to offset position
|
||||
}
|
||||
|
||||
val out = mutableListOf<CallLogRecord>()
|
||||
var count = 0
|
||||
while (cursor.moveToNext() && count < request.limit) {
|
||||
out += CallLogRecord(
|
||||
number = cursor.getString(numberIndex),
|
||||
cachedName = cursor.getString(cachedNameIndex),
|
||||
date = cursor.getLong(dateIndex),
|
||||
duration = cursor.getLong(durationIndex),
|
||||
type = cursor.getInt(typeIndex),
|
||||
)
|
||||
count++
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CallLogHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: CallLogDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCallLogDataSource)
|
||||
|
||||
fun handleCallLogSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasReadPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALL_LOG_PERMISSION_REQUIRED",
|
||||
message = "CALL_LOG_PERMISSION_REQUIRED: grant Call Log permission",
|
||||
)
|
||||
}
|
||||
|
||||
val request = parseSearchRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
|
||||
return try {
|
||||
val callLogs = dataSource.search(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"callLogs",
|
||||
buildJsonArray {
|
||||
callLogs.forEach { add(callLogJson(it)) }
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALL_LOG_UNAVAILABLE",
|
||||
message = "CALL_LOG_UNAVAILABLE: ${err.message ?: "call log query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSearchRequest(paramsJson: String?): CallLogSearchRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return CallLogSearchRequest(
|
||||
limit = DEFAULT_CALL_LOG_LIMIT,
|
||||
offset = 0,
|
||||
cachedName = null,
|
||||
number = null,
|
||||
date = null,
|
||||
dateStart = null,
|
||||
dateEnd = null,
|
||||
duration = null,
|
||||
type = null,
|
||||
)
|
||||
}
|
||||
|
||||
val params = try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALL_LOG_LIMIT)
|
||||
.coerceIn(1, 200)
|
||||
val offset = ((params["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
|
||||
.coerceAtLeast(0)
|
||||
val cachedName = (params["cachedName"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
|
||||
val number = (params["number"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
|
||||
val date = (params["date"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val dateStart = (params["dateStart"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val dateEnd = (params["dateEnd"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val duration = (params["duration"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val type = (params["type"] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
|
||||
return CallLogSearchRequest(
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
cachedName = cachedName,
|
||||
number = number,
|
||||
date = date,
|
||||
dateStart = dateStart,
|
||||
dateEnd = dateEnd,
|
||||
duration = duration,
|
||||
type = type,
|
||||
)
|
||||
}
|
||||
|
||||
private fun callLogJson(callLog: CallLogRecord): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("number", JsonPrimitive(callLog.number))
|
||||
put("cachedName", JsonPrimitive(callLog.cachedName))
|
||||
put("date", JsonPrimitive(callLog.date))
|
||||
put("duration", JsonPrimitive(callLog.duration))
|
||||
put("type", JsonPrimitive(callLog.type))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: CallLogDataSource,
|
||||
): CallLogHandler = CallLogHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ class CanvasController {
|
|||
@Volatile private var debugStatusEnabled: Boolean = false
|
||||
@Volatile private var debugStatusTitle: String? = null
|
||||
@Volatile private var debugStatusSubtitle: String? = null
|
||||
@Volatile private var homeCanvasStateJson: String? = null
|
||||
private val _currentUrl = MutableStateFlow<String?>(null)
|
||||
val currentUrl: StateFlow<String?> = _currentUrl.asStateFlow()
|
||||
|
||||
|
|
@ -56,6 +57,7 @@ class CanvasController {
|
|||
this.webView = webView
|
||||
reload()
|
||||
applyDebugStatus()
|
||||
applyHomeCanvasState()
|
||||
}
|
||||
|
||||
fun detach(webView: WebView) {
|
||||
|
|
@ -88,6 +90,12 @@ class CanvasController {
|
|||
|
||||
fun onPageFinished() {
|
||||
applyDebugStatus()
|
||||
applyHomeCanvasState()
|
||||
}
|
||||
|
||||
fun updateHomeCanvasState(json: String?) {
|
||||
homeCanvasStateJson = json
|
||||
applyHomeCanvasState()
|
||||
}
|
||||
|
||||
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
|
||||
|
|
@ -142,6 +150,22 @@ class CanvasController {
|
|||
}
|
||||
}
|
||||
|
||||
private fun applyHomeCanvasState() {
|
||||
val payload = homeCanvasStateJson ?: "null"
|
||||
withWebViewOnMain { wv ->
|
||||
val js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__openclaw;
|
||||
if (!api || typeof api.renderHome !== 'function') return;
|
||||
api.renderHome($payload);
|
||||
} catch (_) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
wv.evaluateJavascript(js, null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun eval(javaScript: String): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
|
|
|
|||
|
|
@ -212,6 +212,13 @@ class DeviceHandler(
|
|||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"callLog",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.READ_CALL_LOG),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"motion",
|
||||
permissionStateJson(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
|
|||
import ai.openclaw.app.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCapability
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
|
|
@ -84,6 +85,7 @@ object InvokeCommandRegistry {
|
|||
name = OpenClawCapability.Motion.rawValue,
|
||||
availability = NodeCapabilityAvailability.MotionAvailable,
|
||||
),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue),
|
||||
)
|
||||
|
||||
val all: List<InvokeCommandSpec> =
|
||||
|
|
@ -187,6 +189,9 @@ object InvokeCommandRegistry {
|
|||
name = OpenClawSmsCommand.Send.rawValue,
|
||||
availability = InvokeCommandAvailability.SmsAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCallLogCommand.Search.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.logs",
|
||||
availability = InvokeCommandAvailability.DebugBuild,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import ai.openclaw.app.protocol.OpenClawCalendarCommand
|
|||
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.app.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
|
|
@ -27,6 +28,7 @@ class InvokeDispatcher(
|
|||
private val smsHandler: SmsHandler,
|
||||
private val a2uiHandler: A2UIHandler,
|
||||
private val debugHandler: DebugHandler,
|
||||
private val callLogHandler: CallLogHandler,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationEnabled: () -> Boolean,
|
||||
|
|
@ -161,6 +163,9 @@ class InvokeDispatcher(
|
|||
// SMS command
|
||||
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
|
||||
|
||||
// CallLog command
|
||||
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
|
||||
|
||||
// Debug commands
|
||||
"debug.ed25519" -> debugHandler.handleEd25519()
|
||||
"debug.logs" -> debugHandler.handleLogs()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ enum class OpenClawCapability(val rawValue: String) {
|
|||
Contacts("contacts"),
|
||||
Calendar("calendar"),
|
||||
Motion("motion"),
|
||||
CallLog("callLog"),
|
||||
}
|
||||
|
||||
enum class OpenClawCanvasCommand(val rawValue: String) {
|
||||
|
|
@ -137,3 +138,12 @@ enum class OpenClawMotionCommand(val rawValue: String) {
|
|||
const val NamespacePrefix: String = "motion."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawCallLogCommand(val rawValue: String) {
|
||||
Search("callLog.search"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "callLog."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
|
||||
private enum class ConnectInputMode {
|
||||
SetupCode,
|
||||
|
|
@ -91,20 +92,28 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
|||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
title = { Text("Trust this gateway?") },
|
||||
containerColor = mobileCardSurface,
|
||||
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
|
||||
text = {
|
||||
Text(
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
|
||||
TextButton(
|
||||
onClick = { viewModel.acceptGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
|
||||
) {
|
||||
Text("Trust and continue")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
|
||||
TextButton(
|
||||
onClick = { viewModel.declineGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
|
|
@ -144,7 +153,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
|||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
Column {
|
||||
|
|
@ -205,7 +214,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
|||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
containerColor = mobileCardSurface,
|
||||
contentColor = mobileDanger,
|
||||
),
|
||||
border = BorderStroke(1.dp, mobileDanger.copy(alpha = 0.4f)),
|
||||
|
|
@ -298,7 +307,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
|||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -480,7 +489,7 @@ private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) {
|
|||
containerColor = if (active) mobileAccent else mobileSurface,
|
||||
contentColor = if (active) Color.White else mobileText,
|
||||
),
|
||||
border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong),
|
||||
border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
|
||||
) {
|
||||
Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
|
|
@ -509,10 +518,10 @@ private fun CommandBlock(command: String) {
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = mobileCodeBg,
|
||||
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
|
||||
border = BorderStroke(1.dp, mobileCodeBorder),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A)))
|
||||
Box(modifier = Modifier.width(3.dp).height(42.dp).background(mobileCodeAccent))
|
||||
Text(
|
||||
text = command,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
|
|||
"wss", "https" -> true
|
||||
else -> true
|
||||
}
|
||||
val port = uri.port.takeIf { it in 1..65535 } ?: 18789
|
||||
val port = uri.port.takeIf { it in 1..65535 } ?: if (tls) 443 else 18789
|
||||
val displayUrl = "${if (tls) "https" else "http"}://$host:$port"
|
||||
|
||||
return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package ai.openclaw.app.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
|
@ -9,32 +11,147 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.unit.sp
|
||||
import ai.openclaw.app.R
|
||||
|
||||
internal val mobileBackgroundGradient =
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFF7F8FA),
|
||||
Color(0xFFEFF1F5),
|
||||
),
|
||||
// ---------------------------------------------------------------------------
|
||||
// MobileColors – semantic color tokens with light + dark variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal data class MobileColors(
|
||||
val surface: Color,
|
||||
val surfaceStrong: Color,
|
||||
val cardSurface: Color,
|
||||
val border: Color,
|
||||
val borderStrong: Color,
|
||||
val text: Color,
|
||||
val textSecondary: Color,
|
||||
val textTertiary: Color,
|
||||
val accent: Color,
|
||||
val accentSoft: Color,
|
||||
val accentBorderStrong: Color,
|
||||
val success: Color,
|
||||
val successSoft: Color,
|
||||
val warning: Color,
|
||||
val warningSoft: Color,
|
||||
val danger: Color,
|
||||
val dangerSoft: Color,
|
||||
val codeBg: Color,
|
||||
val codeText: Color,
|
||||
val codeBorder: Color,
|
||||
val codeAccent: Color,
|
||||
val chipBorderConnected: Color,
|
||||
val chipBorderConnecting: Color,
|
||||
val chipBorderWarning: Color,
|
||||
val chipBorderError: Color,
|
||||
)
|
||||
|
||||
internal fun lightMobileColors() =
|
||||
MobileColors(
|
||||
surface = Color(0xFFF6F7FA),
|
||||
surfaceStrong = Color(0xFFECEEF3),
|
||||
cardSurface = Color(0xFFFFFFFF),
|
||||
border = Color(0xFFE5E7EC),
|
||||
borderStrong = Color(0xFFD6DAE2),
|
||||
text = Color(0xFF17181C),
|
||||
textSecondary = Color(0xFF5D6472),
|
||||
textTertiary = Color(0xFF99A0AE),
|
||||
accent = Color(0xFF1D5DD8),
|
||||
accentSoft = Color(0xFFECF3FF),
|
||||
accentBorderStrong = Color(0xFF184DAF),
|
||||
success = Color(0xFF2F8C5A),
|
||||
successSoft = Color(0xFFEEF9F3),
|
||||
warning = Color(0xFFC8841A),
|
||||
warningSoft = Color(0xFFFFF8EC),
|
||||
danger = Color(0xFFD04B4B),
|
||||
dangerSoft = Color(0xFFFFF2F2),
|
||||
codeBg = Color(0xFF15171B),
|
||||
codeText = Color(0xFFE8EAEE),
|
||||
codeBorder = Color(0xFF2B2E35),
|
||||
codeAccent = Color(0xFF3FC97A),
|
||||
chipBorderConnected = Color(0xFFCFEBD8),
|
||||
chipBorderConnecting = Color(0xFFD5E2FA),
|
||||
chipBorderWarning = Color(0xFFEED8B8),
|
||||
chipBorderError = Color(0xFFF3C8C8),
|
||||
)
|
||||
|
||||
internal val mobileSurface = Color(0xFFF6F7FA)
|
||||
internal val mobileSurfaceStrong = Color(0xFFECEEF3)
|
||||
internal val mobileBorder = Color(0xFFE5E7EC)
|
||||
internal val mobileBorderStrong = Color(0xFFD6DAE2)
|
||||
internal val mobileText = Color(0xFF17181C)
|
||||
internal val mobileTextSecondary = Color(0xFF5D6472)
|
||||
internal val mobileTextTertiary = Color(0xFF99A0AE)
|
||||
internal val mobileAccent = Color(0xFF1D5DD8)
|
||||
internal val mobileAccentSoft = Color(0xFFECF3FF)
|
||||
internal val mobileSuccess = Color(0xFF2F8C5A)
|
||||
internal val mobileSuccessSoft = Color(0xFFEEF9F3)
|
||||
internal val mobileWarning = Color(0xFFC8841A)
|
||||
internal val mobileWarningSoft = Color(0xFFFFF8EC)
|
||||
internal val mobileDanger = Color(0xFFD04B4B)
|
||||
internal val mobileDangerSoft = Color(0xFFFFF2F2)
|
||||
internal val mobileCodeBg = Color(0xFF15171B)
|
||||
internal val mobileCodeText = Color(0xFFE8EAEE)
|
||||
internal fun darkMobileColors() =
|
||||
MobileColors(
|
||||
surface = Color(0xFF1A1C20),
|
||||
surfaceStrong = Color(0xFF24262B),
|
||||
cardSurface = Color(0xFF1E2024),
|
||||
border = Color(0xFF2E3038),
|
||||
borderStrong = Color(0xFF3A3D46),
|
||||
text = Color(0xFFE4E5EA),
|
||||
textSecondary = Color(0xFFA0A6B4),
|
||||
textTertiary = Color(0xFF6B7280),
|
||||
accent = Color(0xFF6EA8FF),
|
||||
accentSoft = Color(0xFF1A2A44),
|
||||
accentBorderStrong = Color(0xFF5B93E8),
|
||||
success = Color(0xFF5FBB85),
|
||||
successSoft = Color(0xFF152E22),
|
||||
warning = Color(0xFFE8A844),
|
||||
warningSoft = Color(0xFF2E2212),
|
||||
danger = Color(0xFFE87070),
|
||||
dangerSoft = Color(0xFF2E1616),
|
||||
codeBg = Color(0xFF111317),
|
||||
codeText = Color(0xFFE8EAEE),
|
||||
codeBorder = Color(0xFF2B2E35),
|
||||
codeAccent = Color(0xFF3FC97A),
|
||||
chipBorderConnected = Color(0xFF1E4A30),
|
||||
chipBorderConnecting = Color(0xFF1E3358),
|
||||
chipBorderWarning = Color(0xFF3E3018),
|
||||
chipBorderError = Color(0xFF3E1E1E),
|
||||
)
|
||||
|
||||
internal val LocalMobileColors = staticCompositionLocalOf { lightMobileColors() }
|
||||
|
||||
internal object MobileColorsAccessor {
|
||||
val current: MobileColors
|
||||
@Composable get() = LocalMobileColors.current
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compatible top-level accessors (composable getters)
|
||||
// ---------------------------------------------------------------------------
|
||||
// These allow existing call sites to keep using `mobileSurface`, `mobileText`, etc.
|
||||
// without converting every file at once. Each resolves to the themed value.
|
||||
|
||||
internal val mobileSurface: Color @Composable get() = LocalMobileColors.current.surface
|
||||
internal val mobileSurfaceStrong: Color @Composable get() = LocalMobileColors.current.surfaceStrong
|
||||
internal val mobileCardSurface: Color @Composable get() = LocalMobileColors.current.cardSurface
|
||||
internal val mobileBorder: Color @Composable get() = LocalMobileColors.current.border
|
||||
internal val mobileBorderStrong: Color @Composable get() = LocalMobileColors.current.borderStrong
|
||||
internal val mobileText: Color @Composable get() = LocalMobileColors.current.text
|
||||
internal val mobileTextSecondary: Color @Composable get() = LocalMobileColors.current.textSecondary
|
||||
internal val mobileTextTertiary: Color @Composable get() = LocalMobileColors.current.textTertiary
|
||||
internal val mobileAccent: Color @Composable get() = LocalMobileColors.current.accent
|
||||
internal val mobileAccentSoft: Color @Composable get() = LocalMobileColors.current.accentSoft
|
||||
internal val mobileAccentBorderStrong: Color @Composable get() = LocalMobileColors.current.accentBorderStrong
|
||||
internal val mobileSuccess: Color @Composable get() = LocalMobileColors.current.success
|
||||
internal val mobileSuccessSoft: Color @Composable get() = LocalMobileColors.current.successSoft
|
||||
internal val mobileWarning: Color @Composable get() = LocalMobileColors.current.warning
|
||||
internal val mobileWarningSoft: Color @Composable get() = LocalMobileColors.current.warningSoft
|
||||
internal val mobileDanger: Color @Composable get() = LocalMobileColors.current.danger
|
||||
internal val mobileDangerSoft: Color @Composable get() = LocalMobileColors.current.dangerSoft
|
||||
internal val mobileCodeBg: Color @Composable get() = LocalMobileColors.current.codeBg
|
||||
internal val mobileCodeText: Color @Composable get() = LocalMobileColors.current.codeText
|
||||
internal val mobileCodeBorder: Color @Composable get() = LocalMobileColors.current.codeBorder
|
||||
internal val mobileCodeAccent: Color @Composable get() = LocalMobileColors.current.codeAccent
|
||||
|
||||
// Background gradient – light fades white→gray, dark fades near-black→dark-gray
|
||||
internal val mobileBackgroundGradient: Brush
|
||||
@Composable get() {
|
||||
val colors = LocalMobileColors.current
|
||||
return Brush.verticalGradient(
|
||||
listOf(
|
||||
colors.surface,
|
||||
colors.surfaceStrong,
|
||||
colors.surfaceStrong,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography tokens (theme-independent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal val mobileFontFamily =
|
||||
FontFamily(
|
||||
|
|
@ -44,6 +161,15 @@ internal val mobileFontFamily =
|
|||
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
internal val mobileDisplay =
|
||||
TextStyle(
|
||||
fontFamily = mobileFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 34.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.8).sp,
|
||||
)
|
||||
|
||||
internal val mobileTitle1 =
|
||||
TextStyle(
|
||||
fontFamily = mobileFontFamily,
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
|
|
@ -94,10 +93,10 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
|
||||
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
|
||||
|
||||
private enum class OnboardingStep(val index: Int, val label: String) {
|
||||
Welcome(1, "Welcome"),
|
||||
|
|
@ -122,101 +121,87 @@ private enum class PermissionToggle {
|
|||
Calendar,
|
||||
Motion,
|
||||
Sms,
|
||||
CallLog,
|
||||
}
|
||||
|
||||
private enum class SpecialAccessToggle {
|
||||
NotificationListener,
|
||||
}
|
||||
|
||||
private val onboardingBackgroundGradient =
|
||||
listOf(
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFF7F8FA),
|
||||
Color(0xFFEFF1F5),
|
||||
)
|
||||
private val onboardingSurface = Color(0xFFF6F7FA)
|
||||
private val onboardingBorder = Color(0xFFE5E7EC)
|
||||
private val onboardingBorderStrong = Color(0xFFD6DAE2)
|
||||
private val onboardingText = Color(0xFF17181C)
|
||||
private val onboardingTextSecondary = Color(0xFF4D5563)
|
||||
private val onboardingTextTertiary = Color(0xFF8A92A2)
|
||||
private val onboardingAccent = Color(0xFF1D5DD8)
|
||||
private val onboardingAccentSoft = Color(0xFFECF3FF)
|
||||
private val onboardingSuccess = Color(0xFF2F8C5A)
|
||||
private val onboardingWarning = Color(0xFFC8841A)
|
||||
private val onboardingCommandBg = Color(0xFF15171B)
|
||||
private val onboardingCommandBorder = Color(0xFF2B2E35)
|
||||
private val onboardingCommandAccent = Color(0xFF3FC97A)
|
||||
private val onboardingCommandText = Color(0xFFE8EAEE)
|
||||
private val onboardingBackgroundGradient: Brush
|
||||
@Composable get() = mobileBackgroundGradient
|
||||
|
||||
private val onboardingFontFamily =
|
||||
FontFamily(
|
||||
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
|
||||
Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium),
|
||||
Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold),
|
||||
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
|
||||
)
|
||||
private val onboardingSurface: Color
|
||||
@Composable get() = mobileCardSurface
|
||||
|
||||
private val onboardingDisplayStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 34.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.8).sp,
|
||||
)
|
||||
private val onboardingBorder: Color
|
||||
@Composable get() = mobileBorder
|
||||
|
||||
private val onboardingTitle1Style =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 30.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
)
|
||||
private val onboardingBorderStrong: Color
|
||||
@Composable get() = mobileBorderStrong
|
||||
|
||||
private val onboardingHeadlineStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
letterSpacing = (-0.1).sp,
|
||||
)
|
||||
private val onboardingText: Color
|
||||
@Composable get() = mobileText
|
||||
|
||||
private val onboardingBodyStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp,
|
||||
)
|
||||
private val onboardingTextSecondary: Color
|
||||
@Composable get() = mobileTextSecondary
|
||||
|
||||
private val onboardingCalloutStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
)
|
||||
private val onboardingTextTertiary: Color
|
||||
@Composable get() = mobileTextTertiary
|
||||
|
||||
private val onboardingCaption1Style =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.2.sp,
|
||||
)
|
||||
private val onboardingAccent: Color
|
||||
@Composable get() = mobileAccent
|
||||
|
||||
private val onboardingCaption2Style =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 14.sp,
|
||||
letterSpacing = 0.4.sp,
|
||||
)
|
||||
private val onboardingAccentSoft: Color
|
||||
@Composable get() = mobileAccentSoft
|
||||
|
||||
private val onboardingAccentBorderStrong: Color
|
||||
@Composable get() = mobileAccentBorderStrong
|
||||
|
||||
private val onboardingSuccess: Color
|
||||
@Composable get() = mobileSuccess
|
||||
|
||||
private val onboardingSuccessSoft: Color
|
||||
@Composable get() = mobileSuccessSoft
|
||||
|
||||
private val onboardingWarning: Color
|
||||
@Composable get() = mobileWarning
|
||||
|
||||
private val onboardingWarningSoft: Color
|
||||
@Composable get() = mobileWarningSoft
|
||||
|
||||
private val onboardingCommandBg: Color
|
||||
@Composable get() = mobileCodeBg
|
||||
|
||||
private val onboardingCommandBorder: Color
|
||||
@Composable get() = mobileCodeBorder
|
||||
|
||||
private val onboardingCommandAccent: Color
|
||||
@Composable get() = mobileCodeAccent
|
||||
|
||||
private val onboardingCommandText: Color
|
||||
@Composable get() = mobileCodeText
|
||||
|
||||
private val onboardingDisplayStyle: TextStyle
|
||||
get() = mobileDisplay
|
||||
|
||||
private val onboardingTitle1Style: TextStyle
|
||||
get() = mobileTitle1
|
||||
|
||||
private val onboardingHeadlineStyle: TextStyle
|
||||
get() = mobileHeadline
|
||||
|
||||
private val onboardingBodyStyle: TextStyle
|
||||
get() = mobileBody
|
||||
|
||||
private val onboardingCalloutStyle: TextStyle
|
||||
get() = mobileCallout
|
||||
|
||||
private val onboardingCaption1Style: TextStyle
|
||||
get() = mobileCaption1
|
||||
|
||||
private val onboardingCaption2Style: TextStyle
|
||||
get() = mobileCaption2
|
||||
|
||||
@Composable
|
||||
fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
|
|
@ -241,6 +226,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
var attemptedConnect by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val qrScannerOptions =
|
||||
remember {
|
||||
GmsBarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.build()
|
||||
}
|
||||
val qrScanner = remember(context, qrScannerOptions) { GmsBarcodeScanning.getClient(context, qrScannerOptions) }
|
||||
|
||||
val smsAvailable =
|
||||
remember(context) {
|
||||
|
|
@ -297,6 +289,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
rememberSaveable {
|
||||
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
|
||||
}
|
||||
var enableCallLog by
|
||||
rememberSaveable {
|
||||
mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
|
||||
}
|
||||
|
||||
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
|
||||
var pendingSpecialAccessToggle by remember { mutableStateOf<SpecialAccessToggle?>(null) }
|
||||
|
|
@ -313,6 +309,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
PermissionToggle.Calendar -> enableCalendar = enabled
|
||||
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
|
||||
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
|
||||
PermissionToggle.CallLog -> enableCallLog = enabled
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -340,6 +337,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
PermissionToggle.Sms ->
|
||||
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
|
||||
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
|
||||
}
|
||||
|
||||
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
|
||||
|
|
@ -361,6 +359,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
enableCalendar,
|
||||
enableMotion,
|
||||
enableSms,
|
||||
enableCallLog,
|
||||
smsAvailable,
|
||||
motionAvailable,
|
||||
) {
|
||||
|
|
@ -376,6 +375,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
if (enableCalendar) enabled += "Calendar"
|
||||
if (enableMotion && motionAvailable) enabled += "Motion"
|
||||
if (smsAvailable && enableSms) enabled += "SMS"
|
||||
if (enableCallLog) enabled += "Call Log"
|
||||
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
|
||||
}
|
||||
|
||||
|
|
@ -460,40 +460,32 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val qrScanLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
val contents = result.contents?.trim().orEmpty()
|
||||
if (contents.isEmpty()) {
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
val scannedSetupCode = resolveScannedSetupCode(contents)
|
||||
if (scannedSetupCode == null) {
|
||||
gatewayError = "QR code did not contain a valid setup code."
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
setupCode = scannedSetupCode
|
||||
gatewayInputMode = GatewayInputMode.SetupCode
|
||||
gatewayError = null
|
||||
attemptedConnect = false
|
||||
}
|
||||
|
||||
if (pendingTrust != null) {
|
||||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
title = { Text("Trust this gateway?") },
|
||||
containerColor = onboardingSurface,
|
||||
title = { Text("Trust this gateway?", style = onboardingHeadlineStyle, color = onboardingText) },
|
||||
text = {
|
||||
Text(
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingText,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
|
||||
TextButton(
|
||||
onClick = { viewModel.acceptGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = onboardingAccent),
|
||||
) {
|
||||
Text("Trust and continue")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
|
||||
TextButton(
|
||||
onClick = { viewModel.declineGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = onboardingTextSecondary),
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
|
|
@ -504,7 +496,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.verticalGradient(onboardingBackgroundGradient)),
|
||||
.background(onboardingBackgroundGradient),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
|
|
@ -552,14 +544,28 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
gatewayError = gatewayError,
|
||||
onScanQrClick = {
|
||||
gatewayError = null
|
||||
qrScanLauncher.launch(
|
||||
ScanOptions().apply {
|
||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
setPrompt("Scan OpenClaw onboarding QR")
|
||||
setBeepEnabled(false)
|
||||
setOrientationLocked(false)
|
||||
},
|
||||
)
|
||||
qrScanner.startScan()
|
||||
.addOnSuccessListener { barcode ->
|
||||
val contents = barcode.rawValue?.trim().orEmpty()
|
||||
if (contents.isEmpty()) {
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
val scannedSetupCode = resolveScannedSetupCode(contents)
|
||||
if (scannedSetupCode == null) {
|
||||
gatewayError = "QR code did not contain a valid setup code."
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
setupCode = scannedSetupCode
|
||||
gatewayInputMode = GatewayInputMode.SetupCode
|
||||
gatewayError = null
|
||||
attemptedConnect = false
|
||||
}
|
||||
.addOnCanceledListener {
|
||||
// User dismissed the scanner; preserve current form state.
|
||||
}
|
||||
.addOnFailureListener {
|
||||
gatewayError = qrScannerErrorMessage()
|
||||
}
|
||||
},
|
||||
onAdvancedOpenChange = { gatewayAdvancedOpen = it },
|
||||
onInputModeChange = {
|
||||
|
|
@ -598,6 +604,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
motionPermissionRequired = motionPermissionRequired,
|
||||
enableSms = enableSms,
|
||||
smsAvailable = smsAvailable,
|
||||
enableCallLog = enableCallLog,
|
||||
context = context,
|
||||
onDiscoveryChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
|
|
@ -695,6 +702,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
)
|
||||
}
|
||||
},
|
||||
onCallLogChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.CallLog,
|
||||
checked,
|
||||
listOf(Manifest.permission.READ_CALL_LOG),
|
||||
)
|
||||
},
|
||||
)
|
||||
OnboardingStep.FinalCheck ->
|
||||
FinalStep(
|
||||
|
|
@ -750,13 +764,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
onClick = { step = OnboardingStep.Gateway },
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
|
|
@ -802,13 +810,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
},
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
|
|
@ -822,13 +824,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
},
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
|
|
@ -839,13 +835,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
onClick = { viewModel.setOnboardingCompleted(true) },
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Finish", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
|
|
@ -878,13 +868,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
},
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Connect", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
|
|
@ -896,6 +880,36 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun onboardingPrimaryButtonColors() =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.9f),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun onboardingTextFieldColors() =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun onboardingSwitchColors() =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
uncheckedTrackColor = onboardingBorderStrong,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedThumbColor = Color.White,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun StepRail(current: OnboardingStep) {
|
||||
val steps = OnboardingStep.entries
|
||||
|
|
@ -1000,11 +1014,7 @@ private fun GatewayStep(
|
|||
onClick = onScanQrClick,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
|
|
@ -1054,15 +1064,7 @@ private fun GatewayStep(
|
|||
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
onboardingTextFieldColors(),
|
||||
)
|
||||
if (!resolvedEndpoint.isNullOrBlank()) {
|
||||
ResolvedEndpoint(endpoint = resolvedEndpoint)
|
||||
|
|
@ -1092,15 +1094,7 @@ private fun GatewayStep(
|
|||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
onboardingTextFieldColors(),
|
||||
)
|
||||
|
||||
Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
|
|
@ -1114,15 +1108,7 @@ private fun GatewayStep(
|
|||
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
onboardingTextFieldColors(),
|
||||
)
|
||||
|
||||
Row(
|
||||
|
|
@ -1138,12 +1124,7 @@ private fun GatewayStep(
|
|||
checked = manualTls,
|
||||
onCheckedChange = onManualTlsChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
uncheckedTrackColor = onboardingBorderStrong,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedThumbColor = Color.White,
|
||||
),
|
||||
onboardingSwitchColors(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1158,15 +1139,7 @@ private fun GatewayStep(
|
|||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
onboardingTextFieldColors(),
|
||||
)
|
||||
|
||||
Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
|
|
@ -1180,15 +1153,7 @@ private fun GatewayStep(
|
|||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
onboardingTextFieldColors(),
|
||||
)
|
||||
|
||||
if (!manualResolvedEndpoint.isNullOrBlank()) {
|
||||
|
|
@ -1256,7 +1221,7 @@ private fun GatewayModeChip(
|
|||
containerColor = if (active) onboardingAccent else onboardingSurface,
|
||||
contentColor = if (active) Color.White else onboardingText,
|
||||
),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, if (active) Color(0xFF184DAF) else onboardingBorderStrong),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, if (active) onboardingAccentBorderStrong else onboardingBorderStrong),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
|
|
@ -1334,6 +1299,7 @@ private fun PermissionsStep(
|
|||
motionPermissionRequired: Boolean,
|
||||
enableSms: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
enableCallLog: Boolean,
|
||||
context: Context,
|
||||
onDiscoveryChange: (Boolean) -> Unit,
|
||||
onLocationChange: (Boolean) -> Unit,
|
||||
|
|
@ -1346,6 +1312,7 @@ private fun PermissionsStep(
|
|||
onCalendarChange: (Boolean) -> Unit,
|
||||
onMotionChange: (Boolean) -> Unit,
|
||||
onSmsChange: (Boolean) -> Unit,
|
||||
onCallLogChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
|
||||
val locationGranted =
|
||||
|
|
@ -1476,6 +1443,15 @@ private fun PermissionsStep(
|
|||
onCheckedChange = onSmsChange,
|
||||
)
|
||||
}
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Call Log",
|
||||
subtitle = "callLog.search",
|
||||
checked = enableCallLog,
|
||||
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
|
||||
onCheckedChange = onCallLogChange,
|
||||
)
|
||||
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1519,13 +1495,7 @@ private fun PermissionToggleRow(
|
|||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = enabled,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
uncheckedTrackColor = onboardingBorderStrong,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedThumbColor = Color.White,
|
||||
),
|
||||
colors = onboardingSwitchColors(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1600,7 +1570,7 @@ private fun FinalStep(
|
|||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color(0xFFEEF9F3),
|
||||
color = onboardingSuccessSoft,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingSuccess.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -1636,7 +1606,7 @@ private fun FinalStep(
|
|||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color(0xFFFFF8EC),
|
||||
color = onboardingWarningSoft,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -1785,6 +1755,10 @@ private fun isPermissionGranted(context: Context, permission: String): Boolean {
|
|||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun qrScannerErrorMessage(): String {
|
||||
return "Google Code Scanner could not start. Update Google Play services or use the setup code manually."
|
||||
}
|
||||
|
||||
private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
return DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
|
|
@ -13,8 +14,11 @@ fun OpenClawTheme(content: @Composable () -> Unit) {
|
|||
val context = LocalContext.current
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
|
||||
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
CompositionLocalProvider(LocalMobileColors provides mobileColors) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -134,43 +134,14 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||
@Composable
|
||||
private fun ScreenTabScreen(viewModel: MainViewModel) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val canvasUrl by viewModel.canvasCurrentUrl.collectAsState()
|
||||
val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState()
|
||||
val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState()
|
||||
val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
|
||||
val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true
|
||||
val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated))
|
||||
val restoreCtaText =
|
||||
when {
|
||||
canvasRehydratePending -> "Restore requested. Waiting for agent…"
|
||||
!canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!!
|
||||
else -> "Canvas reset. Tap to restore dashboard."
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
|
||||
if (showRestoreCta) {
|
||||
Surface(
|
||||
onClick = {
|
||||
if (canvasRehydratePending) return@Surface
|
||||
viewModel.requestCanvasRehydrate(source = "screen_tab_cta")
|
||||
},
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = mobileSurface.copy(alpha = 0.9f),
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = restoreCtaText,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Medium),
|
||||
color = mobileText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,28 +159,28 @@ private fun TopStatusBar(
|
|||
mobileSuccessSoft,
|
||||
mobileSuccess,
|
||||
mobileSuccess,
|
||||
Color(0xFFCFEBD8),
|
||||
LocalMobileColors.current.chipBorderConnected,
|
||||
)
|
||||
StatusVisual.Connecting ->
|
||||
listOf(
|
||||
mobileAccentSoft,
|
||||
mobileAccent,
|
||||
mobileAccent,
|
||||
Color(0xFFD5E2FA),
|
||||
LocalMobileColors.current.chipBorderConnecting,
|
||||
)
|
||||
StatusVisual.Warning ->
|
||||
listOf(
|
||||
mobileWarningSoft,
|
||||
mobileWarning,
|
||||
mobileWarning,
|
||||
Color(0xFFEED8B8),
|
||||
LocalMobileColors.current.chipBorderWarning,
|
||||
)
|
||||
StatusVisual.Error ->
|
||||
listOf(
|
||||
mobileDangerSoft,
|
||||
mobileDanger,
|
||||
mobileDanger,
|
||||
Color(0xFFF3C8C8),
|
||||
LocalMobileColors.current.chipBorderError,
|
||||
)
|
||||
StatusVisual.Offline ->
|
||||
listOf(
|
||||
|
|
@ -278,7 +249,7 @@ private fun BottomTabBar(
|
|||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.White.copy(alpha = 0.97f),
|
||||
color = mobileCardSurface.copy(alpha = 0.97f),
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
shadowElevation = 6.dp,
|
||||
|
|
@ -299,7 +270,7 @@ private fun BottomTabBar(
|
|||
modifier = Modifier.weight(1f).heightIn(min = 58.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (active) mobileAccentSoft else Color.Transparent,
|
||||
border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null,
|
||||
border = if (active) BorderStroke(1.dp, LocalMobileColors.current.chipBorderConnecting) else null,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Column(
|
||||
|
|
|
|||
|
|
@ -218,6 +218,18 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||
calendarPermissionGranted = readOk && writeOk
|
||||
}
|
||||
|
||||
var callLogPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val callLogPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
callLogPermissionGranted = granted
|
||||
}
|
||||
|
||||
var motionPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
|
|
@ -266,6 +278,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
callLogPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
motionPermissionGranted =
|
||||
!motionPermissionRequired ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||
|
|
@ -601,6 +616,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Call Log", style = mobileHeadline) },
|
||||
supportingContent = { Text("Search recent call history.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (callLogPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (callLogPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (motionAvailable) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
|
|
@ -736,11 +776,12 @@ private fun settingsTextFieldColors() =
|
|||
cursorColor = mobileAccent,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Modifier.settingsRowModifier() =
|
||||
this
|
||||
.fillMaxWidth()
|
||||
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
|
||||
.background(Color.White, RoundedCornerShape(14.dp))
|
||||
.background(mobileCardSurface, RoundedCornerShape(14.dp))
|
||||
|
||||
@Composable
|
||||
private fun settingsPrimaryButtonColors() =
|
||||
|
|
@ -781,7 +822,7 @@ private fun openNotificationListenerSettings(context: Context) {
|
|||
private fun hasNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 33) return true
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
|
|
@ -791,5 +832,5 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
|
|||
private fun hasMotionCapabilities(context: Context): Boolean {
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
|
||||
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
|
|||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.90f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = if (isUser) mobileAccentSoft else Color.White,
|
||||
color = if (isUser) mobileAccentSoft else mobileCardSurface,
|
||||
border = BorderStroke(1.dp, if (isUser) mobileAccent else mobileBorderStrong),
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -391,7 +391,7 @@ private fun VoiceThinkingBubble() {
|
|||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.68f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -46,11 +46,13 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ai.openclaw.app.ui.mobileAccent
|
||||
import ai.openclaw.app.ui.mobileAccentBorderStrong
|
||||
import ai.openclaw.app.ui.mobileAccentSoft
|
||||
import ai.openclaw.app.ui.mobileBorder
|
||||
import ai.openclaw.app.ui.mobileBorderStrong
|
||||
import ai.openclaw.app.ui.mobileCallout
|
||||
import ai.openclaw.app.ui.mobileCaption1
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import ai.openclaw.app.ui.mobileHeadline
|
||||
import ai.openclaw.app.ui.mobileSurface
|
||||
import ai.openclaw.app.ui.mobileText
|
||||
|
|
@ -110,7 +112,7 @@ fun ChatComposer(
|
|||
Surface(
|
||||
onClick = { showThinkingMenu = true },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -126,7 +128,15 @@ fun ChatComposer(
|
|||
}
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
||||
DropdownMenu(
|
||||
expanded = showThinkingMenu,
|
||||
onDismissRequest = { showThinkingMenu = false },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
containerColor = mobileCardSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 8.dp,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
|
|
@ -177,7 +187,7 @@ fun ChatComposer(
|
|||
disabledContainerColor = mobileBorderStrong,
|
||||
disabledContentColor = mobileTextTertiary,
|
||||
),
|
||||
border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong),
|
||||
border = BorderStroke(1.dp, if (canSend) mobileAccentBorderStrong else mobileBorderStrong),
|
||||
) {
|
||||
if (sendBusy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White)
|
||||
|
|
@ -211,9 +221,9 @@ private fun SecondaryActionButton(
|
|||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
containerColor = mobileCardSurface,
|
||||
contentColor = mobileTextSecondary,
|
||||
disabledContainerColor = Color.White,
|
||||
disabledContainerColor = mobileCardSurface,
|
||||
disabledContentColor = mobileTextTertiary,
|
||||
),
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
|
|
@ -303,7 +313,7 @@ private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
|
|||
Surface(
|
||||
onClick = onRemove,
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ private val markdownParser: Parser by lazy {
|
|||
@Composable
|
||||
fun ChatMarkdown(text: String, textColor: Color) {
|
||||
val document = remember(text) { markdownParser.parse(text) as Document }
|
||||
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText)
|
||||
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText, linkColor = mobileAccent, baseCallout = mobileCallout)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
RenderMarkdownBlocks(
|
||||
|
|
@ -124,7 +124,7 @@ private fun RenderMarkdownBlocks(
|
|||
val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) }
|
||||
Text(
|
||||
text = headingText,
|
||||
style = headingStyle(current.level),
|
||||
style = headingStyle(current.level, inlineStyles.baseCallout),
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
|
|
@ -231,7 +231,7 @@ private fun RenderParagraph(
|
|||
|
||||
Text(
|
||||
text = annotated,
|
||||
style = mobileCallout,
|
||||
style = inlineStyles.baseCallout,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
|
|
@ -315,7 +315,7 @@ private fun RenderListItem(
|
|||
) {
|
||||
Text(
|
||||
text = marker,
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
|
||||
style = inlineStyles.baseCallout.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = textColor,
|
||||
modifier = Modifier.width(24.dp),
|
||||
)
|
||||
|
|
@ -360,7 +360,7 @@ private fun RenderTableBlock(
|
|||
val cell = row.cells.getOrNull(index) ?: AnnotatedString("")
|
||||
Text(
|
||||
text = cell,
|
||||
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout,
|
||||
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else inlineStyles.baseCallout,
|
||||
color = textColor,
|
||||
modifier = Modifier
|
||||
.border(1.dp, mobileTextSecondary.copy(alpha = 0.22f))
|
||||
|
|
@ -417,6 +417,7 @@ private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): Annot
|
|||
node = start,
|
||||
inlineCodeBg = inlineStyles.inlineCodeBg,
|
||||
inlineCodeColor = inlineStyles.inlineCodeColor,
|
||||
linkColor = inlineStyles.linkColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -425,6 +426,7 @@ private fun AnnotatedString.Builder.appendInlineNode(
|
|||
node: Node?,
|
||||
inlineCodeBg: Color,
|
||||
inlineCodeColor: Color,
|
||||
linkColor: Color,
|
||||
) {
|
||||
var current = node
|
||||
while (current != null) {
|
||||
|
|
@ -445,27 +447,27 @@ private fun AnnotatedString.Builder.appendInlineNode(
|
|||
}
|
||||
is Emphasis -> {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
}
|
||||
}
|
||||
is StrongEmphasis -> {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
}
|
||||
}
|
||||
is Strikethrough -> {
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
}
|
||||
}
|
||||
is Link -> {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = mobileAccent,
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
}
|
||||
}
|
||||
is MarkdownImage -> {
|
||||
|
|
@ -482,7 +484,7 @@ private fun AnnotatedString.Builder.appendInlineNode(
|
|||
}
|
||||
}
|
||||
else -> {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
}
|
||||
}
|
||||
current = current.next
|
||||
|
|
@ -519,19 +521,21 @@ private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
|
|||
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
|
||||
}
|
||||
|
||||
private fun headingStyle(level: Int): TextStyle {
|
||||
private fun headingStyle(level: Int, baseCallout: TextStyle): TextStyle {
|
||||
return when (level.coerceIn(1, 6)) {
|
||||
1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
|
||||
2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
|
||||
3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
|
||||
4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
|
||||
else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold)
|
||||
1 -> baseCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
|
||||
2 -> baseCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
|
||||
3 -> baseCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
|
||||
4 -> baseCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
|
||||
else -> baseCallout.copy(fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
private data class InlineStyles(
|
||||
val inlineCodeBg: Color,
|
||||
val inlineCodeColor: Color,
|
||||
val linkColor: Color,
|
||||
val baseCallout: TextStyle,
|
||||
)
|
||||
|
||||
private data class TableRenderRow(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import ai.openclaw.app.chat.ChatMessage
|
|||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.ui.mobileBorder
|
||||
import ai.openclaw.app.ui.mobileCallout
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import ai.openclaw.app.ui.mobileHeadline
|
||||
import ai.openclaw.app.ui.mobileText
|
||||
import ai.openclaw.app.ui.mobileTextSecondary
|
||||
|
|
@ -85,7 +86,7 @@ private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) {
|
|||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f),
|
||||
color = mobileCardSurface.copy(alpha = 0.9f),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
androidx.compose.foundation.layout.Column(
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ import ai.openclaw.app.ui.mobileBorderStrong
|
|||
import ai.openclaw.app.ui.mobileCallout
|
||||
import ai.openclaw.app.ui.mobileCaption1
|
||||
import ai.openclaw.app.ui.mobileCaption2
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import ai.openclaw.app.ui.mobileCodeBg
|
||||
import ai.openclaw.app.ui.mobileCodeBorder
|
||||
import ai.openclaw.app.ui.mobileCodeText
|
||||
import ai.openclaw.app.ui.mobileHeadline
|
||||
import ai.openclaw.app.ui.mobileText
|
||||
|
|
@ -194,6 +196,7 @@ fun ChatStreamingAssistantBubble(text: String) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun bubbleStyle(role: String): ChatBubbleStyle {
|
||||
return when (role) {
|
||||
"user" ->
|
||||
|
|
@ -215,7 +218,7 @@ private fun bubbleStyle(role: String): ChatBubbleStyle {
|
|||
else ->
|
||||
ChatBubbleStyle(
|
||||
alignEnd = false,
|
||||
containerColor = Color.White,
|
||||
containerColor = mobileCardSurface,
|
||||
borderColor = mobileBorderStrong,
|
||||
roleColor = mobileTextSecondary,
|
||||
)
|
||||
|
|
@ -239,7 +242,7 @@ private fun ChatBase64Image(base64: String, mimeType: String?) {
|
|||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Image(
|
||||
|
|
@ -277,7 +280,7 @@ fun ChatCodeBlock(code: String, language: String?) {
|
|||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = mobileCodeBg,
|
||||
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
|
||||
border = BorderStroke(1.dp, mobileCodeBorder),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
|
|
|
|||
|
|
@ -36,12 +36,15 @@ import ai.openclaw.app.MainViewModel
|
|||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.ui.mobileAccent
|
||||
import ai.openclaw.app.ui.mobileAccentBorderStrong
|
||||
import ai.openclaw.app.ui.mobileBorder
|
||||
import ai.openclaw.app.ui.mobileBorderStrong
|
||||
import ai.openclaw.app.ui.mobileCallout
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import ai.openclaw.app.ui.mobileCaption1
|
||||
import ai.openclaw.app.ui.mobileCaption2
|
||||
import ai.openclaw.app.ui.mobileDanger
|
||||
import ai.openclaw.app.ui.mobileDangerSoft
|
||||
import ai.openclaw.app.ui.mobileText
|
||||
import ai.openclaw.app.ui.mobileTextSecondary
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
|
@ -168,8 +171,8 @@ private fun ChatThreadSelector(
|
|||
Surface(
|
||||
onClick = { onSelectSession(entry.key) },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (active) mobileAccent else Color.White,
|
||||
border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong),
|
||||
color = if (active) mobileAccent else mobileCardSurface,
|
||||
border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
|
|
@ -190,7 +193,7 @@ private fun ChatThreadSelector(
|
|||
private fun ChatErrorRail(errorText: String) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = androidx.compose.ui.graphics.Color.White,
|
||||
color = mobileDangerSoft,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.OpenClawNode" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
package ai.openclaw.app.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CallLogHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@Test
|
||||
fun handleCallLogSearch_requiresPermission() {
|
||||
val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = false))
|
||||
|
||||
val result = handler.handleCallLogSearch(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CALL_LOG_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_rejectsInvalidJson() {
|
||||
val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = true))
|
||||
|
||||
val result = handler.handleCallLogSearch("invalid json")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_returnsCallLogs() {
|
||||
val callLog =
|
||||
CallLogRecord(
|
||||
number = "+123456",
|
||||
cachedName = "lixuankai",
|
||||
date = 1709280000000L,
|
||||
duration = 60L,
|
||||
type = 1,
|
||||
)
|
||||
val handler =
|
||||
CallLogHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
|
||||
)
|
||||
|
||||
val result = handler.handleCallLogSearch("""{"limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val callLogs = payload.getValue("callLogs").jsonArray
|
||||
assertEquals(1, callLogs.size)
|
||||
assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
|
||||
assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
|
||||
assertEquals(1709280000000L, callLogs.first().jsonObject.getValue("date").jsonPrimitive.content.toLong())
|
||||
assertEquals(60L, callLogs.first().jsonObject.getValue("duration").jsonPrimitive.content.toLong())
|
||||
assertEquals(1, callLogs.first().jsonObject.getValue("type").jsonPrimitive.content.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_withFilters() {
|
||||
val callLog =
|
||||
CallLogRecord(
|
||||
number = "+123456",
|
||||
cachedName = "lixuankai",
|
||||
date = 1709280000000L,
|
||||
duration = 120L,
|
||||
type = 2,
|
||||
)
|
||||
val handler =
|
||||
CallLogHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
|
||||
)
|
||||
|
||||
val result = handler.handleCallLogSearch(
|
||||
"""{"number":"123456","cachedName":"lixuankai","dateStart":1709270000000,"dateEnd":1709290000000,"duration":120,"type":2}"""
|
||||
)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val callLogs = payload.getValue("callLogs").jsonArray
|
||||
assertEquals(1, callLogs.size)
|
||||
assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_withPagination() {
|
||||
val callLogs =
|
||||
listOf(
|
||||
CallLogRecord(
|
||||
number = "+123456",
|
||||
cachedName = "lixuankai",
|
||||
date = 1709280000000L,
|
||||
duration = 60L,
|
||||
type = 1,
|
||||
),
|
||||
CallLogRecord(
|
||||
number = "+654321",
|
||||
cachedName = "lixuankai2",
|
||||
date = 1709280001000L,
|
||||
duration = 120L,
|
||||
type = 2,
|
||||
),
|
||||
)
|
||||
val handler =
|
||||
CallLogHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCallLogDataSource(canRead = true, searchResults = callLogs),
|
||||
)
|
||||
|
||||
val result = handler.handleCallLogSearch("""{"limit":1,"offset":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val callLogsResult = payload.getValue("callLogs").jsonArray
|
||||
assertEquals(1, callLogsResult.size)
|
||||
assertEquals("lixuankai2", callLogsResult.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_withDefaultParams() {
|
||||
val callLog =
|
||||
CallLogRecord(
|
||||
number = "+123456",
|
||||
cachedName = "lixuankai",
|
||||
date = 1709280000000L,
|
||||
duration = 60L,
|
||||
type = 1,
|
||||
)
|
||||
val handler =
|
||||
CallLogHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
|
||||
)
|
||||
|
||||
val result = handler.handleCallLogSearch(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val callLogs = payload.getValue("callLogs").jsonArray
|
||||
assertEquals(1, callLogs.size)
|
||||
assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_withNullFields() {
|
||||
val callLog =
|
||||
CallLogRecord(
|
||||
number = null,
|
||||
cachedName = null,
|
||||
date = 1709280000000L,
|
||||
duration = 60L,
|
||||
type = 1,
|
||||
)
|
||||
val handler =
|
||||
CallLogHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
|
||||
)
|
||||
|
||||
val result = handler.handleCallLogSearch("""{"limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val callLogs = payload.getValue("callLogs").jsonArray
|
||||
assertEquals(1, callLogs.size)
|
||||
// Verify null values are properly serialized
|
||||
val callLogObj = callLogs.first().jsonObject
|
||||
assertTrue(callLogObj.containsKey("number"))
|
||||
assertTrue(callLogObj.containsKey("cachedName"))
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeCallLogDataSource(
|
||||
private val canRead: Boolean,
|
||||
private val searchResults: List<CallLogRecord> = emptyList(),
|
||||
) : CallLogDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean = canRead
|
||||
|
||||
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
|
||||
val startIndex = request.offset.coerceAtLeast(0)
|
||||
val endIndex = (startIndex + request.limit).coerceAtMost(searchResults.size)
|
||||
return if (startIndex < searchResults.size) {
|
||||
searchResults.subList(startIndex, endIndex)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,7 @@ class DeviceHandlerTest {
|
|||
"photos",
|
||||
"contacts",
|
||||
"calendar",
|
||||
"callLog",
|
||||
"motion",
|
||||
)
|
||||
for (key in expected) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package ai.openclaw.app.node
|
|||
|
||||
import ai.openclaw.app.protocol.OpenClawCalendarCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCapability
|
||||
import ai.openclaw.app.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
|
|
@ -25,6 +26,7 @@ class InvokeCommandRegistryTest {
|
|||
OpenClawCapability.Photos.rawValue,
|
||||
OpenClawCapability.Contacts.rawValue,
|
||||
OpenClawCapability.Calendar.rawValue,
|
||||
OpenClawCapability.CallLog.rawValue,
|
||||
)
|
||||
|
||||
private val optionalCapabilities =
|
||||
|
|
@ -50,6 +52,7 @@ class InvokeCommandRegistryTest {
|
|||
OpenClawContactsCommand.Add.rawValue,
|
||||
OpenClawCalendarCommand.Events.rawValue,
|
||||
OpenClawCalendarCommand.Add.rawValue,
|
||||
OpenClawCallLogCommand.Search.rawValue,
|
||||
)
|
||||
|
||||
private val optionalCommands =
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class OpenClawProtocolConstantsTest {
|
|||
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
|
||||
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
|
||||
assertEquals("motion", OpenClawCapability.Motion.rawValue)
|
||||
assertEquals("callLog", OpenClawCapability.CallLog.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -84,4 +85,9 @@ class OpenClawProtocolConstantsTest {
|
|||
assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
|
||||
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callLogCommandsUseStableStrings() {
|
||||
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,30 @@ class GatewayConfigResolverTest {
|
|||
assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveGatewayConnectConfigDefaultsPortlessWssSetupCodeTo443() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"wss://gateway.example","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved =
|
||||
resolveGatewayConnectConfig(
|
||||
useSetupCode = true,
|
||||
setupCode = setupCode,
|
||||
manualHost = "",
|
||||
manualPort = "",
|
||||
manualTls = true,
|
||||
fallbackToken = "shared-token",
|
||||
fallbackPassword = "shared-password",
|
||||
)
|
||||
|
||||
assertEquals("gateway.example", resolved?.host)
|
||||
assertEquals(443, resolved?.port)
|
||||
assertEquals(true, resolved?.tls)
|
||||
assertEquals("bootstrap-1", resolved?.bootstrapToken)
|
||||
assertNull(resolved?.token?.takeIf { it.isNotEmpty() })
|
||||
assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
private fun encodeSetupCode(payloadJson: String): String {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const androidDir = join(scriptDir, "..");
|
||||
const buildGradlePath = join(androidDir, "app", "build.gradle.kts");
|
||||
const bundlePath = join(androidDir, "app", "build", "outputs", "bundle", "release", "app-release.aab");
|
||||
|
||||
type VersionState = {
|
||||
versionName: string;
|
||||
versionCode: number;
|
||||
};
|
||||
|
||||
type ParsedVersionMatches = {
|
||||
versionNameMatch: RegExpMatchArray;
|
||||
versionCodeMatch: RegExpMatchArray;
|
||||
};
|
||||
|
||||
function formatVersionName(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return `${year}.${month}.${day}`;
|
||||
}
|
||||
|
||||
function formatVersionCodePrefix(date: Date): string {
|
||||
const year = date.getFullYear().toString();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
function parseVersionMatches(buildGradleText: string): ParsedVersionMatches {
|
||||
const versionCodeMatch = buildGradleText.match(/versionCode = (\d+)/);
|
||||
const versionNameMatch = buildGradleText.match(/versionName = "([^"]+)"/);
|
||||
if (!versionCodeMatch || !versionNameMatch) {
|
||||
throw new Error(`Couldn't parse versionName/versionCode from ${buildGradlePath}`);
|
||||
}
|
||||
return { versionCodeMatch, versionNameMatch };
|
||||
}
|
||||
|
||||
function resolveNextVersionCode(currentVersionCode: number, todayPrefix: string): number {
|
||||
const currentRaw = currentVersionCode.toString();
|
||||
let nextSuffix = 0;
|
||||
|
||||
if (currentRaw.startsWith(todayPrefix)) {
|
||||
const suffixRaw = currentRaw.slice(todayPrefix.length);
|
||||
nextSuffix = (suffixRaw ? Number.parseInt(suffixRaw, 10) : 0) + 1;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(nextSuffix) || nextSuffix < 0 || nextSuffix > 99) {
|
||||
throw new Error(
|
||||
`Can't auto-bump Android versionCode for ${todayPrefix}: next suffix ${nextSuffix} is invalid`,
|
||||
);
|
||||
}
|
||||
|
||||
return Number.parseInt(`${todayPrefix}${nextSuffix.toString().padStart(2, "0")}`, 10);
|
||||
}
|
||||
|
||||
function resolveNextVersion(buildGradleText: string, date: Date): VersionState {
|
||||
const { versionCodeMatch } = parseVersionMatches(buildGradleText);
|
||||
const currentVersionCode = Number.parseInt(versionCodeMatch[1] ?? "", 10);
|
||||
if (!Number.isInteger(currentVersionCode)) {
|
||||
throw new Error(`Invalid Android versionCode in ${buildGradlePath}`);
|
||||
}
|
||||
|
||||
const versionName = formatVersionName(date);
|
||||
const versionCode = resolveNextVersionCode(currentVersionCode, formatVersionCodePrefix(date));
|
||||
return { versionName, versionCode };
|
||||
}
|
||||
|
||||
function updateBuildGradleVersions(buildGradleText: string, nextVersion: VersionState): string {
|
||||
return buildGradleText
|
||||
.replace(/versionCode = \d+/, `versionCode = ${nextVersion.versionCode}`)
|
||||
.replace(/versionName = "[^"]+"/, `versionName = "${nextVersion.versionName}"`);
|
||||
}
|
||||
|
||||
async function sha256Hex(path: string): Promise<string> {
|
||||
const buffer = await Bun.file(path).arrayBuffer();
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function verifyBundleSignature(path: string): Promise<void> {
|
||||
await $`jarsigner -verify ${path}`.quiet();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const buildGradleFile = Bun.file(buildGradlePath);
|
||||
const originalText = await buildGradleFile.text();
|
||||
const nextVersion = resolveNextVersion(originalText, new Date());
|
||||
const updatedText = updateBuildGradleVersions(originalText, nextVersion);
|
||||
|
||||
if (updatedText === originalText) {
|
||||
throw new Error("Android version bump produced no change");
|
||||
}
|
||||
|
||||
console.log(`Android versionName -> ${nextVersion.versionName}`);
|
||||
console.log(`Android versionCode -> ${nextVersion.versionCode}`);
|
||||
|
||||
await Bun.write(buildGradlePath, updatedText);
|
||||
|
||||
try {
|
||||
await $`./gradlew :app:bundleRelease`.cwd(androidDir);
|
||||
} catch (error) {
|
||||
await Bun.write(buildGradlePath, originalText);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const bundleFile = Bun.file(bundlePath);
|
||||
if (!(await bundleFile.exists())) {
|
||||
throw new Error(`Signed bundle missing at ${bundlePath}`);
|
||||
}
|
||||
|
||||
await verifyBundleSignature(bundlePath);
|
||||
const hash = await sha256Hex(bundlePath);
|
||||
|
||||
console.log(`Signed AAB: ${bundlePath}`);
|
||||
console.log(`SHA-256: ${hash}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
// Shared iOS version defaults.
|
||||
// Generated overrides live in build/Version.xcconfig (git-ignored).
|
||||
|
||||
OPENCLAW_GATEWAY_VERSION = 0.0.0
|
||||
OPENCLAW_MARKETING_VERSION = 0.0.0
|
||||
OPENCLAW_BUILD_VERSION = 0
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.3.14
|
||||
OPENCLAW_MARKETING_VERSION = 2026.3.14
|
||||
OPENCLAW_BUILD_VERSION = 202603140
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ final class ShareViewController: UIViewController {
|
|||
try await gateway.connect(
|
||||
url: url,
|
||||
token: config.token,
|
||||
bootstrapToken: nil,
|
||||
password: config.password,
|
||||
connectOptions: makeOptions("openclaw-ios"),
|
||||
sessionBox: nil,
|
||||
|
|
@ -208,6 +209,7 @@ final class ShareViewController: UIViewController {
|
|||
try await gateway.connect(
|
||||
url: url,
|
||||
token: config.token,
|
||||
bootstrapToken: nil,
|
||||
password: config.password,
|
||||
connectOptions: makeOptions("moltbot-ios"),
|
||||
sessionBox: nil,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ enum OnboardingConnectionMode: String, CaseIterable {
|
|||
|
||||
enum OnboardingStateStore {
|
||||
private static let completedDefaultsKey = "onboarding.completed"
|
||||
private static let firstRunIntroSeenDefaultsKey = "onboarding.first_run_intro_seen"
|
||||
private static let lastModeDefaultsKey = "onboarding.last_mode"
|
||||
private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time"
|
||||
|
||||
|
|
@ -39,10 +40,23 @@ enum OnboardingStateStore {
|
|||
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
|
||||
}
|
||||
|
||||
static func shouldPresentFirstRunIntro(defaults: UserDefaults = .standard) -> Bool {
|
||||
!defaults.bool(forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func markFirstRunIntroSeen(defaults: UserDefaults = .standard) {
|
||||
defaults.set(true, forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func markIncomplete(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
||||
}
|
||||
|
||||
static func reset(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
||||
defaults.set(false, forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
|
||||
let raw = defaults.string(forKey: Self.lastModeDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import SwiftUI
|
|||
import UIKit
|
||||
|
||||
private enum OnboardingStep: Int, CaseIterable {
|
||||
case intro
|
||||
case welcome
|
||||
case mode
|
||||
case connect
|
||||
|
|
@ -29,7 +30,8 @@ private enum OnboardingStep: Int, CaseIterable {
|
|||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .welcome: "Welcome"
|
||||
case .intro: "Welcome"
|
||||
case .welcome: "Connect Gateway"
|
||||
case .mode: "Connection Mode"
|
||||
case .connect: "Connect"
|
||||
case .auth: "Authentication"
|
||||
|
|
@ -38,7 +40,7 @@ private enum OnboardingStep: Int, CaseIterable {
|
|||
}
|
||||
|
||||
var canGoBack: Bool {
|
||||
self != .welcome && self != .success
|
||||
self != .intro && self != .welcome && self != .success
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +51,7 @@ struct OnboardingWizardView: View {
|
|||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
|
||||
@AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false
|
||||
@State private var step: OnboardingStep = .welcome
|
||||
@State private var step: OnboardingStep
|
||||
@State private var selectedMode: OnboardingConnectionMode?
|
||||
@State private var manualHost: String = ""
|
||||
@State private var manualPort: Int = 18789
|
||||
|
|
@ -58,11 +60,10 @@ struct OnboardingWizardView: View {
|
|||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
@State private var connectMessage: String?
|
||||
@State private var statusLine: String = "Scan the QR code from your gateway to connect."
|
||||
@State private var statusLine: String = "In your OpenClaw chat, run /pair qr, then scan the code here."
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var issue: GatewayConnectionIssue = .none
|
||||
@State private var didMarkCompleted = false
|
||||
@State private var didAutoPresentQR = false
|
||||
@State private var pairingRequestId: String?
|
||||
@State private var discoveryRestartTask: Task<Void, Never>?
|
||||
@State private var showQRScanner: Bool = false
|
||||
|
|
@ -74,14 +75,23 @@ struct OnboardingWizardView: View {
|
|||
let allowSkip: Bool
|
||||
let onClose: () -> Void
|
||||
|
||||
init(allowSkip: Bool, onClose: @escaping () -> Void) {
|
||||
self.allowSkip = allowSkip
|
||||
self.onClose = onClose
|
||||
_step = State(
|
||||
initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome)
|
||||
}
|
||||
|
||||
private var isFullScreenStep: Bool {
|
||||
self.step == .welcome || self.step == .success
|
||||
self.step == .intro || self.step == .welcome || self.step == .success
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
switch self.step {
|
||||
case .intro:
|
||||
self.introStep
|
||||
case .welcome:
|
||||
self.welcomeStep
|
||||
case .success:
|
||||
|
|
@ -293,6 +303,83 @@ struct OnboardingWizardView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var introStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "iphone.gen3")
|
||||
.font(.system(size: 60, weight: .semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
Text("Welcome to OpenClaw")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
Text("Turn this iPhone into a secure OpenClaw node for chat, voice, camera, and device tools.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Label("Connect to your gateway", systemImage: "link")
|
||||
Label("Choose device permissions", systemImage: "hand.raised")
|
||||
Label("Use OpenClaw from your phone", systemImage: "message.fill")
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 24)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Security notice")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"The connected OpenClaw agent can use device capabilities you enable, such as camera, microphone, photos, contacts, calendar, and location. Continue only if you trust the gateway and agent you connect to.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
self.advanceFromIntro()
|
||||
} label: {
|
||||
Text("Continue")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var welcomeStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
|
|
@ -303,16 +390,37 @@ struct OnboardingWizardView: View {
|
|||
.foregroundStyle(.tint)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text("Welcome")
|
||||
Text("Connect Gateway")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text("Connect to your OpenClaw gateway")
|
||||
Text("Scan a QR code from your OpenClaw gateway or continue with manual setup.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("How to pair")
|
||||
.font(.headline)
|
||||
Text("In your OpenClaw chat, run")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("/pair qr")
|
||||
.font(.system(.footnote, design: .monospaced).weight(.semibold))
|
||||
Text("Then scan the QR code here to connect this iPhone.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
|
|
@ -342,8 +450,7 @@ struct OnboardingWizardView: View {
|
|||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -727,6 +834,12 @@ struct OnboardingWizardView: View {
|
|||
return nil
|
||||
}
|
||||
|
||||
private func advanceFromIntro() {
|
||||
OnboardingStateStore.markFirstRunIntroSeen()
|
||||
self.statusLine = "In your OpenClaw chat, run /pair qr, then scan the code here."
|
||||
self.step = .welcome
|
||||
}
|
||||
|
||||
private func navigateBack() {
|
||||
guard let target = self.step.previous else { return }
|
||||
self.connectingGatewayID = nil
|
||||
|
|
@ -775,10 +888,8 @@ struct OnboardingWizardView: View {
|
|||
let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil
|
||||
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword {
|
||||
self.didAutoPresentQR = true
|
||||
self.statusLine = "No saved pairing found. Scan QR code to connect."
|
||||
self.showQRScanner = true
|
||||
if !hasSavedGateway, !hasToken, !hasPassword {
|
||||
self.statusLine = "No saved pairing found. In your OpenClaw chat, run /pair qr, then scan the code here."
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1008,6 +1008,7 @@ struct SettingsTab: View {
|
|||
|
||||
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
|
||||
GatewaySettingsStore.clearLastGatewayConnection()
|
||||
OnboardingStateStore.reset()
|
||||
|
||||
// RootCanvas also short-circuits onboarding when these are true.
|
||||
self.onboardingComplete = false
|
||||
|
|
|
|||
|
|
@ -39,6 +39,35 @@ import Testing
|
|||
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
}
|
||||
|
||||
@Test func firstRunIntroDefaultsToVisibleThenPersists() {
|
||||
let testDefaults = self.makeDefaults()
|
||||
let defaults = testDefaults.defaults
|
||||
defer { self.reset(testDefaults) }
|
||||
|
||||
#expect(OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
|
||||
|
||||
OnboardingStateStore.markFirstRunIntroSeen(defaults: defaults)
|
||||
#expect(!OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
|
||||
}
|
||||
|
||||
@Test @MainActor func resetClearsCompletionAndIntroSeen() {
|
||||
let testDefaults = self.makeDefaults()
|
||||
let defaults = testDefaults.defaults
|
||||
defer { self.reset(testDefaults) }
|
||||
|
||||
OnboardingStateStore.markCompleted(mode: .homeNetwork, defaults: defaults)
|
||||
OnboardingStateStore.markFirstRunIntroSeen(defaults: defaults)
|
||||
|
||||
OnboardingStateStore.reset(defaults: defaults)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = nil
|
||||
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
#expect(OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
|
||||
#expect(OnboardingStateStore.lastMode(defaults: defaults) == .homeNetwork)
|
||||
}
|
||||
|
||||
private struct TestDefaults {
|
||||
var suiteName: String
|
||||
var defaults: UserDefaults
|
||||
|
|
|
|||
|
|
@ -18,13 +18,10 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
|||
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard Self.allMessageNames.contains(message.name) else { return }
|
||||
|
||||
// Only accept actions from local Canvas content (not arbitrary web pages).
|
||||
// Only accept actions from the in-app canvas scheme. Local-network HTTP
|
||||
// pages are regular web content and must not get direct agent dispatch.
|
||||
guard let webView = message.webView, let url = webView.url else { return }
|
||||
if let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) {
|
||||
// ok
|
||||
} else if Self.isLocalNetworkCanvasURL(url) {
|
||||
// ok
|
||||
} else {
|
||||
guard let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) else {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -107,10 +104,5 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
|
||||
}
|
||||
|
||||
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,21 +50,24 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||
|
||||
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
|
||||
//
|
||||
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link
|
||||
// (includes the app-generated key so it won't prompt).
|
||||
// Keep the bridge on the trusted in-app canvas scheme only, and do not
|
||||
// expose unattended deep-link credentials to page JavaScript.
|
||||
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
|
||||
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
|
||||
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
|
||||
let allowedSchemesJSON = (
|
||||
try? String(
|
||||
data: JSONSerialization.data(withJSONObject: CanvasScheme.allSchemes),
|
||||
encoding: .utf8)
|
||||
) ?? "[]"
|
||||
let bridgeScript = """
|
||||
(() => {
|
||||
try {
|
||||
const allowedSchemes = \(String(describing: CanvasScheme.allSchemes));
|
||||
const allowedSchemes = \(allowedSchemesJSON);
|
||||
const protocol = location.protocol.replace(':', '');
|
||||
if (!allowedSchemes.includes(protocol)) return;
|
||||
if (globalThis.__openclawA2UIBridgeInstalled) return;
|
||||
globalThis.__openclawA2UIBridgeInstalled = true;
|
||||
|
||||
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
|
||||
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
|
||||
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
|
||||
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
|
||||
|
|
@ -104,24 +107,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||
return;
|
||||
}
|
||||
|
||||
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : '';
|
||||
const message =
|
||||
'CANVAS_A2UI action=' + userAction.name +
|
||||
' session=' + sessionKey +
|
||||
' surface=' + userAction.surfaceId +
|
||||
' component=' + (userAction.sourceComponentId || '-') +
|
||||
' host=' + machineName.replace(/\\s+/g, '_') +
|
||||
' instance=' + instanceId +
|
||||
ctx +
|
||||
' default=update_canvas';
|
||||
const params = new URLSearchParams();
|
||||
params.set('message', message);
|
||||
params.set('sessionKey', sessionKey);
|
||||
params.set('thinking', 'low');
|
||||
params.set('deliver', 'false');
|
||||
params.set('channel', 'last');
|
||||
params.set('key', deepLinkKey);
|
||||
location.href = 'openclaw://agent?' + params.toString();
|
||||
// Without the native handler, fail closed instead of exposing an
|
||||
// unattended deep-link credential to page JavaScript.
|
||||
} catch {}
|
||||
}, true);
|
||||
} catch {}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,14 @@ extension CronJobEditor {
|
|||
self.agentId = job.agentId ?? ""
|
||||
self.enabled = job.enabled
|
||||
self.deleteAfterRun = job.deleteAfterRun ?? false
|
||||
self.sessionTarget = job.sessionTarget
|
||||
switch job.parsedSessionTarget {
|
||||
case .predefined(let target):
|
||||
self.sessionTarget = target
|
||||
self.preservedSessionTargetRaw = nil
|
||||
case .session(let id):
|
||||
self.sessionTarget = .isolated
|
||||
self.preservedSessionTargetRaw = "session:\(id)"
|
||||
}
|
||||
self.wakeMode = job.wakeMode
|
||||
|
||||
switch job.schedule {
|
||||
|
|
@ -51,7 +58,7 @@ extension CronJobEditor {
|
|||
self.channel = trimmed.isEmpty ? "last" : trimmed
|
||||
self.to = delivery.to ?? ""
|
||||
self.bestEffortDeliver = delivery.bestEffort ?? false
|
||||
} else if self.sessionTarget == .isolated {
|
||||
} else if self.isIsolatedLikeSessionTarget {
|
||||
self.deliveryMode = .announce
|
||||
}
|
||||
}
|
||||
|
|
@ -80,7 +87,7 @@ extension CronJobEditor {
|
|||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"sessionTarget": self.effectiveSessionTargetRaw,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
|
|
@ -92,7 +99,7 @@ extension CronJobEditor {
|
|||
root["agentId"] = NSNull()
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
if self.isIsolatedLikeSessionTarget {
|
||||
root["delivery"] = self.buildDelivery()
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +167,7 @@ extension CronJobEditor {
|
|||
}
|
||||
|
||||
func buildSelectedPayload() throws -> [String: Any] {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
if self.isIsolatedLikeSessionTarget { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.trimmed(self.systemEventText)
|
||||
|
|
@ -171,7 +178,7 @@ extension CronJobEditor {
|
|||
}
|
||||
|
||||
func validateSessionTarget(_ payload: [String: Any]) throws {
|
||||
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
|
||||
if self.effectiveSessionTargetRaw == "main", payload["kind"] as? String == "agentTurn" {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
|
|
@ -181,7 +188,7 @@ extension CronJobEditor {
|
|||
])
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
|
||||
if self.effectiveSessionTargetRaw != "main", payload["kind"] as? String == "systemEvent" {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
|
|
@ -257,6 +264,17 @@ extension CronJobEditor {
|
|||
return Int(floor(n * factor))
|
||||
}
|
||||
|
||||
var effectiveSessionTargetRaw: String {
|
||||
if self.sessionTarget == .isolated, let preserved = self.preservedSessionTargetRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !preserved.isEmpty {
|
||||
return preserved
|
||||
}
|
||||
return self.sessionTarget.rawValue
|
||||
}
|
||||
|
||||
var isIsolatedLikeSessionTarget: Bool {
|
||||
self.effectiveSessionTargetRaw != "main"
|
||||
}
|
||||
|
||||
func formatDuration(ms: Int) -> String {
|
||||
DurationFormattingSupport.conciseDuration(ms: ms)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ struct CronJobEditor: View {
|
|||
+ "Use an isolated session for agent turns so your main chat stays clean."
|
||||
static let sessionTargetNote =
|
||||
"Main jobs post a system event into the current main session. "
|
||||
+ "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel."
|
||||
+ "Current and isolated-style jobs run agent turns and can announce results to a channel."
|
||||
static let scheduleKindNote =
|
||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||
static let isolatedPayloadNote =
|
||||
|
|
@ -29,6 +29,7 @@ struct CronJobEditor: View {
|
|||
@State var agentId: String = ""
|
||||
@State var enabled: Bool = true
|
||||
@State var sessionTarget: CronSessionTarget = .main
|
||||
@State var preservedSessionTargetRaw: String?
|
||||
@State var wakeMode: CronWakeMode = .now
|
||||
@State var deleteAfterRun: Bool = false
|
||||
|
||||
|
|
@ -117,6 +118,7 @@ struct CronJobEditor: View {
|
|||
Picker("", selection: self.$sessionTarget) {
|
||||
Text("main").tag(CronSessionTarget.main)
|
||||
Text("isolated").tag(CronSessionTarget.isolated)
|
||||
Text("current").tag(CronSessionTarget.current)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
|
|
@ -209,7 +211,7 @@ struct CronJobEditor: View {
|
|||
|
||||
GroupBox("Payload") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.sessionTarget == .isolated {
|
||||
if self.isIsolatedLikeSessionTarget {
|
||||
Text(Self.isolatedPayloadNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
|
@ -289,8 +291,11 @@ struct CronJobEditor: View {
|
|||
self.sessionTarget = .isolated
|
||||
}
|
||||
}
|
||||
.onChange(of: self.sessionTarget) { _, newValue in
|
||||
if newValue == .isolated {
|
||||
.onChange(of: self.sessionTarget) { oldValue, newValue in
|
||||
if oldValue != newValue {
|
||||
self.preservedSessionTargetRaw = nil
|
||||
}
|
||||
if newValue != .main {
|
||||
self.payloadKind = .agentTurn
|
||||
} else if newValue == .main, self.payloadKind == .agentTurn {
|
||||
self.payloadKind = .systemEvent
|
||||
|
|
|
|||
|
|
@ -3,12 +3,39 @@ import Foundation
|
|||
enum CronSessionTarget: String, CaseIterable, Identifiable, Codable {
|
||||
case main
|
||||
case isolated
|
||||
case current
|
||||
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
enum CronCustomSessionTarget: Codable, Equatable {
|
||||
case predefined(CronSessionTarget)
|
||||
case session(id: String)
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .predefined(let target):
|
||||
return target.rawValue
|
||||
case .session(let id):
|
||||
return "session:\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
static func from(_ value: String) -> CronCustomSessionTarget {
|
||||
if let predefined = CronSessionTarget(rawValue: value) {
|
||||
return .predefined(predefined)
|
||||
}
|
||||
if value.hasPrefix("session:") {
|
||||
let sessionId = String(value.dropFirst(8))
|
||||
return .session(id: sessionId)
|
||||
}
|
||||
// Fallback to isolated for unknown values
|
||||
return .predefined(.isolated)
|
||||
}
|
||||
}
|
||||
|
||||
enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
|
||||
case now
|
||||
case nextHeartbeat = "next-heartbeat"
|
||||
|
|
@ -204,12 +231,69 @@ struct CronJob: Identifiable, Codable, Equatable {
|
|||
let createdAtMs: Int
|
||||
let updatedAtMs: Int
|
||||
let schedule: CronSchedule
|
||||
let sessionTarget: CronSessionTarget
|
||||
private let sessionTargetRaw: String
|
||||
let wakeMode: CronWakeMode
|
||||
let payload: CronPayload
|
||||
let delivery: CronDelivery?
|
||||
let state: CronJobState
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case agentId
|
||||
case name
|
||||
case description
|
||||
case enabled
|
||||
case deleteAfterRun
|
||||
case createdAtMs
|
||||
case updatedAtMs
|
||||
case schedule
|
||||
case sessionTargetRaw = "sessionTarget"
|
||||
case wakeMode
|
||||
case payload
|
||||
case delivery
|
||||
case state
|
||||
}
|
||||
|
||||
/// Parsed session target (predefined or custom session ID)
|
||||
var parsedSessionTarget: CronCustomSessionTarget {
|
||||
CronCustomSessionTarget.from(self.sessionTargetRaw)
|
||||
}
|
||||
|
||||
/// Compatibility shim for existing editor/UI code paths that still use the
|
||||
/// predefined enum.
|
||||
var sessionTarget: CronSessionTarget {
|
||||
switch self.parsedSessionTarget {
|
||||
case .predefined(let target):
|
||||
return target
|
||||
case .session:
|
||||
return .isolated
|
||||
}
|
||||
}
|
||||
|
||||
var sessionTargetDisplayValue: String {
|
||||
self.parsedSessionTarget.rawValue
|
||||
}
|
||||
|
||||
var transcriptSessionKey: String? {
|
||||
switch self.parsedSessionTarget {
|
||||
case .predefined(.main):
|
||||
return nil
|
||||
case .predefined(.isolated), .predefined(.current):
|
||||
return "cron:\(self.id)"
|
||||
case .session(let id):
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
var supportsAnnounceDelivery: Bool {
|
||||
switch self.parsedSessionTarget {
|
||||
case .predefined(.main):
|
||||
return false
|
||||
case .predefined(.isolated), .predefined(.current), .session:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Untitled job" : trimmed
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ extension CronSettings {
|
|||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
|
||||
StatusPill(text: job.sessionTargetDisplayValue, tint: .secondary)
|
||||
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
|
||||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
StatusPill(text: "agent \(agentId)", tint: .secondary)
|
||||
|
|
@ -34,9 +34,9 @@ extension CronSettings {
|
|||
@ViewBuilder
|
||||
func jobContextMenu(_ job: CronJob) -> some View {
|
||||
Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||
if job.sessionTarget == .isolated {
|
||||
if let transcriptSessionKey = job.transcriptSessionKey {
|
||||
Button("Open transcript") {
|
||||
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
|
||||
WebChatManager.shared.show(sessionKey: transcriptSessionKey)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
|
|
@ -75,9 +75,9 @@ extension CronSettings {
|
|||
.labelsHidden()
|
||||
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
if job.sessionTarget == .isolated {
|
||||
if let transcriptSessionKey = job.transcriptSessionKey {
|
||||
Button("Transcript") {
|
||||
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
|
||||
WebChatManager.shared.show(sessionKey: transcriptSessionKey)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ extension CronSettings {
|
|||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
LabeledContent("Agent") { Text(agentId) }
|
||||
}
|
||||
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
|
||||
LabeledContent("Session") { Text(job.sessionTargetDisplayValue) }
|
||||
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
|
||||
LabeledContent("Next run") {
|
||||
if let date = job.nextRunDate {
|
||||
|
|
@ -224,7 +224,7 @@ extension CronSettings {
|
|||
HStack(spacing: 8) {
|
||||
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
|
||||
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
||||
if job.sessionTarget == .isolated {
|
||||
if job.supportsAnnounceDelivery {
|
||||
let delivery = job.delivery
|
||||
if let delivery {
|
||||
if delivery.mode == .announce {
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ enum ExecApprovalEvaluator {
|
|||
|
||||
let skillAllow: Bool
|
||||
if approvals.agent.autoAllowSkills, !allowlistResolutions.isEmpty {
|
||||
let bins = await SkillBinsCache.shared.currentBins()
|
||||
skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) }
|
||||
let bins = await SkillBinsCache.shared.currentTrust()
|
||||
skillAllow = self.isSkillAutoAllowed(allowlistResolutions, trustedBinsByName: bins)
|
||||
} else {
|
||||
skillAllow = false
|
||||
}
|
||||
|
|
@ -65,4 +65,26 @@ enum ExecApprovalEvaluator {
|
|||
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,
|
||||
skillAllow: skillAllow)
|
||||
}
|
||||
|
||||
static func isSkillAutoAllowed(
|
||||
_ resolutions: [ExecCommandResolution],
|
||||
trustedBinsByName: [String: Set<String>]) -> Bool
|
||||
{
|
||||
guard !resolutions.isEmpty, !trustedBinsByName.isEmpty else { return false }
|
||||
return resolutions.allSatisfy { resolution in
|
||||
guard let executableName = SkillBinsCache.normalizeSkillBinName(resolution.executableName),
|
||||
let resolvedPath = SkillBinsCache.normalizeResolvedPath(resolution.resolvedPath)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return trustedBinsByName[executableName]?.contains(resolvedPath) == true
|
||||
}
|
||||
}
|
||||
|
||||
static func _testIsSkillAutoAllowed(
|
||||
_ resolutions: [ExecCommandResolution],
|
||||
trustedBinsByName: [String: Set<String>]) -> Bool
|
||||
{
|
||||
self.isSkillAutoAllowed(resolutions, trustedBinsByName: trustedBinsByName)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,6 +370,17 @@ enum ExecApprovalsStore {
|
|||
|
||||
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||
let file = self.ensureFile()
|
||||
return self.resolveFromFile(file, agentId: agentId)
|
||||
}
|
||||
|
||||
/// Read-only resolve: loads file without writing (no ensureFile side effects).
|
||||
/// Safe to call from background threads / off MainActor.
|
||||
static func resolveReadOnly(agentId: String?) -> ExecApprovalsResolved {
|
||||
let file = self.loadFile()
|
||||
return self.resolveFromFile(file, agentId: agentId)
|
||||
}
|
||||
|
||||
private static func resolveFromFile(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved {
|
||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||
security: defaults.security ?? self.defaultSecurity,
|
||||
|
|
@ -777,6 +788,7 @@ actor SkillBinsCache {
|
|||
static let shared = SkillBinsCache()
|
||||
|
||||
private var bins: Set<String> = []
|
||||
private var trustByName: [String: Set<String>] = [:]
|
||||
private var lastRefresh: Date?
|
||||
private let refreshInterval: TimeInterval = 90
|
||||
|
||||
|
|
@ -787,27 +799,90 @@ actor SkillBinsCache {
|
|||
return self.bins
|
||||
}
|
||||
|
||||
func currentTrust(force: Bool = false) async -> [String: Set<String>] {
|
||||
if force || self.isStale() {
|
||||
await self.refresh()
|
||||
}
|
||||
return self.trustByName
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
do {
|
||||
let report = try await GatewayConnection.shared.skillsStatus()
|
||||
var next = Set<String>()
|
||||
for skill in report.skills {
|
||||
for bin in skill.requirements.bins {
|
||||
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { next.insert(trimmed) }
|
||||
}
|
||||
}
|
||||
self.bins = next
|
||||
let trust = Self.buildTrustIndex(report: report, searchPaths: CommandResolver.preferredPaths())
|
||||
self.bins = trust.names
|
||||
self.trustByName = trust.pathsByName
|
||||
self.lastRefresh = Date()
|
||||
} catch {
|
||||
if self.lastRefresh == nil {
|
||||
self.bins = []
|
||||
self.trustByName = [:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func normalizeSkillBinName(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func normalizeResolvedPath(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return URL(fileURLWithPath: trimmed).standardizedFileURL.path
|
||||
}
|
||||
|
||||
static func buildTrustIndex(
|
||||
report: SkillsStatusReport,
|
||||
searchPaths: [String]) -> SkillBinTrustIndex
|
||||
{
|
||||
var names = Set<String>()
|
||||
var pathsByName: [String: Set<String>] = [:]
|
||||
|
||||
for skill in report.skills {
|
||||
for bin in skill.requirements.bins {
|
||||
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
names.insert(trimmed)
|
||||
|
||||
guard let name = self.normalizeSkillBinName(trimmed),
|
||||
let resolvedPath = self.resolveSkillBinPath(trimmed, searchPaths: searchPaths),
|
||||
let normalizedPath = self.normalizeResolvedPath(resolvedPath)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
var paths = pathsByName[name] ?? Set<String>()
|
||||
paths.insert(normalizedPath)
|
||||
pathsByName[name] = paths
|
||||
}
|
||||
}
|
||||
|
||||
return SkillBinTrustIndex(names: names, pathsByName: pathsByName)
|
||||
}
|
||||
|
||||
private static func resolveSkillBinPath(_ bin: String, searchPaths: [String]) -> String? {
|
||||
let expanded = bin.hasPrefix("~") ? (bin as NSString).expandingTildeInPath : bin
|
||||
if expanded.contains("/") || expanded.contains("\\") {
|
||||
return FileManager().isExecutableFile(atPath: expanded) ? expanded : nil
|
||||
}
|
||||
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
|
||||
}
|
||||
|
||||
private func isStale() -> Bool {
|
||||
guard let lastRefresh else { return true }
|
||||
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
|
||||
}
|
||||
|
||||
static func _testBuildTrustIndex(
|
||||
report: SkillsStatusReport,
|
||||
searchPaths: [String]) -> SkillBinTrustIndex
|
||||
{
|
||||
self.buildTrustIndex(report: report, searchPaths: searchPaths)
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillBinTrustIndex {
|
||||
let names: Set<String>
|
||||
let pathsByName: [String: Set<String>]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,33 @@ final class ExecApprovalsGatewayPrompter {
|
|||
do {
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
||||
guard self.shouldPresent(request: request) else { return }
|
||||
let presentation = self.shouldPresent(request: request)
|
||||
guard presentation.shouldAsk else {
|
||||
// Ask policy says no prompt needed – resolve based on security policy
|
||||
let decision: ExecApprovalDecision = presentation.security == .full ? .allowOnce : .deny
|
||||
try await GatewayConnection.shared.requestVoid(
|
||||
method: .execApprovalResolve,
|
||||
params: [
|
||||
"id": AnyCodable(request.id),
|
||||
"decision": AnyCodable(decision.rawValue),
|
||||
],
|
||||
timeoutMs: 10000)
|
||||
return
|
||||
}
|
||||
guard presentation.canPresent else {
|
||||
let decision = Self.fallbackDecision(
|
||||
request: request.request,
|
||||
askFallback: presentation.askFallback,
|
||||
allowlist: presentation.allowlist)
|
||||
try await GatewayConnection.shared.requestVoid(
|
||||
method: .execApprovalResolve,
|
||||
params: [
|
||||
"id": AnyCodable(request.id),
|
||||
"decision": AnyCodable(decision.rawValue),
|
||||
],
|
||||
timeoutMs: 10000)
|
||||
return
|
||||
}
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
||||
try await GatewayConnection.shared.requestVoid(
|
||||
method: .execApprovalResolve,
|
||||
|
|
@ -57,16 +83,89 @@ final class ExecApprovalsGatewayPrompter {
|
|||
}
|
||||
}
|
||||
|
||||
private func shouldPresent(request: GatewayApprovalRequest) -> Bool {
|
||||
/// Whether the ask policy requires prompting the user.
|
||||
/// Note: this only determines if a prompt is shown, not whether the action is allowed.
|
||||
/// The security policy (full/deny/allowlist) decides the actual outcome.
|
||||
private static func shouldAsk(security: ExecSecurity, ask: ExecAsk) -> Bool {
|
||||
switch ask {
|
||||
case .always:
|
||||
return true
|
||||
case .onMiss:
|
||||
return security == .allowlist
|
||||
case .off:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct PresentationDecision {
|
||||
/// Whether the ask policy requires prompting the user (not whether the action is allowed).
|
||||
var shouldAsk: Bool
|
||||
/// Whether the prompt can actually be shown (session match, recent activity, etc.).
|
||||
var canPresent: Bool
|
||||
/// The resolved security policy, used to determine allow/deny when no prompt is shown.
|
||||
var security: ExecSecurity
|
||||
/// Fallback security policy when a prompt is needed but can't be presented.
|
||||
var askFallback: ExecSecurity
|
||||
var allowlist: [ExecAllowlistEntry]
|
||||
}
|
||||
|
||||
private func shouldPresent(request: GatewayApprovalRequest) -> PresentationDecision {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Self.shouldPresent(
|
||||
|
||||
// Read-only resolve to avoid disk writes on the MainActor
|
||||
let approvals = ExecApprovalsStore.resolveReadOnly(agentId: request.request.agentId)
|
||||
let security = approvals.agent.security
|
||||
let ask = approvals.agent.ask
|
||||
|
||||
let shouldAsk = Self.shouldAsk(security: security, ask: ask)
|
||||
|
||||
let canPresent = shouldAsk && Self.shouldPresent(
|
||||
mode: mode,
|
||||
activeSession: activeSession,
|
||||
requestSession: requestSession,
|
||||
lastInputSeconds: Self.lastInputSeconds(),
|
||||
thresholdSeconds: 120)
|
||||
|
||||
return PresentationDecision(
|
||||
shouldAsk: shouldAsk,
|
||||
canPresent: canPresent,
|
||||
security: security,
|
||||
askFallback: approvals.agent.askFallback,
|
||||
allowlist: approvals.allowlist)
|
||||
}
|
||||
|
||||
private static func fallbackDecision(
|
||||
request: ExecApprovalPromptRequest,
|
||||
askFallback: ExecSecurity,
|
||||
allowlist: [ExecAllowlistEntry]) -> ExecApprovalDecision
|
||||
{
|
||||
guard askFallback == .allowlist else {
|
||||
return askFallback == .full ? .allowOnce : .deny
|
||||
}
|
||||
let resolution = self.fallbackResolution(for: request)
|
||||
let match = ExecAllowlistMatcher.match(entries: allowlist, resolution: resolution)
|
||||
return match == nil ? .deny : .allowOnce
|
||||
}
|
||||
|
||||
private static func fallbackResolution(for request: ExecApprovalPromptRequest) -> ExecCommandResolution? {
|
||||
let resolvedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedResolvedPath = (resolvedPath?.isEmpty == false) ? resolvedPath : nil
|
||||
let rawExecutable = self.firstToken(from: request.command) ?? trimmedResolvedPath ?? ""
|
||||
guard !rawExecutable.isEmpty || trimmedResolvedPath != nil else { return nil }
|
||||
let executableName = trimmedResolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? rawExecutable
|
||||
return ExecCommandResolution(
|
||||
rawExecutable: rawExecutable,
|
||||
resolvedPath: trimmedResolvedPath,
|
||||
executableName: executableName,
|
||||
cwd: request.cwd)
|
||||
}
|
||||
|
||||
private static func firstToken(from command: String) -> String? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||
}
|
||||
|
||||
private static func shouldPresent(
|
||||
|
|
@ -117,5 +216,29 @@ extension ExecApprovalsGatewayPrompter {
|
|||
lastInputSeconds: lastInputSeconds,
|
||||
thresholdSeconds: thresholdSeconds)
|
||||
}
|
||||
|
||||
static func _testShouldAsk(security: ExecSecurity, ask: ExecAsk) -> Bool {
|
||||
self.shouldAsk(security: security, ask: ask)
|
||||
}
|
||||
|
||||
static func _testFallbackDecision(
|
||||
command: String,
|
||||
resolvedPath: String?,
|
||||
askFallback: ExecSecurity,
|
||||
allowlistPatterns: [String]) -> ExecApprovalDecision
|
||||
{
|
||||
self.fallbackDecision(
|
||||
request: ExecApprovalPromptRequest(
|
||||
command: command,
|
||||
cwd: nil,
|
||||
host: nil,
|
||||
security: nil,
|
||||
ask: nil,
|
||||
agentId: nil,
|
||||
resolvedPath: resolvedPath,
|
||||
sessionKey: nil),
|
||||
askFallback: askFallback,
|
||||
allowlist: allowlistPatterns.map { ExecAllowlistEntry(pattern: $0) })
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -37,8 +37,7 @@ struct ExecCommandResolution {
|
|||
var resolutions: [ExecCommandResolution] = []
|
||||
resolutions.reserveCapacity(segments.count)
|
||||
for segment in segments {
|
||||
guard let token = self.parseFirstToken(segment),
|
||||
let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||
guard let resolution = self.resolveShellSegmentExecutable(segment, cwd: cwd, env: env)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
|
@ -88,6 +87,20 @@ struct ExecCommandResolution {
|
|||
cwd: cwd)
|
||||
}
|
||||
|
||||
private static func resolveShellSegmentExecutable(
|
||||
_ segment: String,
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> ExecCommandResolution?
|
||||
{
|
||||
let tokens = self.tokenizeShellWords(segment)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(tokens)
|
||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
private static func parseFirstToken(_ command: String) -> String? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
|
@ -102,6 +115,59 @@ struct ExecCommandResolution {
|
|||
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||
}
|
||||
|
||||
private static func tokenizeShellWords(_ command: String) -> [String] {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
|
||||
var tokens: [String] = []
|
||||
var current = ""
|
||||
var inSingle = false
|
||||
var inDouble = false
|
||||
var escaped = false
|
||||
|
||||
func appendCurrent() {
|
||||
guard !current.isEmpty else { return }
|
||||
tokens.append(current)
|
||||
current.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
for ch in trimmed {
|
||||
if escaped {
|
||||
current.append(ch)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == "\\", !inSingle {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == "'", !inDouble {
|
||||
inSingle.toggle()
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == "\"", !inSingle {
|
||||
inDouble.toggle()
|
||||
continue
|
||||
}
|
||||
|
||||
if ch.isWhitespace, !inSingle, !inDouble {
|
||||
appendCurrent()
|
||||
continue
|
||||
}
|
||||
|
||||
current.append(ch)
|
||||
}
|
||||
|
||||
if escaped {
|
||||
current.append("\\")
|
||||
}
|
||||
appendCurrent()
|
||||
return tokens
|
||||
}
|
||||
|
||||
private enum ShellTokenContext {
|
||||
case unquoted
|
||||
case doubleQuoted
|
||||
|
|
@ -148,8 +214,14 @@ struct ExecCommandResolution {
|
|||
while idx < chars.count {
|
||||
let ch = chars[idx]
|
||||
let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil
|
||||
let lookahead = self.nextShellSignificantCharacter(chars: chars, after: idx, inSingle: inSingle)
|
||||
|
||||
if escaped {
|
||||
if ch == "\n" {
|
||||
escaped = false
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
current.append(ch)
|
||||
escaped = false
|
||||
idx += 1
|
||||
|
|
@ -157,6 +229,10 @@ struct ExecCommandResolution {
|
|||
}
|
||||
|
||||
if ch == "\\", !inSingle {
|
||||
if next == "\n" {
|
||||
idx += 2
|
||||
continue
|
||||
}
|
||||
current.append(ch)
|
||||
escaped = true
|
||||
idx += 1
|
||||
|
|
@ -177,7 +253,7 @@ struct ExecCommandResolution {
|
|||
continue
|
||||
}
|
||||
|
||||
if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next, inDouble: inDouble) {
|
||||
if !inSingle, self.shouldFailClosedForShell(ch: ch, next: lookahead, inDouble: inDouble) {
|
||||
// Fail closed on command/process substitution in allowlist mode,
|
||||
// including command substitution inside double-quoted shell strings.
|
||||
return nil
|
||||
|
|
@ -201,6 +277,25 @@ struct ExecCommandResolution {
|
|||
return segments
|
||||
}
|
||||
|
||||
private static func nextShellSignificantCharacter(
|
||||
chars: [Character],
|
||||
after idx: Int,
|
||||
inSingle: Bool) -> Character?
|
||||
{
|
||||
guard !inSingle else {
|
||||
return idx + 1 < chars.count ? chars[idx + 1] : nil
|
||||
}
|
||||
var cursor = idx + 1
|
||||
while cursor < chars.count {
|
||||
if chars[cursor] == "\\", cursor + 1 < chars.count, chars[cursor + 1] == "\n" {
|
||||
cursor += 2
|
||||
continue
|
||||
}
|
||||
return chars[cursor]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool {
|
||||
let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted
|
||||
guard let rules = self.shellFailClosedRules[context] else {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ actor PortGuardian {
|
|||
let listeners = await self.listeners(on: port)
|
||||
guard !listeners.isEmpty else { continue }
|
||||
for listener in listeners {
|
||||
if self.isExpected(listener, port: port, mode: mode) {
|
||||
if Self.isExpected(listener, port: port, mode: mode) {
|
||||
let message = """
|
||||
port \(port) already served by expected \(listener.command)
|
||||
(pid \(listener.pid)) — keeping
|
||||
|
|
@ -55,6 +55,14 @@ actor PortGuardian {
|
|||
self.logger.info("\(message, privacy: .public)")
|
||||
continue
|
||||
}
|
||||
if mode == .remote {
|
||||
let message = """
|
||||
port \(port) held by \(listener.command)
|
||||
(pid \(listener.pid)) in remote mode — not killing
|
||||
"""
|
||||
self.logger.warning(message)
|
||||
continue
|
||||
}
|
||||
let killed = await self.kill(listener.pid)
|
||||
if killed {
|
||||
let message = """
|
||||
|
|
@ -271,8 +279,8 @@ actor PortGuardian {
|
|||
|
||||
switch mode {
|
||||
case .remote:
|
||||
expectedDesc = "SSH tunnel to remote gateway"
|
||||
okPredicate = { $0.command.lowercased().contains("ssh") }
|
||||
expectedDesc = "Remote gateway (SSH tunnel, Docker, or direct)"
|
||||
okPredicate = { _ in true }
|
||||
case .local:
|
||||
expectedDesc = "Gateway websocket (node/tsx)"
|
||||
okPredicate = { listener in
|
||||
|
|
@ -352,13 +360,12 @@ actor PortGuardian {
|
|||
return sigkill.ok
|
||||
}
|
||||
|
||||
private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
|
||||
private static func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
|
||||
let cmd = listener.command.lowercased()
|
||||
let full = listener.fullCommand.lowercased()
|
||||
switch mode {
|
||||
case .remote:
|
||||
// Remote mode expects an SSH tunnel for the gateway WebSocket port.
|
||||
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
||||
if port == GatewayEnvironment.gatewayPort() { return true }
|
||||
return false
|
||||
case .local:
|
||||
// The gateway daemon may listen as `openclaw` or as its runtime (`node`, `bun`, etc).
|
||||
|
|
@ -406,6 +413,16 @@ extension PortGuardian {
|
|||
self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) }
|
||||
}
|
||||
|
||||
static func _testIsExpected(
|
||||
command: String,
|
||||
fullCommand: String,
|
||||
port: Int,
|
||||
mode: AppState.ConnectionMode) -> Bool
|
||||
{
|
||||
let listener = Listener(pid: 0, command: command, fullCommand: fullCommand, user: nil)
|
||||
return Self.isExpected(listener, port: port, mode: mode)
|
||||
}
|
||||
|
||||
static func _testBuildReport(
|
||||
port: Int,
|
||||
mode: AppState.ConnectionMode,
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.13</string>
|
||||
<string>2026.3.14</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202603130</string>
|
||||
<string>202603140</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ enum RuntimeResolutionError: Error {
|
|||
|
||||
enum RuntimeLocator {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "runtime")
|
||||
private static let minNode = RuntimeVersion(major: 22, minor: 0, patch: 0)
|
||||
private static let minNode = RuntimeVersion(major: 22, minor: 16, patch: 0)
|
||||
|
||||
static func resolve(
|
||||
searchPaths: [String] = CommandResolver.preferredPaths()) -> Result<RuntimeResolution, RuntimeResolutionError>
|
||||
|
|
@ -91,7 +91,7 @@ enum RuntimeLocator {
|
|||
switch error {
|
||||
case let .notFound(searchPaths):
|
||||
[
|
||||
"openclaw needs Node >=22.0.0 but found no runtime.",
|
||||
"openclaw needs Node >=22.16.0 but found no runtime.",
|
||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||
"Install Node: https://nodejs.org/en/download",
|
||||
].joined(separator: "\n")
|
||||
|
|
@ -105,7 +105,7 @@ enum RuntimeLocator {
|
|||
[
|
||||
"Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).",
|
||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||
"Try reinstalling or pinning a supported version (Node >=22.0.0).",
|
||||
"Try reinstalling or pinning a supported version (Node >=22.16.0).",
|
||||
].joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,26 @@ struct ExecAllowlistTests {
|
|||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed on line-continued command substitution`() {
|
||||
let command = ["/bin/sh", "-lc", "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed on chained line-continued command substitution`() {
|
||||
let command = ["/bin/sh", "-lc", "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed on quoted backticks`() {
|
||||
let command = ["/bin/sh", "-lc", "echo \"ok `/usr/bin/id`\""]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
|
|
@ -208,6 +228,30 @@ struct ExecAllowlistTests {
|
|||
#expect(resolutions[1].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env dispatch wrappers inside shell segments`() {
|
||||
let command = ["/bin/sh", "-lc", "env /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "env /usr/bin/touch /tmp/openclaw-allowlist-test",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
|
||||
#expect(resolutions[0].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env assignments inside shell segments`() {
|
||||
let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
|
||||
#expect(resolutions[0].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
|
||||
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
|
|
|
|||
|
|
@ -52,4 +52,51 @@ struct ExecApprovalsGatewayPrompterTests {
|
|||
lastInputSeconds: 400)
|
||||
#expect(!remote)
|
||||
}
|
||||
|
||||
// MARK: - shouldAsk
|
||||
|
||||
@Test func askAlwaysPromptsRegardlessOfSecurity() {
|
||||
#expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .always))
|
||||
#expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .always))
|
||||
#expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .always))
|
||||
}
|
||||
|
||||
@Test func askOnMissPromptsOnlyForAllowlist() {
|
||||
#expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .onMiss))
|
||||
#expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .onMiss))
|
||||
#expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .onMiss))
|
||||
}
|
||||
|
||||
@Test func askOffNeverPrompts() {
|
||||
#expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .off))
|
||||
#expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .off))
|
||||
#expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .off))
|
||||
}
|
||||
|
||||
@Test func fallbackAllowlistAllowsMatchingResolvedPath() {
|
||||
let decision = ExecApprovalsGatewayPrompter._testFallbackDecision(
|
||||
command: "git status",
|
||||
resolvedPath: "/usr/bin/git",
|
||||
askFallback: .allowlist,
|
||||
allowlistPatterns: ["/usr/bin/git"])
|
||||
#expect(decision == .allowOnce)
|
||||
}
|
||||
|
||||
@Test func fallbackAllowlistDeniesAllowlistMiss() {
|
||||
let decision = ExecApprovalsGatewayPrompter._testFallbackDecision(
|
||||
command: "git status",
|
||||
resolvedPath: "/usr/bin/git",
|
||||
askFallback: .allowlist,
|
||||
allowlistPatterns: ["/usr/bin/rg"])
|
||||
#expect(decision == .deny)
|
||||
}
|
||||
|
||||
@Test func fallbackFullAllowsWhenPromptCannotBeShown() {
|
||||
let decision = ExecApprovalsGatewayPrompter._testFallbackDecision(
|
||||
command: "git status",
|
||||
resolvedPath: "/usr/bin/git",
|
||||
askFallback: .full,
|
||||
allowlistPatterns: [])
|
||||
#expect(decision == .allowOnce)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct ExecSkillBinTrustTests {
|
||||
@Test func `build trust index resolves skill bin paths`() throws {
|
||||
let fixture = try Self.makeExecutable(named: "jq")
|
||||
defer { try? FileManager.default.removeItem(at: fixture.root) }
|
||||
|
||||
let trust = SkillBinsCache._testBuildTrustIndex(
|
||||
report: Self.makeReport(bins: ["jq"]),
|
||||
searchPaths: [fixture.root.path])
|
||||
|
||||
#expect(trust.names == ["jq"])
|
||||
#expect(trust.pathsByName["jq"] == [fixture.path])
|
||||
}
|
||||
|
||||
@Test func `skill auto allow accepts trusted resolved skill bin path`() throws {
|
||||
let fixture = try Self.makeExecutable(named: "jq")
|
||||
defer { try? FileManager.default.removeItem(at: fixture.root) }
|
||||
|
||||
let trust = SkillBinsCache._testBuildTrustIndex(
|
||||
report: Self.makeReport(bins: ["jq"]),
|
||||
searchPaths: [fixture.root.path])
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "jq",
|
||||
resolvedPath: fixture.path,
|
||||
executableName: "jq",
|
||||
cwd: nil)
|
||||
|
||||
#expect(ExecApprovalEvaluator._testIsSkillAutoAllowed([resolution], trustedBinsByName: trust.pathsByName))
|
||||
}
|
||||
|
||||
@Test func `skill auto allow rejects same basename at different path`() throws {
|
||||
let trusted = try Self.makeExecutable(named: "jq")
|
||||
let untrusted = try Self.makeExecutable(named: "jq")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: trusted.root)
|
||||
try? FileManager.default.removeItem(at: untrusted.root)
|
||||
}
|
||||
|
||||
let trust = SkillBinsCache._testBuildTrustIndex(
|
||||
report: Self.makeReport(bins: ["jq"]),
|
||||
searchPaths: [trusted.root.path])
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "jq",
|
||||
resolvedPath: untrusted.path,
|
||||
executableName: "jq",
|
||||
cwd: nil)
|
||||
|
||||
#expect(!ExecApprovalEvaluator._testIsSkillAutoAllowed([resolution], trustedBinsByName: trust.pathsByName))
|
||||
}
|
||||
|
||||
private static func makeExecutable(named name: String) throws -> (root: URL, path: String) {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("openclaw-skill-bin-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let file = root.appendingPathComponent(name)
|
||||
try "#!/bin/sh\nexit 0\n".write(to: file, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: NSNumber(value: Int16(0o755))],
|
||||
ofItemAtPath: file.path)
|
||||
return (root, file.path)
|
||||
}
|
||||
|
||||
private static func makeReport(bins: [String]) -> SkillsStatusReport {
|
||||
SkillsStatusReport(
|
||||
workspaceDir: "/tmp/workspace",
|
||||
managedSkillsDir: "/tmp/skills",
|
||||
skills: [
|
||||
SkillStatus(
|
||||
name: "test-skill",
|
||||
description: "test",
|
||||
source: "local",
|
||||
filePath: "/tmp/skills/test-skill/SKILL.md",
|
||||
baseDir: "/tmp/skills/test-skill",
|
||||
skillKey: "test-skill",
|
||||
primaryEnv: nil,
|
||||
emoji: nil,
|
||||
homepage: nil,
|
||||
always: false,
|
||||
disabled: false,
|
||||
eligible: true,
|
||||
requirements: SkillRequirements(bins: bins, env: [], config: []),
|
||||
missing: SkillMissing(bins: [], env: [], config: []),
|
||||
configChecks: [],
|
||||
install: [])
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -139,6 +139,54 @@ struct LowCoverageHelperTests {
|
|||
#expect(emptyReport.summary.contains("Nothing is listening"))
|
||||
}
|
||||
|
||||
@Test func `port guardian remote mode does not kill docker`() {
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "com.docker.backend",
|
||||
fullCommand: "com.docker.backend",
|
||||
port: 18789, mode: .remote) == true)
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "ssh",
|
||||
fullCommand: "ssh -L 18789:localhost:18789 user@host",
|
||||
port: 18789, mode: .remote) == true)
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "podman",
|
||||
fullCommand: "podman",
|
||||
port: 18789, mode: .remote) == true)
|
||||
}
|
||||
|
||||
@Test func `port guardian local mode still rejects unexpected`() {
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "com.docker.backend",
|
||||
fullCommand: "com.docker.backend",
|
||||
port: 18789, mode: .local) == false)
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "python",
|
||||
fullCommand: "python server.py",
|
||||
port: 18789, mode: .local) == false)
|
||||
|
||||
#expect(PortGuardian._testIsExpected(
|
||||
command: "node",
|
||||
fullCommand: "node /path/to/gateway-daemon",
|
||||
port: 18789, mode: .local) == true)
|
||||
}
|
||||
|
||||
@Test func `port guardian remote mode report accepts any listener`() {
|
||||
let dockerReport = PortGuardian._testBuildReport(
|
||||
port: 18789, mode: .remote,
|
||||
listeners: [(pid: 99, command: "com.docker.backend",
|
||||
fullCommand: "com.docker.backend", user: "me")])
|
||||
#expect(dockerReport.offenders.isEmpty)
|
||||
|
||||
let localDockerReport = PortGuardian._testBuildReport(
|
||||
port: 18789, mode: .local,
|
||||
listeners: [(pid: 99, command: "com.docker.backend",
|
||||
fullCommand: "com.docker.backend", user: "me")])
|
||||
#expect(!localDockerReport.offenders.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func `canvas scheme handler resolves files and errors`() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ struct RuntimeLocatorTests {
|
|||
@Test func `resolve succeeds with valid node`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo v22.5.0
|
||||
echo v22.16.0
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
|
|
@ -25,7 +25,23 @@ struct RuntimeLocatorTests {
|
|||
return
|
||||
}
|
||||
#expect(res.path == node.path)
|
||||
#expect(res.version == RuntimeVersion(major: 22, minor: 5, patch: 0))
|
||||
#expect(res.version == RuntimeVersion(major: 22, minor: 16, patch: 0))
|
||||
}
|
||||
|
||||
@Test func `resolve fails on boundary below minimum`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo v22.15.9
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
guard case let .failure(.unsupported(_, found, required, path, _)) = result else {
|
||||
Issue.record("Expected unsupported error, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(found == RuntimeVersion(major: 22, minor: 15, patch: 9))
|
||||
#expect(required == RuntimeVersion(major: 22, minor: 16, patch: 0))
|
||||
#expect(path == node.path)
|
||||
}
|
||||
|
||||
@Test func `resolve fails when too old`() throws {
|
||||
|
|
@ -60,7 +76,17 @@ struct RuntimeLocatorTests {
|
|||
|
||||
@Test func `describe failure includes paths`() {
|
||||
let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"]))
|
||||
#expect(msg.contains("Node >=22.16.0"))
|
||||
#expect(msg.contains("PATH searched: /tmp/a:/tmp/b"))
|
||||
|
||||
let parseMsg = RuntimeLocator.describeFailure(
|
||||
.versionParse(
|
||||
kind: .node,
|
||||
raw: "garbage",
|
||||
path: "/usr/local/bin/node",
|
||||
searchPaths: ["/usr/local/bin"],
|
||||
))
|
||||
#expect(parseMsg.contains("Node >=22.16.0"))
|
||||
}
|
||||
|
||||
@Test func `runtime version parses with leading V and metadata`() {
|
||||
|
|
|
|||
|
|
@ -74,4 +74,22 @@ struct VoiceWakeRuntimeTests {
|
|||
let config = WakeWordGateConfig(triggers: ["openclaw"], minPostTriggerGap: 0.3)
|
||||
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
|
||||
}
|
||||
|
||||
@Test func `gate command text handles foreign string ranges`() {
|
||||
let transcript = "hey openclaw do thing"
|
||||
let other = "do thing"
|
||||
let foreignRange = other.range(of: "do")
|
||||
let segments = [
|
||||
WakeWordSegment(text: "hey", start: 0.0, duration: 0.1, range: transcript.range(of: "hey")),
|
||||
WakeWordSegment(text: "openclaw", start: 0.2, duration: 0.1, range: transcript.range(of: "openclaw")),
|
||||
WakeWordSegment(text: "do", start: 0.9, duration: 0.1, range: foreignRange),
|
||||
WakeWordSegment(text: "thing", start: 1.1, duration: 0.1, range: nil),
|
||||
]
|
||||
|
||||
#expect(
|
||||
WakeWordGate.commandText(
|
||||
transcript: transcript,
|
||||
segments: segments,
|
||||
triggerEndTime: 0.3) == "do thing")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
# Generated Docs Artifacts
|
||||
|
||||
These baseline artifacts are generated from the repo-owned OpenClaw config schema and bundled channel/plugin metadata.
|
||||
|
||||
- Do not edit `config-baseline.json` by hand.
|
||||
- Do not edit `config-baseline.jsonl` by hand.
|
||||
- Regenerate it with `pnpm config:docs:gen`.
|
||||
- Validate it in CI or locally with `pnpm config:docs:check`.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 64 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -25,7 +25,9 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
|
|||
- Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules.
|
||||
- Two execution styles:
|
||||
- **Main session**: enqueue a system event, then run on the next heartbeat.
|
||||
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with delivery (announce by default or none).
|
||||
- **Isolated**: run a dedicated agent turn in `cron:<jobId>` or a custom session, with delivery (announce by default or none).
|
||||
- **Current session**: bind to the session where the cron is created (`sessionTarget: "current"`).
|
||||
- **Custom session**: run in a persistent named session (`sessionTarget: "session:custom-id"`).
|
||||
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
|
||||
- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`.
|
||||
- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode.
|
||||
|
|
@ -86,6 +88,14 @@ Think of a cron job as: **when** to run + **what** to do.
|
|||
2. **Choose where it runs**
|
||||
- `sessionTarget: "main"` → run during the next heartbeat with main context.
|
||||
- `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:<jobId>`.
|
||||
- `sessionTarget: "current"` → bind to the current session (resolved at creation time to `session:<sessionKey>`).
|
||||
- `sessionTarget: "session:custom-id"` → run in a persistent named session that maintains context across runs.
|
||||
|
||||
Default behavior (unchanged):
|
||||
- `systemEvent` payloads default to `main`
|
||||
- `agentTurn` payloads default to `isolated`
|
||||
|
||||
To use current session binding, explicitly set `sessionTarget: "current"`.
|
||||
|
||||
3. **Choose the payload**
|
||||
- Main session → `payload.kind = "systemEvent"`
|
||||
|
|
@ -147,12 +157,13 @@ See [Heartbeat](/gateway/heartbeat).
|
|||
|
||||
#### Isolated jobs (dedicated cron sessions)
|
||||
|
||||
Isolated jobs run a dedicated agent turn in session `cron:<jobId>`.
|
||||
Isolated jobs run a dedicated agent turn in session `cron:<jobId>` or a custom session.
|
||||
|
||||
Key behaviors:
|
||||
|
||||
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
||||
- Each run starts a **fresh session id** (no prior conversation carry-over).
|
||||
- Each run starts a **fresh session id** (no prior conversation carry-over), unless using a custom session.
|
||||
- Custom sessions (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
|
||||
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`).
|
||||
- `delivery.mode` chooses what happens:
|
||||
- `announce`: deliver a summary to the target channel and post a brief summary to the main session.
|
||||
|
|
@ -321,12 +332,42 @@ Recurring, isolated job with delivery:
|
|||
}
|
||||
```
|
||||
|
||||
Recurring job bound to current session (auto-resolved at creation):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Daily standup",
|
||||
"schedule": { "kind": "cron", "expr": "0 9 * * *" },
|
||||
"sessionTarget": "current",
|
||||
"payload": {
|
||||
"kind": "agentTurn",
|
||||
"message": "Summarize yesterday's progress."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Recurring job in a custom persistent session:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Project monitor",
|
||||
"schedule": { "kind": "every", "everyMs": 300000 },
|
||||
"sessionTarget": "session:project-alpha-monitor",
|
||||
"payload": {
|
||||
"kind": "agentTurn",
|
||||
"message": "Check project status and update the running log."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
|
||||
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
|
||||
- `everyMs` is milliseconds.
|
||||
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
|
||||
- `sessionTarget`: `"main"`, `"isolated"`, `"current"`, or `"session:<custom-id>"`.
|
||||
- `"current"` is resolved to `"session:<sessionKey>"` at creation time.
|
||||
- Custom sessions (`session:xxx`) maintain persistent context across runs.
|
||||
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
|
||||
`delivery`.
|
||||
- `wakeMode` defaults to `"now"` when omitted.
|
||||
|
|
|
|||
|
|
@ -219,13 +219,13 @@ See [Lobster](/tools/lobster) for full usage and examples.
|
|||
|
||||
Both heartbeat and cron can interact with the main session, but differently:
|
||||
|
||||
| | Heartbeat | Cron (main) | Cron (isolated) |
|
||||
| ------- | ------------------------------- | ------------------------ | -------------------------- |
|
||||
| Session | Main | Main (via system event) | `cron:<jobId>` |
|
||||
| History | Shared | Shared | Fresh each run |
|
||||
| Context | Full | Full | None (starts clean) |
|
||||
| Model | Main session model | Main session model | Can override |
|
||||
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) |
|
||||
| | Heartbeat | Cron (main) | Cron (isolated) |
|
||||
| ------- | ------------------------------- | ------------------------ | ----------------------------------------------- |
|
||||
| Session | Main | Main (via system event) | `cron:<jobId>` or custom session |
|
||||
| History | Shared | Shared | Fresh each run (isolated) / Persistent (custom) |
|
||||
| Context | Full | Full | None (isolated) / Cumulative (custom) |
|
||||
| Model | Main session model | Main session model | Can override |
|
||||
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) |
|
||||
|
||||
### When to use main session cron
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ await web_search({
|
|||
## Notes
|
||||
|
||||
- OpenClaw uses the Brave **Search** plan. If you have a legacy subscription (e.g. the original Free plan with 2,000 queries/month), it remains valid but does not include newer features like LLM Context or higher rate limits.
|
||||
- Each Brave plan includes **$5/month in free credit** (renewing). The Search plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans.
|
||||
- Each Brave plan includes **\$5/month in free credit** (renewing). The Search plan costs \$5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans.
|
||||
- The Search plan includes the LLM Context endpoint and AI inference rights. Storing results to train or tune models requires a plan with explicit storage rights. See the Brave [Terms of Service](https://api-dashboard.search.brave.com/terms-of-service).
|
||||
- Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`).
|
||||
|
||||
|
|
|
|||
|
|
@ -195,6 +195,8 @@ Groups:
|
|||
|
||||
- `channels.signal.groupPolicy = open | allowlist | disabled`.
|
||||
- `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
|
||||
- `channels.signal.groups["<group-id>" | "*"]` can override group behavior with `requireMention`, `tools`, and `toolsBySender`.
|
||||
- Use `channels.signal.accounts.<id>.groups` for per-account overrides in multi-account setups.
|
||||
- Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
|
||||
|
||||
## How it works (behavior)
|
||||
|
|
@ -312,6 +314,8 @@ Provider options:
|
|||
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids.
|
||||
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.signal.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.signal.groups`: per-group overrides keyed by Signal group id (or `"*"`). Supported fields: `requireMention`, `tools`, `toolsBySender`.
|
||||
- `channels.signal.accounts.<id>.groups`: per-account version of `channels.signal.groups` for multi-account setups.
|
||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
||||
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
||||
|
|
|
|||
|
|
@ -218,6 +218,55 @@ For actions/directory reads, user token can be preferred when configured. For wr
|
|||
- if encoded option values exceed Slack limits, the flow falls back to buttons
|
||||
- For long option payloads, Slash command argument menus use a confirm dialog before dispatching a selected value.
|
||||
|
||||
## Interactive replies
|
||||
|
||||
Slack can render agent-authored interactive reply controls, but this feature is disabled by default.
|
||||
|
||||
Enable it globally:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
capabilities: {
|
||||
interactiveReplies: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Or enable it for one Slack account only:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
ops: {
|
||||
capabilities: {
|
||||
interactiveReplies: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, agents can emit Slack-only reply directives:
|
||||
|
||||
- `[[slack_buttons: Approve:approve, Reject:reject]]`
|
||||
- `[[slack_select: Choose a target | Canary:canary, Production:production]]`
|
||||
|
||||
These directives compile into Slack Block Kit and route clicks or selections back through the existing Slack interaction event path.
|
||||
|
||||
Notes:
|
||||
|
||||
- This is Slack-specific UI. Other channels do not translate Slack Block Kit directives into their own button systems.
|
||||
- The interactive callback values are OpenClaw-generated opaque tokens, not raw agent-authored values.
|
||||
- If generated interactive blocks would exceed Slack Block Kit limits, OpenClaw falls back to the original text reply instead of sending an invalid blocks payload.
|
||||
|
||||
Default slash command settings:
|
||||
|
||||
- `enabled: false`
|
||||
|
|
|
|||
|
|
@ -782,6 +782,11 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
|
|||
- `--poll-public`
|
||||
- `--thread-id` for forum topics (or use a `:topic:` target)
|
||||
|
||||
Telegram send also supports:
|
||||
|
||||
- `--buttons` for inline keyboards when `channels.telegram.capabilities.inlineButtons` allows it
|
||||
- `--force-document` to send outbound images and GIFs as documents instead of compressed photo or animated-media uploads
|
||||
|
||||
Action gating:
|
||||
|
||||
- `channels.telegram.actions.sendMessage=false` disables outbound Telegram messages, including polls
|
||||
|
|
|
|||
36
docs/ci.md
36
docs/ci.md
|
|
@ -9,32 +9,32 @@ read_when:
|
|||
|
||||
# CI Pipeline
|
||||
|
||||
The CI runs on every push to `main` and every pull request. It uses smart scoping to skip expensive jobs when only docs or native code changed.
|
||||
The CI runs on every push to `main` and every pull request. It uses smart scoping to skip expensive jobs when only unrelated areas changed.
|
||||
|
||||
## Job Overview
|
||||
|
||||
| Job | Purpose | When it runs |
|
||||
| ----------------- | ------------------------------------------------------- | ------------------------------------------------- |
|
||||
| `docs-scope` | Detect docs-only changes | Always |
|
||||
| `changed-scope` | Detect which areas changed (node/macos/android/windows) | Non-docs PRs |
|
||||
| `check` | TypeScript types, lint, format | Push to `main`, or PRs with Node-relevant changes |
|
||||
| `check-docs` | Markdown lint + broken link check | Docs changed |
|
||||
| `code-analysis` | LOC threshold check (1000 lines) | PRs only |
|
||||
| `secrets` | Detect leaked secrets | Always |
|
||||
| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes |
|
||||
| `release-check` | Validate npm pack contents | After build |
|
||||
| `checks` | Node/Bun tests + protocol check | Non-docs, node changes |
|
||||
| `checks-windows` | Windows-specific tests | Non-docs, windows-relevant changes |
|
||||
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
|
||||
| `android` | Gradle build + tests | Non-docs, android changes |
|
||||
| Job | Purpose | When it runs |
|
||||
| ----------------- | ------------------------------------------------------- | ---------------------------------- |
|
||||
| `docs-scope` | Detect docs-only changes | Always |
|
||||
| `changed-scope` | Detect which areas changed (node/macos/android/windows) | Non-doc changes |
|
||||
| `check` | TypeScript types, lint, format | Non-docs, node changes |
|
||||
| `check-docs` | Markdown lint + broken link check | Docs changed |
|
||||
| `secrets` | Detect leaked secrets | Always |
|
||||
| `build-artifacts` | Build dist once, share with `release-check` | Pushes to `main`, node changes |
|
||||
| `release-check` | Validate npm pack contents | Pushes to `main` after build |
|
||||
| `checks` | Node tests + protocol check on PRs; Bun compat on push | Non-docs, node changes |
|
||||
| `compat-node22` | Minimum supported Node runtime compatibility | Pushes to `main`, node changes |
|
||||
| `checks-windows` | Windows-specific tests | Non-docs, windows-relevant changes |
|
||||
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
|
||||
| `android` | Gradle build + tests | Non-docs, android changes |
|
||||
|
||||
## Fail-Fast Order
|
||||
|
||||
Jobs are ordered so cheap checks fail before expensive ones run:
|
||||
|
||||
1. `docs-scope` + `code-analysis` + `check` (parallel, ~1-2 min)
|
||||
2. `build-artifacts` (blocked on above)
|
||||
3. `checks`, `checks-windows`, `macos`, `android` (blocked on build)
|
||||
1. `docs-scope` + `changed-scope` + `check` + `secrets` (parallel, cheap gates first)
|
||||
2. PRs: `checks` (Linux Node test split into 2 shards), `checks-windows`, `macos`, `android`
|
||||
3. Pushes to `main`: `build-artifacts` + `release-check` + Bun compat + `compat-node22`
|
||||
|
||||
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`.
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Related:
|
|||
## Quick start (local)
|
||||
|
||||
```bash
|
||||
openclaw browser --browser-profile chrome tabs
|
||||
openclaw browser profiles
|
||||
openclaw browser --browser-profile openclaw start
|
||||
openclaw browser --browser-profile openclaw open https://example.com
|
||||
openclaw browser --browser-profile openclaw snapshot
|
||||
|
|
@ -38,7 +38,8 @@ openclaw browser --browser-profile openclaw snapshot
|
|||
Profiles are named browser routing configs. In practice:
|
||||
|
||||
- `openclaw`: launches/attaches to a dedicated OpenClaw-managed Chrome instance (isolated user data dir).
|
||||
- `chrome`: controls your existing Chrome tab(s) via the Chrome extension relay.
|
||||
- `user`: controls your existing signed-in Chrome session via Chrome DevTools MCP.
|
||||
- `chrome-relay`: controls your existing Chrome tab(s) via the Chrome extension relay.
|
||||
|
||||
```bash
|
||||
openclaw browser profiles
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue