Compare commits
713 Commits
codex/exte
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd372836e | ||
|
|
ce6a48195a | ||
|
|
8a05c05596 | ||
|
|
43513cd1df | ||
|
|
5bb5d7dab4 | ||
|
|
9fb78453e0 | ||
|
|
d78e13f545 | ||
|
|
6b4c24c2e5 | ||
|
|
598f1826d8 | ||
|
|
5e417b44e1 | ||
|
|
b71686ab44 | ||
|
|
c3be293dd5 | ||
|
|
e78129a4d9 | ||
|
|
6a6f1b5351 | ||
|
|
751d5b7849 | ||
|
|
6526074c85 | ||
|
|
0a842de354 | ||
|
|
2364e45fe4 | ||
|
|
e635cedb85 | ||
|
|
d54ebed7c8 | ||
|
|
d1d46c6cfb | ||
|
|
f1802a5bc7 | ||
|
|
6e20c4baa0 | ||
|
|
42ca447189 | ||
|
|
fac64c2392 | ||
|
|
39a4fe576d | ||
|
|
c3972982b5 | ||
|
|
cadbaa34c1 | ||
|
|
994b42a5a5 | ||
|
|
aed1f6d807 | ||
|
|
09cf6d80ec | ||
|
|
7abfff756d | ||
|
|
c7134e629c | ||
|
|
11d71ca352 | ||
|
|
5a5e84ca1d | ||
|
|
fa71ad7c5d | ||
|
|
23fef04c4e | ||
|
|
1b18742e8e | ||
|
|
a20ba74978 | ||
|
|
3da66718f4 | ||
|
|
acf32287b4 | ||
|
|
916f496b51 | ||
|
|
f6b3245a7b | ||
|
|
62ddc9d9e0 | ||
|
|
46854a84a4 | ||
|
|
7b00a0620a | ||
|
|
a05da76718 | ||
|
|
5408a3d1a4 | ||
|
|
39053bddd7 | ||
|
|
a7401366ef | ||
|
|
083f825122 | ||
|
|
b26edfe1ff | ||
|
|
740b345a2e | ||
|
|
483926a6fb | ||
|
|
2e0b445b46 | ||
|
|
16e055c083 | ||
|
|
e4d0fdcc15 | ||
|
|
fb293fa36f | ||
|
|
a4a5ed8948 | ||
|
|
4edab304db | ||
|
|
3d097f1052 | ||
|
|
e18ab85f08 | ||
|
|
5f600e117d | ||
|
|
35ac1f6e07 | ||
|
|
4e45a663e7 | ||
|
|
c64893a9c2 | ||
|
|
ad4536fd7e | ||
|
|
1cabb053ad | ||
|
|
23a119c6ea | ||
|
|
42801f6178 | ||
|
|
5b7ae24e30 | ||
|
|
a2e1991ed3 | ||
|
|
fb3550ef5e | ||
|
|
58889f984f | ||
|
|
06311f89e0 | ||
|
|
fa275fddf8 | ||
|
|
96e1c37685 | ||
|
|
a39c440d39 | ||
|
|
4838e3934b | ||
|
|
4266e260e1 | ||
|
|
85a5d64d8f | ||
|
|
93fbe26adb | ||
|
|
87eeab7034 | ||
|
|
fcabecc9a4 | ||
|
|
18fa2992f9 | ||
|
|
cb89325cd8 | ||
|
|
4c614c230d | ||
|
|
aa78a0c00e | ||
|
|
9b6f286ac2 | ||
|
|
faa9faa767 | ||
|
|
d3ffa1e4e7 | ||
|
|
dbc9d3dd70 | ||
|
|
50ce9ac1c6 | ||
|
|
f6948ce405 | ||
|
|
ba1bb8505f | ||
|
|
06845a1974 | ||
|
|
7c3af3726f | ||
|
|
897cda7d99 | ||
|
|
5607da90d5 | ||
|
|
dc86b6d72a | ||
|
|
99e53612cb | ||
|
|
c6968c39d6 | ||
|
|
4c60956d8e | ||
|
|
3bda64f75c | ||
|
|
57f1cf66ad | ||
|
|
192f859325 | ||
|
|
6cb2fc501a | ||
|
|
df536c3248 | ||
|
|
d774b3f274 | ||
|
|
dc06e4fd22 | ||
|
|
0fae764f10 | ||
|
|
95f890a8b2 | ||
|
|
f0a0a6a5b4 | ||
|
|
68a274c7b3 | ||
|
|
d25f6f1833 | ||
|
|
f1e012e0fc | ||
|
|
9f8af3604d | ||
|
|
faa8e27291 | ||
|
|
8ac4d13a6f | ||
|
|
0c2e6fe97f | ||
|
|
f09f98532c | ||
|
|
ecec0d5b2c | ||
|
|
dfc157e1a2 | ||
|
|
3a72d2d6de | ||
|
|
e56dde815e | ||
|
|
397b0d85f5 | ||
|
|
709c730e2a | ||
|
|
a562fb5550 | ||
|
|
96f21c37b4 | ||
|
|
6c7526f8a0 | ||
|
|
ce878a9eb1 | ||
|
|
36a59d5c79 | ||
|
|
9af42c6590 | ||
|
|
098a0d0d0d | ||
|
|
f2849c2417 | ||
|
|
8d805a02fd | ||
|
|
5036ed2699 | ||
|
|
06fc498d54 | ||
|
|
94ab044387 | ||
|
|
4d9ae5899d | ||
|
|
b90eef50ec | ||
|
|
829beced04 | ||
|
|
3db2cfef07 | ||
|
|
d689b3fc89 | ||
|
|
254ea0c65e | ||
|
|
9c7da58770 | ||
|
|
fe863c5400 | ||
|
|
a73e517ae3 | ||
|
|
2afd65741c | ||
|
|
61965e500f | ||
|
|
47e412bd0b | ||
|
|
4a0341ed03 | ||
|
|
4386a0ace8 | ||
|
|
e3afaca1a6 | ||
|
|
f7fe75a68b | ||
|
|
4ac355babb | ||
|
|
84ee6fbb76 | ||
|
|
b36e456b09 | ||
|
|
914fc265c5 | ||
|
|
1ba70c3707 | ||
|
|
80110c550f | ||
|
|
991eb2ef03 | ||
|
|
4aef83016f | ||
|
|
03c86b3dee | ||
|
|
62e6eb117e | ||
|
|
218f8d74b6 | ||
|
|
2d24f35016 | ||
|
|
9c21637fe9 | ||
|
|
f62be0ddcf | ||
|
|
ab97cc3f11 | ||
|
|
de9f2dc227 | ||
|
|
4f00b3b534 | ||
|
|
f1ce679929 | ||
|
|
65594f972c | ||
|
|
61ae7e033b | ||
|
|
1fb30fbf78 | ||
|
|
a2174f1ff1 | ||
|
|
cf2a66b508 | ||
|
|
e009920256 | ||
|
|
a19f058145 | ||
|
|
f91fad1710 | ||
|
|
ac18a734ac | ||
|
|
55e12bd236 | ||
|
|
c95d1c101b | ||
|
|
6309b1da6c | ||
|
|
a953cb5209 | ||
|
|
d518260bb8 | ||
|
|
41628770f5 | ||
|
|
aa172f2169 | ||
|
|
c38295c7a2 | ||
|
|
0f69b5c11a | ||
|
|
8e132aed6e | ||
|
|
9486f6e379 | ||
|
|
f3971571fe | ||
|
|
009f494cd9 | ||
|
|
192151610f | ||
|
|
20001a50c5 | ||
|
|
801e4bede6 | ||
|
|
bbfeb0b6f9 | ||
|
|
c3b05fc4d9 | ||
|
|
14eb49c18a | ||
|
|
d80b83e8e3 | ||
|
|
a245916dcb | ||
|
|
ac850e815b | ||
|
|
2884ac13b2 | ||
|
|
35bc00c55b | ||
|
|
bbd62469fa | ||
|
|
da8fb70525 | ||
|
|
73e08775d7 | ||
|
|
566e4cf77b | ||
|
|
5841e3b493 | ||
|
|
aeb2adf240 | ||
|
|
38807fff20 | ||
|
|
ec2278192d | ||
|
|
d03c110a0a | ||
|
|
a54d3dc679 | ||
|
|
628b55a825 | ||
|
|
c7cebd608b | ||
|
|
7d50e7fa85 | ||
|
|
3c806a9692 | ||
|
|
247a19a694 | ||
|
|
83a267e2f3 | ||
|
|
ae02f40144 | ||
|
|
8412498c2c | ||
|
|
0e825ece05 | ||
|
|
7f52a8a3a5 | ||
|
|
1878272f67 | ||
|
|
ca757b6b77 | ||
|
|
98298f7931 | ||
|
|
b7c39aa4d4 | ||
|
|
f1be7d4cb3 | ||
|
|
a94e21e0a7 | ||
|
|
46ccbacbd9 | ||
|
|
3b79494cbf | ||
|
|
7bbd01379e | ||
|
|
ca74eb37da | ||
|
|
0aa4950d21 | ||
|
|
7bc7dd055a | ||
|
|
3de8c3d053 | ||
|
|
8dea2b124b | ||
|
|
003ca0123d | ||
|
|
36df0095c4 | ||
|
|
0fd3632d68 | ||
|
|
22528af34d | ||
|
|
f60017d725 | ||
|
|
7a596b2305 | ||
|
|
60253111a3 | ||
|
|
962a8fea90 | ||
|
|
14e84cf0b3 | ||
|
|
9117836981 | ||
|
|
ebb6738e9d | ||
|
|
34adde2e41 | ||
|
|
815d603ce2 | ||
|
|
a6021cf78f | ||
|
|
e466b55661 | ||
|
|
7187d1da06 | ||
|
|
517570d0fb | ||
|
|
66894db1b6 | ||
|
|
3496ecc2ec | ||
|
|
e5b50ba0d5 | ||
|
|
30ddeabfdc | ||
|
|
071319545f | ||
|
|
e1a39c6ba5 | ||
|
|
22c1bda2a0 | ||
|
|
cb78f38da9 | ||
|
|
e121aad2c1 | ||
|
|
392047b49f | ||
|
|
6b9ebffebb | ||
|
|
feb9a3b5b2 | ||
|
|
51519b4086 | ||
|
|
0a8885d6c1 | ||
|
|
cb552bcc42 | ||
|
|
d9e9a9e819 | ||
|
|
13be4b4cc2 | ||
|
|
b28cf6a8a4 | ||
|
|
d57c327d45 | ||
|
|
089c8bc65e | ||
|
|
faf81c5574 | ||
|
|
a18f7d7d35 | ||
|
|
9f2a01d972 | ||
|
|
0b11ee48f8 | ||
|
|
624d536551 | ||
|
|
1dd857f6a6 | ||
|
|
65a2917c8f | ||
|
|
36f394c299 | ||
|
|
3dfd8eef7f | ||
|
|
401ffb59f5 | ||
|
|
7fb142d115 | ||
|
|
639f78d257 | ||
|
|
dcbcecfb85 | ||
|
|
f1e4f8e8d2 | ||
|
|
91104ac740 | ||
|
|
5b1836d700 | ||
|
|
7a57082466 | ||
|
|
9d772d6eab | ||
|
|
ff6541f69d | ||
|
|
5a41229a6d | ||
|
|
e1b5ffadca | ||
|
|
fb18034011 | ||
|
|
bfe979dd5b | ||
|
|
12ad809e79 | ||
|
|
8268c28053 | ||
|
|
44cd4fb55f | ||
|
|
0c4fdf1284 | ||
|
|
f4f0b171d3 | ||
|
|
8c01347989 | ||
|
|
c7cbc8cc0b | ||
|
|
79d7fdce93 | ||
|
|
a0445b192e | ||
|
|
1c1a3b6a75 | ||
|
|
dd10f290e8 | ||
|
|
191e1947c1 | ||
|
|
5508374669 | ||
|
|
7f86be1037 | ||
|
|
20728e1035 | ||
|
|
47b02435c1 | ||
|
|
75e6c8fe9c | ||
|
|
16129272dc | ||
|
|
f8eb23de1c | ||
|
|
34ee75b174 | ||
|
|
4443cc771a | ||
|
|
f69450b170 | ||
|
|
c4a4050ce4 | ||
|
|
009a10bce2 | ||
|
|
c37a92ca6e | ||
|
|
040c43ae21 | ||
|
|
f3097b4c09 | ||
|
|
0443ee82be | ||
|
|
22943f24a9 | ||
|
|
bcc725ffe2 | ||
|
|
b965ef3802 | ||
|
|
ddd921ff0b | ||
|
|
c5c2416ec2 | ||
|
|
94693f7ff0 | ||
|
|
513b4869d8 | ||
|
|
1d3e596021 | ||
|
|
608b9a9af2 | ||
|
|
a2fa799a5c | ||
|
|
03f18ec043 | ||
|
|
eaee01042b | ||
|
|
b48194a07e | ||
|
|
8467fb6601 | ||
|
|
d978ace90b | ||
|
|
68bc6effc0 | ||
|
|
53a34c39f6 | ||
|
|
3261a2a0b1 | ||
|
|
74b9ad010a | ||
|
|
a2a9a553e1 | ||
|
|
3abffe0967 | ||
|
|
afa95fade0 | ||
|
|
83d284610c | ||
|
|
a98ffa41d0 | ||
|
|
16567ba4e7 | ||
|
|
b8b1e2cf50 | ||
|
|
f6c57edd5c | ||
|
|
79e13e0a5e | ||
|
|
5b7b5529f1 | ||
|
|
126839380c | ||
|
|
74756b91b7 | ||
|
|
f7675eca6b | ||
|
|
59269f3534 | ||
|
|
25015161fe | ||
|
|
19126033dd | ||
|
|
b7ca56f662 | ||
|
|
83c5bc946d | ||
|
|
c86de678f3 | ||
|
|
58cf9b865f | ||
|
|
8404f56841 | ||
|
|
30a94dfd3b | ||
|
|
510f4276b5 | ||
|
|
7b61ca1b06 | ||
|
|
a837ebdd67 | ||
|
|
a290f5e50f | ||
|
|
ffc1d5459c | ||
|
|
6ae68faf5f | ||
|
|
0f0cecd2e8 | ||
|
|
7b151afeeb | ||
|
|
371b3d22f5 | ||
|
|
42b9212eb2 | ||
|
|
f8c70bf1f1 | ||
|
|
de86e25fd4 | ||
|
|
8884643f40 | ||
|
|
002cc07322 | ||
|
|
f19cb738af | ||
|
|
4cc0bb07c1 | ||
|
|
b736a92e19 | ||
|
|
c70837f07d | ||
|
|
62b7b350c9 | ||
|
|
9a9db87952 | ||
|
|
60a55c9cbe | ||
|
|
d7018aaf19 | ||
|
|
07d9f725b6 | ||
|
|
bea90b72e6 | ||
|
|
5f97645382 | ||
|
|
46f49eb6eb | ||
|
|
6e044ace28 | ||
|
|
b9c4db1a77 | ||
|
|
a996f60f11 | ||
|
|
757c2cc2de | ||
|
|
7d8d3d9d77 | ||
|
|
67da67b61a | ||
|
|
2661de384f | ||
|
|
859889aae9 | ||
|
|
91d37ccfc3 | ||
|
|
6ebcd853be | ||
|
|
b526098eb2 | ||
|
|
c749957c93 | ||
|
|
e5a1185796 | ||
|
|
be3f4a7966 | ||
|
|
198de10523 | ||
|
|
63e09f8267 | ||
|
|
2797ae1583 | ||
|
|
cc5bd57bd7 | ||
|
|
e9903c9133 | ||
|
|
e6911f0448 | ||
|
|
ef1346e503 | ||
|
|
ecfa79ee4c | ||
|
|
600f57c979 | ||
|
|
4b5487ee85 | ||
|
|
8f0727d75c | ||
|
|
1746e130f9 | ||
|
|
a0d3dc94d0 | ||
|
|
fa52d122c4 | ||
|
|
62edfdffbd | ||
|
|
152d179302 | ||
|
|
8240fd900a | ||
|
|
505d140aeb | ||
|
|
ea74123ab2 | ||
|
|
7d08070dd7 | ||
|
|
8d73bc77fa | ||
|
|
656679e6e0 | ||
|
|
b49946a67e | ||
|
|
ff326e90c3 | ||
|
|
467ec4d5f3 | ||
|
|
05b1cdec3c | ||
|
|
891e2a3da8 | ||
|
|
b4f16bad32 | ||
|
|
a02bfd30c5 | ||
|
|
f187e8bac4 | ||
|
|
e64cc1983f | ||
|
|
b3ca855283 | ||
|
|
27f655ed11 | ||
|
|
3e02635df3 | ||
|
|
382640e674 | ||
|
|
d8008a9a67 | ||
|
|
3d8afb96bd | ||
|
|
b64f4e313d | ||
|
|
823a09acbe | ||
|
|
10dc4d65d1 | ||
|
|
5fd482d6b0 | ||
|
|
73539ac787 | ||
|
|
947dac48f2 | ||
|
|
cfdc0fdbe1 | ||
|
|
22fc5a5442 | ||
|
|
49b248a333 | ||
|
|
ebb10c0852 | ||
|
|
6a381e80bc | ||
|
|
a0e7a2fcc1 | ||
|
|
f6928617b7 | ||
|
|
7943e83c6c | ||
|
|
c0c3c4824d | ||
|
|
e9b19ca1d1 | ||
|
|
861fcb1575 | ||
|
|
b5d2123156 | ||
|
|
0cddb5fb7c | ||
|
|
ea476de1e4 | ||
|
|
5d41fd4497 | ||
|
|
ca13256913 | ||
|
|
4a44ca8f79 | ||
|
|
c2402e48c9 | ||
|
|
13f396b395 | ||
|
|
86e9dcfc1b | ||
|
|
79c6158ac6 | ||
|
|
4157bcd024 | ||
|
|
d41c9ad4cb | ||
|
|
089a43f5e8 | ||
|
|
f58e0f5592 | ||
|
|
06832112ee | ||
|
|
0e9b899aee | ||
|
|
f2655e1e92 | ||
|
|
b9e08a6839 | ||
|
|
238c036b0d | ||
|
|
f96ee99bbc | ||
|
|
93a31b69de | ||
|
|
afad0697aa | ||
|
|
d8a1ad0f0d | ||
|
|
1890089f49 | ||
|
|
1040ae56b5 | ||
|
|
2f3bc89f4f | ||
|
|
61a19107e1 | ||
|
|
4ac9024de9 | ||
|
|
7ac23ae7c2 | ||
|
|
5625cf4724 | ||
|
|
3cecbcf8b6 | ||
|
|
d1ef7d64e9 | ||
|
|
25011bdb1e | ||
|
|
0567f111ac | ||
|
|
d9e776eb47 | ||
|
|
9b6859e5db | ||
|
|
2afa556746 | ||
|
|
da2289869d | ||
|
|
0ae3e70a5c | ||
|
|
bde4c7995f | ||
|
|
fbd88e2c8f | ||
|
|
e6c6aaa11b | ||
|
|
80e681a60c | ||
|
|
8193af6d4e | ||
|
|
466510b6d8 | ||
|
|
6802a768cf | ||
|
|
4e265fe7d6 | ||
|
|
3a28bc7d8f | ||
|
|
198ed08a38 | ||
|
|
6538c87673 | ||
|
|
4ebd3d11aa | ||
|
|
50a81c8731 | ||
|
|
c99c4b1e27 | ||
|
|
e17d10f7cd | ||
|
|
21c2ba480a | ||
|
|
79f2173cd2 | ||
|
|
1cbfd53ed1 | ||
|
|
0dda3e66b5 | ||
|
|
3d31ba7830 | ||
|
|
8ac4b09fa4 | ||
|
|
bd444435c9 | ||
|
|
5eea523f39 | ||
|
|
0385553918 | ||
|
|
98fbbebf6a | ||
|
|
a5fa75cdb3 | ||
|
|
d341d68180 | ||
|
|
d1fe30b35f | ||
|
|
fe84354a33 | ||
|
|
c36a493e80 | ||
|
|
ad185dd4a8 | ||
|
|
732e075e92 | ||
|
|
b333eb137b | ||
|
|
100d7b0227 | ||
|
|
b48413e252 | ||
|
|
b9b891b614 | ||
|
|
d1d10007a9 | ||
|
|
77dfa73736 | ||
|
|
8af4628a6d | ||
|
|
c81b4a5389 | ||
|
|
6e723dfd69 | ||
|
|
df79113593 | ||
|
|
0bdd17aef2 | ||
|
|
9282d5d09e | ||
|
|
08a0219b1a | ||
|
|
75f98fe19a | ||
|
|
d949a513c5 | ||
|
|
c245c8b39d | ||
|
|
8c436a470e | ||
|
|
1aab71cf5b | ||
|
|
4d551e6f33 | ||
|
|
02826eaa0c | ||
|
|
ed479f96a1 | ||
|
|
0a065bc6c2 | ||
|
|
5642fb2682 | ||
|
|
645c5bda2c | ||
|
|
2ef28a7a3e | ||
|
|
7b27f8a9ae | ||
|
|
7f0f8dd268 | ||
|
|
937f118d8e | ||
|
|
ff849613a4 | ||
|
|
dc20a7cd89 | ||
|
|
cd2752346c | ||
|
|
5f89897df1 | ||
|
|
2c579b6ac1 | ||
|
|
4ca87fa4b0 | ||
|
|
4c160d2c3a | ||
|
|
bfecc58a62 | ||
|
|
5464ad113e | ||
|
|
0354d49a82 | ||
|
|
67ce726bba | ||
|
|
05603e4e6c | ||
|
|
2c5fd8e0c1 | ||
|
|
e1cae60294 | ||
|
|
1ef7e544e9 | ||
|
|
b9dfb6cc23 | ||
|
|
b85d97f22c | ||
|
|
243dabc186 | ||
|
|
23f618d62d | ||
|
|
edcf3e9d32 | ||
|
|
6aaf0d0f24 | ||
|
|
77fb2589b1 | ||
|
|
112d1d3a7c | ||
|
|
2fbf2c0a47 | ||
|
|
9932d2984c | ||
|
|
873ac8bc79 | ||
|
|
aa3739167c | ||
|
|
6710a2be61 | ||
|
|
04eb17bfab | ||
|
|
e5363b0268 | ||
|
|
a8907d80dd | ||
|
|
2b5fa0931d | ||
|
|
b86bc9de95 | ||
|
|
4e94f3aa02 | ||
|
|
e93412b5ce | ||
|
|
0ffcc308f2 | ||
|
|
bf470b711b | ||
|
|
f842de046c | ||
|
|
e5eda19db2 | ||
|
|
1e1bc24f80 | ||
|
|
2d87bc703f | ||
|
|
2b67a3f76e | ||
|
|
4285eb3539 | ||
|
|
0636c6eafa | ||
|
|
2a02337be2 | ||
|
|
c03b0877d0 | ||
|
|
de0285d8ea | ||
|
|
b8dd6548aa | ||
|
|
33bcf11c3f | ||
|
|
6816c76738 | ||
|
|
0f7cd59824 | ||
|
|
d6c13d9dc0 | ||
|
|
028f3c4d15 | ||
|
|
d1d36da700 | ||
|
|
fa896704d2 | ||
|
|
6ba15aadcc | ||
|
|
4079de21ce | ||
|
|
826c592deb | ||
|
|
92a40d324a | ||
|
|
3de973ffff | ||
|
|
df72ca1ece | ||
|
|
1a9114a169 | ||
|
|
1c81b82f48 | ||
|
|
24dc91c6ef | ||
|
|
e691345774 | ||
|
|
326c660775 | ||
|
|
a2518a16ac | ||
|
|
fb5ab95e03 | ||
|
|
a89cb3e10e | ||
|
|
4c9028439c | ||
|
|
2c35faf437 | ||
|
|
d2ef865073 | ||
|
|
1aae93b1fa | ||
|
|
f253f14b0b | ||
|
|
a8f433d611 | ||
|
|
bf8702973f | ||
|
|
4e706da898 | ||
|
|
1f5f3fc2ef | ||
|
|
c29458d407 | ||
|
|
a4b98f95c2 | ||
|
|
9c12b41c52 | ||
|
|
005b25e9d4 | ||
|
|
6556a40330 | ||
|
|
5c4903d3fd | ||
|
|
7ba8dd112f | ||
|
|
a34944c918 | ||
|
|
f8f9e06b58 | ||
|
|
0bfaa36126 | ||
|
|
9350cb19dd | ||
|
|
9e556f75f5 | ||
|
|
889011c08c | ||
|
|
abaa9107c5 | ||
|
|
2f21eeb3cb | ||
|
|
1777b99ccc | ||
|
|
56066dccb0 | ||
|
|
0a90b07f8d | ||
|
|
28b888cbcd | ||
|
|
cd5c2f4cb2 | ||
|
|
3cc83cb81e | ||
|
|
a41840f717 | ||
|
|
53dcafbec3 | ||
|
|
206d1be082 | ||
|
|
27d4fdf3bb | ||
|
|
6b9b32a160 | ||
|
|
682f4d1ca3 | ||
|
|
870f260772 | ||
|
|
25e6cd38b6 | ||
|
|
fa34cb887d | ||
|
|
5b2c5ee2bc | ||
|
|
055632460d | ||
|
|
889bb8a78a | ||
|
|
1313767825 | ||
|
|
b942dacf48 | ||
|
|
44521d6b20 | ||
|
|
6f060d7e6c | ||
|
|
841b1a59d7 | ||
|
|
01ae160108 | ||
|
|
d8b95d2315 | ||
|
|
fa73f5aeb5 | ||
|
|
9e8b9aba1f | ||
|
|
bb803a42ac | ||
|
|
09de192b77 | ||
|
|
8e98019b6a | ||
|
|
fb0d04c834 | ||
|
|
1c6676cd57 | ||
|
|
ed7269518f | ||
|
|
b5c38b1095 | ||
|
|
8165db758b | ||
|
|
b3ae50c71c | ||
|
|
c3386d34d2 | ||
|
|
9df3e9b617 | ||
|
|
4c36436fb4 | ||
|
|
d3fc6c0cc7 | ||
|
|
d073ec42cd | ||
|
|
2d3bcbfe08 | ||
|
|
9a455a8c08 | ||
|
|
50cac39657 | ||
|
|
53df7ff86d | ||
|
|
f2de673130 | ||
|
|
ab62f3b9f4 | ||
|
|
ab1da26f4d | ||
|
|
7dabcf287d | ||
|
|
951f3f992b | ||
|
|
da948a8073 | ||
|
|
cac1c62208 | ||
|
|
28ab5061bf | ||
|
|
60104de428 | ||
|
|
0a0ca804aa | ||
|
|
c9ba985839 | ||
|
|
bb365dba73 | ||
|
|
144b95ffce |
87
.agents/skills/openclaw-ghsa-maintainer/SKILL.md
Normal file
87
.agents/skills/openclaw-ghsa-maintainer/SKILL.md
Normal file
@ -0,0 +1,87 @@
|
||||
---
|
||||
name: openclaw-ghsa-maintainer
|
||||
description: Maintainer workflow for OpenClaw GitHub Security Advisories (GHSA). Use when Codex needs to inspect, patch, validate, or publish a repo advisory, verify private-fork state, prepare advisory Markdown or JSON payloads safely, handle GHSA API-specific publish constraints, or confirm advisory publish success.
|
||||
---
|
||||
|
||||
# OpenClaw GHSA Maintainer
|
||||
|
||||
Use this skill for repo security advisory workflow only. Keep general release work in `openclaw-release-maintainer`.
|
||||
|
||||
## Respect advisory guardrails
|
||||
|
||||
- Before reviewing or publishing a repo advisory, read `SECURITY.md`.
|
||||
- Ask permission before any publish action.
|
||||
- Treat this skill as GHSA-only. Do not use it for stable or beta release work.
|
||||
|
||||
## Fetch and inspect advisory state
|
||||
|
||||
Fetch the current advisory and the latest published npm version:
|
||||
|
||||
```bash
|
||||
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
|
||||
npm view openclaw version --userconfig "$(mktemp)"
|
||||
```
|
||||
|
||||
Use the fetch output to confirm the advisory state, linked private fork, and vulnerability payload shape before patching.
|
||||
|
||||
## Verify private fork PRs are closed
|
||||
|
||||
Before publishing, verify that the advisory's private fork has no open PRs:
|
||||
|
||||
```bash
|
||||
fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)
|
||||
gh pr list -R "$fork" --state open
|
||||
```
|
||||
|
||||
The PR list must be empty before publish.
|
||||
|
||||
## Prepare advisory Markdown and JSON safely
|
||||
|
||||
- Write advisory Markdown via heredoc to a temp file. Do not use escaped `\n` strings.
|
||||
- Build PATCH payload JSON with `jq`, not hand-escaped shell JSON.
|
||||
|
||||
Example pattern:
|
||||
|
||||
```bash
|
||||
cat > /tmp/ghsa.desc.md <<'EOF'
|
||||
<markdown description>
|
||||
EOF
|
||||
|
||||
jq -n --rawfile desc /tmp/ghsa.desc.md \
|
||||
'{summary,severity,description:$desc,vulnerabilities:[...]}' \
|
||||
> /tmp/ghsa.patch.json
|
||||
```
|
||||
|
||||
## Apply PATCH calls in the correct sequence
|
||||
|
||||
- Do not set `severity` and `cvss_vector_string` in the same PATCH call.
|
||||
- Use separate calls when the advisory requires both fields.
|
||||
- Publish by PATCHing the advisory and setting `"state":"published"`. There is no separate `/publish` endpoint.
|
||||
|
||||
Example shape:
|
||||
|
||||
```bash
|
||||
gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> \
|
||||
--input /tmp/ghsa.patch.json
|
||||
```
|
||||
|
||||
## Publish and verify success
|
||||
|
||||
After publish, re-fetch the advisory and confirm:
|
||||
|
||||
- `state=published`
|
||||
- `published_at` is set
|
||||
- the description does not contain literal escaped `\\n`
|
||||
|
||||
Verification pattern:
|
||||
|
||||
```bash
|
||||
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
|
||||
jq -r .description < /tmp/ghsa.refetch.json | rg '\\\\n'
|
||||
```
|
||||
|
||||
## Common GHSA footguns
|
||||
|
||||
- Publishing fails with HTTP 422 if required fields are missing or the private fork still has open PRs.
|
||||
- A payload that looks correct in shell can still be wrong if Markdown was assembled with escaped newline strings.
|
||||
- Advisory PATCH sequencing matters; separate field updates when GHSA API constraints require it.
|
||||
58
.agents/skills/openclaw-parallels-smoke/SKILL.md
Normal file
58
.agents/skills/openclaw-parallels-smoke/SKILL.md
Normal file
@ -0,0 +1,58 @@
|
||||
---
|
||||
name: openclaw-parallels-smoke
|
||||
description: End-to-end Parallels smoke, upgrade, and rerun workflow for OpenClaw across macOS, Windows, and Linux guests. Use when Codex needs to run, rerun, debug, or interpret VM-based install, onboarding, gateway smoke tests, latest-release-to-main upgrade checks, fresh snapshot retests, or optional Discord roundtrip verification under Parallels.
|
||||
---
|
||||
|
||||
# OpenClaw Parallels Smoke
|
||||
|
||||
Use this skill for Parallels guest workflows and smoke interpretation. Do not load it for normal repo work.
|
||||
|
||||
## Global rules
|
||||
|
||||
- Use the snapshot most closely matching the requested fresh baseline.
|
||||
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc` unless the stable version being checked does not support it yet.
|
||||
- Stable `2026.3.12` pre-upgrade diagnostics may require a plain `gateway status --deep` fallback.
|
||||
- Treat `precheck=latest-ref-fail` on that stable pre-upgrade lane as baseline, not automatically a regression.
|
||||
- Pass `--json` for machine-readable summaries.
|
||||
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
|
||||
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
|
||||
|
||||
## macOS flow
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:macos`
|
||||
- Target the snapshot closest to `macOS 26.3.1 fresh`.
|
||||
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
|
||||
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
|
||||
- Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
|
||||
- Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task.
|
||||
|
||||
## Windows flow
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:windows`
|
||||
- Use the snapshot closest to `pre-openclaw-native-e2e-2026-03-12`.
|
||||
- Always use `prlctl exec --current-user`; plain `prlctl exec` lands in `NT AUTHORITY\\SYSTEM`.
|
||||
- Prefer explicit `npm.cmd` and `openclaw.cmd`.
|
||||
- Use PowerShell only as the transport with `-ExecutionPolicy Bypass`, then call the `.cmd` shims from inside it.
|
||||
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
|
||||
|
||||
## Linux flow
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:linux`
|
||||
- Use the snapshot closest to fresh `Ubuntu 24.04.3 ARM64`.
|
||||
- Use plain `prlctl exec`; `--current-user` is not the right transport on this snapshot.
|
||||
- Fresh snapshots may be missing `curl`, and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates`.
|
||||
- Fresh `main` tgz smoke still needs the latest-release installer first because the snapshot has no Node or npm before bootstrap.
|
||||
- This snapshot does not have a usable `systemd --user` session; managed daemon install is unsupported.
|
||||
- `prlctl exec` reaps detached Linux child processes on this snapshot, so detached background gateway runs are not trustworthy smoke signals.
|
||||
|
||||
## Discord roundtrip
|
||||
|
||||
- Discord roundtrip is optional and should be enabled with:
|
||||
- `--discord-token-env`
|
||||
- `--discord-guild-id`
|
||||
- `--discord-channel-id`
|
||||
- Keep the Discord token only in a host env var.
|
||||
- Use installed `openclaw message send/read`, not `node openclaw.mjs message ...`.
|
||||
- Set `channels.discord.guilds` as one JSON object, not dotted config paths with snowflakes.
|
||||
- Avoid long `prlctl enter` or expect-driven Discord config scripts; prefer `prlctl exec --current-user /bin/sh -lc ...` with short commands.
|
||||
- For a narrower macOS-only Discord proof run, the existing `parallels-discord-roundtrip` skill is the deep-dive companion.
|
||||
75
.agents/skills/openclaw-pr-maintainer/SKILL.md
Normal file
75
.agents/skills/openclaw-pr-maintainer/SKILL.md
Normal file
@ -0,0 +1,75 @@
|
||||
---
|
||||
name: openclaw-pr-maintainer
|
||||
description: Maintainer workflow for reviewing, triaging, preparing, closing, or landing OpenClaw pull requests and related issues. Use when Codex needs to validate bug-fix claims, search for related issues or PRs, apply or recommend close/reason labels, prepare GitHub comments safely, check review-thread follow-up, or perform maintainer-style PR decision making before merge or closure.
|
||||
---
|
||||
|
||||
# OpenClaw PR Maintainer
|
||||
|
||||
Use this skill for maintainer-facing GitHub workflow, not for ordinary code changes.
|
||||
|
||||
## Apply close and triage labels correctly
|
||||
|
||||
- If an issue or PR matches an auto-close reason, apply the label and let `.github/workflows/auto-response.yml` handle the comment/close/lock flow.
|
||||
- Do not manually close plus manually comment for these reasons.
|
||||
- `r:*` labels can be used on both issues and PRs.
|
||||
- Current reasons:
|
||||
- `r: skill`
|
||||
- `r: support`
|
||||
- `r: no-ci-pr`
|
||||
- `r: too-many-prs`
|
||||
- `r: testflight`
|
||||
- `r: third-party-extension`
|
||||
- `r: moltbook`
|
||||
- `r: spam`
|
||||
- `invalid`
|
||||
- `dirty` for PRs only
|
||||
|
||||
## Enforce the bug-fix evidence bar
|
||||
|
||||
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
|
||||
- Before landing, require:
|
||||
1. symptom evidence such as a repro, logs, or a failing test
|
||||
2. a verified root cause in code with file/line
|
||||
3. a fix that touches the implicated code path
|
||||
4. a regression test when feasible, or explicit manual verification plus a reason no test was added
|
||||
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
|
||||
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
|
||||
|
||||
## Handle GitHub text safely
|
||||
|
||||
- For issue comments and PR comments, use literal multiline strings or `-F - <<'EOF'` for real newlines. Never embed `\n`.
|
||||
- Do not use `gh issue/pr comment -b "..."` when the body contains backticks or shell characters. Prefer a single-quoted heredoc.
|
||||
- Do not wrap issue or PR refs like `#24643` in backticks when you want auto-linking.
|
||||
- PR landing comments should include clickable full commit links for landed and source SHAs when present.
|
||||
|
||||
## Search broadly before deciding
|
||||
|
||||
- Prefer targeted keyword search before proposing new work or closing something as duplicate.
|
||||
- Use `--repo openclaw/openclaw` with `--match title,body` first.
|
||||
- Add `--match comments` when triaging follow-up discussion.
|
||||
- Do not stop at the first 500 results when the task requires a full search.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
|
||||
gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
|
||||
gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
|
||||
--json number,title,state,url,updatedAt -- "auto update" \
|
||||
--jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'
|
||||
```
|
||||
|
||||
## Follow PR review and landing hygiene
|
||||
|
||||
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
|
||||
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
|
||||
- When landing or merging any PR, follow the global `/landpr` process.
|
||||
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
|
||||
- Keep commit messages concise and action-oriented.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues.
|
||||
|
||||
## Extra safety
|
||||
|
||||
- If a close or reopen action would affect more than 5 PRs, ask for explicit confirmation with the exact count and target query first.
|
||||
- `sync` means: if the tree is dirty, commit all changes with a sensible Conventional Commit message, then `git pull --rebase`, then `git push`. Stop if rebase conflicts cannot be resolved safely.
|
||||
74
.agents/skills/openclaw-release-maintainer/SKILL.md
Normal file
74
.agents/skills/openclaw-release-maintainer/SKILL.md
Normal file
@ -0,0 +1,74 @@
|
||||
---
|
||||
name: openclaw-release-maintainer
|
||||
description: Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
|
||||
---
|
||||
|
||||
# OpenClaw Release Maintainer
|
||||
|
||||
Use this skill for release and publish-time workflow. Keep ordinary development changes and GHSA-specific advisory work outside this skill.
|
||||
|
||||
## Respect release guardrails
|
||||
|
||||
- Do not change version numbers without explicit operator approval.
|
||||
- Ask permission before any npm publish or release step.
|
||||
- Use the private maintainer release docs for the actual runbook and `docs/reference/RELEASING.md` for public policy.
|
||||
|
||||
## Keep release channel naming aligned
|
||||
|
||||
- `stable`: tagged releases only, with npm dist-tag `latest`
|
||||
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
|
||||
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
|
||||
- `dev`: moving head on `main`
|
||||
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
|
||||
|
||||
## Handle versions and release files consistently
|
||||
|
||||
- Version locations include:
|
||||
- `package.json`
|
||||
- `apps/android/app/build.gradle.kts`
|
||||
- `apps/ios/Sources/Info.plist`
|
||||
- `apps/ios/Tests/Info.plist`
|
||||
- `apps/macos/Sources/OpenClaw/Resources/Info.plist`
|
||||
- `docs/install/updating.md`
|
||||
- Peekaboo Xcode project and plist version fields
|
||||
- “Bump version everywhere” means all version locations above except `appcast.xml`.
|
||||
- Release signing and notary credentials live outside the repo in the private maintainer docs.
|
||||
|
||||
## Build changelog-backed release notes
|
||||
|
||||
- Changelog entries should be user-facing, not internal release-process notes.
|
||||
- When cutting a mac release with a beta GitHub prerelease:
|
||||
- tag `vYYYY.M.D-beta.N` from the release commit
|
||||
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
|
||||
- use release notes from the matching `CHANGELOG.md` version section
|
||||
- attach at least the zip and dSYM zip, plus dmg if available
|
||||
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
|
||||
- `### Changes` first
|
||||
- `### Fixes` deduped with user-facing fixes first
|
||||
|
||||
## Run publish-time validation
|
||||
|
||||
Before tagging or publishing, run:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/release-check.ts
|
||||
pnpm release:check
|
||||
pnpm test:install:smoke
|
||||
```
|
||||
|
||||
For a non-root smoke path:
|
||||
|
||||
```bash
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
|
||||
```
|
||||
|
||||
## Use the right auth flow
|
||||
|
||||
- Core `openclaw` publish uses GitHub trusted publishing.
|
||||
- Do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
|
||||
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
|
||||
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
|
||||
|
||||
## GHSA advisory work
|
||||
|
||||
- Use `openclaw-ghsa-maintainer` for GHSA advisory inspection, patch/publish flow, private-fork validation, and GHSA API-specific publish checks.
|
||||
71
.agents/skills/openclaw-test-heap-leaks/SKILL.md
Normal file
71
.agents/skills/openclaw-test-heap-leaks/SKILL.md
Normal file
@ -0,0 +1,71 @@
|
||||
---
|
||||
name: openclaw-test-heap-leaks
|
||||
description: Investigate `pnpm test` memory growth, Vitest worker OOMs, and suspicious RSS increases in OpenClaw using the `scripts/test-parallel.mjs` heap snapshot tooling. Use when Codex needs to reproduce test-lane memory growth, collect repeated `.heapsnapshot` files, compare snapshots from the same worker PID, distinguish transformed-module retention from real data leaks, and fix or reduce the impact by patching cleanup logic or isolating hotspot tests.
|
||||
---
|
||||
|
||||
# OpenClaw Test Heap Leaks
|
||||
|
||||
Use this skill for test-memory investigations. Do not guess from RSS alone when heap snapshots are available.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Reproduce the failing shape first.
|
||||
- Match the real entrypoint if possible. For Linux CI-style unit failures, start with:
|
||||
- `pnpm canvas:a2ui:bundle && OPENCLAW_TEST_MEMORY_TRACE=1 OPENCLAW_TEST_HEAPSNAPSHOT_INTERVAL_MS=60000 OPENCLAW_TEST_HEAPSNAPSHOT_DIR=.tmp/heapsnap OPENCLAW_TEST_WORKERS=2 OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144 pnpm test`
|
||||
- Keep `OPENCLAW_TEST_MEMORY_TRACE=1` enabled so the wrapper prints per-file RSS summaries alongside the snapshots.
|
||||
- If the report is about a specific shard or worker budget, preserve that shape.
|
||||
|
||||
2. Wait for repeated snapshots before concluding anything.
|
||||
- Take at least two intervals from the same lane.
|
||||
- Compare snapshots from the same PID inside one lane directory such as `.tmp/heapsnap/unit-fast/`.
|
||||
- Use `scripts/heapsnapshot-delta.mjs` to compare either two files directly or the earliest/latest pair per PID in one lane directory.
|
||||
|
||||
3. Classify the growth before choosing a fix.
|
||||
- If growth is dominated by Vite/Vitest transformed source strings, `Module`, `system / Context`, bytecode, descriptor arrays, or property maps, treat it as retained module graph growth in long-lived workers.
|
||||
- If growth is dominated by app objects, caches, buffers, server handles, timers, mock state, sqlite state, or similar runtime objects, treat it as a likely cleanup or lifecycle leak.
|
||||
|
||||
4. Fix the right layer.
|
||||
- For retained transformed-module growth in shared workers:
|
||||
- Move hotspot files out of `unit-fast` by updating `test/fixtures/test-parallel.behavior.json`.
|
||||
- Prefer `singletonIsolated` for files that are safe alone but inflate shared worker heaps.
|
||||
- If the file should already have been peeled out by timings but is absent from `test/fixtures/test-timings.unit.json`, call that out explicitly. Missing timings are a scheduling blind spot.
|
||||
- For real leaks:
|
||||
- Patch the implicated test or runtime cleanup path.
|
||||
- Look for missing `afterEach`/`afterAll`, module-reset gaps, retained global state, unreleased DB handles, or listeners/timers that survive the file.
|
||||
|
||||
5. Verify with the most direct proof.
|
||||
- Re-run the targeted lane or file with heap snapshots enabled if the suite still finishes in reasonable time.
|
||||
- If snapshot overhead pushes tests over Vitest timeouts, fall back to the same lane without snapshots and confirm the RSS trend or OOM is reduced.
|
||||
- For wrapper-only changes, at minimum verify the expected lanes start and the snapshot files are written.
|
||||
|
||||
## Heuristics
|
||||
|
||||
- Do not call everything a leak. In this repo, large `unit-fast` growth can be a worker-lifetime problem rather than an application object leak.
|
||||
- `scripts/test-parallel.mjs` and `scripts/test-parallel-memory.mjs` are the primary control points for wrapper diagnostics.
|
||||
- The lane names printed by `[test-parallel] start ...` and `[test-parallel][mem] summary ...` tell you where to focus.
|
||||
- When one or two files account for most of the delta and they are missing from timings, reducing impact by isolating them is usually the first pragmatic fix.
|
||||
- When the same retained object families grow across multiple intervals in the same worker PID, trust the snapshots over intuition.
|
||||
|
||||
## Snapshot Comparison
|
||||
|
||||
- Direct comparison:
|
||||
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs before.heapsnapshot after.heapsnapshot`
|
||||
- Auto-select earliest/latest snapshots per PID within one lane:
|
||||
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs --lane-dir .tmp/heapsnap/unit-fast`
|
||||
- Useful flags:
|
||||
- `--top 40`
|
||||
- `--min-kb 32`
|
||||
- `--pid 16133`
|
||||
|
||||
Read the top positive deltas first. Large positive growth in module-transform artifacts suggests lane isolation; large positive growth in runtime objects suggests a real leak.
|
||||
|
||||
## Output Expectations
|
||||
|
||||
When using this skill, report:
|
||||
|
||||
- The exact reproduce command.
|
||||
- Which lane and PID were compared.
|
||||
- The dominant retained object families from the snapshot delta.
|
||||
- Whether the issue is a real leak or shared-worker retained module growth.
|
||||
- The concrete fix or impact-reduction patch.
|
||||
- What you verified, and what snapshot overhead prevented you from verifying.
|
||||
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Test Heap Leaks"
|
||||
short_description: "Investigate test OOMs with heap snapshots"
|
||||
default_prompt: "Use $openclaw-test-heap-leaks to investigate test memory growth with heap snapshots and reduce its impact."
|
||||
@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function printUsage() {
|
||||
console.error(
|
||||
"Usage: node heapsnapshot-delta.mjs <before.heapsnapshot> <after.heapsnapshot> [--top N] [--min-kb N]",
|
||||
);
|
||||
console.error(
|
||||
" or: node heapsnapshot-delta.mjs --lane-dir <dir> [--pid PID] [--top N] [--min-kb N]",
|
||||
);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
top: 30,
|
||||
minKb: 64,
|
||||
laneDir: null,
|
||||
pid: null,
|
||||
files: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--top") {
|
||||
options.top = Number.parseInt(argv[index + 1] ?? "", 10);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--min-kb") {
|
||||
options.minKb = Number.parseInt(argv[index + 1] ?? "", 10);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--lane-dir") {
|
||||
options.laneDir = argv[index + 1] ?? null;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--pid") {
|
||||
options.pid = Number.parseInt(argv[index + 1] ?? "", 10);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
options.files.push(arg);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(options.top) || options.top <= 0) {
|
||||
fail("--top must be a positive integer");
|
||||
}
|
||||
if (!Number.isFinite(options.minKb) || options.minKb < 0) {
|
||||
fail("--min-kb must be a non-negative integer");
|
||||
}
|
||||
if (options.pid !== null && (!Number.isInteger(options.pid) || options.pid <= 0)) {
|
||||
fail("--pid must be a positive integer");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseHeapFilename(filePath) {
|
||||
const base = path.basename(filePath);
|
||||
const match = base.match(
|
||||
/^Heap\.(?<stamp>\d{8}\.\d{6})\.(?<pid>\d+)\.0\.(?<seq>\d+)\.heapsnapshot$/u,
|
||||
);
|
||||
if (!match?.groups) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
filePath,
|
||||
pid: Number.parseInt(match.groups.pid, 10),
|
||||
stamp: match.groups.stamp,
|
||||
sequence: Number.parseInt(match.groups.seq, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePair(options) {
|
||||
if (options.laneDir) {
|
||||
const entries = fs
|
||||
.readdirSync(options.laneDir)
|
||||
.map((name) => parseHeapFilename(path.join(options.laneDir, name)))
|
||||
.filter((entry) => entry !== null)
|
||||
.filter((entry) => options.pid === null || entry.pid === options.pid)
|
||||
.toSorted((left, right) => {
|
||||
if (left.pid !== right.pid) {
|
||||
return left.pid - right.pid;
|
||||
}
|
||||
if (left.stamp !== right.stamp) {
|
||||
return left.stamp.localeCompare(right.stamp);
|
||||
}
|
||||
return left.sequence - right.sequence;
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
fail(`No matching heap snapshots found in ${options.laneDir}`);
|
||||
}
|
||||
|
||||
const groups = new Map();
|
||||
for (const entry of entries) {
|
||||
const group = groups.get(entry.pid) ?? [];
|
||||
group.push(entry);
|
||||
groups.set(entry.pid, group);
|
||||
}
|
||||
|
||||
const candidates = Array.from(groups.values())
|
||||
.map((group) => ({
|
||||
pid: group[0].pid,
|
||||
before: group[0],
|
||||
after: group.at(-1),
|
||||
count: group.length,
|
||||
}))
|
||||
.filter((entry) => entry.count >= 2);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
fail(`Need at least two snapshots for one PID in ${options.laneDir}`);
|
||||
}
|
||||
|
||||
const chosen =
|
||||
options.pid !== null
|
||||
? (candidates.find((entry) => entry.pid === options.pid) ?? null)
|
||||
: candidates.toSorted((left, right) => right.count - left.count || left.pid - right.pid)[0];
|
||||
|
||||
if (!chosen) {
|
||||
fail(`No PID with at least two snapshots matched in ${options.laneDir}`);
|
||||
}
|
||||
|
||||
return {
|
||||
before: chosen.before.filePath,
|
||||
after: chosen.after.filePath,
|
||||
pid: chosen.pid,
|
||||
snapshotCount: chosen.count,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.files.length !== 2) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
before: options.files[0],
|
||||
after: options.files[1],
|
||||
pid: null,
|
||||
snapshotCount: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function loadSummary(filePath) {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
const meta = data.snapshot?.meta;
|
||||
if (!meta) {
|
||||
fail(`Invalid heap snapshot: ${filePath}`);
|
||||
}
|
||||
|
||||
const nodeFieldCount = meta.node_fields.length;
|
||||
const typeNames = meta.node_types[0];
|
||||
const strings = data.strings;
|
||||
const typeIndex = meta.node_fields.indexOf("type");
|
||||
const nameIndex = meta.node_fields.indexOf("name");
|
||||
const selfSizeIndex = meta.node_fields.indexOf("self_size");
|
||||
|
||||
const summary = new Map();
|
||||
for (let offset = 0; offset < data.nodes.length; offset += nodeFieldCount) {
|
||||
const type = typeNames[data.nodes[offset + typeIndex]];
|
||||
const name = strings[data.nodes[offset + nameIndex]];
|
||||
const selfSize = data.nodes[offset + selfSizeIndex];
|
||||
const key = `${type}\t${name}`;
|
||||
const current = summary.get(key) ?? {
|
||||
type,
|
||||
name,
|
||||
selfSize: 0,
|
||||
count: 0,
|
||||
};
|
||||
current.selfSize += selfSize;
|
||||
current.count += 1;
|
||||
summary.set(key, current);
|
||||
}
|
||||
return {
|
||||
nodeCount: data.snapshot.node_count,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (Math.abs(bytes) >= 1024 ** 2) {
|
||||
return `${(bytes / 1024 ** 2).toFixed(2)} MiB`;
|
||||
}
|
||||
if (Math.abs(bytes) >= 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function formatDelta(bytes) {
|
||||
return `${bytes >= 0 ? "+" : "-"}${formatBytes(Math.abs(bytes))}`;
|
||||
}
|
||||
|
||||
function truncate(text, maxLength) {
|
||||
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const pair = resolvePair(options);
|
||||
const before = loadSummary(pair.before);
|
||||
const after = loadSummary(pair.after);
|
||||
const minBytes = options.minKb * 1024;
|
||||
|
||||
const rows = [];
|
||||
for (const [key, next] of after.summary) {
|
||||
const previous = before.summary.get(key) ?? { selfSize: 0, count: 0 };
|
||||
const sizeDelta = next.selfSize - previous.selfSize;
|
||||
const countDelta = next.count - previous.count;
|
||||
if (sizeDelta < minBytes) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
type: next.type,
|
||||
name: next.name,
|
||||
sizeDelta,
|
||||
countDelta,
|
||||
afterSize: next.selfSize,
|
||||
afterCount: next.count,
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort(
|
||||
(left, right) => right.sizeDelta - left.sizeDelta || right.countDelta - left.countDelta,
|
||||
);
|
||||
|
||||
console.log(`before: ${pair.before}`);
|
||||
console.log(`after: ${pair.after}`);
|
||||
if (pair.pid !== null) {
|
||||
console.log(`pid: ${pair.pid} (${pair.snapshotCount} snapshots found)`);
|
||||
}
|
||||
console.log(
|
||||
`nodes: ${before.nodeCount} -> ${after.nodeCount} (${after.nodeCount - before.nodeCount >= 0 ? "+" : ""}${after.nodeCount - before.nodeCount})`,
|
||||
);
|
||||
console.log(`filter: top=${options.top} min=${options.minKb} KiB`);
|
||||
console.log("");
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("No entries exceeded the minimum delta.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows.slice(0, options.top)) {
|
||||
console.log(
|
||||
[
|
||||
formatDelta(row.sizeDelta).padStart(11),
|
||||
`count ${row.countDelta >= 0 ? "+" : ""}${row.countDelta}`.padStart(10),
|
||||
row.type.padEnd(16),
|
||||
truncate(row.name || "(empty)", 96),
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@ -1,7 +1,7 @@
|
||||
.git
|
||||
.worktrees
|
||||
|
||||
# Sensitive files – docker-setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN
|
||||
# Sensitive files – scripts/docker/setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN
|
||||
# into the project root; keep it out of the build context.
|
||||
.env
|
||||
.env.*
|
||||
|
||||
46
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
46
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -7,7 +7,8 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
|
||||
Thanks for filing this report. Keep every answer concise, reproducible, and grounded in observed evidence.
|
||||
Do not speculate or infer beyond the evidence. If a narrative section cannot be answered from the available evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
- type: dropdown
|
||||
id: bug_type
|
||||
attributes:
|
||||
@ -23,35 +24,35 @@ body:
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-sentence statement of what is broken.
|
||||
placeholder: After upgrading to <version>, <channel> behavior regressed from <prior version>.
|
||||
description: One-sentence statement of what is broken, based only on observed evidence. If the evidence is insufficient, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: After upgrading from 2026.2.10 to 2026.2.17, Telegram thread replies stopped posting; reproduced twice and confirmed by gateway logs.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide the shortest deterministic repro path.
|
||||
description: Provide the shortest deterministic repro path supported by direct observation. If the repro path cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: |
|
||||
1. Configure channel X.
|
||||
2. Send message Y.
|
||||
3. Run command Z.
|
||||
1. Start OpenClaw 2026.2.17 with the attached config.
|
||||
2. Send a Telegram thread reply in the affected chat.
|
||||
3. Observe no reply and confirm the attached `reply target not found` log line.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What should happen if the bug does not exist.
|
||||
placeholder: Agent posts a reply in the same thread.
|
||||
description: State the expected result using a concrete reference such as prior observed behavior, attached docs, or a known-good version. If no grounded reference exists, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: In 2026.2.10, the agent posted replies in the same Telegram thread under the same workflow.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What happened instead, including user-visible errors.
|
||||
placeholder: No reply is posted; gateway logs "reply target not found".
|
||||
description: Describe only the observed result, including user-visible errors and cited evidence. If the observed result cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: No reply is posted in the thread; the attached gateway log shows `reply target not found` at 14:23:08 UTC.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@ -92,12 +93,6 @@ body:
|
||||
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: config_location
|
||||
attributes:
|
||||
label: Config file / key location
|
||||
description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets.
|
||||
placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents/<agentId>/agent/models.json
|
||||
- type: textarea
|
||||
id: provider_setup_details
|
||||
attributes:
|
||||
@ -111,27 +106,28 @@ body:
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, and evidence
|
||||
description: Include redacted logs/screenshots/recordings that prove the behavior.
|
||||
description: Include the redacted logs, screenshots, recordings, docs, or version comparisons that support the grounded answers above.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact and severity
|
||||
description: |
|
||||
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
|
||||
Explain who is affected, how severe it is, how often it happens, and the practical consequence using only observed evidence.
|
||||
If any part cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
Include:
|
||||
- Affected users/systems/channels
|
||||
- Severity (annoying, blocks workflow, data risk, etc.)
|
||||
- Frequency (always/intermittent/edge case)
|
||||
- Consequence (missed messages, failed onboarding, extra cost, etc.)
|
||||
placeholder: |
|
||||
Affected: Telegram group users on <version>
|
||||
Severity: High (blocks replies)
|
||||
Frequency: 100% repro
|
||||
Consequence: Agents cannot respond in threads
|
||||
Affected: Telegram group users on 2026.2.17
|
||||
Severity: High (blocks thread replies)
|
||||
Frequency: 4/4 observed attempts
|
||||
Consequence: Agents do not respond in the affected threads
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions.
|
||||
placeholder: Last known good version <...>, first known bad version <...>, temporary workaround is ...
|
||||
description: Add any remaining grounded context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions when observed. If there is not enough evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: Last known good version 2026.2.10, first known bad version 2026.2.17, temporary workaround is sending a top-level message instead of a thread reply.
|
||||
|
||||
11
.github/labeler.yml
vendored
11
.github/labeler.yml
vendored
@ -165,7 +165,10 @@
|
||||
- "Dockerfile.*"
|
||||
- "docker-compose.yml"
|
||||
- "docker-setup.sh"
|
||||
- "setup-podman.sh"
|
||||
- ".dockerignore"
|
||||
- "scripts/docker/setup.sh"
|
||||
- "scripts/podman/setup.sh"
|
||||
- "scripts/**/*docker*"
|
||||
- "scripts/**/Dockerfile*"
|
||||
- "scripts/sandbox-*.sh"
|
||||
@ -290,6 +293,10 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/synthetic/**"
|
||||
"extensions: tavily":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/tavily/**"
|
||||
"extensions: talk-voice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@ -314,3 +321,7 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/xiaomi/**"
|
||||
"extensions: fal":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/fal/**"
|
||||
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@ -11,7 +11,7 @@ Describe the problem and fix in 2–5 bullets:
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactor
|
||||
- [ ] Refactor required for the fix
|
||||
- [ ] Docs
|
||||
- [ ] Security hardening
|
||||
- [ ] Chore/infra
|
||||
|
||||
183
.github/workflows/ci.yml
vendored
183
.github/workflows/ci.yml
vendored
@ -215,26 +215,37 @@ jobs:
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
|
||||
- runtime: node
|
||||
task: compat-node22
|
||||
node_version: "22.x"
|
||||
cache_key_suffix: "node22"
|
||||
command: |
|
||||
pnpm build
|
||||
pnpm test
|
||||
node scripts/stage-bundled-plugin-runtime-deps.mjs
|
||||
node --import tsx scripts/release-check.ts
|
||||
steps:
|
||||
- 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: Skip compatibility lanes on pull requests
|
||||
if: github.event_name == 'pull_request' && (matrix.runtime == 'bun' || matrix.task == 'compat-node22')
|
||||
run: echo "Skipping push-only lane on pull requests."
|
||||
|
||||
- name: Checkout
|
||||
if: github.event_name != 'pull_request' || matrix.runtime != 'bun'
|
||||
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: "${{ matrix.node_version || '24.x' }}"
|
||||
cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}"
|
||||
install-bun: "${{ matrix.runtime == 'bun' }}"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Configure Node test resources
|
||||
if: (github.event_name != 'pull_request' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
if: (github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')) && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'compat-node22')
|
||||
env:
|
||||
SHARD_COUNT: ${{ matrix.shard_count || '' }}
|
||||
SHARD_INDEX: ${{ matrix.shard_index || '' }}
|
||||
@ -249,11 +260,11 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
extension-fast:
|
||||
name: "extension-fast (${{ matrix.extension }})"
|
||||
name: "extension-fast"
|
||||
needs: [docs-scope, changed-scope, changed-extensions]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
@ -301,9 +312,89 @@ jobs:
|
||||
- name: Strict TS build smoke
|
||||
run: pnpm build:strict-smoke
|
||||
|
||||
check-additional:
|
||||
name: "check-additional"
|
||||
needs: [docs-scope, changed-scope]
|
||||
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@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run plugin extension boundary guard
|
||||
id: plugin_extension_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:plugins:no-extension-imports
|
||||
|
||||
- name: Run web search provider boundary guard
|
||||
id: web_search_provider_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:web-search-provider-boundaries
|
||||
|
||||
- name: Run extension src boundary guard
|
||||
id: extension_src_outside_plugin_sdk_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:extensions:no-src-outside-plugin-sdk
|
||||
|
||||
- name: Run extension plugin-sdk-internal guard
|
||||
id: extension_plugin_sdk_internal_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:extensions:no-plugin-sdk-internal
|
||||
|
||||
- name: Enforce safe external URL opening policy
|
||||
id: no_raw_window_open
|
||||
continue-on-error: true
|
||||
run: pnpm lint:ui:no-raw-window-open
|
||||
|
||||
- name: Run gateway watch regression harness
|
||||
id: gateway_watch_regression
|
||||
continue-on-error: true
|
||||
run: pnpm test:gateway:watch-regression
|
||||
|
||||
- name: Upload gateway watch regression artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gateway-watch-regression
|
||||
path: .local/gateway-watch-regression/
|
||||
retention-days: 7
|
||||
|
||||
- name: Fail if any additional check failed
|
||||
if: always()
|
||||
env:
|
||||
PLUGIN_EXTENSION_BOUNDARY_OUTCOME: ${{ steps.plugin_extension_boundary.outcome }}
|
||||
WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
|
||||
EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
|
||||
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
|
||||
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
|
||||
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
|
||||
run: |
|
||||
failures=0
|
||||
for result in \
|
||||
"plugin-extension-boundary|$PLUGIN_EXTENSION_BOUNDARY_OUTCOME" \
|
||||
"web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
|
||||
"extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
|
||||
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
|
||||
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
|
||||
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do
|
||||
name="${result%%|*}"
|
||||
outcome="${result#*|}"
|
||||
if [ "$outcome" != "success" ]; then
|
||||
echo "::error title=${name} failed::${name} outcome: ${outcome}"
|
||||
failures=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$failures"
|
||||
|
||||
build-smoke:
|
||||
name: "build-smoke"
|
||||
needs: [docs-scope, changed-scope]
|
||||
@ -336,34 +427,6 @@ jobs:
|
||||
- name: Check CLI startup memory
|
||||
run: pnpm test:startup:memory
|
||||
|
||||
gateway-watch-regression:
|
||||
name: "gateway-watch-regression"
|
||||
needs: [docs-scope, changed-scope]
|
||||
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@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run gateway watch regression harness
|
||||
run: pnpm test:gateway:watch-regression
|
||||
|
||||
- name: Upload gateway watch regression artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gateway-watch-regression
|
||||
path: .local/gateway-watch-regression/
|
||||
retention-days: 7
|
||||
|
||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||
check-docs:
|
||||
needs: [docs-scope]
|
||||
@ -384,43 +447,9 @@ jobs:
|
||||
- name: Check docs
|
||||
run: pnpm check:docs
|
||||
|
||||
compat-node22:
|
||||
name: "compat-node22"
|
||||
needs: [docs-scope, changed-scope]
|
||||
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@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node 22 compatibility environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: "22.x"
|
||||
cache-key-suffix: "node22"
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Configure Node 22 test resources
|
||||
run: |
|
||||
# Keep the compatibility lane aligned with the default Node test lane.
|
||||
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build under Node 22
|
||||
run: pnpm build
|
||||
|
||||
- name: Run tests under Node 22
|
||||
run: pnpm test
|
||||
|
||||
- name: Verify npm pack under Node 22
|
||||
run: pnpm release:check
|
||||
|
||||
skills-python:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_skills_python == 'true'
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_skills_python == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
@ -890,10 +919,14 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: test
|
||||
command: ./gradlew --no-daemon :app:testDebugUnitTest
|
||||
- task: build
|
||||
command: ./gradlew --no-daemon :app:assembleDebug
|
||||
- task: test-play
|
||||
command: ./gradlew --no-daemon :app:testPlayDebugUnitTest
|
||||
- task: test-third-party
|
||||
command: ./gradlew --no-daemon :app:testThirdPartyDebugUnitTest
|
||||
- task: build-play
|
||||
command: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||
- task: build-third-party
|
||||
command: ./gradlew --no-daemon :app:assembleThirdPartyDebug
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@ -116,7 +116,7 @@ jobs:
|
||||
- name: Build Android for CodeQL
|
||||
if: matrix.language == 'java-kotlin'
|
||||
working-directory: apps/android
|
||||
run: ./gradlew --no-daemon :app:assembleDebug
|
||||
run: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||
|
||||
- name: Build Swift for CodeQL
|
||||
if: matrix.language == 'swift'
|
||||
|
||||
53
.github/workflows/install-smoke.yml
vendored
53
.github/workflows/install-smoke.yml
vendored
@ -62,24 +62,65 @@ jobs:
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
# This smoke only validates that the build-arg path preinstalls selected
|
||||
# extension deps without breaking image build or basic CLI startup. It
|
||||
# does not exercise runtime loading/registration of diagnostics-otel.
|
||||
# This smoke validates that the build-arg path preinstalls the matrix
|
||||
# runtime deps declared by the plugin and that matrix discovery stays
|
||||
# healthy in the final runtime image.
|
||||
- name: Build extension Dockerfile smoke image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
||||
OPENCLAW_EXTENSIONS=matrix
|
||||
tags: openclaw-ext-smoke:local
|
||||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
|
||||
- name: Smoke test Dockerfile with extension build arg
|
||||
- name: Smoke test Dockerfile with matrix extension build arg
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
|
||||
which openclaw &&
|
||||
openclaw --version &&
|
||||
node -e "
|
||||
const Module = require(\"node:module\");
|
||||
const matrixPackage = require(\"/app/extensions/matrix/package.json\");
|
||||
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
|
||||
const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
|
||||
if (runtimeDeps.length === 0) {
|
||||
throw new Error(
|
||||
\"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
|
||||
);
|
||||
}
|
||||
for (const dep of runtimeDeps) {
|
||||
requireFromMatrix.resolve(dep);
|
||||
}
|
||||
const { spawnSync } = require(\"node:child_process\");
|
||||
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
|
||||
if (run.status !== 0) {
|
||||
process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\");
|
||||
process.exit(run.status ?? 1);
|
||||
}
|
||||
const parsed = JSON.parse(run.stdout);
|
||||
const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\");
|
||||
if (!matrix) {
|
||||
throw new Error(\"matrix plugin missing from bundled plugin list\");
|
||||
}
|
||||
const matrixDiag = (parsed.diagnostics || []).filter(
|
||||
(diag) =>
|
||||
typeof diag.source === \"string\" &&
|
||||
diag.source.includes(\"/extensions/matrix\") &&
|
||||
typeof diag.message === \"string\" &&
|
||||
diag.message.includes(\"extension entry escapes package directory\"),
|
||||
);
|
||||
if (matrixDiag.length > 0) {
|
||||
throw new Error(
|
||||
\"unexpected matrix diagnostics: \" +
|
||||
matrixDiag.map((diag) => diag.message).join(\"; \"),
|
||||
);
|
||||
}
|
||||
"
|
||||
'
|
||||
|
||||
- name: Build installer smoke image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
|
||||
214
.github/workflows/plugin-npm-release.yml
vendored
Normal file
214
.github/workflows/plugin-npm-release.yml
vendored
Normal file
@ -0,0 +1,214 @@
|
||||
name: Plugin NPM Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/workflows/plugin-npm-release.yml"
|
||||
- "extensions/**"
|
||||
- "package.json"
|
||||
- "scripts/lib/plugin-npm-release.ts"
|
||||
- "scripts/plugin-npm-publish.sh"
|
||||
- "scripts/plugin-npm-release-check.ts"
|
||||
- "scripts/plugin-npm-release-plan.ts"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_scope:
|
||||
description: Publish the selected plugins or all publishable plugins from the ref
|
||||
required: true
|
||||
default: selected
|
||||
type: choice
|
||||
options:
|
||||
- selected
|
||||
- all-publishable
|
||||
ref:
|
||||
description: Commit SHA on main to publish from (copy from the preview run)
|
||||
required: true
|
||||
type: string
|
||||
plugins:
|
||||
description: Comma-separated plugin package names to publish when publish_scope=selected
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
|
||||
jobs:
|
||||
preview_plugins_npm:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
ref_sha: ${{ steps.ref.outputs.sha }}
|
||||
has_candidates: ${{ steps.plan.outputs.has_candidates }}
|
||||
candidate_count: ${{ steps.plan.outputs.candidate_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
id: ref
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate ref is on main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
git merge-base --is-ancestor HEAD origin/main
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
env:
|
||||
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
|
||||
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
|
||||
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
|
||||
HEAD_REF: ${{ steps.ref.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${PUBLISH_SCOPE}" ]]; then
|
||||
release_args=(--selection-mode "${PUBLISH_SCOPE}")
|
||||
if [[ -n "${RELEASE_PLUGINS}" ]]; then
|
||||
release_args+=(--plugins "${RELEASE_PLUGINS}")
|
||||
fi
|
||||
pnpm release:plugins:npm:check -- "${release_args[@]}"
|
||||
elif [[ -n "${BASE_REF}" ]]; then
|
||||
pnpm release:plugins:npm:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}"
|
||||
else
|
||||
pnpm release:plugins:npm:check
|
||||
fi
|
||||
|
||||
- name: Resolve plugin release plan
|
||||
id: plan
|
||||
env:
|
||||
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
|
||||
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
|
||||
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
|
||||
HEAD_REF: ${{ steps.ref.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .local
|
||||
if [[ -n "${PUBLISH_SCOPE}" ]]; then
|
||||
plan_args=(--selection-mode "${PUBLISH_SCOPE}")
|
||||
if [[ -n "${RELEASE_PLUGINS}" ]]; then
|
||||
plan_args+=(--plugins "${RELEASE_PLUGINS}")
|
||||
fi
|
||||
node --import tsx scripts/plugin-npm-release-plan.ts "${plan_args[@]}" > .local/plugin-npm-release-plan.json
|
||||
elif [[ -n "${BASE_REF}" ]]; then
|
||||
node --import tsx scripts/plugin-npm-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-npm-release-plan.json
|
||||
else
|
||||
node --import tsx scripts/plugin-npm-release-plan.ts > .local/plugin-npm-release-plan.json
|
||||
fi
|
||||
|
||||
cat .local/plugin-npm-release-plan.json
|
||||
|
||||
candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)"
|
||||
has_candidates="false"
|
||||
if [[ "${candidate_count}" != "0" ]]; then
|
||||
has_candidates="true"
|
||||
fi
|
||||
matrix_json="$(jq -c '.candidates' .local/plugin-npm-release-plan.json)"
|
||||
|
||||
{
|
||||
echo "candidate_count=${candidate_count}"
|
||||
echo "has_candidates=${has_candidates}"
|
||||
echo "matrix=${matrix_json}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Plugin release candidates:"
|
||||
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-npm-release-plan.json
|
||||
|
||||
echo "Already published / skipped:"
|
||||
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_npm
|
||||
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Preview publish command
|
||||
run: bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Preview npm pack contents
|
||||
working-directory: ${{ matrix.plugin.packageDir }}
|
||||
run: npm pack --dry-run --json --ignore-scripts
|
||||
|
||||
publish_plugins_npm:
|
||||
needs: [preview_plugins_npm, preview_plugin_pack]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -31,6 +31,7 @@ apps/android/.gradle/
|
||||
apps/android/app/build/
|
||||
apps/android/.cxx/
|
||||
apps/android/.kotlin/
|
||||
apps/android/benchmark/results/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
@ -100,8 +101,6 @@ USER.md
|
||||
/local/
|
||||
package-lock.json
|
||||
.claude/
|
||||
.agents/
|
||||
.agents
|
||||
.agent/
|
||||
skills-lock.json
|
||||
|
||||
@ -135,3 +134,6 @@ ui/src/ui/__screenshots__
|
||||
ui/src/ui/views/__screenshots__
|
||||
ui/.vitest-attachments
|
||||
docs/superpowers
|
||||
|
||||
# Deprecated changelog fragment workflow
|
||||
changelog/fragments/
|
||||
|
||||
3
.npmrc
3
.npmrc
@ -1 +1,4 @@
|
||||
# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies.
|
||||
# TS 7 native-preview fails to resolve packages reliably from pnpm's isolated linker.
|
||||
# Keep the workspace on a hoisted layout so pnpm check/build stay stable.
|
||||
node-linker=hoisted
|
||||
|
||||
202
AGENTS.md
202
AGENTS.md
@ -2,52 +2,17 @@
|
||||
|
||||
- Repo: https://github.com/openclaw/openclaw
|
||||
- In chat replies, file references must be repo-root relative only (example: `extensions/bluebubbles/src/channel.ts:80`); never absolute paths or `~/...`.
|
||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
|
||||
- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
|
||||
- PR landing comments: always make commit SHAs clickable with full commit links (both landed SHA + source SHA when present).
|
||||
- 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)
|
||||
|
||||
- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock.
|
||||
- Do not manually close + manually comment for these reasons.
|
||||
- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label.
|
||||
- `r:*` labels can be used on both issues and PRs.
|
||||
|
||||
- `r: skill`: close with guidance to publish skills on Clawhub.
|
||||
- `r: support`: close with redirect to Discord support + stuck FAQ.
|
||||
- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation.
|
||||
- `r: too-many-prs`: close when author exceeds active PR limit.
|
||||
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
|
||||
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
|
||||
- `r: moltbook`: close + lock as off-topic (not affiliated).
|
||||
- `r: spam`: close + lock as spam (`lock_reason: spam`).
|
||||
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
|
||||
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
|
||||
|
||||
## PR truthfulness and bug-fix validation
|
||||
|
||||
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
|
||||
- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims.
|
||||
- Minimum merge gate for bug-fix PRs:
|
||||
1. symptom evidence (repro/log/failing test),
|
||||
2. verified root cause in code with file/line,
|
||||
3. fix touches the implicated code path,
|
||||
4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added.
|
||||
- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate.
|
||||
- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. `extensions/*` remains the internal directory/package path to avoid repo-wide churn from a rename.
|
||||
- Plugins: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias).
|
||||
- Import boundaries: extension production code should treat `openclaw/plugin-sdk/*` plus local `api.ts` / `runtime-api.ts` barrels as the public surface. Do not import core `src/**`, `src/plugin-sdk-internal/**`, or another extension's `src/**` directly.
|
||||
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||
- Core channel docs: `docs/channels/`
|
||||
@ -106,14 +71,24 @@
|
||||
- Format check: `pnpm format` (oxfmt --check)
|
||||
- Format fix: `pnpm format:fix` (oxfmt --write)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
- For narrowly scoped changes, prefer narrowly scoped tests that directly validate the touched behavior. If no meaningful scoped test exists, say so explicitly and use the next most direct validation available.
|
||||
- Preferred landing bar for pushes to `main`: `pnpm check` and `pnpm test`, with a green result when feasible.
|
||||
- Scoped tests prove the change itself. `pnpm test` remains the default `main` landing bar; scoped tests do not replace full-suite gates by default.
|
||||
- Hard gate: if the change can affect build output, packaging, lazy-loading/module boundaries, or published surfaces, `pnpm build` MUST be run and MUST pass before pushing `main`.
|
||||
- Default rule: do not commit or push with failing format, lint, type, build, or required test checks when those failures are caused by the change or plausibly related to the touched surface.
|
||||
- For narrowly scoped changes, if unrelated failures already exist on latest `origin/main`, state that clearly, report the scoped tests you ran, and ask before broadening scope into unrelated fixes or landing despite those failures.
|
||||
- Do not use scoped tests as permission to ignore plausibly related failures.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
|
||||
- Formatting/linting via Oxlint and Oxfmt.
|
||||
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
|
||||
- Dynamic import guardrail: do not mix `await import("x")` and static `import ... from "x"` for the same module in production code paths. If you need lazy loading, create a dedicated `*.runtime.ts` boundary (that re-exports from `x`) and dynamically import that boundary from lazy callers only.
|
||||
- Dynamic import verification: after refactors that touch lazy-loading/module boundaries, run `pnpm build` and check for `[INEFFECTIVE_DYNAMIC_IMPORT]` warnings before submitting.
|
||||
- Extension SDK self-import guardrail: inside an extension package, do not import that same extension via `openclaw/plugin-sdk/<extension>` from production files. Route internal imports through a local barrel such as `./api.ts` or `./runtime-api.ts`, and keep the `plugin-sdk/<extension>` path as the external contract only.
|
||||
- Extension package boundary guardrail: inside `extensions/<id>/**`, do not use relative imports/exports that resolve outside that same `extensions/<id>` package root. If shared code belongs in the plugin SDK, import `openclaw/plugin-sdk/<subpath>` instead of reaching into `src/plugin-sdk/**` or other repo paths via `../`.
|
||||
- Extension API surface rule: `openclaw/plugin-sdk/<subpath>` is the only public cross-package contract for extension-facing SDK code. If an extension needs a new seam, add a public subpath first; do not reach into `src/plugin-sdk/**` by relative path.
|
||||
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
|
||||
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
|
||||
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
|
||||
@ -123,23 +98,24 @@
|
||||
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
|
||||
- Written English: use American spelling and grammar in code, comments, docs, and UI strings (e.g. "color" not "colour", "behavior" not "behaviour", "analyze" not "analyse").
|
||||
|
||||
## Release Channels (Naming)
|
||||
## Release / Advisory Workflows
|
||||
|
||||
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
|
||||
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
|
||||
- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-<patch>` and `vYYYY.M.D.beta.N` remain recognized.
|
||||
- dev: moving head on `main` (no tag; git checkout main).
|
||||
- Use `$openclaw-release-maintainer` at `.agents/skills/openclaw-release-maintainer/SKILL.md` for release naming, version coordination, release auth, and changelog-backed release-note workflows.
|
||||
- Use `$openclaw-ghsa-maintainer` at `.agents/skills/openclaw-ghsa-maintainer/SKILL.md` for GHSA advisory inspection, patch/publish flow, private-fork checks, and GHSA API validation.
|
||||
- Release and publish remain explicit-approval actions even when using the skill.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- 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.
|
||||
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
|
||||
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- Do not switch CI `pnpm test` lanes back to Vitest `vmForks` by default without fresh green evidence on current `main`; keep CI on `forks` unless explicitly re-validated.
|
||||
- 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`.
|
||||
- Full kit + what’s covered: `docs/testing.md`.
|
||||
- Full kit + what’s covered: `docs/help/testing.md`.
|
||||
- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process).
|
||||
- Changelog placement: in the active version block, append new entries to the end of the target section (`### Changes` or `### Fixes`); do not insert new entries at the top of a section.
|
||||
- Changelog attribution: use at most one contributor mention per line; prefer `Thanks @author` and do not also add `by @author` on the same entry.
|
||||
@ -148,7 +124,9 @@
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW.
|
||||
- Use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md` for maintainer PR triage, review, close, search, and landing workflows.
|
||||
- This includes auto-close labels, bug-fix evidence gates, GitHub comment/search footguns, and maintainer PR decision flow.
|
||||
- For the repo's end-to-end maintainer PR workflow, use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md`.
|
||||
|
||||
- `/landpr` lives in the global Codex prompts (`~/.codex/prompts/landpr.md`); when landing or merging any PR, always follow that `/landpr` process.
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
@ -157,105 +135,30 @@
|
||||
- PR submission template (canonical): `.github/pull_request_template.md`
|
||||
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
|
||||
|
||||
## Shorthand Commands
|
||||
|
||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
|
||||
## Git Notes
|
||||
|
||||
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
|
||||
- Agents MUST NOT create or push merge commits on `main`. If `main` has advanced, rebase local commits onto the latest `origin/main` before pushing.
|
||||
- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query.
|
||||
|
||||
## GitHub Search (`gh`)
|
||||
|
||||
- Prefer targeted keyword search before proposing new work or duplicating fixes.
|
||||
- Use `--repo openclaw/openclaw` + `--match title,body` first; add `--match comments` when triaging follow-up threads.
|
||||
- PRs: `gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
|
||||
- Issues: `gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
|
||||
- Structured output example:
|
||||
`gh search issues --repo openclaw/openclaw --match title,body --limit 50 --json number,title,state,url,updatedAt -- "auto update" --jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'`
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
|
||||
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook; use `docs/reference/RELEASING.md` for the public release policy.
|
||||
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook, `docs/reference/RELEASING.md` for the public release policy, and `$openclaw-release-maintainer` for the maintainership workflow.
|
||||
|
||||
## GHSA (Repo Advisory) Patch/Publish
|
||||
|
||||
- Before reviewing security advisories, read `SECURITY.md`.
|
||||
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
|
||||
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
|
||||
- Private fork PRs must be closed:
|
||||
`fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)`
|
||||
`gh pr list -R "$fork" --state open` (must be empty)
|
||||
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
|
||||
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
|
||||
- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls.
|
||||
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
|
||||
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
|
||||
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
## Agent-Specific Notes
|
||||
## Local Runtime / Platform 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.
|
||||
- Discord roundtrip smoke is opt-in. Pass `--discord-token-env <VAR> --discord-guild-id <guild> --discord-channel-id <channel>`; the harness will configure Discord in-guest, post a guest message, verify host-side visibility via the Discord REST API, post a fresh host-side message back into the channel, then verify `openclaw message read` sees it in-guest.
|
||||
- Keep the Discord token in a host env var only. For Peter’s Mac Studio bot, fetch it into a temp env var from `~/.openclaw/openclaw.json` over SSH instead of hardcoding it in repo files/shell history.
|
||||
- For Discord smoke on this snapshot: use `openclaw message send/read` via the installed wrapper, not `node openclaw.mjs message ...`; lazy `message` subcommands do not resolve the same way through the direct module entrypoint.
|
||||
- For Discord guild allowlists: set `channels.discord.guilds` as one JSON object. Do not use dotted `config set channels.discord.guilds.<snowflake>...` paths; numeric snowflakes get treated as array indexes.
|
||||
- Avoid `prlctl enter` / expect for the Discord config phase; long lines get mangled. Use `prlctl exec --current-user /bin/sh -lc ...` with short commands or temp files.
|
||||
- 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.
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||
- Use `$openclaw-parallels-smoke` at `.agents/skills/openclaw-parallels-smoke/SKILL.md` for Parallels smoke, rerun, upgrade, debug, and result-interpretation workflows across macOS, Windows, and Linux guests.
|
||||
- For the macOS Discord roundtrip deep dive, use the narrower `.agents/skills/parallels-discord-roundtrip/SKILL.md` companion skill.
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- If you need local-only `.agents` ignores, use `.git/info/exclude` instead of repo `.gitignore`.
|
||||
- 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`.
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
@ -270,6 +173,20 @@
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
||||
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
|
||||
- Lobster palette: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
|
||||
|
||||
## Collaboration / Safety Notes
|
||||
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
||||
@ -280,41 +197,12 @@
|
||||
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
|
||||
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
|
||||
- Only ask when changes are semantic (logic/data/behavior).
|
||||
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
|
||||
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
|
||||
|
||||
## Release Auth
|
||||
|
||||
- Core `openclaw` publish uses GitHub trusted publishing; do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
|
||||
- Separate `@openclaw/*` plugin publishes use a different maintainer-only auth flow.
|
||||
- Plugin scope: only publish already-on-npm `@openclaw/*` plugins. Bundled disk-tree-only plugins stay out.
|
||||
- Maintainers: private 1Password item names, tmux rules, plugin publish helpers, and local mac signing/notary setup live in the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md).
|
||||
|
||||
## Changelog Release Notes
|
||||
|
||||
- When cutting a mac release with beta GitHub prerelease:
|
||||
- Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`).
|
||||
- Create prerelease with title `openclaw YYYY.M.D-beta.N`.
|
||||
- Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate).
|
||||
- Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available.
|
||||
|
||||
- Keep top version entries in `CHANGELOG.md` sorted by impact:
|
||||
- `### Changes` first.
|
||||
- `### Fixes` deduped and ranked with user-facing fixes first.
|
||||
- Before tagging/publishing, run:
|
||||
- `node --import tsx scripts/release-check.ts`
|
||||
- `pnpm release:check`
|
||||
- `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path.
|
||||
|
||||
329
CHANGELOG.md
329
CHANGELOG.md
@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Models/Anthropic Vertex: add core `anthropic-vertex` provider support for Claude via Google Vertex AI, including GCP auth/discovery and main run-path routing. (#43356) Thanks @sallyom and @yossiovadia.
|
||||
- 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/docs: clarify that empty URL input allowlists are treated as unset, document `allowUrl: false` as the deny-all switch, and add regression coverage for the normalization path.
|
||||
- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only.
|
||||
@ -23,7 +24,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) Thanks @Takhoffman.
|
||||
- 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. (#46029) Thanks @day253.
|
||||
- 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.
|
||||
- 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.
|
||||
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lixuankai.
|
||||
- Android/nodes: add `sms.search` plus shared SMS permission wiring so Android nodes can search device text messages through the gateway. (#48299) Thanks @lixuankai.
|
||||
- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility.
|
||||
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
|
||||
- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF.
|
||||
@ -35,21 +37,34 @@ Docs: https://docs.openclaw.ai
|
||||
- Models/OpenAI: add native forward-compat support for `gpt-5.4-mini` and `gpt-5.4-nano` in the OpenAI provider catalog, runtime resolution, and reasoning capability gates. Thanks @vincentkoc.
|
||||
- Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import.
|
||||
- Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant.
|
||||
- Plugins/testing: add a public `openclaw/plugin-sdk/testing` seam for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers.
|
||||
- Plugins/testing: add a public `openclaw/plugin-sdk/testing` surface for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers.
|
||||
- Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor.
|
||||
- Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo.
|
||||
- CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
|
||||
- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root.
|
||||
- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly.
|
||||
- Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev.
|
||||
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
|
||||
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
|
||||
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
|
||||
- Plugins/Xiaomi: switch the bundled Xiaomi provider to the `/v1` OpenAI-compatible endpoint and add MiMo V2 Pro plus MiMo V2 Omni to the built-in catalog. (#49214) thanks @DJjjjhao.
|
||||
- Android/Talk: move Talk speech synthesis behind gateway `talk.speak`, keep Talk secrets on the gateway, and switch Android playback to final-response audio instead of device-local ElevenLabs streaming. (#50849)
|
||||
- Plugins/Matrix: add `allowBots` room policy so configured Matrix bot accounts can talk to each other, with optional mention-only gating. Thanks @gumadeiras.
|
||||
- Plugins/Matrix: add per-account `allowPrivateNetwork` opt-in for private/internal homeservers, while keeping public cleartext homeservers blocked. Thanks @gumadeiras.
|
||||
- Web tools/Tavily: add Tavily as a bundled web-search provider with dedicated `tavily_search` and `tavily_extract` tools, using canonical plugin-owned config under `plugins.entries.tavily.config.webSearch.*`. (#49200) thanks @lakshyaag-tavily.
|
||||
- Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp.
|
||||
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
|
||||
- Plugins/context engines: pass the embedded runner `modelId` into context-engine `assemble()` so plugins can adapt context formatting per model. (#47437) thanks @jscianna.
|
||||
- Plugins/context engines: add transcript maintenance rewrites for context engines, preserve active-branch transcript metadata during rewrites, and harden overflow-recovery truncation to rewrite sessions under the normal session write lock. (#51191) Thanks @jalehman.
|
||||
- Telegram/apiRoot: add per-account custom Bot API endpoint support across send, probe, setup, doctor repair, and inbound media download paths so proxied or self-hosted Telegram deployments work end to end. (#48842) Thanks @Cypherm.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/config: make `config set --strict-json` enforce real JSON, prefer `JSON.parse` with JSON5 fallback for machine-written cron/subagent stores, and relabel raw config surfaces as `JSON/JSON5` to match actual compatibility. Related: #48415, #43127, #14529, #21332. Thanks @adhitShet and @vincentkoc.
|
||||
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
|
||||
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
|
||||
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
|
||||
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
|
||||
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
|
||||
- Agents/openai-responses: strip `prompt_cache_key` and `prompt_cache_retention` for non-OpenAI-compatible Responses endpoints while keeping them on direct OpenAI and Azure OpenAI paths, so third-party OpenAI-compatible providers no longer reject those requests with HTTP 400. (#49877) Thanks @ShaunTsai.
|
||||
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
|
||||
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
||||
- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. (#47902) Fixes #46924 and #47041. Thanks @steipete.
|
||||
@ -74,6 +89,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Telegram/setup: warn when setup leaves DMs on pairing without an allowlist, and show valid account-scoped remediation commands. (#50710) Thanks @ernestodeoliveira.
|
||||
- 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.
|
||||
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
|
||||
- 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. (#45890) Thanks @vincentkoc.
|
||||
@ -89,6 +105,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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. (#46663) Fixes #40146. Thanks @Takhoffman.
|
||||
- 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.
|
||||
- Onboarding/custom providers: store Azure OpenAI and Azure AI Foundry custom endpoints with the Responses API config shape, normalized `/openai/v1` base URLs, and Azure-safe defaults so TUI and agent runs work after setup. (#49543) Thanks @kunalk16.
|
||||
- Docker/live tests: mount external CLI auth homes into writable container copies, derive Codex OAuth expiry from JWT `exp`, refresh synced CLI creds instead of trusting stale cached expiry, and make gateway live probes wait on transcript output so `pnpm test:docker:all` stays green in Linux.
|
||||
- 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`. (#46722) Thanks @Takhoffman.
|
||||
- Control UI/logging: make browser-safe logger imports avoid eager temp-dir resolution so the bundled Control UI no longer crashes to a blank screen when logging reaches `tmp-openclaw-dir`. (#48469) Fixes #48062. Thanks @7inspire.
|
||||
@ -106,6 +123,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
||||
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
||||
- Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path.
|
||||
- Gateway/agent events: stop broadcasting false end-of-run `seq gap` errors to clients, and isolate node-driven ingress turns with per-turn run IDs so stale tail events cannot leak into later session runs. (#43751) Thanks @caesargattuso.
|
||||
- Docs/security audit: spell out that `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy and should be avoided outside tightly controlled local testing.
|
||||
- Gateway/auth: clear self-declared scopes for device-less trusted-proxy Control UI sessions so proxy-authenticated connects cannot claim admin or secrets scopes without a bound device identity.
|
||||
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
|
||||
@ -114,6 +132,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) Thanks @merc1305.
|
||||
- Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI.
|
||||
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. (#47601) Thanks @ngutman.
|
||||
- Gateway/WS handshake: raise the default pre-auth handshake timeout to 10 seconds and add `OPENCLAW_HANDSHAKE_TIMEOUT_MS` as a runtime override so busy local gateways stop dropping healthy CLI connections at 3 seconds. (#49262) Thanks @fuller-stack-dev.
|
||||
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement for Control UI operator sessions when `gateway.auth.mode=none`, so reverse-proxied dashboards no longer get stuck on `pairing required` despite auth being explicitly disabled. (#47148) Thanks @ademczuk.
|
||||
- Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham.
|
||||
- Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw.
|
||||
@ -128,6 +147,17 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr.
|
||||
- Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman.
|
||||
- Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468.
|
||||
- Models/chat commands: keep `/model ...@YYYYMMDD` version suffixes intact by default, but still honor matching stored numeric auth-profile overrides for the same provider. (#48896) Thanks @Alix-007.
|
||||
- Gateway/channels: serialize per-account channel startup so overlapping starts do not boot the same provider twice, preventing MS Teams `EADDRINUSE` crash loops during startup and restart. (#49583) Thanks @sudie-codes.
|
||||
- Tests/OpenAI Codex auth: align login expectations with the default `gpt-5.4` model so CI coverage stays consistent with the current OpenAI Codex default. (#44367) Thanks @jrrcdev.
|
||||
- Discord: enforce strict DM component allowlist auth (#49997) Thanks @joshavant.
|
||||
- Stabilize plugin loader and Docker extension smoke (#50058) Thanks @joshavant.
|
||||
- Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155) Thanks @joshavant.
|
||||
- Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant.
|
||||
- Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant.
|
||||
- LINE: harden Express webhook parsing to verified raw body (#51202) Thanks @gladiator9797 and @joshavant.
|
||||
- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant.
|
||||
- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek
|
||||
|
||||
### Fixes
|
||||
|
||||
@ -140,6 +170,45 @@ Docs: https://docs.openclaw.ai
|
||||
- Mattermost/DM send: retry transient direct-channel creation failures for DM deliveries, with configurable backoff and per-request timeout. (#42398) Thanks @JonathanJing.
|
||||
- Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus.
|
||||
- Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob.
|
||||
- Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc.
|
||||
- Plugins/runtime-api: pin extension runtime-api export surfaces with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc.
|
||||
- Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc.
|
||||
- Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant.
|
||||
- Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc.
|
||||
- xAI/web search: add missing Grok credential metadata so the bundled provider registration type-checks again. (#49472) thanks @scoootscooob.
|
||||
- Signal/runtime API: re-export `SignalAccountConfig` so Signal account resolution type-checks again. (#49470) Thanks @scoootscooob.
|
||||
- Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob.
|
||||
- WhatsApp: stabilize inbound monitor and setup tests (#50007) Thanks @joshavant.
|
||||
- Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant.
|
||||
- Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant.
|
||||
- WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67.
|
||||
- Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo.
|
||||
- Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. (#50535) Thanks @obviyus.
|
||||
- Plugins/update: let `openclaw plugins update <npm-spec>` target tracked npm installs by dist-tag or exact version, and preserve the recorded npm spec for later id-based updates. (#49998) Thanks @huntharo.
|
||||
- Tests/CLI: reduce command-secret gateway test import pressure while keeping the real protocol payload validator in place, so the isolated lane no longer carries the heavier runtime-web and message-channel graphs. (#50663) Thanks @huntharo.
|
||||
- Gateway/plugins: share plugin interactive callback routing and plugin bind approval state across duplicate module graphs so Telegram Codex picker buttons and plugin bind approvals no longer fall through to normal inbound message routing. (#50722) Thanks @huntharo.
|
||||
- Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp.
|
||||
- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys.
|
||||
- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
|
||||
- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman.
|
||||
- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
|
||||
- Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob.
|
||||
- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscooob.
|
||||
- Gateway/probe: honor caller `--timeout` for active local loopback probes in `gateway status`, keep inactive remote-mode loopback probes fast, and clamp probe timers to JS-safe bounds so slow local/container gateways stop reporting false timeouts. (#47533) Thanks @MonkeyLeeT.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead.
|
||||
|
||||
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
|
||||
- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root.
|
||||
- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly.
|
||||
- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead.
|
||||
- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras.
|
||||
- Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702)
|
||||
- Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras.
|
||||
- Discord/commands: switch native command deployment to Carbon reconcile by default so Discord restarts stop churning slash commands through OpenClaw’s local deploy path. (#46597) Thanks @huntharo and @thewilloftheshadow.
|
||||
- Plugins/Matrix: durably dedupe inbound room events across gateway restarts so previously handled Matrix messages are not replayed as new, while preserving clean-restart backlog delivery for unseen events. (#50922) thanks @gumadeiras
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
@ -155,10 +224,6 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
@ -194,6 +259,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- CLI/startup: stop `openclaw devices list` and similar loopback gateway commands from failing during startup by isolating heavy import-time side effects from the normal CLI path. (#50212) Thanks @obviyus.
|
||||
- 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.
|
||||
@ -218,7 +284,13 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc.
|
||||
- Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen.
|
||||
- Sessions/BlueBubbles/cron: persist outbound session routing and transcript mirroring for new targets, auto-create BlueBubbles chats before attachment sends, and only suppress isolated cron deliveries when the run started hours late instead of merely finishing late. (#50092)
|
||||
|
||||
### 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.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
@ -320,10 +392,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
### Security
|
||||
|
||||
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
|
||||
|
||||
### Changes
|
||||
|
||||
- OpenRouter/models: add temporary Hunter Alpha and Healer Alpha entries to the built-in catalog so OpenRouter users can try the new free stealth models during their roughly one-week availability window. (#43642) Thanks @ping-Toven.
|
||||
@ -345,10 +413,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Mattermost/reply threading: add `channels.mattermost.replyToMode` for channel and group messages so top-level posts can start thread-scoped sessions without the manual reply-then-thread workaround. (#29587) Thanks @teconomix.
|
||||
- iOS/push relay: add relay-backed official-build push delivery with App Attest + receipt verification, gateway-bound send delegation, and config-based relay URL setup on the gateway. (#43369) Thanks @ngutman.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Windows/install: stop auto-installing `node-llama-cpp` during normal npm CLI installs so `openclaw@latest` no longer fails on Windows while building optional local-embedding dependencies.
|
||||
@ -461,6 +525,14 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- Exec/env sandbox: block JVM agent injection (`JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS`), Python breakpoint hijack (`PYTHONBREAKPOINT`), and .NET startup hooks (`DOTNET_STARTUP_HOOKS`) from the host exec environment. (#49025)
|
||||
|
||||
### Security
|
||||
|
||||
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
|
||||
|
||||
### Breaking
|
||||
|
||||
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
### Changes
|
||||
@ -573,10 +645,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
|
||||
- Agents/compaction model override: allow `agents.defaults.compaction.model` to route compaction summarization through a different model than the main session, and document the override across config help/reference surfaces. (#38753) thanks @starbuck100.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Models/MiniMax: stop advertising removed `MiniMax-M2.5-Lightning` in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as `MiniMax-M2.5-highspeed`.
|
||||
@ -647,6 +715,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- Web UI/config form: treat `additionalProperties: true` 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.
|
||||
- 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 `message.reply` 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.
|
||||
- macOS/tray menu: keep injected sessions and device rows below the controls section so toggles and action buttons stay visible even when many sessions are active. (#38079) Thanks @bernesto.
|
||||
- Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so `requireMention` checks compare against current bot identity instead of stale config names, fixing missed `@bot` handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai.
|
||||
- Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd.
|
||||
- Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd.
|
||||
@ -901,6 +970,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
|
||||
- Voice-call/OpenAI TTS config parity: add missing `speed`, `instructions`, and `baseUrl` fields to the OpenAI TTS config schema and gate `instructions` to supported models so voice-call overrides validate and route cleanly through core TTS. (#39226) Thanks @ademczuk.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
### Changes
|
||||
@ -929,13 +1002,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/input_image MIME validation: sniff uploaded image bytes before MIME allowlist enforcement again so declared image types cannot mask concrete non-image payloads, while keeping HEIC/HEIF normalization behavior scoped to actual HEIC inputs. Thanks @vincentkoc.
|
||||
- Zalo Personal plugin (`@openclaw/zalouser`): keep canonical DM routing while preserving legacy DM session continuity on upgrade, and preserve provider-native `g-`/`u-` target ids in outbound send and directory flows so #33992 lands without breaking existing sessions or stored targets. (#33992) Thanks @darkamenosa.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
|
||||
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Outbound render mode: respect Feishu account `renderMode` in outbound sends so card mode (and auto-detected markdown tables/code blocks) uses markdown card delivery instead of always sending plain text. (#31562) Thanks @arkyu2077.
|
||||
@ -1122,6 +1188,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
|
||||
- Control UI/markdown recursion fallback: catch markdown parser failures and safely render escaped plain-text fallback instead of crashing the Control UI on pathological markdown history payloads. (#36445, fixes #36213) Thanks @BinHPdev.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
|
||||
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
|
||||
## 2026.3.1
|
||||
|
||||
### Changes
|
||||
@ -1149,11 +1222,6 @@ Docs: https://docs.openclaw.ai
|
||||
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
|
||||
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Streaming card text fidelity: merge throttled/fragmented partial updates without dropping content and avoid newline injection when stitching chunk-style deltas so card-stream output matches final reply text. (#29616) Thanks @HaoHuaqing.
|
||||
@ -1248,6 +1316,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Signal/Sync message null-handling: treat `syncMessage` presence (including `null`) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Infra/fs-safe: sanitize directory-read failures so raw `EISDIR` text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
## 2026.2.27
|
||||
|
||||
### Changes
|
||||
@ -1523,10 +1596,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
|
||||
- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Slack/Identity: thread agent outbound identity (`chat:write.customize` overrides) through the channel reply delivery path so per-agent username, icon URL, and icon emoji are applied to all Slack replies including media messages. (#27134) Thanks @hou-rong.
|
||||
@ -1590,6 +1659,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
|
||||
- Feishu/WebSocket proxy: pass a proxy agent to Feishu WS clients from standard proxy environment variables and include plugin-local runtime dependency wiring so websocket mode works in proxy-constrained installs. (#26397) Thanks @colin719.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
|
||||
|
||||
## 2026.2.24
|
||||
|
||||
### Changes
|
||||
@ -1600,11 +1673,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
|
||||
- Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping `@buape/carbon` pinned.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:<id>`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
|
||||
- **BREAKING:** Security/Sandbox: block Docker `network: "container:<id>"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.
|
||||
@ -1684,6 +1752,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
|
||||
- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:<id>`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
|
||||
- **BREAKING:** Security/Sandbox: block Docker `network: "container:<id>"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting.
|
||||
|
||||
## 2026.2.23
|
||||
|
||||
### Changes
|
||||
@ -1698,10 +1771,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed.
|
||||
- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305.
|
||||
@ -1747,6 +1816,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc.
|
||||
- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically.
|
||||
|
||||
## 2026.2.22
|
||||
|
||||
### Changes
|
||||
@ -1771,14 +1844,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead.
|
||||
- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers.
|
||||
- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`.
|
||||
- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3.
|
||||
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
|
||||
- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc.
|
||||
@ -2005,6 +2070,14 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Daemon: verify gateway health after daemon restart.
|
||||
- Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers.
|
||||
- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`.
|
||||
- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3.
|
||||
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
|
||||
- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected.
|
||||
|
||||
## 2026.2.21
|
||||
|
||||
### Changes
|
||||
@ -2653,10 +2726,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Onboarding/Providers: add first-class Hugging Face Inference provider support (provider wiring, onboarding auth choice/API key flow, and default-model selection), and preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) while skipping env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
|
||||
- Onboarding/Providers: add `minimax-api-key-cn` auth choice for the MiniMax China API endpoint. (#15191) Thanks @liuy.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
|
||||
@ -2759,6 +2828,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
|
||||
- Security/Pairing: generate 256-bit base64url device and node pairing tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. (#16535) Thanks @FaizanKolega, @gumadeiras.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly.
|
||||
|
||||
## 2026.2.12
|
||||
|
||||
### Changes
|
||||
@ -2770,10 +2843,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord: add role-based allowlists and role-based agent routing. (#10650) Thanks @Minidoracat.
|
||||
- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates.
|
||||
@ -2856,6 +2925,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
|
||||
- Update/Daemon: fix post-update restart compatibility by generating `dist/cli/daemon-cli.js` with alias-aware exports from hashed daemon bundles, preventing `registerDaemonCli` import failures during `openclaw update`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting.
|
||||
|
||||
## 2026.2.9
|
||||
|
||||
### Added
|
||||
@ -2945,6 +3018,12 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Added
|
||||
|
||||
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
|
||||
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
|
||||
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
|
||||
|
||||
### Changes
|
||||
|
||||
- Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204.
|
||||
@ -2960,12 +3039,6 @@ Docs: https://docs.openclaw.ai
|
||||
- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr.
|
||||
- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids.
|
||||
|
||||
### Added
|
||||
|
||||
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
|
||||
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
|
||||
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
|
||||
|
||||
### Fixes
|
||||
|
||||
- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393)
|
||||
@ -3264,10 +3337,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
|
||||
- Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Skills: update session-logs paths to use ~/.openclaw. (#4502) Thanks @bonald.
|
||||
@ -3320,6 +3389,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present.
|
||||
- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||
|
||||
## 2026.1.24-3
|
||||
|
||||
### Fixes
|
||||
@ -3551,11 +3624,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||
@ -3578,6 +3646,11 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
||||
- Embedded runner: forward sender identity into attempt execution so Feishu doc auto-grant receives requester context again. (#32915) Thanks @cszhouwei.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
### Changes
|
||||
@ -3659,10 +3732,6 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS: stop syncing Peekaboo in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Discovery: shorten Bonjour DNS-SD service type to `_moltbot-gw._tcp` and update discovery clients/docs.
|
||||
@ -3761,6 +3830,10 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any.
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
### Changes
|
||||
@ -3779,15 +3852,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.openclaw.ai/concepts/session
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.openclaw.ai/tools/web
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks
|
||||
- **BREAKING:** `openclaw plugins install <path>` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
### Changes
|
||||
|
||||
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
|
||||
@ -3879,6 +3943,15 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
|
||||
- Skills: fix skills watcher ignored list typing (tsc).
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks
|
||||
- **BREAKING:** `openclaw plugins install <path>` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Highlights
|
||||
@ -3888,11 +3961,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
|
||||
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
|
||||
|
||||
### Changes
|
||||
|
||||
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.
|
||||
@ -3965,6 +4033,11 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
### Highlights
|
||||
@ -4101,10 +4174,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool.
|
||||
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
|
||||
|
||||
### Installer
|
||||
|
||||
- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
|
||||
@ -4130,6 +4199,10 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow.
|
||||
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete.
|
||||
|
||||
### Installer
|
||||
|
||||
- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
|
||||
@ -4181,15 +4254,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests.
|
||||
- macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present.
|
||||
|
||||
### Installer
|
||||
|
||||
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
|
||||
- Postinstall: skip pnpm patch fallback when the new patcher is active.
|
||||
- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped.
|
||||
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
|
||||
- Installer UX: add `install.sh --help` with flags/env and git install hint.
|
||||
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias).
|
||||
@ -4228,6 +4292,15 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Sandbox/Gateway: treat `agent:<id>:main` as a main-session alias when `session.mainKey` is customized (backwards compatible).
|
||||
- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model).
|
||||
|
||||
### Installer
|
||||
|
||||
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
|
||||
- Postinstall: skip pnpm patch fallback when the new patcher is active.
|
||||
- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped.
|
||||
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
|
||||
- Installer UX: add `install.sh --help` with flags/env and git install hint.
|
||||
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
|
||||
|
||||
## 2026.1.10
|
||||
|
||||
### Highlights
|
||||
@ -4336,11 +4409,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.
|
||||
- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.
|
||||
|
||||
### Breaking
|
||||
|
||||
- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
|
||||
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
|
||||
|
||||
### New Features and Changes
|
||||
|
||||
- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.
|
||||
@ -4382,6 +4450,11 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
|
||||
- Agent loop: guard overflow compaction throws and restore compaction hooks for engine-owned context engines. (#41361) — thanks @davidrudduck
|
||||
|
||||
### Breaking
|
||||
|
||||
- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
|
||||
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Dependencies: bump pi-\* stack to 0.42.2.
|
||||
@ -4401,6 +4474,18 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Control UI: logs tab, streaming stability, focus mode, and large-output rendering fixes.
|
||||
- CLI/Gateway/Doctor: daemon/logs/status, auth migration, and diagnostics significantly expanded.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints.
|
||||
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking.
|
||||
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification.
|
||||
- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram.
|
||||
- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification.
|
||||
- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth.
|
||||
- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes.
|
||||
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install.
|
||||
- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack.
|
||||
@ -4416,18 +4501,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
|
||||
- CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; move `login/logout` to `providers login/logout` (top-level aliases hidden); use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints.
|
||||
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking.
|
||||
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification.
|
||||
- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram.
|
||||
- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification.
|
||||
- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth.
|
||||
- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes.
|
||||
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install.
|
||||
- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Skills additions (Himalaya email, CodexBar, 1Password).
|
||||
|
||||
@ -83,7 +83,9 @@ Welcome to the lobster tank! 🦞
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
|
||||
3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||
3. **Refactor-only PRs** → Don't open a PR. We are not accepting refactor-only changes unless a maintainer explicitly asks for them as part of a concrete fix.
|
||||
4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
|
||||
5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||
|
||||
## Before You PR
|
||||
|
||||
@ -96,6 +98,9 @@ Welcome to the lobster tank! 🦞
|
||||
- For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins`
|
||||
- If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
|
||||
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
|
||||
- Do not submit refactor-only PRs unless a maintainer explicitly requested that refactor for an active fix or deliverable.
|
||||
- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first.
|
||||
- Do not submit test-only PRs that just try to make known `main` CI failures pass. Test changes are acceptable when they are required to validate a new fix or cover new behavior in the same PR.
|
||||
- Ensure CI checks pass
|
||||
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
||||
- Describe what & why
|
||||
|
||||
@ -146,6 +146,10 @@ COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions
|
||||
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
|
||||
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
|
||||
|
||||
# In npm-installed Docker images, prefer the copied source extension tree for
|
||||
# bundled discovery so package metadata that points at source entries stays valid.
|
||||
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/extensions
|
||||
|
||||
# Keep pnpm available in the runtime image for container-local workflows.
|
||||
# Use a shared Corepack home so the non-root `node` user does not need a
|
||||
# first-run network fetch when invoking pnpm.
|
||||
|
||||
@ -49,7 +49,7 @@ Model note: while many providers/models are supported, for the best experience a
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
@ -62,7 +62,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
|
||||
|
||||
## Quick start (TL;DR)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
@ -293,7 +293,7 @@ If you plan to build/run companion apps, follow the platform runbooks below.
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see [macOS Permissions](https://docs.openclaw.ai/platforms/mac/permissions)).
|
||||
|
||||
### iOS node (optional)
|
||||
|
||||
|
||||
@ -27,14 +27,34 @@ Status: **extremely alpha**. The app is actively being rebuilt from the ground u
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :app:assembleDebug
|
||||
./gradlew :app:installDebug
|
||||
./gradlew :app:testDebugUnitTest
|
||||
./gradlew :app:assemblePlayDebug
|
||||
./gradlew :app:installPlayDebug
|
||||
./gradlew :app:testPlayDebugUnitTest
|
||||
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`.
|
||||
Third-party debug flavor:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :app:assembleThirdPartyDebug
|
||||
./gradlew :app:installThirdPartyDebug
|
||||
./gradlew :app:testThirdPartyDebugUnitTest
|
||||
```
|
||||
|
||||
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds two signed release bundles:
|
||||
|
||||
- Play build: `apps/android/build/release-bundles/openclaw-<version>-play-release.aab`
|
||||
- Third-party build: `apps/android/build/release-bundles/openclaw-<version>-third-party-release.aab`
|
||||
|
||||
Flavor-specific direct Gradle tasks:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :app:bundlePlayRelease
|
||||
./gradlew :app:bundleThirdPartyRelease
|
||||
```
|
||||
|
||||
## Kotlin Lint + Format
|
||||
|
||||
@ -176,6 +196,48 @@ More details: `docs/platforms/android.md`.
|
||||
- `CAMERA` for `camera.snap` and `camera.clip`
|
||||
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
||||
|
||||
## Google Play Restricted Permissions
|
||||
|
||||
As of March 19, 2026, these manifest permissions are the main Google Play policy risk for this app:
|
||||
|
||||
- `READ_SMS`
|
||||
- `SEND_SMS`
|
||||
- `READ_CALL_LOG`
|
||||
|
||||
Why these matter:
|
||||
|
||||
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
|
||||
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
|
||||
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
|
||||
|
||||
Current OpenClaw Android implication:
|
||||
|
||||
- APK / sideload build can keep SMS and Call Log features.
|
||||
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
|
||||
- The repo now ships this split as Android product flavors:
|
||||
- `play`: removes `READ_SMS`, `SEND_SMS`, and `READ_CALL_LOG`, and hides SMS / Call Log surfaces in onboarding, settings, and advertised node capabilities.
|
||||
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log functionality.
|
||||
|
||||
Policy links:
|
||||
|
||||
- [Google Play SMS and Call Log policy](https://support.google.com/googleplay/android-developer/answer/10208820?hl=en)
|
||||
- [Google Play sensitive permissions policy hub](https://support.google.com/googleplay/android-developer/answer/16558241)
|
||||
- [Android default handlers guide](https://developer.android.com/guide/topics/permissions/default-handlers)
|
||||
|
||||
Other Play-restricted surfaces to watch if added later:
|
||||
|
||||
- `ACCESS_BACKGROUND_LOCATION`
|
||||
- `MANAGE_EXTERNAL_STORAGE`
|
||||
- `QUERY_ALL_PACKAGES`
|
||||
- `REQUEST_INSTALL_PACKAGES`
|
||||
- `AccessibilityService`
|
||||
|
||||
Reference links:
|
||||
|
||||
- [Background location policy](https://support.google.com/googleplay/android-developer/answer/9799150)
|
||||
- [AccessibilityService policy](https://support.google.com/googleplay/android-developer/answer/10964491?hl=en-GB)
|
||||
- [Photo and Video Permissions policy](https://support.google.com/googleplay/android-developer/answer/14594990)
|
||||
|
||||
## Integration Capability Test (Preconditioned)
|
||||
|
||||
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.
|
||||
|
||||
@ -65,14 +65,29 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026031400
|
||||
versionName = "2026.3.14"
|
||||
versionCode = 2026032000
|
||||
versionName = "2026.3.20"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "store"
|
||||
|
||||
productFlavors {
|
||||
create("play") {
|
||||
dimension = "store"
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "false")
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "false")
|
||||
}
|
||||
create("thirdParty") {
|
||||
dimension = "store"
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "true")
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "true")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
if (hasAndroidReleaseSigning) {
|
||||
@ -140,8 +155,13 @@ androidComponents {
|
||||
.forEach { output ->
|
||||
val versionName = output.versionName.orNull ?: "0"
|
||||
val buildType = variant.buildType
|
||||
|
||||
val outputFileName = "openclaw-$versionName-$buildType.apk"
|
||||
val flavorName = variant.flavorName?.takeIf { it.isNotBlank() }
|
||||
val outputFileName =
|
||||
if (flavorName == null) {
|
||||
"openclaw-$versionName-$buildType.apk"
|
||||
} else {
|
||||
"openclaw-$versionName-$flavorName-$buildType.apk"
|
||||
}
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||
<uses-permission
|
||||
|
||||
@ -129,7 +129,13 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
foreground = value
|
||||
runtimeRef.value?.setForeground(value)
|
||||
val runtime =
|
||||
if (value && prefs.onboardingCompleted.value) {
|
||||
ensureRuntime()
|
||||
} else {
|
||||
runtimeRef.value
|
||||
}
|
||||
runtime?.setForeground(value)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
|
||||
@ -89,6 +89,8 @@ class NodeRuntime(
|
||||
|
||||
private val deviceHandler: DeviceHandler = DeviceHandler(
|
||||
appContext = appContext,
|
||||
smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS,
|
||||
callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
|
||||
)
|
||||
|
||||
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
|
||||
@ -137,7 +139,9 @@ class NodeRuntime(
|
||||
voiceWakeMode = { VoiceWakeMode.Off },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
|
||||
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
|
||||
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
manualTls = { manualTls.value },
|
||||
)
|
||||
@ -160,7 +164,9 @@ class NodeRuntime(
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
|
||||
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
|
||||
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
|
||||
onCanvasA2uiPush = {
|
||||
@ -566,43 +572,8 @@ class NodeRuntime(
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
gateways.collect { list ->
|
||||
if (list.isNotEmpty()) {
|
||||
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
|
||||
// UX parity with iOS: only set once when unset.
|
||||
if (lastDiscoveredStableId.value.trim().isEmpty()) {
|
||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||
}
|
||||
}
|
||||
|
||||
if (didAutoConnect) return@collect
|
||||
if (_isConnected.value) return@collect
|
||||
|
||||
if (manualEnabled.value) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isNotEmpty() && port in 1..65535) {
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
if (!manualTls.value) return@collect
|
||||
val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
return@collect
|
||||
}
|
||||
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return@collect
|
||||
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
||||
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(target)
|
||||
seedLastDiscoveredGateway(list)
|
||||
autoConnectIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@ -627,11 +598,53 @@ class NodeRuntime(
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
_isForeground.value = value
|
||||
if (!value) {
|
||||
if (value) {
|
||||
reconnectPreferredGatewayOnForeground()
|
||||
} else {
|
||||
stopActiveVoiceSession()
|
||||
}
|
||||
}
|
||||
|
||||
private fun seedLastDiscoveredGateway(list: List<GatewayEndpoint>) {
|
||||
if (list.isEmpty()) return
|
||||
if (lastDiscoveredStableId.value.trim().isNotEmpty()) return
|
||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||
}
|
||||
|
||||
private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? {
|
||||
if (manualEnabled.value) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isEmpty() || port !in 1..65535) return null
|
||||
return GatewayEndpoint.manual(host = host, port = port)
|
||||
}
|
||||
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return null
|
||||
val endpoint = gateways.value.firstOrNull { it.stableId == targetStableId } ?: return null
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(endpoint.stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return null
|
||||
return endpoint
|
||||
}
|
||||
|
||||
private fun autoConnectIfNeeded() {
|
||||
if (didAutoConnect) return
|
||||
if (_isConnected.value) return
|
||||
val endpoint = resolvePreferredGatewayEndpoint() ?: return
|
||||
didAutoConnect = true
|
||||
connect(endpoint)
|
||||
}
|
||||
|
||||
private fun reconnectPreferredGatewayOnForeground() {
|
||||
if (_isConnected.value) return
|
||||
if (_pendingGatewayTrust.value != null) return
|
||||
if (connectedEndpoint != null) {
|
||||
refreshGatewayConnection()
|
||||
return
|
||||
}
|
||||
resolvePreferredGatewayEndpoint()?.let(::connect)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
prefs.setDisplayName(value)
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@ class ChatController(
|
||||
fun load(sessionKey: String) {
|
||||
val key = sessionKey.trim().ifEmpty { "main" }
|
||||
_sessionKey.value = key
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||
}
|
||||
|
||||
fun applyMainSessionKey(mainSessionKey: String) {
|
||||
@ -84,11 +84,11 @@ class ChatController(
|
||||
if (_sessionKey.value == trimmed) return
|
||||
if (_sessionKey.value != "main") return
|
||||
_sessionKey.value = trimmed
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||
}
|
||||
|
||||
fun refreshSessions(limit: Int? = null) {
|
||||
@ -106,7 +106,9 @@ class ChatController(
|
||||
if (key.isEmpty()) return
|
||||
if (key == _sessionKey.value) return
|
||||
_sessionKey.value = key
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
// Keep the thread switch path lean: history + health are needed immediately,
|
||||
// but the session list is usually unchanged and can refresh on explicit pull-to-refresh.
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
|
||||
}
|
||||
|
||||
fun sendMessage(
|
||||
@ -249,7 +251,7 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun bootstrap(forceHealth: Boolean) {
|
||||
private suspend fun bootstrap(forceHealth: Boolean, refreshSessions: Boolean) {
|
||||
_errorText.value = null
|
||||
_healthOk.value = false
|
||||
clearPendingRuns()
|
||||
@ -271,7 +273,9 @@ class ChatController(
|
||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||
|
||||
pollHealthIfNeeded(force = forceHealth)
|
||||
fetchSessions(limit = 50)
|
||||
if (refreshSessions) {
|
||||
fetchSessions(limit = 50)
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
_errorText.value = err.message
|
||||
}
|
||||
|
||||
@ -17,7 +17,9 @@ class ConnectionManager(
|
||||
private val voiceWakeMode: () -> VoiceWakeMode,
|
||||
private val motionActivityAvailable: () -> Boolean,
|
||||
private val motionPedometerAvailable: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val sendSmsAvailable: () -> Boolean,
|
||||
private val readSmsAvailable: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
@ -78,7 +80,9 @@ class ConnectionManager(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = cameraEnabled(),
|
||||
locationEnabled = locationMode() != LocationMode.Off,
|
||||
smsAvailable = smsAvailable(),
|
||||
sendSmsAvailable = sendSmsAvailable(),
|
||||
readSmsAvailable = readSmsAvailable(),
|
||||
callLogAvailable = callLogAvailable(),
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
|
||||
@ -25,6 +25,8 @@ import kotlinx.serialization.json.put
|
||||
|
||||
class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS,
|
||||
private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
|
||||
) {
|
||||
private data class BatterySnapshot(
|
||||
val status: Int,
|
||||
@ -173,8 +175,8 @@ class DeviceHandler(
|
||||
put(
|
||||
"sms",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
|
||||
promptableWhenDenied = canSendSms,
|
||||
granted = smsEnabled && hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
|
||||
promptableWhenDenied = smsEnabled && canSendSms,
|
||||
),
|
||||
)
|
||||
put(
|
||||
@ -215,8 +217,8 @@ class DeviceHandler(
|
||||
put(
|
||||
"callLog",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.READ_CALL_LOG),
|
||||
promptableWhenDenied = true,
|
||||
granted = callLogEnabled && hasPermission(Manifest.permission.READ_CALL_LOG),
|
||||
promptableWhenDenied = callLogEnabled,
|
||||
),
|
||||
)
|
||||
put(
|
||||
|
||||
@ -18,7 +18,9 @@ import ai.openclaw.app.protocol.OpenClawSystemCommand
|
||||
data class NodeRuntimeFlags(
|
||||
val cameraEnabled: Boolean,
|
||||
val locationEnabled: Boolean,
|
||||
val smsAvailable: Boolean,
|
||||
val sendSmsAvailable: Boolean,
|
||||
val readSmsAvailable: Boolean,
|
||||
val callLogAvailable: Boolean,
|
||||
val voiceWakeEnabled: Boolean,
|
||||
val motionActivityAvailable: Boolean,
|
||||
val motionPedometerAvailable: Boolean,
|
||||
@ -29,7 +31,9 @@ enum class InvokeCommandAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
SendSmsAvailable,
|
||||
ReadSmsAvailable,
|
||||
CallLogAvailable,
|
||||
MotionActivityAvailable,
|
||||
MotionPedometerAvailable,
|
||||
DebugBuild,
|
||||
@ -40,6 +44,7 @@ enum class NodeCapabilityAvailability {
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
CallLogAvailable,
|
||||
VoiceWakeEnabled,
|
||||
MotionAvailable,
|
||||
}
|
||||
@ -85,7 +90,10 @@ object InvokeCommandRegistry {
|
||||
name = OpenClawCapability.Motion.rawValue,
|
||||
availability = NodeCapabilityAvailability.MotionAvailable,
|
||||
),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.CallLog.rawValue,
|
||||
availability = NodeCapabilityAvailability.CallLogAvailable,
|
||||
),
|
||||
)
|
||||
|
||||
val all: List<InvokeCommandSpec> =
|
||||
@ -187,10 +195,15 @@ object InvokeCommandRegistry {
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Send.rawValue,
|
||||
availability = InvokeCommandAvailability.SmsAvailable,
|
||||
availability = InvokeCommandAvailability.SendSmsAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Search.rawValue,
|
||||
availability = InvokeCommandAvailability.ReadSmsAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCallLogCommand.Search.rawValue,
|
||||
availability = InvokeCommandAvailability.CallLogAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.logs",
|
||||
@ -213,7 +226,8 @@ object InvokeCommandRegistry {
|
||||
NodeCapabilityAvailability.Always -> true
|
||||
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
|
||||
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
|
||||
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
|
||||
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
|
||||
NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
|
||||
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
|
||||
}
|
||||
@ -228,7 +242,9 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandAvailability.Always -> true
|
||||
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
|
||||
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
|
||||
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
|
||||
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
|
||||
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
|
||||
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||
|
||||
@ -32,7 +32,9 @@ class InvokeDispatcher(
|
||||
private val isForeground: () -> Boolean,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationEnabled: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val sendSmsAvailable: () -> Boolean,
|
||||
private val readSmsAvailable: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val refreshNodeCanvasCapability: suspend () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
@ -162,6 +164,7 @@ class InvokeDispatcher(
|
||||
|
||||
// SMS command
|
||||
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
|
||||
OpenClawSmsCommand.Search.rawValue -> smsHandler.handleSmsSearch(paramsJson)
|
||||
|
||||
// CallLog command
|
||||
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
|
||||
@ -256,8 +259,8 @@ class InvokeDispatcher(
|
||||
message = "PEDOMETER_UNAVAILABLE: step counter not available",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.SmsAvailable ->
|
||||
if (smsAvailable()) {
|
||||
InvokeCommandAvailability.SendSmsAvailable ->
|
||||
if (sendSmsAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
@ -265,6 +268,24 @@ class InvokeDispatcher(
|
||||
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.ReadSmsAvailable ->
|
||||
if (readSmsAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "SMS_UNAVAILABLE",
|
||||
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.CallLogAvailable ->
|
||||
if (callLogAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALL_LOG_UNAVAILABLE",
|
||||
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
|
||||
@ -8,27 +8,85 @@ import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
class LocationHandler(
|
||||
internal interface LocationDataSource {
|
||||
fun hasFinePermission(context: Context): Boolean
|
||||
|
||||
fun hasCoarsePermission(context: Context): Boolean
|
||||
|
||||
suspend fun fetchLocation(
|
||||
desiredProviders: List<String>,
|
||||
maxAgeMs: Long?,
|
||||
timeoutMs: Long,
|
||||
isPrecise: Boolean,
|
||||
): LocationCaptureManager.Payload
|
||||
}
|
||||
|
||||
private class DefaultLocationDataSource(
|
||||
private val capture: LocationCaptureManager,
|
||||
) : LocationDataSource {
|
||||
override fun hasFinePermission(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun hasCoarsePermission(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override suspend fun fetchLocation(
|
||||
desiredProviders: List<String>,
|
||||
maxAgeMs: Long?,
|
||||
timeoutMs: Long,
|
||||
isPrecise: Boolean,
|
||||
): LocationCaptureManager.Payload =
|
||||
capture.getLocation(
|
||||
desiredProviders = desiredProviders,
|
||||
maxAgeMs = maxAgeMs,
|
||||
timeoutMs = timeoutMs,
|
||||
isPrecise = isPrecise,
|
||||
)
|
||||
}
|
||||
|
||||
class LocationHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val location: LocationCaptureManager,
|
||||
private val dataSource: LocationDataSource,
|
||||
private val json: Json,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val locationPreciseEnabled: () -> Boolean,
|
||||
) {
|
||||
fun hasFineLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
constructor(
|
||||
appContext: Context,
|
||||
location: LocationCaptureManager,
|
||||
json: Json,
|
||||
isForeground: () -> Boolean,
|
||||
locationPreciseEnabled: () -> Boolean,
|
||||
) : this(
|
||||
appContext = appContext,
|
||||
dataSource = DefaultLocationDataSource(location),
|
||||
json = json,
|
||||
isForeground = isForeground,
|
||||
locationPreciseEnabled = locationPreciseEnabled,
|
||||
)
|
||||
|
||||
fun hasCoarseLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
fun hasFineLocationPermission(): Boolean = dataSource.hasFinePermission(appContext)
|
||||
|
||||
fun hasCoarseLocationPermission(): Boolean = dataSource.hasCoarsePermission(appContext)
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: LocationDataSource,
|
||||
json: Json = Json { ignoreUnknownKeys = true },
|
||||
isForeground: () -> Boolean = { true },
|
||||
locationPreciseEnabled: () -> Boolean = { true },
|
||||
): LocationHandler =
|
||||
LocationHandler(
|
||||
appContext = appContext,
|
||||
dataSource = dataSource,
|
||||
json = json,
|
||||
isForeground = isForeground,
|
||||
locationPreciseEnabled = locationPreciseEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@ -39,7 +97,7 @@ class LocationHandler(
|
||||
message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open",
|
||||
)
|
||||
}
|
||||
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
|
||||
if (!dataSource.hasFinePermission(appContext) && !dataSource.hasCoarsePermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
||||
@ -49,9 +107,9 @@ class LocationHandler(
|
||||
val preciseEnabled = locationPreciseEnabled()
|
||||
val accuracy =
|
||||
when (desiredAccuracy) {
|
||||
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
"precise" -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
|
||||
"coarse" -> "coarse"
|
||||
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
else -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
|
||||
}
|
||||
val providers =
|
||||
when (accuracy) {
|
||||
@ -61,7 +119,7 @@ class LocationHandler(
|
||||
}
|
||||
try {
|
||||
val payload =
|
||||
location.getLocation(
|
||||
dataSource.fetchLocation(
|
||||
desiredProviders = providers,
|
||||
maxAgeMs = maxAgeMs,
|
||||
timeoutMs = timeoutMs,
|
||||
|
||||
@ -16,4 +16,16 @@ class SmsHandler(
|
||||
return GatewaySession.InvokeResult.error(code = code, message = error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val res = sms.search(paramsJson)
|
||||
if (res.ok) {
|
||||
return GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} else {
|
||||
val error = res.error ?: "SMS_SEARCH_FAILED"
|
||||
val idx = error.indexOf(':')
|
||||
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEARCH_FAILED"
|
||||
return GatewaySession.InvokeResult.error(code = code, message = error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,19 +3,27 @@ package ai.openclaw.app.node
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.Telephony
|
||||
import android.telephony.SmsManager as AndroidSmsManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.Serializable
|
||||
import ai.openclaw.app.PermissionRequester
|
||||
|
||||
/**
|
||||
* Sends SMS messages via the Android SMS API.
|
||||
* Requires SEND_SMS permission to be granted.
|
||||
*
|
||||
* Also provides SMS query functionality with READ_SMS permission.
|
||||
*/
|
||||
class SmsManager(private val context: Context) {
|
||||
|
||||
@ -30,6 +38,30 @@ class SmsManager(private val context: Context) {
|
||||
val payloadJson: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a single SMS message
|
||||
*/
|
||||
@Serializable
|
||||
data class SmsMessage(
|
||||
val id: Long,
|
||||
val threadId: Long,
|
||||
val address: String?,
|
||||
val person: String?,
|
||||
val date: Long,
|
||||
val dateSent: Long,
|
||||
val read: Boolean,
|
||||
val type: Int,
|
||||
val body: String?,
|
||||
val status: Int,
|
||||
)
|
||||
|
||||
data class SearchResult(
|
||||
val ok: Boolean,
|
||||
val messages: List<SmsMessage>,
|
||||
val error: String? = null,
|
||||
val payloadJson: String,
|
||||
)
|
||||
|
||||
internal data class ParsedParams(
|
||||
val to: String,
|
||||
val message: String,
|
||||
@ -44,12 +76,30 @@ class SmsManager(private val context: Context) {
|
||||
) : ParseResult()
|
||||
}
|
||||
|
||||
internal data class QueryParams(
|
||||
val startTime: Long? = null,
|
||||
val endTime: Long? = null,
|
||||
val contactName: String? = null,
|
||||
val phoneNumber: String? = null,
|
||||
val keyword: String? = null,
|
||||
val type: Int? = null,
|
||||
val isRead: Boolean? = null,
|
||||
val limit: Int = DEFAULT_SMS_LIMIT,
|
||||
val offset: Int = 0,
|
||||
)
|
||||
|
||||
internal sealed class QueryParseResult {
|
||||
data class Ok(val params: QueryParams) : QueryParseResult()
|
||||
data class Error(val error: String) : QueryParseResult()
|
||||
}
|
||||
|
||||
internal data class SendPlan(
|
||||
val parts: List<String>,
|
||||
val useMultipart: Boolean,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_SMS_LIMIT = 25
|
||||
internal val JsonConfig = Json { ignoreUnknownKeys = true }
|
||||
|
||||
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
|
||||
@ -88,6 +138,52 @@ class SmsManager(private val context: Context) {
|
||||
return ParseResult.Ok(ParsedParams(to = to, message = message))
|
||||
}
|
||||
|
||||
internal fun parseQueryParams(paramsJson: String?, json: Json = JsonConfig): QueryParseResult {
|
||||
val params = paramsJson?.trim().orEmpty()
|
||||
if (params.isEmpty()) {
|
||||
return QueryParseResult.Ok(QueryParams())
|
||||
}
|
||||
|
||||
val obj = try {
|
||||
json.parseToJsonElement(params).jsonObject
|
||||
} catch (_: Throwable) {
|
||||
return QueryParseResult.Error("INVALID_REQUEST: expected JSON object")
|
||||
}
|
||||
|
||||
val startTime = (obj["startTime"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val endTime = (obj["endTime"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val contactName = (obj["contactName"] as? JsonPrimitive)?.content?.trim()
|
||||
val phoneNumber = (obj["phoneNumber"] as? JsonPrimitive)?.content?.trim()
|
||||
val keyword = (obj["keyword"] as? JsonPrimitive)?.content?.trim()
|
||||
val type = (obj["type"] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
val isRead = (obj["isRead"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull()
|
||||
val limit = ((obj["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_SMS_LIMIT)
|
||||
.coerceIn(1, 200)
|
||||
val offset = ((obj["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
|
||||
.coerceAtLeast(0)
|
||||
|
||||
// Validate time range
|
||||
if (startTime != null && endTime != null && startTime > endTime) {
|
||||
return QueryParseResult.Error("INVALID_REQUEST: startTime must be less than or equal to endTime")
|
||||
}
|
||||
|
||||
return QueryParseResult.Ok(QueryParams(
|
||||
startTime = startTime,
|
||||
endTime = endTime,
|
||||
contactName = contactName,
|
||||
phoneNumber = phoneNumber,
|
||||
keyword = keyword,
|
||||
type = type,
|
||||
isRead = isRead,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
))
|
||||
}
|
||||
|
||||
private fun normalizePhoneNumber(phone: String): String {
|
||||
return phone.replace(Regex("""[\s\-()]"""), "")
|
||||
}
|
||||
|
||||
internal fun buildSendPlan(
|
||||
message: String,
|
||||
divider: (String) -> List<String>,
|
||||
@ -112,6 +208,25 @@ class SmsManager(private val context: Context) {
|
||||
}
|
||||
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
|
||||
}
|
||||
|
||||
internal fun buildQueryPayloadJson(
|
||||
json: Json = JsonConfig,
|
||||
ok: Boolean,
|
||||
messages: List<SmsMessage>,
|
||||
error: String? = null,
|
||||
): String {
|
||||
val messagesArray = json.encodeToString(messages)
|
||||
val messagesElement = json.parseToJsonElement(messagesArray)
|
||||
val payload = mutableMapOf<String, JsonElement>(
|
||||
"ok" to JsonPrimitive(ok),
|
||||
"count" to JsonPrimitive(messages.size),
|
||||
"messages" to messagesElement
|
||||
)
|
||||
if (!ok && error != null) {
|
||||
payload["error"] = JsonPrimitive(error)
|
||||
}
|
||||
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
|
||||
}
|
||||
}
|
||||
|
||||
fun hasSmsPermission(): Boolean {
|
||||
@ -121,10 +236,28 @@ class SmsManager(private val context: Context) {
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun hasReadSmsPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_SMS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun hasReadContactsPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_CONTACTS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun canSendSms(): Boolean {
|
||||
return hasSmsPermission() && hasTelephonyFeature()
|
||||
}
|
||||
|
||||
fun canReadSms(): Boolean {
|
||||
return hasReadSmsPermission() && hasTelephonyFeature()
|
||||
}
|
||||
|
||||
fun hasTelephonyFeature(): Boolean {
|
||||
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
@ -208,6 +341,20 @@ class SmsManager(private val context: Context) {
|
||||
return results[Manifest.permission.SEND_SMS] == true
|
||||
}
|
||||
|
||||
private suspend fun ensureReadSmsPermission(): Boolean {
|
||||
if (hasReadSmsPermission()) return true
|
||||
val requester = permissionRequester ?: return false
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_SMS))
|
||||
return results[Manifest.permission.READ_SMS] == true
|
||||
}
|
||||
|
||||
private suspend fun ensureReadContactsPermission(): Boolean {
|
||||
if (hasReadContactsPermission()) return true
|
||||
val requester = permissionRequester ?: return false
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_CONTACTS))
|
||||
return results[Manifest.permission.READ_CONTACTS] == true
|
||||
}
|
||||
|
||||
private fun okResult(to: String, message: String): SendResult {
|
||||
return SendResult(
|
||||
ok = true,
|
||||
@ -227,4 +374,240 @@ class SmsManager(private val context: Context) {
|
||||
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* search SMS messages with the specified parameters.
|
||||
*
|
||||
* @param paramsJson JSON with optional fields:
|
||||
* - startTime (Long): Start time in milliseconds
|
||||
* - endTime (Long): End time in milliseconds
|
||||
* - contactName (String): Contact name to search
|
||||
* - phoneNumber (String): Phone number to search (supports partial matching)
|
||||
* - keyword (String): Keyword to search in message body
|
||||
* - type (Int): SMS type (1=Inbox, 2=Sent, 3=Draft, etc.)
|
||||
* - isRead (Boolean): Read status
|
||||
* - limit (Int): Number of records to return (default: 25, range: 1-200)
|
||||
* - offset (Int): Number of records to skip (default: 0)
|
||||
* @return SearchResult containing the list of SMS messages or an error
|
||||
*/
|
||||
suspend fun search(paramsJson: String?): SearchResult = withContext(Dispatchers.IO) {
|
||||
if (!hasTelephonyFeature()) {
|
||||
return@withContext SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = "SMS_UNAVAILABLE: telephony not available",
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_UNAVAILABLE: telephony not available")
|
||||
)
|
||||
}
|
||||
|
||||
if (!ensureReadSmsPermission()) {
|
||||
return@withContext SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission",
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission")
|
||||
)
|
||||
}
|
||||
|
||||
val parseResult = parseQueryParams(paramsJson, json)
|
||||
if (parseResult is QueryParseResult.Error) {
|
||||
return@withContext SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = parseResult.error,
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = parseResult.error)
|
||||
)
|
||||
}
|
||||
val params = (parseResult as QueryParseResult.Ok).params
|
||||
|
||||
return@withContext try {
|
||||
// Get phone numbers from contact name if provided
|
||||
val phoneNumbers = if (!params.contactName.isNullOrEmpty()) {
|
||||
if (!ensureReadContactsPermission()) {
|
||||
return@withContext SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission",
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission")
|
||||
)
|
||||
}
|
||||
getPhoneNumbersFromContactName(params.contactName)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val messages = querySmsMessages(params, phoneNumbers)
|
||||
SearchResult(
|
||||
ok = true,
|
||||
messages = messages,
|
||||
error = null,
|
||||
payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages)
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: ${e.message}")
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}",
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all phone numbers associated with a contact name
|
||||
*/
|
||||
private fun getPhoneNumbersFromContactName(contactName: String): List<String> {
|
||||
val phoneNumbers = mutableListOf<String>()
|
||||
val selection = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ?"
|
||||
val selectionArgs = arrayOf("%$contactName%")
|
||||
|
||||
val cursor = context.contentResolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
|
||||
selection,
|
||||
selectionArgs,
|
||||
null
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
while (it.moveToNext()) {
|
||||
val number = it.getString(numberIndex)
|
||||
if (!number.isNullOrBlank()) {
|
||||
phoneNumbers.add(normalizePhoneNumber(number))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return phoneNumbers
|
||||
}
|
||||
|
||||
/**
|
||||
* Query SMS messages based on the provided parameters
|
||||
*/
|
||||
private fun querySmsMessages(params: QueryParams, phoneNumbers: List<String>): List<SmsMessage> {
|
||||
val messages = mutableListOf<SmsMessage>()
|
||||
|
||||
// Build selection and selectionArgs
|
||||
val selections = mutableListOf<String>()
|
||||
val selectionArgs = mutableListOf<String>()
|
||||
|
||||
// Time range
|
||||
if (params.startTime != null) {
|
||||
selections.add("${Telephony.Sms.DATE} >= ?")
|
||||
selectionArgs.add(params.startTime.toString())
|
||||
}
|
||||
if (params.endTime != null) {
|
||||
selections.add("${Telephony.Sms.DATE} <= ?")
|
||||
selectionArgs.add(params.endTime.toString())
|
||||
}
|
||||
|
||||
// Phone numbers (from contact name or direct phone number)
|
||||
val allPhoneNumbers = if (!params.phoneNumber.isNullOrEmpty()) {
|
||||
phoneNumbers + normalizePhoneNumber(params.phoneNumber)
|
||||
} else {
|
||||
phoneNumbers
|
||||
}
|
||||
|
||||
if (allPhoneNumbers.isNotEmpty()) {
|
||||
val addressSelection = allPhoneNumbers.joinToString(" OR ") {
|
||||
"${Telephony.Sms.ADDRESS} LIKE ?"
|
||||
}
|
||||
selections.add("($addressSelection)")
|
||||
allPhoneNumbers.forEach {
|
||||
selectionArgs.add("%$it%")
|
||||
}
|
||||
}
|
||||
|
||||
// Keyword in body
|
||||
if (!params.keyword.isNullOrEmpty()) {
|
||||
selections.add("${Telephony.Sms.BODY} LIKE ?")
|
||||
selectionArgs.add("%${params.keyword}%")
|
||||
}
|
||||
|
||||
// Type
|
||||
if (params.type != null) {
|
||||
selections.add("${Telephony.Sms.TYPE} = ?")
|
||||
selectionArgs.add(params.type.toString())
|
||||
}
|
||||
|
||||
// Read status
|
||||
if (params.isRead != null) {
|
||||
selections.add("${Telephony.Sms.READ} = ?")
|
||||
selectionArgs.add(if (params.isRead) "1" else "0")
|
||||
}
|
||||
|
||||
val selection = if (selections.isNotEmpty()) {
|
||||
selections.joinToString(" AND ")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val selectionArgsArray = if (selectionArgs.isNotEmpty()) {
|
||||
selectionArgs.toTypedArray()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Query SMS with SQL-level LIMIT and OFFSET to avoid loading all matching rows
|
||||
val sortOrder = "${Telephony.Sms.DATE} DESC LIMIT ${params.limit} OFFSET ${params.offset}"
|
||||
val cursor = context.contentResolver.query(
|
||||
Telephony.Sms.CONTENT_URI,
|
||||
arrayOf(
|
||||
Telephony.Sms._ID,
|
||||
Telephony.Sms.THREAD_ID,
|
||||
Telephony.Sms.ADDRESS,
|
||||
Telephony.Sms.PERSON,
|
||||
Telephony.Sms.DATE,
|
||||
Telephony.Sms.DATE_SENT,
|
||||
Telephony.Sms.READ,
|
||||
Telephony.Sms.TYPE,
|
||||
Telephony.Sms.BODY,
|
||||
Telephony.Sms.STATUS
|
||||
),
|
||||
selection,
|
||||
selectionArgsArray,
|
||||
sortOrder
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
val idIndex = it.getColumnIndex(Telephony.Sms._ID)
|
||||
val threadIdIndex = it.getColumnIndex(Telephony.Sms.THREAD_ID)
|
||||
val addressIndex = it.getColumnIndex(Telephony.Sms.ADDRESS)
|
||||
val personIndex = it.getColumnIndex(Telephony.Sms.PERSON)
|
||||
val dateIndex = it.getColumnIndex(Telephony.Sms.DATE)
|
||||
val dateSentIndex = it.getColumnIndex(Telephony.Sms.DATE_SENT)
|
||||
val readIndex = it.getColumnIndex(Telephony.Sms.READ)
|
||||
val typeIndex = it.getColumnIndex(Telephony.Sms.TYPE)
|
||||
val bodyIndex = it.getColumnIndex(Telephony.Sms.BODY)
|
||||
val statusIndex = it.getColumnIndex(Telephony.Sms.STATUS)
|
||||
|
||||
var count = 0
|
||||
while (it.moveToNext() && count < params.limit) {
|
||||
val message = SmsMessage(
|
||||
id = it.getLong(idIndex),
|
||||
threadId = it.getLong(threadIdIndex),
|
||||
address = it.getString(addressIndex),
|
||||
person = it.getString(personIndex),
|
||||
date = it.getLong(dateIndex),
|
||||
dateSent = it.getLong(dateSentIndex),
|
||||
read = it.getInt(readIndex) == 1,
|
||||
type = it.getInt(typeIndex),
|
||||
body = it.getString(bodyIndex),
|
||||
status = it.getInt(statusIndex)
|
||||
)
|
||||
messages.add(message)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +53,7 @@ enum class OpenClawCameraCommand(val rawValue: String) {
|
||||
|
||||
enum class OpenClawSmsCommand(val rawValue: String) {
|
||||
Send("sms.send"),
|
||||
Search("sms.search"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
@ -25,7 +25,7 @@ import ai.openclaw.app.MainViewModel
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
val webViewRef = remember { mutableStateOf<WebView?>(null) }
|
||||
@ -45,6 +45,7 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
modifier = modifier,
|
||||
factory = {
|
||||
WebView(context).apply {
|
||||
visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
@ -127,6 +128,16 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
webViewRef.value = this
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
webView.visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
||||
if (visible) {
|
||||
webView.resumeTimers()
|
||||
webView.onResume()
|
||||
} else {
|
||||
webView.onPause()
|
||||
webView.pauseTimers()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@ -20,6 +20,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
@ -49,6 +50,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
@ -60,6 +62,7 @@ private enum class ConnectInputMode {
|
||||
|
||||
@Composable
|
||||
fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
@ -134,7 +137,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway"
|
||||
val showDiagnostics = !isConnected && gatewayStatusHasDiagnostics(statusText)
|
||||
val statusLabel = gatewayStatusForDisplay(statusText)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
@ -279,6 +283,46 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
if (showDiagnostics) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileWarningSoft,
|
||||
border = BorderStroke(1.dp, mobileWarning.copy(alpha = 0.25f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Last gateway error", style = mobileHeadline, color = mobileWarning)
|
||||
Text(statusLabel, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
|
||||
Text("OpenClaw Android ${openClawAndroidVersionLabel()}", style = mobileCaption1, color = mobileTextSecondary)
|
||||
Button(
|
||||
onClick = {
|
||||
copyGatewayDiagnosticsReport(
|
||||
context = context,
|
||||
screen = "connect tab",
|
||||
gatewayAddress = activeEndpoint,
|
||||
statusText = statusLabel,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(46.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = mobileCardSurface,
|
||||
contentColor = mobileWarning,
|
||||
),
|
||||
border = BorderStroke(1.dp, mobileWarning.copy(alpha = 0.3f)),
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Copy Report for Claw", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import ai.openclaw.app.BuildConfig
|
||||
|
||||
internal fun openClawAndroidVersionLabel(): String {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gatewayStatusForDisplay(statusText: String): String {
|
||||
return statusText.trim().ifEmpty { "Offline" }
|
||||
}
|
||||
|
||||
internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean {
|
||||
val lower = gatewayStatusForDisplay(statusText).lowercase()
|
||||
return lower != "offline" && !lower.contains("connecting")
|
||||
}
|
||||
|
||||
internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean {
|
||||
val lower = gatewayStatusForDisplay(statusText).lowercase()
|
||||
return lower.contains("pair") || lower.contains("approve")
|
||||
}
|
||||
|
||||
internal fun buildGatewayDiagnosticsReport(
|
||||
screen: String,
|
||||
gatewayAddress: String,
|
||||
statusText: String,
|
||||
): String {
|
||||
val device =
|
||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { "Android" }
|
||||
val androidVersion = Build.VERSION.RELEASE?.trim().orEmpty().ifEmpty { Build.VERSION.SDK_INT.toString() }
|
||||
val endpoint = gatewayAddress.trim().ifEmpty { "unknown" }
|
||||
val status = gatewayStatusForDisplay(statusText)
|
||||
return """
|
||||
Help diagnose this OpenClaw Android gateway connection failure.
|
||||
|
||||
Please:
|
||||
- pick one route only: same machine, same LAN, Tailscale, or public URL
|
||||
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
|
||||
- quote the exact app status/error below
|
||||
- tell me whether `openclaw devices list` should show a pending pairing request
|
||||
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`
|
||||
- give the next exact command or tap
|
||||
|
||||
Debug info:
|
||||
- screen: $screen
|
||||
- app version: ${openClawAndroidVersionLabel()}
|
||||
- device: $device
|
||||
- android: $androidVersion (SDK ${Build.VERSION.SDK_INT})
|
||||
- gateway address: $endpoint
|
||||
- status/error: $status
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
internal fun copyGatewayDiagnosticsReport(
|
||||
context: Context,
|
||||
screen: String,
|
||||
gatewayAddress: String,
|
||||
statusText: String,
|
||||
) {
|
||||
val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return
|
||||
val report = buildGatewayDiagnosticsReport(screen = screen, gatewayAddress = gatewayAddress, statusText = statusText)
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw gateway diagnostics", report))
|
||||
Toast.makeText(context, "Copied gateway diagnostics", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@ -9,6 +9,7 @@ import android.hardware.SensorManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@ -60,6 +61,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
@ -91,6 +93,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
@ -236,8 +239,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
|
||||
val smsAvailable =
|
||||
remember(context) {
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
BuildConfig.OPENCLAW_ENABLE_SMS &&
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
val callLogAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
|
||||
val motionAvailable =
|
||||
remember(context) {
|
||||
hasMotionCapabilities(context)
|
||||
@ -287,11 +292,15 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
}
|
||||
var enableSms by
|
||||
rememberSaveable {
|
||||
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
|
||||
mutableStateOf(
|
||||
smsAvailable &&
|
||||
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
|
||||
isPermissionGranted(context, Manifest.permission.READ_SMS)
|
||||
)
|
||||
}
|
||||
var enableCallLog by
|
||||
rememberSaveable {
|
||||
mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
|
||||
mutableStateOf(callLogAvailable && isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
|
||||
}
|
||||
|
||||
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
|
||||
@ -309,7 +318,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
|
||||
PermissionToggle.CallLog -> enableCallLog = enabled && callLogAvailable
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,8 +345,11 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
!motionPermissionRequired ||
|
||||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
PermissionToggle.Sms ->
|
||||
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
|
||||
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
|
||||
!smsAvailable ||
|
||||
(isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
|
||||
isPermissionGranted(context, Manifest.permission.READ_SMS))
|
||||
PermissionToggle.CallLog ->
|
||||
!callLogAvailable || isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
|
||||
}
|
||||
|
||||
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
|
||||
@ -361,6 +373,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
enableSms,
|
||||
enableCallLog,
|
||||
smsAvailable,
|
||||
callLogAvailable,
|
||||
motionAvailable,
|
||||
) {
|
||||
val enabled = mutableListOf<String>()
|
||||
@ -375,7 +388,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 (callLogAvailable && enableCallLog) enabled += "Call Log"
|
||||
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
|
||||
}
|
||||
|
||||
@ -604,6 +617,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
motionPermissionRequired = motionPermissionRequired,
|
||||
enableSms = enableSms,
|
||||
smsAvailable = smsAvailable,
|
||||
callLogAvailable = callLogAvailable,
|
||||
enableCallLog = enableCallLog,
|
||||
context = context,
|
||||
onDiscoveryChange = { checked ->
|
||||
@ -698,16 +712,20 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Sms,
|
||||
checked,
|
||||
listOf(Manifest.permission.SEND_SMS),
|
||||
listOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS),
|
||||
)
|
||||
}
|
||||
},
|
||||
onCallLogChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.CallLog,
|
||||
checked,
|
||||
listOf(Manifest.permission.READ_CALL_LOG),
|
||||
)
|
||||
if (!callLogAvailable) {
|
||||
setPermissionToggleEnabled(PermissionToggle.CallLog, false)
|
||||
} else {
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.CallLog,
|
||||
checked,
|
||||
listOf(Manifest.permission.READ_CALL_LOG),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
OnboardingStep.FinalCheck ->
|
||||
@ -1299,6 +1317,7 @@ private fun PermissionsStep(
|
||||
motionPermissionRequired: Boolean,
|
||||
enableSms: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
callLogAvailable: Boolean,
|
||||
enableCallLog: Boolean,
|
||||
context: Context,
|
||||
onDiscoveryChange: (Boolean) -> Unit,
|
||||
@ -1437,20 +1456,24 @@ private fun PermissionsStep(
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "SMS",
|
||||
subtitle = "Send text messages via the gateway",
|
||||
subtitle = "Send and search text messages via the gateway",
|
||||
checked = enableSms,
|
||||
granted = isPermissionGranted(context, Manifest.permission.SEND_SMS),
|
||||
granted =
|
||||
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
|
||||
isPermissionGranted(context, Manifest.permission.READ_SMS),
|
||||
onCheckedChange = onSmsChange,
|
||||
)
|
||||
}
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Call Log",
|
||||
subtitle = "callLog.search",
|
||||
checked = enableCallLog,
|
||||
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
|
||||
onCheckedChange = onCallLogChange,
|
||||
)
|
||||
if (callLogAvailable) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1511,6 +1534,12 @@ private fun FinalStep(
|
||||
enabledPermissions: String,
|
||||
methodLabel: String,
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val gatewayAddress = parsedGateway?.displayUrl ?: "Invalid gateway URL"
|
||||
val statusLabel = gatewayStatusForDisplay(statusText)
|
||||
val showDiagnostics = gatewayStatusHasDiagnostics(statusText)
|
||||
val pairingRequired = gatewayStatusLooksLikePairing(statusText)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Review", style = onboardingTitle1Style, color = onboardingText)
|
||||
|
||||
@ -1523,7 +1552,7 @@ private fun FinalStep(
|
||||
SummaryCard(
|
||||
icon = Icons.Default.Cloud,
|
||||
label = "Gateway",
|
||||
value = parsedGateway?.displayUrl ?: "Invalid gateway URL",
|
||||
value = gatewayAddress,
|
||||
accentColor = Color(0xFF7C5AC7),
|
||||
)
|
||||
SummaryCard(
|
||||
@ -1607,7 +1636,7 @@ private fun FinalStep(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = onboardingWarningSoft,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
|
||||
border = BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
@ -1632,13 +1661,66 @@ private fun FinalStep(
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Pairing Required", style = onboardingHeadlineStyle, color = onboardingWarning)
|
||||
Text("Run these on your gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
Text(
|
||||
if (pairingRequired) "Pairing Required" else "Connection Failed",
|
||||
style = onboardingHeadlineStyle,
|
||||
color = onboardingWarning,
|
||||
)
|
||||
Text(
|
||||
if (pairingRequired) {
|
||||
"Approve this phone on the gateway host, or copy the report below."
|
||||
} else {
|
||||
"Copy this report and give it to your Claw."
|
||||
},
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
CommandBlock("openclaw devices list")
|
||||
CommandBlock("openclaw devices approve <requestId>")
|
||||
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
if (showDiagnostics) {
|
||||
Text("Error", style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), color = onboardingTextSecondary)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = onboardingCommandBg,
|
||||
border = BorderStroke(1.dp, onboardingCommandBorder),
|
||||
) {
|
||||
Text(
|
||||
statusLabel,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace),
|
||||
color = onboardingCommandText,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"OpenClaw Android ${openClawAndroidVersionLabel()}",
|
||||
style = onboardingCaption1Style,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
copyGatewayDiagnosticsReport(
|
||||
context = context,
|
||||
screen = "onboarding final check",
|
||||
gatewayAddress = gatewayAddress,
|
||||
statusText = statusLabel,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = onboardingSurface, contentColor = onboardingWarning),
|
||||
border = BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.3f)),
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Copy Report for Claw", style = onboardingCalloutStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
if (pairingRequired) {
|
||||
CommandBlock("openclaw devices list")
|
||||
CommandBlock("openclaw devices approve <requestId>")
|
||||
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +39,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@ -68,10 +70,19 @@ private enum class StatusVisual {
|
||||
@Composable
|
||||
fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
|
||||
var chatTabStarted by rememberSaveable { mutableStateOf(false) }
|
||||
var screenTabStarted by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Stop TTS when user navigates away from voice tab
|
||||
// Stop TTS when user navigates away from voice tab, and lazily keep the Chat/Screen tabs
|
||||
// alive after the first visit so repeated tab switches do not rebuild their UI trees.
|
||||
LaunchedEffect(activeTab) {
|
||||
viewModel.setVoiceScreenActive(activeTab == HomeTab.Voice)
|
||||
if (activeTab == HomeTab.Chat) {
|
||||
chatTabStarted = true
|
||||
}
|
||||
if (activeTab == HomeTab.Screen) {
|
||||
screenTabStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
@ -120,11 +131,35 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.background(mobileBackgroundGradient),
|
||||
) {
|
||||
if (chatTabStarted) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.alpha(if (activeTab == HomeTab.Chat) 1f else 0f)
|
||||
.zIndex(if (activeTab == HomeTab.Chat) 1f else 0f),
|
||||
) {
|
||||
ChatSheet(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if (screenTabStarted) {
|
||||
ScreenTabScreen(
|
||||
viewModel = viewModel,
|
||||
visible = activeTab == HomeTab.Screen,
|
||||
modifier =
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.alpha(if (activeTab == HomeTab.Screen) 1f else 0f)
|
||||
.zIndex(if (activeTab == HomeTab.Screen) 1f else 0f),
|
||||
)
|
||||
}
|
||||
|
||||
when (activeTab) {
|
||||
HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel)
|
||||
HomeTab.Chat -> ChatSheet(viewModel = viewModel)
|
||||
HomeTab.Chat -> if (!chatTabStarted) ChatSheet(viewModel = viewModel)
|
||||
HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel)
|
||||
HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel)
|
||||
HomeTab.Screen -> Unit
|
||||
HomeTab.Settings -> SettingsSheet(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
@ -132,16 +167,19 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenTabScreen(viewModel: MainViewModel) {
|
||||
private fun ScreenTabScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier = Modifier) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
var refreshedForCurrentConnection by rememberSaveable(isConnected) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(isConnected, visible, refreshedForCurrentConnection) {
|
||||
if (visible && isConnected && !refreshedForCurrentConnection) {
|
||||
viewModel.refreshHomeCanvasOverviewIfConnected()
|
||||
refreshedForCurrentConnection = true
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
CanvasScreen(viewModel = viewModel, visible = visible, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -149,8 +149,10 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
val smsPermissionAvailable =
|
||||
remember {
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
BuildConfig.OPENCLAW_ENABLE_SMS &&
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
val callLogPermissionAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
@ -247,12 +249,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val smsPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
smsPermissionGranted = granted
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val sendOk = perms[Manifest.permission.SEND_SMS] == true
|
||||
val readOk = perms[Manifest.permission.READ_SMS] == true
|
||||
smsPermissionGranted = sendOk && readOk
|
||||
viewModel.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
@ -287,6 +293,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
smsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@ -507,7 +515,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("SMS", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text("Send SMS from this device.", style = mobileCallout)
|
||||
Text("Send and search SMS from this device.", style = mobileCallout)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
@ -515,7 +523,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
if (smsPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
|
||||
smsPermissionLauncher.launch(arrayOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS))
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
@ -616,31 +624,33 @@ 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 (callLogPermissionAvailable) {
|
||||
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(
|
||||
|
||||
@ -63,7 +63,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
|
||||
LaunchedEffect(mainSessionKey) {
|
||||
viewModel.loadChat(mainSessionKey)
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
@ -1,338 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import okhttp3.*
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Streams text chunks to ElevenLabs WebSocket API and plays audio in real-time.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Create instance with voice/API config
|
||||
* 2. Call [start] to open WebSocket + AudioTrack
|
||||
* 3. Call [sendText] with incremental text chunks as they arrive
|
||||
* 4. Call [finish] when the full response is ready (sends EOS to ElevenLabs)
|
||||
* 5. Call [stop] to cancel/cleanup at any time
|
||||
*
|
||||
* Audio playback begins as soon as the first audio chunk arrives from ElevenLabs,
|
||||
* typically within ~100ms of the first text chunk for eleven_flash_v2_5.
|
||||
*
|
||||
* Note: eleven_v3 does NOT support WebSocket streaming. Use eleven_flash_v2_5
|
||||
* or eleven_flash_v2 for lowest latency.
|
||||
*/
|
||||
class ElevenLabsStreamingTts(
|
||||
private val scope: CoroutineScope,
|
||||
private val voiceId: String,
|
||||
private val apiKey: String,
|
||||
private val modelId: String = "eleven_flash_v2_5",
|
||||
private val outputFormat: String = "pcm_24000",
|
||||
private val sampleRate: Int = 24000,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ElevenLabsStreamTTS"
|
||||
private const val BASE_URL = "wss://api.elevenlabs.io/v1/text-to-speech"
|
||||
|
||||
/** Models that support WebSocket input streaming */
|
||||
val STREAMING_MODELS = setOf(
|
||||
"eleven_flash_v2_5",
|
||||
"eleven_flash_v2",
|
||||
"eleven_multilingual_v2",
|
||||
"eleven_turbo_v2_5",
|
||||
"eleven_turbo_v2",
|
||||
"eleven_monolingual_v1",
|
||||
)
|
||||
|
||||
fun supportsStreaming(modelId: String): Boolean = modelId in STREAMING_MODELS
|
||||
}
|
||||
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
val isPlaying: StateFlow<Boolean> = _isPlaying
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var audioTrack: AudioTrack? = null
|
||||
private var trackStarted = false
|
||||
private var client: OkHttpClient? = null
|
||||
@Volatile private var stopped = false
|
||||
@Volatile private var finished = false
|
||||
@Volatile var hasReceivedAudio = false
|
||||
private set
|
||||
private var drainJob: Job? = null
|
||||
|
||||
// Track text already sent so we only send incremental chunks
|
||||
private var sentTextLength = 0
|
||||
@Volatile private var wsReady = false
|
||||
private val pendingText = mutableListOf<String>()
|
||||
|
||||
/**
|
||||
* Open the WebSocket connection and prepare AudioTrack.
|
||||
* Must be called before [sendText].
|
||||
*/
|
||||
fun start() {
|
||||
stopped = false
|
||||
finished = false
|
||||
hasReceivedAudio = false
|
||||
sentTextLength = 0
|
||||
trackStarted = false
|
||||
wsReady = false
|
||||
sentFullText = ""
|
||||
synchronized(pendingText) { pendingText.clear() }
|
||||
|
||||
// Prepare AudioTrack
|
||||
val minBuffer = AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
val bufferSize = max(minBuffer * 2, 8 * 1024)
|
||||
val track = AudioTrack(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.build(),
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.build(),
|
||||
bufferSize,
|
||||
AudioTrack.MODE_STREAM,
|
||||
AudioManager.AUDIO_SESSION_ID_GENERATE,
|
||||
)
|
||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
||||
track.release()
|
||||
Log.e(TAG, "AudioTrack init failed")
|
||||
return
|
||||
}
|
||||
audioTrack = track
|
||||
_isPlaying.value = true
|
||||
|
||||
// Open WebSocket
|
||||
val url = "$BASE_URL/$voiceId/stream-input?model_id=$modelId&output_format=$outputFormat"
|
||||
val okClient = OkHttpClient.Builder()
|
||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
client = okClient
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("xi-api-key", apiKey)
|
||||
.build()
|
||||
|
||||
webSocket = okClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d(TAG, "WebSocket connected")
|
||||
// Send initial config with voice settings
|
||||
val config = JSONObject().apply {
|
||||
put("text", " ")
|
||||
put("voice_settings", JSONObject().apply {
|
||||
put("stability", 0.5)
|
||||
put("similarity_boost", 0.8)
|
||||
put("use_speaker_boost", false)
|
||||
})
|
||||
put("generation_config", JSONObject().apply {
|
||||
put("chunk_length_schedule", org.json.JSONArray(listOf(120, 160, 250, 290)))
|
||||
})
|
||||
}
|
||||
webSocket.send(config.toString())
|
||||
wsReady = true
|
||||
// Flush any text that was queued before WebSocket was ready
|
||||
synchronized(pendingText) {
|
||||
for (queued in pendingText) {
|
||||
val msg = JSONObject().apply { put("text", queued) }
|
||||
webSocket.send(msg.toString())
|
||||
Log.d(TAG, "flushed queued chunk: ${queued.length} chars")
|
||||
}
|
||||
pendingText.clear()
|
||||
}
|
||||
// Send deferred EOS if finish() was called before WebSocket was ready
|
||||
if (finished) {
|
||||
val eos = JSONObject().apply { put("text", "") }
|
||||
webSocket.send(eos.toString())
|
||||
Log.d(TAG, "sent deferred EOS")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (stopped) return
|
||||
try {
|
||||
val json = JSONObject(text)
|
||||
val audio = json.optString("audio", "")
|
||||
if (audio.isNotEmpty()) {
|
||||
val pcmBytes = Base64.decode(audio, Base64.DEFAULT)
|
||||
writeToTrack(pcmBytes)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing WebSocket message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(TAG, "WebSocket error: ${t.message}")
|
||||
stopped = true
|
||||
cleanup()
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(TAG, "WebSocket closed: $code $reason")
|
||||
// Wait for AudioTrack to finish playing buffered audio, then cleanup
|
||||
drainJob = scope.launch(Dispatchers.IO) {
|
||||
drainAudioTrack()
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send incremental text. Call with the full accumulated text so far —
|
||||
* only the new portion (since last send) will be transmitted.
|
||||
*/
|
||||
// Track the full text we've sent so we can detect replacement vs append
|
||||
private var sentFullText = ""
|
||||
|
||||
/**
|
||||
// If we already sent a superset of this text, it's just a stale/out-of-order
|
||||
// event from a different thread — not a real divergence. Ignore it.
|
||||
if (sentFullText.startsWith(fullText)) return true
|
||||
* Returns true if text was accepted, false if text diverged (caller should restart).
|
||||
*/
|
||||
@Synchronized
|
||||
fun sendText(fullText: String): Boolean {
|
||||
if (stopped) return false
|
||||
if (finished) return true // Already finishing — not a diverge, don't restart
|
||||
|
||||
// Detect text replacement: if the new text doesn't start with what we already sent,
|
||||
// the stream has diverged (e.g., tool call interrupted and text was replaced).
|
||||
if (sentFullText.isNotEmpty() && !fullText.startsWith(sentFullText)) {
|
||||
// If we already sent a superset of this text, it's just a stale/out-of-order
|
||||
// event from a different thread — not a real divergence. Ignore it.
|
||||
if (sentFullText.startsWith(fullText)) return true
|
||||
Log.d(TAG, "text diverged — sent='${sentFullText.take(60)}' new='${fullText.take(60)}'")
|
||||
return false
|
||||
}
|
||||
|
||||
if (fullText.length > sentTextLength) {
|
||||
val newText = fullText.substring(sentTextLength)
|
||||
sentTextLength = fullText.length
|
||||
sentFullText = fullText
|
||||
|
||||
val ws = webSocket
|
||||
if (ws != null && wsReady) {
|
||||
val msg = JSONObject().apply { put("text", newText) }
|
||||
ws.send(msg.toString())
|
||||
Log.d(TAG, "sent chunk: ${newText.length} chars")
|
||||
} else {
|
||||
// Queue if WebSocket not connected yet (ws null = still connecting, wsReady false = handshake pending)
|
||||
synchronized(pendingText) { pendingText.add(newText) }
|
||||
Log.d(TAG, "queued chunk: ${newText.length} chars (ws not ready)")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that no more text is coming. Sends EOS to ElevenLabs.
|
||||
* The WebSocket will close after generating remaining audio.
|
||||
*/
|
||||
@Synchronized
|
||||
fun finish() {
|
||||
if (stopped || finished) return
|
||||
finished = true
|
||||
val ws = webSocket
|
||||
if (ws != null && wsReady) {
|
||||
// Send empty text to signal end of stream
|
||||
val eos = JSONObject().apply { put("text", "") }
|
||||
ws.send(eos.toString())
|
||||
Log.d(TAG, "sent EOS")
|
||||
}
|
||||
// else: WebSocket not ready yet; onOpen will send EOS after flushing queued text
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately stop playback and close everything.
|
||||
*/
|
||||
fun stop() {
|
||||
stopped = true
|
||||
finished = true
|
||||
drainJob?.cancel()
|
||||
drainJob = null
|
||||
webSocket?.cancel()
|
||||
webSocket = null
|
||||
val track = audioTrack
|
||||
audioTrack = null
|
||||
if (track != null) {
|
||||
try {
|
||||
track.pause()
|
||||
track.flush()
|
||||
track.release()
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
_isPlaying.value = false
|
||||
client?.dispatcher?.executorService?.shutdown()
|
||||
client = null
|
||||
}
|
||||
|
||||
private fun writeToTrack(pcmBytes: ByteArray) {
|
||||
val track = audioTrack ?: return
|
||||
if (stopped) return
|
||||
|
||||
// Start playback on first audio chunk — avoids underrun
|
||||
if (!trackStarted) {
|
||||
track.play()
|
||||
trackStarted = true
|
||||
hasReceivedAudio = true
|
||||
Log.d(TAG, "AudioTrack started on first chunk")
|
||||
}
|
||||
|
||||
var offset = 0
|
||||
while (offset < pcmBytes.size && !stopped) {
|
||||
val wrote = track.write(pcmBytes, offset, pcmBytes.size - offset)
|
||||
if (wrote <= 0) {
|
||||
if (stopped) return
|
||||
Log.w(TAG, "AudioTrack write returned $wrote")
|
||||
break
|
||||
}
|
||||
offset += wrote
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainAudioTrack() {
|
||||
if (stopped) return
|
||||
// Wait up to 10s for audio to finish playing
|
||||
val deadline = System.currentTimeMillis() + 10_000
|
||||
while (!stopped && System.currentTimeMillis() < deadline) {
|
||||
// Check if track is still playing
|
||||
val track = audioTrack ?: return
|
||||
if (track.playState != AudioTrack.PLAYSTATE_PLAYING) return
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (_: InterruptedException) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
val track = audioTrack
|
||||
audioTrack = null
|
||||
if (track != null) {
|
||||
try {
|
||||
track.stop()
|
||||
track.release()
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
_isPlaying.value = false
|
||||
client?.dispatcher?.executorService?.shutdown()
|
||||
client = null
|
||||
}
|
||||
}
|
||||
@ -1,98 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import android.media.MediaDataSource
|
||||
import kotlin.math.min
|
||||
|
||||
internal class StreamingMediaDataSource : MediaDataSource() {
|
||||
private data class Chunk(val start: Long, val data: ByteArray)
|
||||
|
||||
private val lock = Object()
|
||||
private val chunks = ArrayList<Chunk>()
|
||||
private var totalSize: Long = 0
|
||||
private var closed = false
|
||||
private var finished = false
|
||||
private var lastReadIndex = 0
|
||||
|
||||
fun append(data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
synchronized(lock) {
|
||||
if (closed || finished) return
|
||||
val chunk = Chunk(totalSize, data)
|
||||
chunks.add(chunk)
|
||||
totalSize += data.size.toLong()
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
synchronized(lock) {
|
||||
if (closed) return
|
||||
finished = true
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun fail() {
|
||||
synchronized(lock) {
|
||||
closed = true
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
||||
if (position < 0) return -1
|
||||
synchronized(lock) {
|
||||
while (!closed && !finished && position >= totalSize) {
|
||||
lock.wait()
|
||||
}
|
||||
if (closed) return -1
|
||||
if (position >= totalSize && finished) return -1
|
||||
|
||||
val available = (totalSize - position).toInt()
|
||||
val toRead = min(size, available)
|
||||
var remaining = toRead
|
||||
var destOffset = offset
|
||||
var pos = position
|
||||
|
||||
var index = findChunkIndex(pos)
|
||||
while (remaining > 0 && index < chunks.size) {
|
||||
val chunk = chunks[index]
|
||||
val inChunkOffset = (pos - chunk.start).toInt()
|
||||
if (inChunkOffset >= chunk.data.size) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
val copyLen = min(remaining, chunk.data.size - inChunkOffset)
|
||||
System.arraycopy(chunk.data, inChunkOffset, buffer, destOffset, copyLen)
|
||||
remaining -= copyLen
|
||||
destOffset += copyLen
|
||||
pos += copyLen
|
||||
if (inChunkOffset + copyLen >= chunk.data.size) {
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
return toRead - remaining
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSize(): Long = -1
|
||||
|
||||
override fun close() {
|
||||
synchronized(lock) {
|
||||
closed = true
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findChunkIndex(position: Long): Int {
|
||||
var index = lastReadIndex
|
||||
while (index < chunks.size) {
|
||||
val chunk = chunks[index]
|
||||
if (position < chunk.start + chunk.data.size) break
|
||||
index++
|
||||
}
|
||||
lastReadIndex = index
|
||||
return index
|
||||
}
|
||||
}
|
||||
@ -4,116 +4,23 @@ import ai.openclaw.app.normalizeMainKey
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
internal data class TalkProviderConfigSelection(
|
||||
val provider: String,
|
||||
val config: JsonObject,
|
||||
val normalizedPayload: Boolean,
|
||||
)
|
||||
|
||||
internal data class TalkModeGatewayConfigState(
|
||||
val activeProvider: String,
|
||||
val normalizedPayload: Boolean,
|
||||
val missingResolvedPayload: Boolean,
|
||||
val mainSessionKey: String,
|
||||
val defaultVoiceId: String?,
|
||||
val voiceAliases: Map<String, String>,
|
||||
val defaultModelId: String,
|
||||
val defaultOutputFormat: String,
|
||||
val apiKey: String?,
|
||||
val interruptOnSpeech: Boolean?,
|
||||
val silenceTimeoutMs: Long,
|
||||
)
|
||||
|
||||
internal object TalkModeGatewayConfigParser {
|
||||
private const val defaultTalkProvider = "elevenlabs"
|
||||
|
||||
fun parse(
|
||||
config: JsonObject?,
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultOutputFormatFallback: String,
|
||||
envVoice: String?,
|
||||
sagVoice: String?,
|
||||
envKey: String?,
|
||||
): TalkModeGatewayConfigState {
|
||||
fun parse(config: JsonObject?): TalkModeGatewayConfigState {
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
val selection = selectTalkProviderConfig(talk)
|
||||
val activeProvider = selection?.provider ?: defaultProvider
|
||||
val activeConfig = selection?.config
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val aliases =
|
||||
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
|
||||
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
normalizeTalkAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
|
||||
}?.toMap().orEmpty()
|
||||
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val outputFormat =
|
||||
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
||||
val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk)
|
||||
|
||||
return TalkModeGatewayConfigState(
|
||||
activeProvider = activeProvider,
|
||||
normalizedPayload = selection?.normalizedPayload == true,
|
||||
missingResolvedPayload = talk != null && selection == null,
|
||||
mainSessionKey = mainKey,
|
||||
defaultVoiceId =
|
||||
if (activeProvider == defaultProvider) {
|
||||
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
} else {
|
||||
voice
|
||||
},
|
||||
voiceAliases = aliases,
|
||||
defaultModelId = model ?: defaultModelIdFallback,
|
||||
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback,
|
||||
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() },
|
||||
interruptOnSpeech = interrupt,
|
||||
silenceTimeoutMs = silenceTimeoutMs,
|
||||
)
|
||||
}
|
||||
|
||||
fun fallback(
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultOutputFormatFallback: String,
|
||||
envVoice: String?,
|
||||
sagVoice: String?,
|
||||
envKey: String?,
|
||||
): TalkModeGatewayConfigState =
|
||||
TalkModeGatewayConfigState(
|
||||
activeProvider = defaultProvider,
|
||||
normalizedPayload = false,
|
||||
missingResolvedPayload = false,
|
||||
mainSessionKey = "main",
|
||||
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() },
|
||||
voiceAliases = emptyMap(),
|
||||
defaultModelId = defaultModelIdFallback,
|
||||
defaultOutputFormat = defaultOutputFormatFallback,
|
||||
apiKey = envKey?.takeIf { it.isNotEmpty() },
|
||||
interruptOnSpeech = null,
|
||||
silenceTimeoutMs = TalkDefaults.defaultSilenceTimeoutMs,
|
||||
)
|
||||
|
||||
fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
|
||||
if (talk == null) return null
|
||||
selectResolvedTalkProviderConfig(talk)?.let { return it }
|
||||
val rawProvider = talk["provider"].asStringOrNull()
|
||||
val rawProviders = talk["providers"].asObjectOrNull()
|
||||
val hasNormalizedPayload = rawProvider != null || rawProviders != null
|
||||
if (hasNormalizedPayload) {
|
||||
return null
|
||||
}
|
||||
return TalkProviderConfigSelection(
|
||||
provider = defaultTalkProvider,
|
||||
config = talk,
|
||||
normalizedPayload = false,
|
||||
mainSessionKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()),
|
||||
interruptOnSpeech = talk?.get("interruptOnSpeech").asBooleanOrNull(),
|
||||
silenceTimeoutMs = resolvedSilenceTimeoutMs(talk),
|
||||
)
|
||||
}
|
||||
|
||||
@ -127,26 +34,8 @@ internal object TalkModeGatewayConfigParser {
|
||||
}
|
||||
return timeout.toLong()
|
||||
}
|
||||
|
||||
private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? {
|
||||
val resolved = talk["resolved"].asObjectOrNull() ?: return null
|
||||
val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null
|
||||
return TalkProviderConfigSelection(
|
||||
provider = providerId,
|
||||
config = resolved["config"].asObjectOrNull() ?: buildJsonObject {},
|
||||
normalizedPayload = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeTalkProviderId(raw: String?): String? {
|
||||
val trimmed = raw?.trim()?.lowercase().orEmpty()
|
||||
return trimmed.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeTalkAliasKey(value: String): String =
|
||||
value.trim().lowercase()
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
this?.let { element ->
|
||||
element as? JsonPrimitive
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,122 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
internal data class ElevenLabsVoice(val voiceId: String, val name: String?)
|
||||
|
||||
internal data class TalkModeResolvedVoice(
|
||||
val voiceId: String?,
|
||||
val fallbackVoiceId: String?,
|
||||
val defaultVoiceId: String?,
|
||||
val currentVoiceId: String?,
|
||||
val selectedVoiceName: String? = null,
|
||||
)
|
||||
|
||||
internal object TalkModeVoiceResolver {
|
||||
fun resolveVoiceAlias(value: String?, voiceAliases: Map<String, String>): String? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val normalized = normalizeAliasKey(trimmed)
|
||||
voiceAliases[normalized]?.let { return it }
|
||||
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
|
||||
return if (isLikelyVoiceId(trimmed)) trimmed else null
|
||||
}
|
||||
|
||||
suspend fun resolveVoiceId(
|
||||
preferred: String?,
|
||||
fallbackVoiceId: String?,
|
||||
defaultVoiceId: String?,
|
||||
currentVoiceId: String?,
|
||||
voiceOverrideActive: Boolean,
|
||||
listVoices: suspend () -> List<ElevenLabsVoice>,
|
||||
): TalkModeResolvedVoice {
|
||||
val trimmed = preferred?.trim().orEmpty()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = trimmed,
|
||||
fallbackVoiceId = fallbackVoiceId,
|
||||
defaultVoiceId = defaultVoiceId,
|
||||
currentVoiceId = currentVoiceId,
|
||||
)
|
||||
}
|
||||
if (!fallbackVoiceId.isNullOrBlank()) {
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = fallbackVoiceId,
|
||||
fallbackVoiceId = fallbackVoiceId,
|
||||
defaultVoiceId = defaultVoiceId,
|
||||
currentVoiceId = currentVoiceId,
|
||||
)
|
||||
}
|
||||
|
||||
val first = listVoices().firstOrNull()
|
||||
if (first == null) {
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = null,
|
||||
fallbackVoiceId = fallbackVoiceId,
|
||||
defaultVoiceId = defaultVoiceId,
|
||||
currentVoiceId = currentVoiceId,
|
||||
)
|
||||
}
|
||||
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = first.voiceId,
|
||||
fallbackVoiceId = first.voiceId,
|
||||
defaultVoiceId = if (defaultVoiceId.isNullOrBlank()) first.voiceId else defaultVoiceId,
|
||||
currentVoiceId = if (voiceOverrideActive) currentVoiceId else first.voiceId,
|
||||
selectedVoiceName = first.name,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun listVoices(apiKey: String, json: Json): List<ElevenLabsVoice> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val url = URL("https://api.elevenlabs.io/v1/voices")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
try {
|
||||
conn.requestMethod = "GET"
|
||||
conn.connectTimeout = 15_000
|
||||
conn.readTimeout = 15_000
|
||||
conn.setRequestProperty("xi-api-key", apiKey)
|
||||
|
||||
val code = conn.responseCode
|
||||
val stream = if (code >= 400) conn.errorStream else conn.inputStream
|
||||
val data = stream?.use { it.readBytes() } ?: byteArrayOf()
|
||||
if (code >= 400) {
|
||||
val message = data.toString(Charsets.UTF_8)
|
||||
throw IllegalStateException("ElevenLabs voices failed: $code $message")
|
||||
}
|
||||
|
||||
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
|
||||
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
|
||||
voices.mapNotNull { entry ->
|
||||
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
|
||||
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
|
||||
val name = obj["name"].asStringOrNull()
|
||||
ElevenLabsVoice(voiceId, name)
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLikelyVoiceId(value: String): Boolean {
|
||||
if (value.length < 10) return false
|
||||
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
|
||||
}
|
||||
|
||||
private fun normalizeAliasKey(value: String): String =
|
||||
value.trim().lowercase()
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
13
apps/android/app/src/play/AndroidManifest.xml
Normal file
13
apps/android/app/src/play/AndroidManifest.xml
Normal file
@ -0,0 +1,13 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission
|
||||
android:name="android.permission.SEND_SMS"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_SMS"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_CALL_LOG"
|
||||
tools:node="remove" />
|
||||
</manifest>
|
||||
@ -26,7 +26,6 @@ class InvokeCommandRegistryTest {
|
||||
OpenClawCapability.Photos.rawValue,
|
||||
OpenClawCapability.Contacts.rawValue,
|
||||
OpenClawCapability.Calendar.rawValue,
|
||||
OpenClawCapability.CallLog.rawValue,
|
||||
)
|
||||
|
||||
private val optionalCapabilities =
|
||||
@ -34,6 +33,7 @@ class InvokeCommandRegistryTest {
|
||||
OpenClawCapability.Camera.rawValue,
|
||||
OpenClawCapability.Location.rawValue,
|
||||
OpenClawCapability.Sms.rawValue,
|
||||
OpenClawCapability.CallLog.rawValue,
|
||||
OpenClawCapability.VoiceWake.rawValue,
|
||||
OpenClawCapability.Motion.rawValue,
|
||||
)
|
||||
@ -52,7 +52,6 @@ class InvokeCommandRegistryTest {
|
||||
OpenClawContactsCommand.Add.rawValue,
|
||||
OpenClawCalendarCommand.Events.rawValue,
|
||||
OpenClawCalendarCommand.Add.rawValue,
|
||||
OpenClawCallLogCommand.Search.rawValue,
|
||||
)
|
||||
|
||||
private val optionalCommands =
|
||||
@ -64,6 +63,8 @@ class InvokeCommandRegistryTest {
|
||||
OpenClawMotionCommand.Activity.rawValue,
|
||||
OpenClawMotionCommand.Pedometer.rawValue,
|
||||
OpenClawSmsCommand.Send.rawValue,
|
||||
OpenClawSmsCommand.Search.rawValue,
|
||||
OpenClawCallLogCommand.Search.rawValue,
|
||||
)
|
||||
|
||||
private val debugCommands = setOf("debug.logs", "debug.ed25519")
|
||||
@ -83,7 +84,9 @@ class InvokeCommandRegistryTest {
|
||||
defaultFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
sendSmsAvailable = true,
|
||||
readSmsAvailable = true,
|
||||
callLogAvailable = true,
|
||||
voiceWakeEnabled = true,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
@ -108,7 +111,9 @@ class InvokeCommandRegistryTest {
|
||||
defaultFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
sendSmsAvailable = true,
|
||||
readSmsAvailable = true,
|
||||
callLogAvailable = true,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = true,
|
||||
@ -125,7 +130,9 @@ class InvokeCommandRegistryTest {
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
sendSmsAvailable = false,
|
||||
readSmsAvailable = false,
|
||||
callLogAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = false,
|
||||
@ -137,10 +144,58 @@ class InvokeCommandRegistryTest {
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_splitsSmsSendAndSearchAvailability() {
|
||||
val readOnlyCommands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
defaultFlags(readSmsAvailable = true),
|
||||
)
|
||||
val sendOnlyCommands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
defaultFlags(sendSmsAvailable = true),
|
||||
)
|
||||
|
||||
assertTrue(readOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
|
||||
assertFalse(readOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertTrue(sendOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertFalse(sendOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_includeSmsWhenEitherSmsPathIsAvailable() {
|
||||
val readOnlyCapabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
defaultFlags(readSmsAvailable = true),
|
||||
)
|
||||
val sendOnlyCapabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
defaultFlags(sendSmsAvailable = true),
|
||||
)
|
||||
|
||||
assertTrue(readOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_excludesCallLogWhenUnavailable() {
|
||||
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags(callLogAvailable = false))
|
||||
|
||||
assertFalse(commands.contains(OpenClawCallLogCommand.Search.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_excludesCallLogWhenUnavailable() {
|
||||
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags(callLogAvailable = false))
|
||||
|
||||
assertFalse(capabilities.contains(OpenClawCapability.CallLog.rawValue))
|
||||
}
|
||||
|
||||
private fun defaultFlags(
|
||||
cameraEnabled: Boolean = false,
|
||||
locationEnabled: Boolean = false,
|
||||
smsAvailable: Boolean = false,
|
||||
sendSmsAvailable: Boolean = false,
|
||||
readSmsAvailable: Boolean = false,
|
||||
callLogAvailable: Boolean = false,
|
||||
voiceWakeEnabled: Boolean = false,
|
||||
motionActivityAvailable: Boolean = false,
|
||||
motionPedometerAvailable: Boolean = false,
|
||||
@ -149,7 +204,9 @@ class InvokeCommandRegistryTest {
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = cameraEnabled,
|
||||
locationEnabled = locationEnabled,
|
||||
smsAvailable = smsAvailable,
|
||||
sendSmsAvailable = sendSmsAvailable,
|
||||
readSmsAvailable = readSmsAvailable,
|
||||
callLogAvailable = callLogAvailable,
|
||||
voiceWakeEnabled = voiceWakeEnabled,
|
||||
motionActivityAvailable = motionActivityAvailable,
|
||||
motionPedometerAvailable = motionPedometerAvailable,
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LocationHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@Test
|
||||
fun handleLocationGet_requiresLocationPermissionWhenNeitherFineNorCoarse() =
|
||||
runTest {
|
||||
val handler =
|
||||
LocationHandler.forTesting(
|
||||
appContext = appContext(),
|
||||
dataSource =
|
||||
FakeLocationDataSource(
|
||||
fineGranted = false,
|
||||
coarseGranted = false,
|
||||
),
|
||||
)
|
||||
|
||||
val result = handler.handleLocationGet(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("LOCATION_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleLocationGet_requiresForegroundBeforeLocationPermission() =
|
||||
runTest {
|
||||
val handler =
|
||||
LocationHandler.forTesting(
|
||||
appContext = appContext(),
|
||||
dataSource =
|
||||
FakeLocationDataSource(
|
||||
fineGranted = true,
|
||||
coarseGranted = true,
|
||||
),
|
||||
isForeground = { false },
|
||||
)
|
||||
|
||||
val result = handler.handleLocationGet(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("LOCATION_BACKGROUND_UNAVAILABLE", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hasFineLocationPermission_reflectsDataSource() {
|
||||
val denied =
|
||||
LocationHandler.forTesting(
|
||||
appContext = appContext(),
|
||||
dataSource = FakeLocationDataSource(fineGranted = false, coarseGranted = true),
|
||||
)
|
||||
assertFalse(denied.hasFineLocationPermission())
|
||||
assertTrue(denied.hasCoarseLocationPermission())
|
||||
|
||||
val granted =
|
||||
LocationHandler.forTesting(
|
||||
appContext = appContext(),
|
||||
dataSource = FakeLocationDataSource(fineGranted = true, coarseGranted = false),
|
||||
)
|
||||
assertTrue(granted.hasFineLocationPermission())
|
||||
assertFalse(granted.hasCoarseLocationPermission())
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeLocationDataSource(
|
||||
private val fineGranted: Boolean,
|
||||
private val coarseGranted: Boolean,
|
||||
) : LocationDataSource {
|
||||
override fun hasFinePermission(context: Context): Boolean = fineGranted
|
||||
|
||||
override fun hasCoarsePermission(context: Context): Boolean = coarseGranted
|
||||
|
||||
override suspend fun fetchLocation(
|
||||
desiredProviders: List<String>,
|
||||
maxAgeMs: Long?,
|
||||
timeoutMs: Long,
|
||||
isPrecise: Boolean,
|
||||
): LocationCaptureManager.Payload {
|
||||
throw IllegalStateException(
|
||||
"LocationHandlerTest: fetchLocation must not run in this scenario",
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -88,4 +88,95 @@ class SmsManagerTest {
|
||||
assertFalse(plan.useMultipart)
|
||||
assertEquals(listOf("hello"), plan.parts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsAcceptsEmptyPayload() {
|
||||
val result = SmsManager.parseQueryParams(null, json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(25, ok.params.limit)
|
||||
assertEquals(0, ok.params.offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsRejectsInvalidJson() {
|
||||
val result = SmsManager.parseQueryParams("not-json", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Error)
|
||||
val error = result as SmsManager.QueryParseResult.Error
|
||||
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsRejectsNonObjectJson() {
|
||||
val result = SmsManager.parseQueryParams("[]", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Error)
|
||||
val error = result as SmsManager.QueryParseResult.Error
|
||||
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesLimitAndOffset() {
|
||||
val result = SmsManager.parseQueryParams("{\"limit\":10,\"offset\":5}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(10, ok.params.limit)
|
||||
assertEquals(5, ok.params.offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsClampsLimitRange() {
|
||||
val result = SmsManager.parseQueryParams("{\"limit\":300}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(200, ok.params.limit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesPhoneNumber() {
|
||||
val result = SmsManager.parseQueryParams("{\"phoneNumber\":\"+1234567890\"}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals("+1234567890", ok.params.phoneNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesContactName() {
|
||||
val result = SmsManager.parseQueryParams("{\"contactName\":\"lixuankai\"}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals("lixuankai", ok.params.contactName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesKeyword() {
|
||||
val result = SmsManager.parseQueryParams("{\"keyword\":\"test\"}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals("test", ok.params.keyword)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesTimeRange() {
|
||||
val result = SmsManager.parseQueryParams("{\"startTime\":1000,\"endTime\":2000}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(1000L, ok.params.startTime)
|
||||
assertEquals(2000L, ok.params.endTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesType() {
|
||||
val result = SmsManager.parseQueryParams("{\"type\":1}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(1, ok.params.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesReadStatus() {
|
||||
val result = SmsManager.parseQueryParams("{\"isRead\":true}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(true, ok.params.isRead)
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,4 +90,9 @@ class OpenClawProtocolConstantsTest {
|
||||
fun callLogCommandsUseStableStrings() {
|
||||
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun smsCommandsUseStableStrings() {
|
||||
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import java.io.File
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
@Serializable
|
||||
private data class TalkConfigContractFixture(
|
||||
@SerialName("selectionCases") val selectionCases: List<SelectionCase>,
|
||||
@SerialName("timeoutCases") val timeoutCases: List<TimeoutCase>,
|
||||
) {
|
||||
@Serializable
|
||||
data class SelectionCase(
|
||||
val id: String,
|
||||
val defaultProvider: String,
|
||||
val payloadValid: Boolean,
|
||||
val expectedSelection: ExpectedSelection? = null,
|
||||
val talk: JsonObject,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExpectedSelection(
|
||||
val provider: String,
|
||||
val normalizedPayload: Boolean,
|
||||
val voiceId: String? = null,
|
||||
val apiKey: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TimeoutCase(
|
||||
val id: String,
|
||||
val fallback: Long,
|
||||
val expectedTimeoutMs: Long,
|
||||
val talk: JsonObject,
|
||||
)
|
||||
}
|
||||
|
||||
class TalkModeConfigContractTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun selectionFixtures() {
|
||||
for (fixture in loadFixtures().selectionCases) {
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(fixture.talk)
|
||||
val expected = fixture.expectedSelection
|
||||
if (expected == null) {
|
||||
assertNull(fixture.id, selection)
|
||||
continue
|
||||
}
|
||||
assertNotNull(fixture.id, selection)
|
||||
assertEquals(fixture.id, expected.provider, selection?.provider)
|
||||
assertEquals(fixture.id, expected.normalizedPayload, selection?.normalizedPayload)
|
||||
assertEquals(
|
||||
fixture.id,
|
||||
expected.voiceId,
|
||||
(selection?.config?.get("voiceId") as? JsonPrimitive)?.content,
|
||||
)
|
||||
assertEquals(
|
||||
fixture.id,
|
||||
expected.apiKey,
|
||||
(selection?.config?.get("apiKey") as? JsonPrimitive)?.content,
|
||||
)
|
||||
assertEquals(fixture.id, true, fixture.payloadValid)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timeoutFixtures() {
|
||||
for (fixture in loadFixtures().timeoutCases) {
|
||||
val timeout = TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(fixture.talk)
|
||||
assertEquals(fixture.id, fixture.expectedTimeoutMs, timeout)
|
||||
assertEquals(fixture.id, TalkDefaults.defaultSilenceTimeoutMs, fixture.fallback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFixtures(): TalkConfigContractFixture {
|
||||
val fixturePath = findFixtureFile()
|
||||
return json.decodeFromString(File(fixturePath).readText())
|
||||
}
|
||||
|
||||
private fun findFixtureFile(): String {
|
||||
val startDir = System.getProperty("user.dir") ?: error("user.dir unavailable")
|
||||
var current = File(startDir).absoluteFile
|
||||
while (true) {
|
||||
val candidate = File(current, "test-fixtures/talk-config-contract.json")
|
||||
if (candidate.exists()) {
|
||||
return candidate.absolutePath
|
||||
}
|
||||
current = current.parentFile ?: break
|
||||
}
|
||||
error("talk-config-contract.json not found from $startDir")
|
||||
}
|
||||
}
|
||||
@ -2,135 +2,37 @@ package ai.openclaw.app.voice
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TalkModeConfigParsingTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun prefersCanonicalResolvedTalkProviderPayload() {
|
||||
val talk =
|
||||
fun readsMainSessionKeyAndInterruptFlag() {
|
||||
val config =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"resolved": {
|
||||
"provider": "elevenlabs",
|
||||
"config": {
|
||||
"voiceId": "voice-resolved"
|
||||
}
|
||||
"talk": {
|
||||
"interruptOnSpeech": true,
|
||||
"silenceTimeoutMs": 1800
|
||||
},
|
||||
"provider": "elevenlabs",
|
||||
"providers": {
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
"session": {
|
||||
"mainKey": "voice-main"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertNotNull(selection)
|
||||
assertEquals("elevenlabs", selection?.provider)
|
||||
assertTrue(selection?.normalizedPayload == true)
|
||||
assertEquals("voice-resolved", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
|
||||
}
|
||||
val parsed = TalkModeGatewayConfigParser.parse(config)
|
||||
|
||||
@Test
|
||||
fun prefersNormalizedTalkProviderPayload() {
|
||||
val talk =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"provider": "elevenlabs",
|
||||
"providers": {
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
},
|
||||
"voiceId": "voice-legacy"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertEquals(null, selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNormalizedTalkProviderPayloadWhenProviderMissingFromProviders() {
|
||||
val talk =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"provider": "acme",
|
||||
"providers": {
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertEquals(null, selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNormalizedTalkProviderPayloadWhenProviderIsAmbiguous() {
|
||||
val talk =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"providers": {
|
||||
"acme": {
|
||||
"voiceId": "voice-acme"
|
||||
},
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertEquals(null, selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() {
|
||||
val legacyApiKey = "legacy-key" // pragma: allowlist secret
|
||||
val talk =
|
||||
buildJsonObject {
|
||||
put("voiceId", "voice-legacy")
|
||||
put("apiKey", legacyApiKey) // pragma: allowlist secret
|
||||
}
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertNotNull(selection)
|
||||
assertEquals("elevenlabs", selection?.provider)
|
||||
assertTrue(selection?.normalizedPayload == false)
|
||||
assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
|
||||
assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsConfiguredSilenceTimeoutMs() {
|
||||
val talk = buildJsonObject { put("silenceTimeoutMs", 1500) }
|
||||
|
||||
assertEquals(1500L, TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk))
|
||||
assertEquals("voice-main", parsed.mainSessionKey)
|
||||
assertEquals(true, parsed.interruptOnSpeech)
|
||||
assertEquals(1800L, parsed.silenceTimeoutMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class TalkModeVoiceResolverTest {
|
||||
@Test
|
||||
fun resolvesVoiceAliasCaseInsensitively() {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceAlias(
|
||||
" Clawd ",
|
||||
mapOf("clawd" to "voice-123"),
|
||||
)
|
||||
|
||||
assertEquals("voice-123", resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsDirectVoiceIds() {
|
||||
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("21m00Tcm4TlvDq8ikWAM", emptyMap())
|
||||
|
||||
assertEquals("21m00Tcm4TlvDq8ikWAM", resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsUnknownAliases() {
|
||||
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("nickname", emptyMap())
|
||||
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reusesCachedFallbackVoiceBeforeFetchingCatalog() =
|
||||
runBlocking {
|
||||
var fetchCount = 0
|
||||
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = "cached-voice",
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = false,
|
||||
listVoices = {
|
||||
fetchCount += 1
|
||||
emptyList()
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals("cached-voice", resolved.voiceId)
|
||||
assertEquals(0, fetchCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seedsDefaultVoiceFromCatalogWhenNeeded() =
|
||||
runBlocking {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = null,
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = false,
|
||||
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
|
||||
)
|
||||
|
||||
assertEquals("voice-1", resolved.voiceId)
|
||||
assertEquals("voice-1", resolved.fallbackVoiceId)
|
||||
assertEquals("voice-1", resolved.defaultVoiceId)
|
||||
assertEquals("voice-1", resolved.currentVoiceId)
|
||||
assertEquals("First", resolved.selectedVoiceName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesCurrentVoiceWhenOverrideIsActive() =
|
||||
runBlocking {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = null,
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = true,
|
||||
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
|
||||
)
|
||||
|
||||
assertEquals("voice-1", resolved.voiceId)
|
||||
assertNull(resolved.currentVoiceId)
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("com.android.test") version "9.0.1" apply false
|
||||
id("com.android.application") version "9.1.0" apply false
|
||||
id("com.android.test") version "9.1.0" apply false
|
||||
id("org.jlleitschuh.gradle.ktlint") version "14.0.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@ -7,7 +7,28 @@ 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");
|
||||
const releaseOutputDir = join(androidDir, "build", "release-bundles");
|
||||
|
||||
const releaseVariants = [
|
||||
{
|
||||
flavorName: "play",
|
||||
gradleTask: ":app:bundlePlayRelease",
|
||||
bundlePath: join(androidDir, "app", "build", "outputs", "bundle", "playRelease", "app-play-release.aab"),
|
||||
},
|
||||
{
|
||||
flavorName: "third-party",
|
||||
gradleTask: ":app:bundleThirdPartyRelease",
|
||||
bundlePath: join(
|
||||
androidDir,
|
||||
"app",
|
||||
"build",
|
||||
"outputs",
|
||||
"bundle",
|
||||
"thirdPartyRelease",
|
||||
"app-thirdParty-release.aab",
|
||||
),
|
||||
},
|
||||
] as const;
|
||||
|
||||
type VersionState = {
|
||||
versionName: string;
|
||||
@ -88,6 +109,15 @@ async function verifyBundleSignature(path: string): Promise<void> {
|
||||
await $`jarsigner -verify ${path}`.quiet();
|
||||
}
|
||||
|
||||
async function copyBundle(sourcePath: string, destinationPath: string): Promise<void> {
|
||||
const sourceFile = Bun.file(sourcePath);
|
||||
if (!(await sourceFile.exists())) {
|
||||
throw new Error(`Signed bundle missing at ${sourcePath}`);
|
||||
}
|
||||
|
||||
await Bun.write(destinationPath, sourceFile);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const buildGradleFile = Bun.file(buildGradlePath);
|
||||
const originalText = await buildGradleFile.text();
|
||||
@ -102,24 +132,28 @@ async function main() {
|
||||
console.log(`Android versionCode -> ${nextVersion.versionCode}`);
|
||||
|
||||
await Bun.write(buildGradlePath, updatedText);
|
||||
await $`mkdir -p ${releaseOutputDir}`;
|
||||
|
||||
try {
|
||||
await $`./gradlew :app:bundleRelease`.cwd(androidDir);
|
||||
await $`./gradlew ${releaseVariants[0].gradleTask} ${releaseVariants[1].gradleTask}`.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}`);
|
||||
for (const variant of releaseVariants) {
|
||||
const outputPath = join(
|
||||
releaseOutputDir,
|
||||
`openclaw-${nextVersion.versionName}-${variant.flavorName}-release.aab`,
|
||||
);
|
||||
|
||||
await copyBundle(variant.bundlePath, outputPath);
|
||||
await verifyBundleSignature(outputPath);
|
||||
const hash = await sha256Hex(outputPath);
|
||||
|
||||
console.log(`Signed AAB (${variant.flavorName}): ${outputPath}`);
|
||||
console.log(`SHA-256 (${variant.flavorName}): ${hash}`);
|
||||
}
|
||||
|
||||
await verifyBundleSignature(bundlePath);
|
||||
const hash = await sha256Hex(bundlePath);
|
||||
|
||||
console.log(`Signed AAB: ${bundlePath}`);
|
||||
console.log(`SHA-256: ${hash}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
|
||||
430
apps/android/scripts/perf-online-benchmark.sh
Executable file
430
apps/android/scripts/perf-online-benchmark.sh
Executable file
@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
RESULTS_DIR="$ANDROID_DIR/benchmark/results"
|
||||
|
||||
PACKAGE="ai.openclaw.app"
|
||||
ACTIVITY=".MainActivity"
|
||||
DEVICE_SERIAL=""
|
||||
INSTALL_APP="1"
|
||||
LAUNCH_RUNS="4"
|
||||
SCREEN_LOOPS="6"
|
||||
CHAT_LOOPS="8"
|
||||
POLL_ATTEMPTS="40"
|
||||
POLL_INTERVAL_SECONDS="0.3"
|
||||
SCREEN_MODE="transition"
|
||||
CHAT_MODE="session-switch"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/perf-online-benchmark.sh [options]
|
||||
|
||||
Measures the fully-online Android app path on a connected device/emulator.
|
||||
Assumes the app can reach a live gateway and will show "Connected" in the UI.
|
||||
|
||||
Options:
|
||||
--device <serial> adb device serial
|
||||
--package <pkg> package name (default: ai.openclaw.app)
|
||||
--activity <activity> launch activity (default: .MainActivity)
|
||||
--skip-install skip :app:installDebug
|
||||
--launch-runs <n> launch-to-connected runs (default: 4)
|
||||
--screen-loops <n> screen benchmark loops (default: 6)
|
||||
--chat-loops <n> chat benchmark loops (default: 8)
|
||||
--screen-mode <mode> transition | scroll (default: transition)
|
||||
--chat-mode <mode> session-switch | scroll (default: session-switch)
|
||||
-h, --help show help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--device)
|
||||
DEVICE_SERIAL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--package)
|
||||
PACKAGE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--activity)
|
||||
ACTIVITY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-install)
|
||||
INSTALL_APP="0"
|
||||
shift
|
||||
;;
|
||||
--launch-runs)
|
||||
LAUNCH_RUNS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--screen-loops)
|
||||
SCREEN_LOOPS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--chat-loops)
|
||||
CHAT_LOOPS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--screen-mode)
|
||||
SCREEN_MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--chat-mode)
|
||||
CHAT_MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "$1 required but missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd adb
|
||||
require_cmd awk
|
||||
require_cmd rg
|
||||
require_cmd node
|
||||
|
||||
adb_cmd() {
|
||||
if [[ -n "$DEVICE_SERIAL" ]]; then
|
||||
adb -s "$DEVICE_SERIAL" "$@"
|
||||
else
|
||||
adb "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
|
||||
if [[ -z "$DEVICE_SERIAL" && "$device_count" -lt 1 ]]; then
|
||||
echo "No connected Android device (adb state=device)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$DEVICE_SERIAL" && "$device_count" -gt 1 ]]; then
|
||||
echo "Multiple adb devices found. Pass --device <serial>." >&2
|
||||
adb devices -l >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SCREEN_MODE" != "transition" && "$SCREEN_MODE" != "scroll" ]]; then
|
||||
echo "Unsupported --screen-mode: $SCREEN_MODE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "$CHAT_MODE" != "session-switch" && "$CHAT_MODE" != "scroll" ]]; then
|
||||
echo "Unsupported --chat-mode: $CHAT_MODE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$RESULTS_DIR"
|
||||
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
run_dir="$RESULTS_DIR/online-$timestamp"
|
||||
mkdir -p "$run_dir"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$run_dir"/ui-*.xml
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ "$INSTALL_APP" == "1" ]]; then
|
||||
(
|
||||
cd "$ANDROID_DIR"
|
||||
./gradlew :app:installDebug --console=plain >"$run_dir/install.log" 2>&1
|
||||
)
|
||||
fi
|
||||
|
||||
read -r display_width display_height <<<"$(
|
||||
adb_cmd shell wm size \
|
||||
| awk '/Physical size:/ { split($3, dims, "x"); print dims[1], dims[2]; exit }'
|
||||
)"
|
||||
|
||||
if [[ -z "${display_width:-}" || -z "${display_height:-}" ]]; then
|
||||
echo "Failed to read device display size." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pct_of() {
|
||||
local total="$1"
|
||||
local pct="$2"
|
||||
awk -v total="$total" -v pct="$pct" 'BEGIN { printf "%d", total * pct }'
|
||||
}
|
||||
|
||||
tab_connect_x="$(pct_of "$display_width" "0.11")"
|
||||
tab_chat_x="$(pct_of "$display_width" "0.31")"
|
||||
tab_screen_x="$(pct_of "$display_width" "0.69")"
|
||||
tab_y="$(pct_of "$display_height" "0.93")"
|
||||
chat_session_y="$(pct_of "$display_height" "0.13")"
|
||||
chat_session_left_x="$(pct_of "$display_width" "0.16")"
|
||||
chat_session_right_x="$(pct_of "$display_width" "0.85")"
|
||||
center_x="$(pct_of "$display_width" "0.50")"
|
||||
screen_swipe_top_y="$(pct_of "$display_height" "0.27")"
|
||||
screen_swipe_mid_y="$(pct_of "$display_height" "0.38")"
|
||||
screen_swipe_low_y="$(pct_of "$display_height" "0.75")"
|
||||
screen_swipe_bottom_y="$(pct_of "$display_height" "0.77")"
|
||||
chat_swipe_top_y="$(pct_of "$display_height" "0.29")"
|
||||
chat_swipe_mid_y="$(pct_of "$display_height" "0.38")"
|
||||
chat_swipe_bottom_y="$(pct_of "$display_height" "0.71")"
|
||||
|
||||
dump_ui() {
|
||||
local name="$1"
|
||||
local file="$run_dir/ui-$name.xml"
|
||||
adb_cmd shell uiautomator dump "/sdcard/$name.xml" >/dev/null 2>&1
|
||||
adb_cmd shell cat "/sdcard/$name.xml" >"$file"
|
||||
printf '%s\n' "$file"
|
||||
}
|
||||
|
||||
ui_has() {
|
||||
local pattern="$1"
|
||||
local name="$2"
|
||||
local file
|
||||
file="$(dump_ui "$name")"
|
||||
rg -q "$pattern" "$file"
|
||||
}
|
||||
|
||||
wait_for_pattern() {
|
||||
local pattern="$1"
|
||||
local prefix="$2"
|
||||
for attempt in $(seq 1 "$POLL_ATTEMPTS"); do
|
||||
if ui_has "$pattern" "$prefix-$attempt"; then
|
||||
return 0
|
||||
fi
|
||||
sleep "$POLL_INTERVAL_SECONDS"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_connected() {
|
||||
if ! wait_for_pattern 'text="Connected"' "connected"; then
|
||||
echo "App never reached visible Connected state." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_screen_online() {
|
||||
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||
sleep 2
|
||||
if ! ui_has 'android\.webkit\.WebView' "screen"; then
|
||||
echo "Screen benchmark expected a live WebView." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_chat_online() {
|
||||
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
|
||||
sleep 2
|
||||
if ! ui_has 'Type a message' "chat"; then
|
||||
echo "Chat benchmark expected the live chat composer." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
capture_mem() {
|
||||
local file="$1"
|
||||
adb_cmd shell dumpsys meminfo "$PACKAGE" >"$file"
|
||||
}
|
||||
|
||||
start_cpu_sampler() {
|
||||
local file="$1"
|
||||
local samples="$2"
|
||||
: >"$file"
|
||||
(
|
||||
for _ in $(seq 1 "$samples"); do
|
||||
adb_cmd shell top -b -n 1 \
|
||||
| awk -v pkg="$PACKAGE" '$NF==pkg { print $9 }' >>"$file"
|
||||
sleep 0.5
|
||||
done
|
||||
) &
|
||||
CPU_SAMPLER_PID="$!"
|
||||
}
|
||||
|
||||
summarize_cpu() {
|
||||
local file="$1"
|
||||
local prefix="$2"
|
||||
local avg max median count
|
||||
avg="$(awk '{sum+=$1; n++} END {if(n) printf "%.1f", sum/n; else print 0}' "$file")"
|
||||
max="$(sort -n "$file" | tail -n 1)"
|
||||
median="$(
|
||||
sort -n "$file" \
|
||||
| awk '{a[NR]=$1} END { if (NR==0) { print 0 } else if (NR%2==1) { printf "%.1f", a[(NR+1)/2] } else { printf "%.1f", (a[NR/2]+a[NR/2+1])/2 } }'
|
||||
)"
|
||||
count="$(wc -l <"$file" | tr -d ' ')"
|
||||
printf '%s.cpu_avg_pct=%s\n' "$prefix" "$avg" >>"$run_dir/summary.txt"
|
||||
printf '%s.cpu_median_pct=%s\n' "$prefix" "$median" >>"$run_dir/summary.txt"
|
||||
printf '%s.cpu_peak_pct=%s\n' "$prefix" "$max" >>"$run_dir/summary.txt"
|
||||
printf '%s.cpu_count=%s\n' "$prefix" "$count" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
summarize_mem() {
|
||||
local file="$1"
|
||||
local prefix="$2"
|
||||
awk -v prefix="$prefix" '
|
||||
/TOTAL PSS:/ { printf "%s.pss_kb=%s\n%s.rss_kb=%s\n", prefix, $3, prefix, $6 }
|
||||
/Graphics:/ { printf "%s.graphics_kb=%s\n", prefix, $2 }
|
||||
/WebViews:/ { printf "%s.webviews=%s\n", prefix, $NF }
|
||||
' "$file" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
summarize_gfx() {
|
||||
local file="$1"
|
||||
local prefix="$2"
|
||||
awk -v prefix="$prefix" '
|
||||
/Total frames rendered:/ { printf "%s.frames=%s\n", prefix, $4 }
|
||||
/Janky frames:/ && $4 ~ /\(/ {
|
||||
pct=$4
|
||||
gsub(/[()%]/, "", pct)
|
||||
printf "%s.janky_frames=%s\n%s.janky_pct=%s\n", prefix, $3, prefix, pct
|
||||
}
|
||||
/50th percentile:/ { gsub(/ms/, "", $3); printf "%s.p50_ms=%s\n", prefix, $3 }
|
||||
/90th percentile:/ { gsub(/ms/, "", $3); printf "%s.p90_ms=%s\n", prefix, $3 }
|
||||
/95th percentile:/ { gsub(/ms/, "", $3); printf "%s.p95_ms=%s\n", prefix, $3 }
|
||||
/99th percentile:/ { gsub(/ms/, "", $3); printf "%s.p99_ms=%s\n", prefix, $3 }
|
||||
' "$file" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
measure_launch() {
|
||||
: >"$run_dir/launch-runs.txt"
|
||||
for run in $(seq 1 "$LAUNCH_RUNS"); do
|
||||
adb_cmd shell am force-stop "$PACKAGE" >/dev/null
|
||||
sleep 1
|
||||
start_ms="$(node -e 'console.log(Date.now())')"
|
||||
am_out="$(adb_cmd shell am start -W -n "$PACKAGE/$ACTIVITY")"
|
||||
total_time="$(printf '%s\n' "$am_out" | awk -F: '/TotalTime:/{gsub(/ /, "", $2); print $2}')"
|
||||
connected_ms="timeout"
|
||||
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
|
||||
if ui_has 'text="Connected"' "launch-run-$run"; then
|
||||
now_ms="$(node -e 'console.log(Date.now())')"
|
||||
connected_ms="$((now_ms - start_ms))"
|
||||
break
|
||||
fi
|
||||
sleep "$POLL_INTERVAL_SECONDS"
|
||||
done
|
||||
printf 'run=%s total_time_ms=%s connected_ms=%s\n' "$run" "${total_time:-na}" "$connected_ms" \
|
||||
| tee -a "$run_dir/launch-runs.txt"
|
||||
done
|
||||
|
||||
awk -F'[ =]' '
|
||||
/total_time_ms=[0-9]+/ {
|
||||
value=$4
|
||||
sum+=value
|
||||
count+=1
|
||||
if (min==0 || value<min) min=value
|
||||
if (value>max) max=value
|
||||
}
|
||||
END {
|
||||
if (count==0) exit
|
||||
printf "launch.total_time_avg_ms=%.1f\nlaunch.total_time_min_ms=%d\nlaunch.total_time_max_ms=%d\n", sum/count, min, max
|
||||
}
|
||||
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
|
||||
|
||||
awk -F'[ =]' '
|
||||
/connected_ms=[0-9]+/ {
|
||||
value=$6
|
||||
sum+=value
|
||||
count+=1
|
||||
if (min==0 || value<min) min=value
|
||||
if (value>max) max=value
|
||||
}
|
||||
END {
|
||||
if (count==0) exit
|
||||
printf "launch.connected_avg_ms=%.1f\nlaunch.connected_min_ms=%d\nlaunch.connected_max_ms=%d\n", sum/count, min, max
|
||||
}
|
||||
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
run_screen_benchmark() {
|
||||
ensure_screen_online
|
||||
capture_mem "$run_dir/screen-mem-before.txt"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
|
||||
start_cpu_sampler "$run_dir/screen-cpu.txt" 18
|
||||
|
||||
if [[ "$SCREEN_MODE" == "transition" ]]; then
|
||||
for _ in $(seq 1 "$SCREEN_LOOPS"); do
|
||||
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||
sleep 1.0
|
||||
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
|
||||
sleep 0.8
|
||||
done
|
||||
else
|
||||
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||
sleep 1.5
|
||||
for _ in $(seq 1 "$SCREEN_LOOPS"); do
|
||||
adb_cmd shell input swipe "$center_x" "$screen_swipe_bottom_y" "$center_x" "$screen_swipe_top_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
adb_cmd shell input swipe "$center_x" "$screen_swipe_mid_y" "$center_x" "$screen_swipe_low_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
done
|
||||
fi
|
||||
|
||||
wait "$CPU_SAMPLER_PID"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/screen-gfx.txt"
|
||||
capture_mem "$run_dir/screen-mem-after.txt"
|
||||
summarize_gfx "$run_dir/screen-gfx.txt" "screen"
|
||||
summarize_cpu "$run_dir/screen-cpu.txt" "screen"
|
||||
summarize_mem "$run_dir/screen-mem-before.txt" "screen.before"
|
||||
summarize_mem "$run_dir/screen-mem-after.txt" "screen.after"
|
||||
}
|
||||
|
||||
run_chat_benchmark() {
|
||||
ensure_chat_online
|
||||
capture_mem "$run_dir/chat-mem-before.txt"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
|
||||
start_cpu_sampler "$run_dir/chat-cpu.txt" 18
|
||||
|
||||
if [[ "$CHAT_MODE" == "session-switch" ]]; then
|
||||
for _ in $(seq 1 "$CHAT_LOOPS"); do
|
||||
adb_cmd shell input tap "$chat_session_left_x" "$chat_session_y" >/dev/null
|
||||
sleep 0.8
|
||||
adb_cmd shell input tap "$chat_session_right_x" "$chat_session_y" >/dev/null
|
||||
sleep 0.8
|
||||
done
|
||||
else
|
||||
for _ in $(seq 1 "$CHAT_LOOPS"); do
|
||||
adb_cmd shell input swipe "$center_x" "$chat_swipe_bottom_y" "$center_x" "$chat_swipe_top_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
adb_cmd shell input swipe "$center_x" "$chat_swipe_mid_y" "$center_x" "$chat_swipe_bottom_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
done
|
||||
fi
|
||||
|
||||
wait "$CPU_SAMPLER_PID"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/chat-gfx.txt"
|
||||
capture_mem "$run_dir/chat-mem-after.txt"
|
||||
summarize_gfx "$run_dir/chat-gfx.txt" "chat"
|
||||
summarize_cpu "$run_dir/chat-cpu.txt" "chat"
|
||||
summarize_mem "$run_dir/chat-mem-before.txt" "chat.before"
|
||||
summarize_mem "$run_dir/chat-mem-after.txt" "chat.after"
|
||||
}
|
||||
|
||||
printf 'device.serial=%s\n' "${DEVICE_SERIAL:-default}" >"$run_dir/summary.txt"
|
||||
printf 'device.display=%sx%s\n' "$display_width" "$display_height" >>"$run_dir/summary.txt"
|
||||
printf 'config.launch_runs=%s\n' "$LAUNCH_RUNS" >>"$run_dir/summary.txt"
|
||||
printf 'config.screen_loops=%s\n' "$SCREEN_LOOPS" >>"$run_dir/summary.txt"
|
||||
printf 'config.chat_loops=%s\n' "$CHAT_LOOPS" >>"$run_dir/summary.txt"
|
||||
printf 'config.screen_mode=%s\n' "$SCREEN_MODE" >>"$run_dir/summary.txt"
|
||||
printf 'config.chat_mode=%s\n' "$CHAT_MODE" >>"$run_dir/summary.txt"
|
||||
|
||||
ensure_connected
|
||||
measure_launch
|
||||
ensure_connected
|
||||
run_screen_benchmark
|
||||
ensure_connected
|
||||
run_chat_benchmark
|
||||
|
||||
printf 'results_dir=%s\n' "$run_dir"
|
||||
cat "$run_dir/summary.txt"
|
||||
@ -174,7 +174,12 @@ final class GatewayConnectionController {
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
if resolvedUseTLS, stored == nil {
|
||||
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
|
||||
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
|
||||
guard let fp = await self.probeTLSFingerprint(url: url) else {
|
||||
self.appModel?.gatewayStatusText =
|
||||
"TLS handshake failed for \(host):\(resolvedPort). "
|
||||
+ "Remote gateways must use HTTPS/WSS."
|
||||
return
|
||||
}
|
||||
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
|
||||
self.pendingTrustPrompt = TrustPrompt(
|
||||
stableID: stableID,
|
||||
|
||||
@ -607,7 +607,7 @@ struct OnboardingWizardView: View {
|
||||
private var authStep: some View {
|
||||
Group {
|
||||
Section("Authentication") {
|
||||
TextField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
SecureField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
@ -724,6 +724,12 @@ struct OnboardingWizardView: View {
|
||||
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
if self.selectedMode == .remoteDomain {
|
||||
SecureField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
}
|
||||
self.manualConnectButton
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ struct ExecApprovalEvaluation {
|
||||
let env: [String: String]
|
||||
let resolution: ExecCommandResolution?
|
||||
let allowlistResolutions: [ExecCommandResolution]
|
||||
let allowAlwaysPatterns: [String]
|
||||
let allowlistMatches: [ExecAllowlistEntry]
|
||||
let allowlistSatisfied: Bool
|
||||
let allowlistMatch: ExecAllowlistEntry?
|
||||
@ -31,9 +32,16 @@ enum ExecApprovalEvaluator {
|
||||
let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper
|
||||
let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper)
|
||||
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
|
||||
let allowlistRawCommand = ExecSystemRunCommandValidator.allowlistEvaluationRawCommand(
|
||||
command: command,
|
||||
rawCommand: rawCommand)
|
||||
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: rawCommand,
|
||||
rawCommand: allowlistRawCommand,
|
||||
cwd: cwd,
|
||||
env: env)
|
||||
let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
env: env)
|
||||
let allowlistMatches = security == .allowlist
|
||||
@ -60,6 +68,7 @@ enum ExecApprovalEvaluator {
|
||||
env: env,
|
||||
resolution: allowlistResolutions.first,
|
||||
allowlistResolutions: allowlistResolutions,
|
||||
allowAlwaysPatterns: allowAlwaysPatterns,
|
||||
allowlistMatches: allowlistMatches,
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,
|
||||
|
||||
@ -378,7 +378,7 @@ private enum ExecHostExecutor {
|
||||
let context = await self.buildContext(
|
||||
request: request,
|
||||
command: validatedRequest.command,
|
||||
rawCommand: validatedRequest.displayCommand)
|
||||
rawCommand: validatedRequest.evaluationRawCommand)
|
||||
|
||||
switch ExecHostRequestEvaluator.evaluate(
|
||||
context: context,
|
||||
@ -476,13 +476,7 @@ private enum ExecHostExecutor {
|
||||
{
|
||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||
var seenPatterns = Set<String>()
|
||||
for candidate in context.allowlistResolutions {
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||
command: context.command,
|
||||
resolution: candidate)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
for pattern in context.allowAlwaysPatterns {
|
||||
if seenPatterns.insert(pattern).inserted {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
|
||||
}
|
||||
|
||||
@ -52,6 +52,23 @@ struct ExecCommandResolution {
|
||||
return [resolution]
|
||||
}
|
||||
|
||||
static func resolveAllowAlwaysPatterns(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> [String]
|
||||
{
|
||||
var patterns: [String] = []
|
||||
var seen = Set<String>()
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: 0,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return patterns
|
||||
}
|
||||
|
||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
|
||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
@ -101,6 +118,115 @@ struct ExecCommandResolution {
|
||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
private static func collectAllowAlwaysPatterns(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
depth: Int,
|
||||
patterns: inout [String],
|
||||
seen: inout Set<String>)
|
||||
{
|
||||
guard depth < 3, !command.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
ExecCommandToken.basenameLower(token0) == "env",
|
||||
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
|
||||
!envUnwrapped.isEmpty
|
||||
{
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: envUnwrapped,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return
|
||||
}
|
||||
|
||||
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) {
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: shellMultiplexer,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return
|
||||
}
|
||||
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||
if shell.isWrapper {
|
||||
guard let shellCommand = shell.command,
|
||||
let segments = self.splitShellCommandChain(shellCommand)
|
||||
else {
|
||||
return
|
||||
}
|
||||
for segment in segments {
|
||||
let tokens = self.tokenizeShellWords(segment)
|
||||
guard !tokens.isEmpty else {
|
||||
continue
|
||||
}
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: tokens,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
|
||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
|
||||
seen.insert(pattern).inserted
|
||||
else {
|
||||
return
|
||||
}
|
||||
patterns.append(pattern)
|
||||
}
|
||||
|
||||
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
|
||||
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let wrapper = ExecCommandToken.basenameLower(token0)
|
||||
guard wrapper == "busybox" || wrapper == "toybox" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var appletIndex = 1
|
||||
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
|
||||
appletIndex += 1
|
||||
}
|
||||
guard appletIndex < argv.count else {
|
||||
return nil
|
||||
}
|
||||
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !applet.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let normalizedApplet = ExecCommandToken.basenameLower(applet)
|
||||
let shellWrappers = Set([
|
||||
"ash",
|
||||
"bash",
|
||||
"dash",
|
||||
"fish",
|
||||
"ksh",
|
||||
"powershell",
|
||||
"pwsh",
|
||||
"sh",
|
||||
"zsh",
|
||||
])
|
||||
guard shellWrappers.contains(normalizedApplet) else {
|
||||
return nil
|
||||
}
|
||||
return Array(argv[appletIndex...])
|
||||
}
|
||||
|
||||
private static func parseFirstToken(_ command: String) -> String? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
@ -12,14 +12,24 @@ enum ExecCommandToken {
|
||||
enum ExecEnvInvocationUnwrapper {
|
||||
static let maxWrapperDepth = 4
|
||||
|
||||
struct UnwrapResult {
|
||||
let command: [String]
|
||||
let usesModifiers: Bool
|
||||
}
|
||||
|
||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||
return token.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
static func unwrap(_ command: [String]) -> [String]? {
|
||||
self.unwrapWithMetadata(command)?.command
|
||||
}
|
||||
|
||||
static func unwrapWithMetadata(_ command: [String]) -> UnwrapResult? {
|
||||
var idx = 1
|
||||
var expectsOptionValue = false
|
||||
var usesModifiers = false
|
||||
while idx < command.count {
|
||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
@ -28,6 +38,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
}
|
||||
if expectsOptionValue {
|
||||
expectsOptionValue = false
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@ -36,6 +47,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
break
|
||||
}
|
||||
if self.isEnvAssignment(token) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@ -43,10 +55,12 @@ enum ExecEnvInvocationUnwrapper {
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if ExecEnvOptions.withValue.contains(flag) {
|
||||
usesModifiers = true
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
}
|
||||
@ -63,6 +77,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
lower.hasPrefix("--ignore-signal=") ||
|
||||
lower.hasPrefix("--block-signal=")
|
||||
{
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@ -70,8 +85,8 @@ enum ExecEnvInvocationUnwrapper {
|
||||
}
|
||||
break
|
||||
}
|
||||
guard idx < command.count else { return nil }
|
||||
return Array(command[idx...])
|
||||
guard !expectsOptionValue, idx < command.count else { return nil }
|
||||
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
|
||||
}
|
||||
|
||||
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
|
||||
@ -84,10 +99,13 @@ enum ExecEnvInvocationUnwrapper {
|
||||
guard ExecCommandToken.basenameLower(token) == "env" else {
|
||||
break
|
||||
}
|
||||
guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else {
|
||||
guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else {
|
||||
break
|
||||
}
|
||||
current = unwrapped
|
||||
if unwrapped.usesModifiers {
|
||||
break
|
||||
}
|
||||
current = unwrapped.command
|
||||
depth += 1
|
||||
}
|
||||
return current
|
||||
|
||||
@ -3,6 +3,7 @@ import Foundation
|
||||
struct ExecHostValidatedRequest {
|
||||
let command: [String]
|
||||
let displayCommand: String
|
||||
let evaluationRawCommand: String?
|
||||
}
|
||||
|
||||
enum ExecHostPolicyDecision {
|
||||
@ -27,7 +28,10 @@ enum ExecHostRequestEvaluator {
|
||||
rawCommand: request.rawCommand)
|
||||
switch validatedCommand {
|
||||
case let .ok(resolved):
|
||||
return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand))
|
||||
return .success(ExecHostValidatedRequest(
|
||||
command: command,
|
||||
displayCommand: resolved.displayCommand,
|
||||
evaluationRawCommand: resolved.evaluationRawCommand))
|
||||
case let .invalid(message):
|
||||
return .failure(
|
||||
ExecHostError(
|
||||
|
||||
@ -3,6 +3,7 @@ import Foundation
|
||||
enum ExecSystemRunCommandValidator {
|
||||
struct ResolvedCommand {
|
||||
let displayCommand: String
|
||||
let evaluationRawCommand: String?
|
||||
}
|
||||
|
||||
enum ValidationResult {
|
||||
@ -52,18 +53,43 @@ enum ExecSystemRunCommandValidator {
|
||||
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
|
||||
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
|
||||
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
|
||||
|
||||
let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
let formattedArgv = ExecCommandFormatter.displayString(for: command)
|
||||
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
shellCommand
|
||||
} else {
|
||||
ExecCommandFormatter.displayString(for: command)
|
||||
nil
|
||||
}
|
||||
|
||||
if let raw = normalizedRaw, raw != inferred {
|
||||
if let raw = normalizedRaw, raw != formattedArgv, raw != previewCommand {
|
||||
return .invalid(message: "INVALID_REQUEST: rawCommand does not match command")
|
||||
}
|
||||
|
||||
return .ok(ResolvedCommand(displayCommand: normalizedRaw ?? inferred))
|
||||
return .ok(ResolvedCommand(
|
||||
displayCommand: formattedArgv,
|
||||
evaluationRawCommand: self.allowlistEvaluationRawCommand(
|
||||
normalizedRaw: normalizedRaw,
|
||||
shellIsWrapper: shell.isWrapper,
|
||||
previewCommand: previewCommand)))
|
||||
}
|
||||
|
||||
static func allowlistEvaluationRawCommand(command: [String], rawCommand: String?) -> String? {
|
||||
let normalizedRaw = self.normalizeRaw(rawCommand)
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||
let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil
|
||||
|
||||
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
|
||||
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
|
||||
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
|
||||
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
shellCommand
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return self.allowlistEvaluationRawCommand(
|
||||
normalizedRaw: normalizedRaw,
|
||||
shellIsWrapper: shell.isWrapper,
|
||||
previewCommand: previewCommand)
|
||||
}
|
||||
|
||||
private static func normalizeRaw(_ rawCommand: String?) -> String? {
|
||||
@ -76,6 +102,20 @@ enum ExecSystemRunCommandValidator {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func allowlistEvaluationRawCommand(
|
||||
normalizedRaw: String?,
|
||||
shellIsWrapper: Bool,
|
||||
previewCommand: String?) -> String?
|
||||
{
|
||||
guard shellIsWrapper else {
|
||||
return normalizedRaw
|
||||
}
|
||||
guard let normalizedRaw else {
|
||||
return nil
|
||||
}
|
||||
return normalizedRaw == previewCommand ? normalizedRaw : nil
|
||||
}
|
||||
|
||||
private static func normalizeExecutableToken(_ token: String) -> String {
|
||||
let base = ExecCommandToken.basenameLower(token)
|
||||
if base.hasSuffix(".exe") {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
struct HostEnvOverrideDiagnostics: Equatable {
|
||||
var blockedKeys: [String]
|
||||
var invalidKeys: [String]
|
||||
}
|
||||
|
||||
enum HostEnvSanitizer {
|
||||
/// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
|
||||
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
|
||||
@ -41,6 +46,67 @@ enum HostEnvSanitizer {
|
||||
return filtered.isEmpty ? nil : filtered
|
||||
}
|
||||
|
||||
private static func isPortableHead(_ scalar: UnicodeScalar) -> Bool {
|
||||
let value = scalar.value
|
||||
return value == 95 || (65...90).contains(value) || (97...122).contains(value)
|
||||
}
|
||||
|
||||
private static func isPortableTail(_ scalar: UnicodeScalar) -> Bool {
|
||||
let value = scalar.value
|
||||
return self.isPortableHead(scalar) || (48...57).contains(value)
|
||||
}
|
||||
|
||||
private static func normalizeOverrideKey(_ rawKey: String) -> String? {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return nil }
|
||||
guard let first = key.unicodeScalars.first, self.isPortableHead(first) else {
|
||||
return nil
|
||||
}
|
||||
for scalar in key.unicodeScalars.dropFirst() {
|
||||
if self.isPortableTail(scalar) || scalar == "(" || scalar == ")" {
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
private static func sortedUnique(_ values: [String]) -> [String] {
|
||||
Array(Set(values)).sorted()
|
||||
}
|
||||
|
||||
static func inspectOverrides(
|
||||
overrides: [String: String]?,
|
||||
blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics
|
||||
{
|
||||
guard let overrides else {
|
||||
return HostEnvOverrideDiagnostics(blockedKeys: [], invalidKeys: [])
|
||||
}
|
||||
|
||||
var blocked: [String] = []
|
||||
var invalid: [String] = []
|
||||
for (rawKey, _) in overrides {
|
||||
let candidate = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let normalized = self.normalizeOverrideKey(rawKey) else {
|
||||
invalid.append(candidate.isEmpty ? rawKey : candidate)
|
||||
continue
|
||||
}
|
||||
let upper = normalized.uppercased()
|
||||
if blockPathOverrides, upper == "PATH" {
|
||||
blocked.append(upper)
|
||||
continue
|
||||
}
|
||||
if self.isBlockedOverride(upper) || self.isBlocked(upper) {
|
||||
blocked.append(upper)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return HostEnvOverrideDiagnostics(
|
||||
blockedKeys: self.sortedUnique(blocked),
|
||||
invalidKeys: self.sortedUnique(invalid))
|
||||
}
|
||||
|
||||
static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] {
|
||||
var merged: [String: String] = [:]
|
||||
for (rawKey, value) in ProcessInfo.processInfo.environment {
|
||||
@ -57,8 +123,7 @@ enum HostEnvSanitizer {
|
||||
|
||||
guard let effectiveOverrides else { return merged }
|
||||
for (rawKey, value) in effectiveOverrides {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
guard let key = self.normalizeOverrideKey(rawKey) else { continue }
|
||||
let upper = key.uppercased()
|
||||
// PATH is part of the security boundary (command resolution + safe-bin checks). Never
|
||||
// allow request-scoped PATH overrides from agents/gateways.
|
||||
|
||||
@ -28,11 +28,18 @@ enum HostEnvSecurityPolicy {
|
||||
"_JAVA_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
"PYTHONBREAKPOINT",
|
||||
"DOTNET_STARTUP_HOOKS"
|
||||
"DOTNET_STARTUP_HOOKS",
|
||||
"DOTNET_ADDITIONAL_DEPS",
|
||||
"GLIBC_TUNABLES",
|
||||
"MAVEN_OPTS",
|
||||
"SBT_OPTS",
|
||||
"GRADLE_OPTS",
|
||||
"ANT_OPTS"
|
||||
]
|
||||
|
||||
static let blockedOverrideKeys: Set<String> = [
|
||||
"HOME",
|
||||
"GRADLE_USER_HOME",
|
||||
"ZDOTDIR",
|
||||
"GIT_SSH_COMMAND",
|
||||
"GIT_SSH",
|
||||
@ -56,7 +63,23 @@ enum HostEnvSecurityPolicy {
|
||||
"OPENSSL_ENGINES",
|
||||
"PYTHONSTARTUP",
|
||||
"WGETRC",
|
||||
"CURL_HOME"
|
||||
"CURL_HOME",
|
||||
"CLASSPATH",
|
||||
"CGO_CFLAGS",
|
||||
"CGO_LDFLAGS",
|
||||
"GOFLAGS",
|
||||
"CORECLR_PROFILER_PATH",
|
||||
"PHPRC",
|
||||
"PHP_INI_SCAN_DIR",
|
||||
"DENO_DIR",
|
||||
"BUN_CONFIG_REGISTRY",
|
||||
"LUA_PATH",
|
||||
"LUA_CPATH",
|
||||
"GEM_HOME",
|
||||
"GEM_PATH",
|
||||
"BUNDLE_GEMFILE",
|
||||
"COMPOSER_HOME",
|
||||
"XDG_CONFIG_HOME"
|
||||
]
|
||||
|
||||
static let blockedOverridePrefixes: [String] = [
|
||||
|
||||
@ -1099,38 +1099,33 @@ extension MenuSessionsInjector {
|
||||
// MARK: - Width + placement
|
||||
|
||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||
// Insert right before the separator above "Send Heartbeats".
|
||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
|
||||
if menu.items.count >= 1 { return 1 }
|
||||
return menu.items.count
|
||||
self.findDynamicSectionInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
private func findNodesInsertIndex(in menu: NSMenu) -> Int? {
|
||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
return idx
|
||||
self.findDynamicSectionInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
private func findDynamicSectionInsertIndex(in menu: NSMenu) -> Int? {
|
||||
// Keep controls and action buttons visible by inserting dynamic rows at the
|
||||
// built-in footer boundary, not by matching localized menu item titles.
|
||||
if let footerSeparatorIndex = menu.items.lastIndex(where: { item in
|
||||
item.isSeparatorItem && !self.isInjectedItem(item)
|
||||
}) {
|
||||
return footerSeparatorIndex
|
||||
}
|
||||
|
||||
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
if let firstBaseItemIndex = menu.items.firstIndex(where: { !self.isInjectedItem($0) }) {
|
||||
return min(firstBaseItemIndex + 1, menu.items.count)
|
||||
}
|
||||
|
||||
if menu.items.count >= 1 { return 1 }
|
||||
return menu.items.count
|
||||
}
|
||||
|
||||
private func isInjectedItem(_ item: NSMenuItem) -> Bool {
|
||||
item.tag == self.tag || item.tag == self.nodesTag
|
||||
}
|
||||
|
||||
private func initialWidth(for menu: NSMenu) -> CGFloat {
|
||||
if let openWidth = self.menuOpenWidth {
|
||||
return max(300, openWidth)
|
||||
@ -1236,5 +1231,13 @@ extension MenuSessionsInjector {
|
||||
func injectForTesting(into menu: NSMenu) {
|
||||
self.inject(into: menu)
|
||||
}
|
||||
|
||||
func testingFindInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findNodesInsertIndex(in: menu)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -465,6 +465,23 @@ actor MacNodeRuntime {
|
||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: self.mainSessionKey
|
||||
let runId = UUID().uuidString
|
||||
let envOverrideDiagnostics = HostEnvSanitizer.inspectOverrides(
|
||||
overrides: params.env,
|
||||
blockPathOverrides: true)
|
||||
if !envOverrideDiagnostics.blockedKeys.isEmpty || !envOverrideDiagnostics.invalidKeys.isEmpty {
|
||||
var details: [String] = []
|
||||
if !envOverrideDiagnostics.blockedKeys.isEmpty {
|
||||
details.append("blocked override keys: \(envOverrideDiagnostics.blockedKeys.joined(separator: ", "))")
|
||||
}
|
||||
if !envOverrideDiagnostics.invalidKeys.isEmpty {
|
||||
details.append(
|
||||
"invalid non-portable override keys: \(envOverrideDiagnostics.invalidKeys.joined(separator: ", "))")
|
||||
}
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "SYSTEM_RUN_DENIED: environment override rejected (\(details.joined(separator: "; ")))")
|
||||
}
|
||||
let evaluation = await ExecApprovalEvaluator.evaluate(
|
||||
command: command,
|
||||
rawCommand: params.rawCommand,
|
||||
@ -507,8 +524,7 @@ actor MacNodeRuntime {
|
||||
persistAllowlist: persistAllowlist,
|
||||
security: evaluation.security,
|
||||
agentId: evaluation.agentId,
|
||||
command: command,
|
||||
allowlistResolutions: evaluation.allowlistResolutions)
|
||||
allowAlwaysPatterns: evaluation.allowAlwaysPatterns)
|
||||
|
||||
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
@ -795,15 +811,11 @@ extension MacNodeRuntime {
|
||||
persistAllowlist: Bool,
|
||||
security: ExecSecurity,
|
||||
agentId: String?,
|
||||
command: [String],
|
||||
allowlistResolutions: [ExecCommandResolution])
|
||||
allowAlwaysPatterns: [String])
|
||||
{
|
||||
guard persistAllowlist, security == .allowlist else { return }
|
||||
var seenPatterns = Set<String>()
|
||||
for candidate in allowlistResolutions {
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else {
|
||||
continue
|
||||
}
|
||||
for pattern in allowAlwaysPatterns {
|
||||
if seenPatterns.insert(pattern).inserted {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
|
||||
@ -1326,6 +1326,124 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let agentid: String?
|
||||
public let label: String?
|
||||
public let model: String?
|
||||
public let parentsessionkey: String?
|
||||
public let task: String?
|
||||
public let message: String?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
agentid: String?,
|
||||
label: String?,
|
||||
model: String?,
|
||||
parentsessionkey: String?,
|
||||
task: String?,
|
||||
message: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.label = label
|
||||
self.model = model
|
||||
self.parentsessionkey = parentsessionkey
|
||||
self.task = task
|
||||
self.message = message
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case label
|
||||
case model
|
||||
case parentsessionkey = "parentSessionKey"
|
||||
case task
|
||||
case message
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsSendParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let message: String
|
||||
public let thinking: String?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let timeoutms: Int?
|
||||
public let idempotencykey: String?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
message: String,
|
||||
thinking: String?,
|
||||
attachments: [AnyCodable]?,
|
||||
timeoutms: Int?,
|
||||
idempotencykey: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.message = message
|
||||
self.thinking = thinking
|
||||
self.attachments = attachments
|
||||
self.timeoutms = timeoutms
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case message
|
||||
case thinking
|
||||
case attachments
|
||||
case timeoutms = "timeoutMs"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsMessagesSubscribeParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsMessagesUnsubscribeParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsAbortParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let runid: String?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
runid: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.runid = runid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case runid = "runId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let label: AnyCodable?
|
||||
@ -1894,6 +2012,98 @@ public struct TalkConfigResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkSpeakParams: Codable, Sendable {
|
||||
public let text: String
|
||||
public let voiceid: String?
|
||||
public let modelid: String?
|
||||
public let outputformat: String?
|
||||
public let speed: Double?
|
||||
public let stability: Double?
|
||||
public let similarity: Double?
|
||||
public let style: Double?
|
||||
public let speakerboost: Bool?
|
||||
public let seed: Int?
|
||||
public let normalize: String?
|
||||
public let language: String?
|
||||
|
||||
public init(
|
||||
text: String,
|
||||
voiceid: String?,
|
||||
modelid: String?,
|
||||
outputformat: String?,
|
||||
speed: Double?,
|
||||
stability: Double?,
|
||||
similarity: Double?,
|
||||
style: Double?,
|
||||
speakerboost: Bool?,
|
||||
seed: Int?,
|
||||
normalize: String?,
|
||||
language: String?)
|
||||
{
|
||||
self.text = text
|
||||
self.voiceid = voiceid
|
||||
self.modelid = modelid
|
||||
self.outputformat = outputformat
|
||||
self.speed = speed
|
||||
self.stability = stability
|
||||
self.similarity = similarity
|
||||
self.style = style
|
||||
self.speakerboost = speakerboost
|
||||
self.seed = seed
|
||||
self.normalize = normalize
|
||||
self.language = language
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case text
|
||||
case voiceid = "voiceId"
|
||||
case modelid = "modelId"
|
||||
case outputformat = "outputFormat"
|
||||
case speed
|
||||
case stability
|
||||
case similarity
|
||||
case style
|
||||
case speakerboost = "speakerBoost"
|
||||
case seed
|
||||
case normalize
|
||||
case language
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkSpeakResult: Codable, Sendable {
|
||||
public let audiobase64: String
|
||||
public let provider: String
|
||||
public let outputformat: String?
|
||||
public let voicecompatible: Bool?
|
||||
public let mimetype: String?
|
||||
public let fileextension: String?
|
||||
|
||||
public init(
|
||||
audiobase64: String,
|
||||
provider: String,
|
||||
outputformat: String?,
|
||||
voicecompatible: Bool?,
|
||||
mimetype: String?,
|
||||
fileextension: String?)
|
||||
{
|
||||
self.audiobase64 = audiobase64
|
||||
self.provider = provider
|
||||
self.outputformat = outputformat
|
||||
self.voicecompatible = voicecompatible
|
||||
self.mimetype = mimetype
|
||||
self.fileextension = fileextension
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case audiobase64 = "audioBase64"
|
||||
case provider
|
||||
case outputformat = "outputFormat"
|
||||
case voicecompatible = "voiceCompatible"
|
||||
case mimetype = "mimeType"
|
||||
case fileextension = "fileExtension"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsStatusParams: Codable, Sendable {
|
||||
public let probe: Bool?
|
||||
public let timeoutms: Int?
|
||||
|
||||
@ -45,7 +45,7 @@ import Testing
|
||||
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
||||
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
|
||||
try makeExecutableForTests(at: nodePath)
|
||||
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||
try makeExecutableForTests(at: scriptPath)
|
||||
|
||||
|
||||
@ -240,7 +240,7 @@ struct ExecAllowlistTests {
|
||||
#expect(resolutions[0].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env assignments inside shell segments`() {
|
||||
@Test func `resolve for allowlist preserves 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,
|
||||
@ -248,11 +248,11 @@ struct ExecAllowlistTests {
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
|
||||
#expect(resolutions[0].executableName == "touch")
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||
#expect(resolutions[0].executableName == "env")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
|
||||
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
|
||||
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
@ -260,8 +260,33 @@ struct ExecAllowlistTests {
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(resolutions[0].executableName == "printf")
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||
#expect(resolutions[0].executableName == "env")
|
||||
}
|
||||
|
||||
@Test func `approval evaluator resolves shell payload from canonical wrapper text`() async {
|
||||
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
|
||||
let evaluation = await ExecApprovalEvaluator.evaluate(
|
||||
command: command,
|
||||
rawCommand: rawCommand,
|
||||
cwd: nil,
|
||||
envOverrides: ["PATH": "/usr/bin:/bin"],
|
||||
agentId: nil)
|
||||
|
||||
#expect(evaluation.displayCommand == rawCommand)
|
||||
#expect(evaluation.allowlistResolutions.count == 1)
|
||||
#expect(evaluation.allowlistResolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
|
||||
}
|
||||
|
||||
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns == ["/usr/bin/printf"])
|
||||
}
|
||||
|
||||
@Test func `match all requires every segment to match`() {
|
||||
|
||||
@ -21,13 +21,12 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let url = ExecApprovalsStore.fileURL()
|
||||
let firstWriteDate = try Self.modificationDate(at: url)
|
||||
let firstIdentity = try Self.fileIdentity(at: url)
|
||||
|
||||
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let secondWriteDate = try Self.modificationDate(at: url)
|
||||
let secondIdentity = try Self.fileIdentity(at: url)
|
||||
|
||||
#expect(firstWriteDate == secondWriteDate)
|
||||
#expect(firstIdentity == secondIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,12 +80,12 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
}
|
||||
|
||||
private static func modificationDate(at url: URL) throws -> Date {
|
||||
private static func fileIdentity(at url: URL) throws -> Int {
|
||||
let attributes = try FileManager().attributesOfItem(atPath: url.path)
|
||||
guard let date = attributes[.modificationDate] as? Date else {
|
||||
struct MissingDateError: Error {}
|
||||
throw MissingDateError()
|
||||
guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else {
|
||||
struct MissingIdentifierError: Error {}
|
||||
throw MissingIdentifierError()
|
||||
}
|
||||
return date
|
||||
return identifier
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,6 +77,7 @@ struct ExecHostRequestEvaluatorTests {
|
||||
env: [:],
|
||||
resolution: nil,
|
||||
allowlistResolutions: [],
|
||||
allowAlwaysPatterns: [],
|
||||
allowlistMatches: [],
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
allowlistMatch: nil,
|
||||
|
||||
@ -50,6 +50,20 @@ struct ExecSystemRunCommandValidatorTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `validator keeps canonical wrapper text out of allowlist raw parsing`() {
|
||||
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
|
||||
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand)
|
||||
|
||||
switch result {
|
||||
case let .ok(resolved):
|
||||
#expect(resolved.displayCommand == rawCommand)
|
||||
#expect(resolved.evaluationRawCommand == nil)
|
||||
case let .invalid(message):
|
||||
Issue.record("unexpected invalid result: \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
|
||||
let fixtureURL = try self.findContractFixtureURL()
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
|
||||
@ -33,4 +33,24 @@ struct HostEnvSanitizerTests {
|
||||
let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"])
|
||||
#expect(env["OPENCLAW_TOKEN"] == "secret")
|
||||
}
|
||||
|
||||
@Test func `inspect overrides rejects blocked and invalid keys`() {
|
||||
let diagnostics = HostEnvSanitizer.inspectOverrides(overrides: [
|
||||
"CLASSPATH": "/tmp/evil-classpath",
|
||||
"BAD-KEY": "x",
|
||||
"ProgramFiles(x86)": "C:\\Program Files (x86)",
|
||||
])
|
||||
|
||||
#expect(diagnostics.blockedKeys == ["CLASSPATH"])
|
||||
#expect(diagnostics.invalidKeys == ["BAD-KEY"])
|
||||
}
|
||||
|
||||
@Test func `sanitize accepts Windows-style override key names`() {
|
||||
let env = HostEnvSanitizer.sanitize(overrides: [
|
||||
"ProgramFiles(x86)": "D:\\SDKs",
|
||||
"CommonProgramFiles(x86)": "D:\\Common",
|
||||
])
|
||||
#expect(env["ProgramFiles(x86)"] == "D:\\SDKs")
|
||||
#expect(env["CommonProgramFiles(x86)"] == "D:\\Common")
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,32 @@ struct MacNodeRuntimeTests {
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects blocked system run env override before execution`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/sh", "-lc", "echo ok"],
|
||||
env: ["CLASSPATH": "/tmp/evil-classpath"])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
|
||||
#expect(response.error?.message.contains("CLASSPATH") == true)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects invalid system run env override key before execution`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/sh", "-lc", "echo ok"],
|
||||
env: ["BAD-KEY": "x"])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
|
||||
#expect(response.error?.message.contains("BAD-KEY") == true)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects empty system which`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemWhichParams(bins: [])
|
||||
|
||||
@ -5,7 +5,26 @@ import Testing
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct MenuSessionsInjectorTests {
|
||||
@Test func `injects disconnected message`() {
|
||||
@Test func anchorsDynamicRowsBelowControlsAndActions() throws {
|
||||
let injector = MenuSessionsInjector()
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(NSMenuItem(title: "Open Chat", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: ""))
|
||||
|
||||
let footerSeparatorIndex = try #require(menu.items.lastIndex(where: { $0.isSeparatorItem }))
|
||||
#expect(injector.testingFindInsertIndex(in: menu) == footerSeparatorIndex)
|
||||
#expect(injector.testingFindNodesInsertIndex(in: menu) == footerSeparatorIndex)
|
||||
}
|
||||
|
||||
@Test func injectsDisconnectedMessage() {
|
||||
let injector = MenuSessionsInjector()
|
||||
injector.setTestingControlChannelConnected(false)
|
||||
injector.setTestingSnapshot(nil, errorText: nil)
|
||||
@ -19,7 +38,7 @@ struct MenuSessionsInjectorTests {
|
||||
#expect(menu.items.contains { $0.tag == 9_415_557 })
|
||||
}
|
||||
|
||||
@Test func `injects session rows`() {
|
||||
@Test func injectsSessionRows() throws {
|
||||
let injector = MenuSessionsInjector()
|
||||
injector.setTestingControlChannelConnected(true)
|
||||
|
||||
@ -88,10 +107,22 @@ struct MenuSessionsInjectorTests {
|
||||
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: ""))
|
||||
|
||||
injector.injectForTesting(into: menu)
|
||||
#expect(menu.items.contains { $0.tag == 9_415_557 })
|
||||
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
|
||||
let sendHeartbeatsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }))
|
||||
let openDashboardIndex = try #require(menu.items.firstIndex(where: { $0.title == "Open Dashboard" }))
|
||||
let firstInjectedIndex = try #require(menu.items.firstIndex(where: { $0.tag == 9_415_557 }))
|
||||
let settingsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Settings…" }))
|
||||
#expect(sendHeartbeatsIndex < firstInjectedIndex)
|
||||
#expect(openDashboardIndex < firstInjectedIndex)
|
||||
#expect(firstInjectedIndex < settingsIndex)
|
||||
}
|
||||
|
||||
@Test func `cost usage submenu does not use injector delegate`() {
|
||||
|
||||
@ -289,6 +289,17 @@ public final class OpenClawChatViewModel {
|
||||
stopReason: message.stopReason)
|
||||
}
|
||||
|
||||
private static func messageContentFingerprint(for message: OpenClawChatMessage) -> String {
|
||||
message.content.map { item in
|
||||
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
|
||||
}.joined(separator: "\\u{001E}")
|
||||
}
|
||||
|
||||
private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
|
||||
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !role.isEmpty else { return nil }
|
||||
@ -298,15 +309,7 @@ public final class OpenClawChatViewModel {
|
||||
return String(format: "%.3f", value)
|
||||
}()
|
||||
|
||||
let contentFingerprint = message.content.map { item in
|
||||
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
|
||||
}.joined(separator: "\\u{001E}")
|
||||
|
||||
let contentFingerprint = Self.messageContentFingerprint(for: message)
|
||||
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
||||
@ -315,6 +318,19 @@ public final class OpenClawChatViewModel {
|
||||
return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
||||
}
|
||||
|
||||
private static func userRefreshIdentityKey(for message: OpenClawChatMessage) -> String? {
|
||||
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard role == "user" else { return nil }
|
||||
|
||||
let contentFingerprint = Self.messageContentFingerprint(for: message)
|
||||
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return [role, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
||||
}
|
||||
|
||||
private static func reconcileMessageIDs(
|
||||
previous: [OpenClawChatMessage],
|
||||
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
||||
@ -353,6 +369,75 @@ public final class OpenClawChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private static func reconcileRunRefreshMessages(
|
||||
previous: [OpenClawChatMessage],
|
||||
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
||||
{
|
||||
guard !previous.isEmpty else { return incoming }
|
||||
guard !incoming.isEmpty else { return previous }
|
||||
|
||||
func countKeys(_ keys: [String]) -> [String: Int] {
|
||||
keys.reduce(into: [:]) { counts, key in
|
||||
counts[key, default: 0] += 1
|
||||
}
|
||||
}
|
||||
|
||||
var reconciled = Self.reconcileMessageIDs(previous: previous, incoming: incoming)
|
||||
let incomingIdentityKeys = Set(reconciled.compactMap(Self.messageIdentityKey(for:)))
|
||||
var remainingIncomingUserRefreshCounts = countKeys(
|
||||
reconciled.compactMap(Self.userRefreshIdentityKey(for:)))
|
||||
|
||||
var lastMatchedPreviousIndex: Int?
|
||||
for (index, message) in previous.enumerated() {
|
||||
if let key = Self.messageIdentityKey(for: message),
|
||||
incomingIdentityKeys.contains(key)
|
||||
{
|
||||
lastMatchedPreviousIndex = index
|
||||
continue
|
||||
}
|
||||
if let userKey = Self.userRefreshIdentityKey(for: message),
|
||||
let remaining = remainingIncomingUserRefreshCounts[userKey],
|
||||
remaining > 0
|
||||
{
|
||||
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
|
||||
lastMatchedPreviousIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
let trailingUserMessages = (lastMatchedPreviousIndex != nil
|
||||
? previous.suffix(from: previous.index(after: lastMatchedPreviousIndex!))
|
||||
: ArraySlice(previous))
|
||||
.filter { message in
|
||||
guard message.role.lowercased() == "user" else { return false }
|
||||
guard let key = Self.userRefreshIdentityKey(for: message) else { return false }
|
||||
let remaining = remainingIncomingUserRefreshCounts[key] ?? 0
|
||||
if remaining > 0 {
|
||||
remainingIncomingUserRefreshCounts[key] = remaining - 1
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
guard !trailingUserMessages.isEmpty else {
|
||||
return reconciled
|
||||
}
|
||||
|
||||
for message in trailingUserMessages {
|
||||
guard let messageTimestamp = message.timestamp else {
|
||||
reconciled.append(message)
|
||||
continue
|
||||
}
|
||||
|
||||
let insertIndex = reconciled.firstIndex { existing in
|
||||
guard let existingTimestamp = existing.timestamp else { return false }
|
||||
return existingTimestamp > messageTimestamp
|
||||
} ?? reconciled.endIndex
|
||||
reconciled.insert(message, at: insertIndex)
|
||||
}
|
||||
|
||||
return Self.dedupeMessages(reconciled)
|
||||
}
|
||||
|
||||
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
||||
var result: [OpenClawChatMessage] = []
|
||||
result.reserveCapacity(messages.count)
|
||||
@ -919,7 +1004,7 @@ public final class OpenClawChatViewModel {
|
||||
private func refreshHistoryAfterRun() async {
|
||||
do {
|
||||
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
|
||||
self.messages = Self.reconcileMessageIDs(
|
||||
self.messages = Self.reconcileRunRefreshMessages(
|
||||
previous: self.messages,
|
||||
incoming: Self.decodeMessages(payload.messages ?? []))
|
||||
self.sessionId = payload.sessionId
|
||||
|
||||
@ -513,8 +513,11 @@ public actor GatewayChannelActor {
|
||||
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
||||
let authToken =
|
||||
explicitToken ??
|
||||
(includeDeviceIdentity && explicitPassword == nil &&
|
||||
(explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil)
|
||||
// A freshly scanned setup code should force the bootstrap pairing path instead of
|
||||
// silently reusing an older stored device token.
|
||||
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
|
||||
? storedToken
|
||||
: nil)
|
||||
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
|
||||
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
||||
let authSource: GatewayAuthSource
|
||||
|
||||
@ -1326,6 +1326,124 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let agentid: String?
|
||||
public let label: String?
|
||||
public let model: String?
|
||||
public let parentsessionkey: String?
|
||||
public let task: String?
|
||||
public let message: String?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
agentid: String?,
|
||||
label: String?,
|
||||
model: String?,
|
||||
parentsessionkey: String?,
|
||||
task: String?,
|
||||
message: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.label = label
|
||||
self.model = model
|
||||
self.parentsessionkey = parentsessionkey
|
||||
self.task = task
|
||||
self.message = message
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case label
|
||||
case model
|
||||
case parentsessionkey = "parentSessionKey"
|
||||
case task
|
||||
case message
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsSendParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let message: String
|
||||
public let thinking: String?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let timeoutms: Int?
|
||||
public let idempotencykey: String?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
message: String,
|
||||
thinking: String?,
|
||||
attachments: [AnyCodable]?,
|
||||
timeoutms: Int?,
|
||||
idempotencykey: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.message = message
|
||||
self.thinking = thinking
|
||||
self.attachments = attachments
|
||||
self.timeoutms = timeoutms
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case message
|
||||
case thinking
|
||||
case attachments
|
||||
case timeoutms = "timeoutMs"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsMessagesSubscribeParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsMessagesUnsubscribeParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsAbortParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let runid: String?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
runid: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.runid = runid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case runid = "runId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let label: AnyCodable?
|
||||
@ -1894,6 +2012,98 @@ public struct TalkConfigResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkSpeakParams: Codable, Sendable {
|
||||
public let text: String
|
||||
public let voiceid: String?
|
||||
public let modelid: String?
|
||||
public let outputformat: String?
|
||||
public let speed: Double?
|
||||
public let stability: Double?
|
||||
public let similarity: Double?
|
||||
public let style: Double?
|
||||
public let speakerboost: Bool?
|
||||
public let seed: Int?
|
||||
public let normalize: String?
|
||||
public let language: String?
|
||||
|
||||
public init(
|
||||
text: String,
|
||||
voiceid: String?,
|
||||
modelid: String?,
|
||||
outputformat: String?,
|
||||
speed: Double?,
|
||||
stability: Double?,
|
||||
similarity: Double?,
|
||||
style: Double?,
|
||||
speakerboost: Bool?,
|
||||
seed: Int?,
|
||||
normalize: String?,
|
||||
language: String?)
|
||||
{
|
||||
self.text = text
|
||||
self.voiceid = voiceid
|
||||
self.modelid = modelid
|
||||
self.outputformat = outputformat
|
||||
self.speed = speed
|
||||
self.stability = stability
|
||||
self.similarity = similarity
|
||||
self.style = style
|
||||
self.speakerboost = speakerboost
|
||||
self.seed = seed
|
||||
self.normalize = normalize
|
||||
self.language = language
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case text
|
||||
case voiceid = "voiceId"
|
||||
case modelid = "modelId"
|
||||
case outputformat = "outputFormat"
|
||||
case speed
|
||||
case stability
|
||||
case similarity
|
||||
case style
|
||||
case speakerboost = "speakerBoost"
|
||||
case seed
|
||||
case normalize
|
||||
case language
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkSpeakResult: Codable, Sendable {
|
||||
public let audiobase64: String
|
||||
public let provider: String
|
||||
public let outputformat: String?
|
||||
public let voicecompatible: Bool?
|
||||
public let mimetype: String?
|
||||
public let fileextension: String?
|
||||
|
||||
public init(
|
||||
audiobase64: String,
|
||||
provider: String,
|
||||
outputformat: String?,
|
||||
voicecompatible: Bool?,
|
||||
mimetype: String?,
|
||||
fileextension: String?)
|
||||
{
|
||||
self.audiobase64 = audiobase64
|
||||
self.provider = provider
|
||||
self.outputformat = outputformat
|
||||
self.voicecompatible = voicecompatible
|
||||
self.mimetype = mimetype
|
||||
self.fileextension = fileextension
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case audiobase64 = "audioBase64"
|
||||
case provider
|
||||
case outputformat = "outputFormat"
|
||||
case voicecompatible = "voiceCompatible"
|
||||
case mimetype = "mimeType"
|
||||
case fileextension = "fileExtension"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsStatusParams: Codable, Sendable {
|
||||
public let probe: Bool?
|
||||
public let timeoutms: Int?
|
||||
|
||||
@ -126,6 +126,28 @@ private func sendUserMessage(_ vm: OpenClawChatViewModel, text: String = "hi") a
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func sendMessageAndEmitFinal(
|
||||
transport: TestChatTransport,
|
||||
vm: OpenClawChatViewModel,
|
||||
text: String,
|
||||
sessionKey: String = "main") async throws -> String
|
||||
{
|
||||
await sendUserMessage(vm, text: text)
|
||||
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
|
||||
|
||||
let runId = try #require(await transport.lastSentRunId())
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: runId,
|
||||
sessionKey: sessionKey,
|
||||
state: "final",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
return runId
|
||||
}
|
||||
|
||||
private func emitAssistantText(
|
||||
transport: TestChatTransport,
|
||||
runId: String,
|
||||
@ -439,6 +461,141 @@ extension TestChatTransportState {
|
||||
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
|
||||
}
|
||||
|
||||
@Test func keepsOptimisticUserMessageWhenFinalRefreshReturnsOnlyAssistantHistory() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let history1 = historyPayload(sessionId: sessionId)
|
||||
let history2 = historyPayload(
|
||||
sessionId: sessionId,
|
||||
messages: [
|
||||
chatTextMessage(
|
||||
role: "assistant",
|
||||
text: "final answer",
|
||||
timestamp: now + 1),
|
||||
])
|
||||
|
||||
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
|
||||
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
||||
try await sendMessageAndEmitFinal(
|
||||
transport: transport,
|
||||
vm: vm,
|
||||
text: "hello from mac webchat")
|
||||
|
||||
try await waitUntil("assistant history refreshes without dropping user message") {
|
||||
await MainActor.run {
|
||||
let texts = vm.messages.map { message in
|
||||
(message.role, message.content.compactMap(\.text).joined(separator: "\n"))
|
||||
}
|
||||
return texts.contains(where: { $0.0 == "assistant" && $0.1 == "final answer" }) &&
|
||||
texts.contains(where: { $0.0 == "user" && $0.1 == "hello from mac webchat" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func keepsOptimisticUserMessageWhenFinalRefreshHistoryIsTemporarilyEmpty() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history1 = historyPayload(sessionId: sessionId)
|
||||
let history2 = historyPayload(sessionId: sessionId, messages: [])
|
||||
|
||||
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
|
||||
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
||||
try await sendMessageAndEmitFinal(
|
||||
transport: transport,
|
||||
vm: vm,
|
||||
text: "hello from mac webchat")
|
||||
|
||||
try await waitUntil("empty refresh does not clear optimistic user message") {
|
||||
await MainActor.run {
|
||||
vm.messages.contains { message in
|
||||
message.role == "user" &&
|
||||
message.content.compactMap(\.text).joined(separator: "\n") == "hello from mac webchat"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func doesNotDuplicateUserMessageWhenRefreshReturnsCanonicalTimestamp() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let history1 = historyPayload(sessionId: sessionId)
|
||||
let history2 = historyPayload(
|
||||
sessionId: sessionId,
|
||||
messages: [
|
||||
chatTextMessage(
|
||||
role: "user",
|
||||
text: "hello from mac webchat",
|
||||
timestamp: now + 5_000),
|
||||
chatTextMessage(
|
||||
role: "assistant",
|
||||
text: "final answer",
|
||||
timestamp: now + 6_000),
|
||||
])
|
||||
|
||||
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
|
||||
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
||||
try await sendMessageAndEmitFinal(
|
||||
transport: transport,
|
||||
vm: vm,
|
||||
text: "hello from mac webchat")
|
||||
|
||||
try await waitUntil("canonical refresh keeps one user message") {
|
||||
await MainActor.run {
|
||||
let userMessages = vm.messages.filter { message in
|
||||
message.role == "user" &&
|
||||
message.content.compactMap(\.text).joined(separator: "\n") == "hello from mac webchat"
|
||||
}
|
||||
let hasAssistant = vm.messages.contains { message in
|
||||
message.role == "assistant" &&
|
||||
message.content.compactMap(\.text).joined(separator: "\n") == "final answer"
|
||||
}
|
||||
return hasAssistant && userMessages.count == 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func preservesRepeatedOptimisticUserMessagesWithIdenticalContentDuringRefresh() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let history1 = historyPayload(sessionId: sessionId)
|
||||
let history2 = historyPayload(
|
||||
sessionId: sessionId,
|
||||
messages: [
|
||||
chatTextMessage(
|
||||
role: "user",
|
||||
text: "retry",
|
||||
timestamp: now + 5_000),
|
||||
chatTextMessage(
|
||||
role: "assistant",
|
||||
text: "first answer",
|
||||
timestamp: now + 6_000),
|
||||
])
|
||||
|
||||
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2, history2])
|
||||
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
||||
try await sendMessageAndEmitFinal(
|
||||
transport: transport,
|
||||
vm: vm,
|
||||
text: "retry")
|
||||
try await sendMessageAndEmitFinal(
|
||||
transport: transport,
|
||||
vm: vm,
|
||||
text: "retry")
|
||||
|
||||
try await waitUntil("repeated optimistic user message is preserved") {
|
||||
await MainActor.run {
|
||||
let retryMessages = vm.messages.filter { message in
|
||||
message.role == "user" &&
|
||||
message.content.compactMap(\.text).joined(separator: "\n") == "retry"
|
||||
}
|
||||
let hasAssistant = vm.messages.contains { message in
|
||||
message.role == "assistant" &&
|
||||
message.content.compactMap(\.text).joined(separator: "\n") == "first answer"
|
||||
}
|
||||
return hasAssistant && retryMessages.count == 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
|
||||
let history1 = historyPayload()
|
||||
let history2 = historyPayload(
|
||||
|
||||
@ -15,6 +15,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
private let lock = NSLock()
|
||||
private var _state: URLSessionTask.State = .suspended
|
||||
private var connectRequestId: String?
|
||||
private var connectAuth: [String: Any]?
|
||||
private var receivePhase = 0
|
||||
private var pendingReceiveHandler:
|
||||
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
@ -50,10 +51,18 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
obj["method"] as? String == "connect",
|
||||
let id = obj["id"] as? String
|
||||
{
|
||||
self.lock.withLock { self.connectRequestId = id }
|
||||
let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:]
|
||||
self.lock.withLock {
|
||||
self.connectRequestId = id
|
||||
self.connectAuth = auth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func latestConnectAuth() -> [String: Any]? {
|
||||
self.lock.withLock { self.connectAuth }
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
||||
pongReceiveHandler(nil)
|
||||
}
|
||||
@ -169,6 +178,62 @@ private actor SeqGapProbe {
|
||||
}
|
||||
|
||||
struct GatewayNodeSessionTests {
|
||||
@Test
|
||||
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
token: "stored-device-token")
|
||||
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "ui",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "ws://example.invalid")!,
|
||||
token: nil,
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
|
||||
let auth = try #require(session.latestTask()?.latestConnectAuth())
|
||||
#expect(auth["bootstrapToken"] as? String == "fresh-bootstrap-token")
|
||||
#expect(auth["token"] == nil)
|
||||
#expect(auth["deviceToken"] == nil)
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
|
||||
let normalized = canonicalizeCanvasHostUrl(
|
||||
|
||||
@ -1 +0,0 @@
|
||||
- tests: align OpenAI Codex auth login expectations with the `gpt-5.4` default model to prevent stale CI failures. (#44367) thanks @jrrcdev
|
||||
@ -1 +0,0 @@
|
||||
- runner: infer canonical tool names from malformed `toolCallId` variants (e.g. `functionsread3`, `functionswrite4`) when allowlist is present, preventing `Tool not found` regressions in strict routers.
|
||||
@ -16,7 +16,7 @@ services:
|
||||
## Uncomment the lines below to enable sandbox isolation
|
||||
## (agents.defaults.sandbox). Requires Docker CLI in the image
|
||||
## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use
|
||||
## docker-setup.sh with OPENCLAW_SANDBOX=1 for automated setup.
|
||||
## scripts/docker/setup.sh with OPENCLAW_SANDBOX=1 for automated setup.
|
||||
## Set DOCKER_GID to the host's docker group GID (run: stat -c '%g' /var/run/docker.sock).
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
# group_add:
|
||||
|
||||
612
docker-setup.sh
612
docker-setup.sh
@ -2,615 +2,11 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
|
||||
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
|
||||
IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}"
|
||||
EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}"
|
||||
HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}"
|
||||
RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}"
|
||||
SANDBOX_ENABLED=""
|
||||
DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}"
|
||||
TIMEZONE="${OPENCLAW_TZ:-}"
|
||||
SCRIPT_PATH="$ROOT_DIR/scripts/docker/setup.sh"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Missing dependency: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
is_truthy_value() {
|
||||
local raw="${1:-}"
|
||||
raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$raw" in
|
||||
1 | true | yes | on) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
read_config_gateway_token() {
|
||||
local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json"
|
||||
if [[ ! -f "$config_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$config_path" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
except Exception:
|
||||
raise SystemExit(0)
|
||||
|
||||
gateway = cfg.get("gateway")
|
||||
if not isinstance(gateway, dict):
|
||||
raise SystemExit(0)
|
||||
auth = gateway.get("auth")
|
||||
if not isinstance(auth, dict):
|
||||
raise SystemExit(0)
|
||||
token = auth.get("token")
|
||||
if isinstance(token, str):
|
||||
token = token.strip()
|
||||
if token:
|
||||
print(token)
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node - "$config_path" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const configPath = process.argv[2];
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const token = cfg?.gateway?.auth?.token;
|
||||
if (typeof token === "string" && token.trim().length > 0) {
|
||||
process.stdout.write(token.trim());
|
||||
}
|
||||
} catch {
|
||||
// Keep docker-setup resilient when config parsing fails.
|
||||
}
|
||||
NODE
|
||||
fi
|
||||
}
|
||||
|
||||
read_env_gateway_token() {
|
||||
local env_path="$1"
|
||||
local line=""
|
||||
local token=""
|
||||
if [[ ! -f "$env_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
if [[ "$line" == OPENCLAW_GATEWAY_TOKEN=* ]]; then
|
||||
token="${line#OPENCLAW_GATEWAY_TOKEN=}"
|
||||
fi
|
||||
done <"$env_path"
|
||||
if [[ -n "$token" ]]; then
|
||||
printf '%s' "$token"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_control_ui_allowed_origins() {
|
||||
if [[ "${OPENCLAW_GATEWAY_BIND}" == "loopback" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local allowed_origin_json
|
||||
local current_allowed_origins
|
||||
allowed_origin_json="$(printf '["http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT")"
|
||||
current_allowed_origins="$(
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config get gateway.controlUi.allowedOrigins 2>/dev/null || true
|
||||
)"
|
||||
current_allowed_origins="${current_allowed_origins//$'\r'/}"
|
||||
|
||||
if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then
|
||||
echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged."
|
||||
return 0
|
||||
fi
|
||||
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json >/dev/null
|
||||
echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind."
|
||||
}
|
||||
|
||||
sync_gateway_mode_and_bind() {
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config set gateway.mode local >/dev/null
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config set gateway.bind "$OPENCLAW_GATEWAY_BIND" >/dev/null
|
||||
echo "Pinned gateway.mode=local and gateway.bind=$OPENCLAW_GATEWAY_BIND for Docker setup."
|
||||
}
|
||||
|
||||
contains_disallowed_chars() {
|
||||
local value="$1"
|
||||
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
|
||||
}
|
||||
|
||||
is_valid_timezone() {
|
||||
local value="$1"
|
||||
[[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]]
|
||||
}
|
||||
|
||||
validate_mount_path_value() {
|
||||
local label="$1"
|
||||
local value="$2"
|
||||
if [[ -z "$value" ]]; then
|
||||
fail "$label cannot be empty."
|
||||
fi
|
||||
if contains_disallowed_chars "$value"; then
|
||||
fail "$label contains unsupported control characters."
|
||||
fi
|
||||
if [[ "$value" =~ [[:space:]] ]]; then
|
||||
fail "$label cannot contain whitespace."
|
||||
fi
|
||||
}
|
||||
|
||||
validate_named_volume() {
|
||||
local value="$1"
|
||||
if [[ ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
|
||||
fail "OPENCLAW_HOME_VOLUME must match [A-Za-z0-9][A-Za-z0-9_.-]* when using a named volume."
|
||||
fi
|
||||
}
|
||||
|
||||
validate_mount_spec() {
|
||||
local mount="$1"
|
||||
if contains_disallowed_chars "$mount"; then
|
||||
fail "OPENCLAW_EXTRA_MOUNTS entries cannot contain control characters."
|
||||
fi
|
||||
# Keep mount specs strict to avoid YAML structure injection.
|
||||
# Expected format: source:target[:options]
|
||||
if [[ ! "$mount" =~ ^[^[:space:],:]+:[^[:space:],:]+(:[^[:space:],:]+)?$ ]]; then
|
||||
fail "Invalid mount format '$mount'. Expected source:target[:options] without spaces."
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd docker
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
echo "Docker Compose not available (try: docker compose version)" >&2
|
||||
if [[ ! -f "$SCRIPT_PATH" ]]; then
|
||||
echo "Docker setup script not found at $SCRIPT_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$DOCKER_SOCKET_PATH" && "${DOCKER_HOST:-}" == unix://* ]]; then
|
||||
DOCKER_SOCKET_PATH="${DOCKER_HOST#unix://}"
|
||||
fi
|
||||
if [[ -z "$DOCKER_SOCKET_PATH" ]]; then
|
||||
DOCKER_SOCKET_PATH="/var/run/docker.sock"
|
||||
fi
|
||||
if is_truthy_value "$RAW_SANDBOX_SETTING"; then
|
||||
SANDBOX_ENABLED="1"
|
||||
fi
|
||||
|
||||
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}"
|
||||
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
|
||||
|
||||
validate_mount_path_value "OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR"
|
||||
validate_mount_path_value "OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_WORKSPACE_DIR"
|
||||
if [[ -n "$HOME_VOLUME_NAME" ]]; then
|
||||
if [[ "$HOME_VOLUME_NAME" == *"/"* ]]; then
|
||||
validate_mount_path_value "OPENCLAW_HOME_VOLUME" "$HOME_VOLUME_NAME"
|
||||
else
|
||||
validate_named_volume "$HOME_VOLUME_NAME"
|
||||
fi
|
||||
fi
|
||||
if contains_disallowed_chars "$EXTRA_MOUNTS"; then
|
||||
fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters."
|
||||
fi
|
||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
validate_mount_path_value "OPENCLAW_DOCKER_SOCKET" "$DOCKER_SOCKET_PATH"
|
||||
fi
|
||||
if [[ -n "$TIMEZONE" ]]; then
|
||||
if contains_disallowed_chars "$TIMEZONE"; then
|
||||
fail "OPENCLAW_TZ contains unsupported control characters."
|
||||
fi
|
||||
if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then
|
||||
fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)."
|
||||
fi
|
||||
if ! is_valid_timezone "$TIMEZONE"; then
|
||||
fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)."
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$OPENCLAW_CONFIG_DIR"
|
||||
mkdir -p "$OPENCLAW_WORKSPACE_DIR"
|
||||
# Seed directory tree eagerly so bind mounts work even on Docker Desktop/Windows
|
||||
# where the container (even as root) cannot create new host subdirectories.
|
||||
mkdir -p "$OPENCLAW_CONFIG_DIR/identity"
|
||||
mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/agent"
|
||||
mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/sessions"
|
||||
|
||||
export OPENCLAW_CONFIG_DIR
|
||||
export OPENCLAW_WORKSPACE_DIR
|
||||
export OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}"
|
||||
export OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}"
|
||||
export OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}"
|
||||
export OPENCLAW_IMAGE="$IMAGE_NAME"
|
||||
export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}"
|
||||
export OPENCLAW_EXTENSIONS="${OPENCLAW_EXTENSIONS:-}"
|
||||
export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
|
||||
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
|
||||
export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}"
|
||||
export OPENCLAW_SANDBOX="$SANDBOX_ENABLED"
|
||||
export OPENCLAW_DOCKER_SOCKET="$DOCKER_SOCKET_PATH"
|
||||
export OPENCLAW_TZ="$TIMEZONE"
|
||||
|
||||
# Detect Docker socket GID for sandbox group_add.
|
||||
DOCKER_GID=""
|
||||
if [[ -n "$SANDBOX_ENABLED" && -S "$DOCKER_SOCKET_PATH" ]]; then
|
||||
DOCKER_GID="$(stat -c '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || stat -f '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || echo "")"
|
||||
fi
|
||||
export DOCKER_GID
|
||||
|
||||
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
|
||||
EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)"
|
||||
if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then
|
||||
OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN"
|
||||
echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json"
|
||||
else
|
||||
DOTENV_GATEWAY_TOKEN="$(read_env_gateway_token "$ROOT_DIR/.env" || true)"
|
||||
if [[ -n "$DOTENV_GATEWAY_TOKEN" ]]; then
|
||||
OPENCLAW_GATEWAY_TOKEN="$DOTENV_GATEWAY_TOKEN"
|
||||
echo "Reusing gateway token from $ROOT_DIR/.env"
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)"
|
||||
else
|
||||
OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY'
|
||||
import secrets
|
||||
print(secrets.token_hex(32))
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
export OPENCLAW_GATEWAY_TOKEN
|
||||
|
||||
COMPOSE_FILES=("$COMPOSE_FILE")
|
||||
COMPOSE_ARGS=()
|
||||
|
||||
write_extra_compose() {
|
||||
local home_volume="$1"
|
||||
shift
|
||||
local mount
|
||||
local gateway_home_mount
|
||||
local gateway_config_mount
|
||||
local gateway_workspace_mount
|
||||
|
||||
cat >"$EXTRA_COMPOSE_FILE" <<'YAML'
|
||||
services:
|
||||
openclaw-gateway:
|
||||
volumes:
|
||||
YAML
|
||||
|
||||
if [[ -n "$home_volume" ]]; then
|
||||
gateway_home_mount="${home_volume}:/home/node"
|
||||
gateway_config_mount="${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw"
|
||||
gateway_workspace_mount="${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace"
|
||||
validate_mount_spec "$gateway_home_mount"
|
||||
validate_mount_spec "$gateway_config_mount"
|
||||
validate_mount_spec "$gateway_workspace_mount"
|
||||
printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE"
|
||||
printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE"
|
||||
printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE"
|
||||
fi
|
||||
|
||||
for mount in "$@"; do
|
||||
validate_mount_spec "$mount"
|
||||
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
|
||||
done
|
||||
|
||||
cat >>"$EXTRA_COMPOSE_FILE" <<'YAML'
|
||||
openclaw-cli:
|
||||
volumes:
|
||||
YAML
|
||||
|
||||
if [[ -n "$home_volume" ]]; then
|
||||
printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE"
|
||||
printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE"
|
||||
printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE"
|
||||
fi
|
||||
|
||||
for mount in "$@"; do
|
||||
validate_mount_spec "$mount"
|
||||
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
|
||||
done
|
||||
|
||||
if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then
|
||||
validate_named_volume "$home_volume"
|
||||
cat >>"$EXTRA_COMPOSE_FILE" <<YAML
|
||||
volumes:
|
||||
${home_volume}:
|
||||
YAML
|
||||
fi
|
||||
}
|
||||
|
||||
# When sandbox is requested, ensure Docker CLI build arg is set for local builds.
|
||||
# Docker socket mount is deferred until sandbox prerequisites are verified.
|
||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
if [[ -z "${OPENCLAW_INSTALL_DOCKER_CLI:-}" ]]; then
|
||||
export OPENCLAW_INSTALL_DOCKER_CLI=1
|
||||
fi
|
||||
fi
|
||||
|
||||
VALID_MOUNTS=()
|
||||
if [[ -n "$EXTRA_MOUNTS" ]]; then
|
||||
IFS=',' read -r -a mounts <<<"$EXTRA_MOUNTS"
|
||||
for mount in "${mounts[@]}"; do
|
||||
mount="${mount#"${mount%%[![:space:]]*}"}"
|
||||
mount="${mount%"${mount##*[![:space:]]}"}"
|
||||
if [[ -n "$mount" ]]; then
|
||||
VALID_MOUNTS+=("$mount")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then
|
||||
# Bash 3.2 + nounset treats "${array[@]}" on an empty array as unbound.
|
||||
if [[ ${#VALID_MOUNTS[@]} -gt 0 ]]; then
|
||||
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
|
||||
else
|
||||
write_extra_compose "$HOME_VOLUME_NAME"
|
||||
fi
|
||||
COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE")
|
||||
fi
|
||||
for compose_file in "${COMPOSE_FILES[@]}"; do
|
||||
COMPOSE_ARGS+=("-f" "$compose_file")
|
||||
done
|
||||
# Keep a base compose arg set without sandbox overlay so rollback paths can
|
||||
# force a known-safe gateway service definition (no docker.sock mount).
|
||||
BASE_COMPOSE_ARGS=("${COMPOSE_ARGS[@]}")
|
||||
COMPOSE_HINT="docker compose"
|
||||
for compose_file in "${COMPOSE_FILES[@]}"; do
|
||||
COMPOSE_HINT+=" -f ${compose_file}"
|
||||
done
|
||||
|
||||
ENV_FILE="$ROOT_DIR/.env"
|
||||
upsert_env() {
|
||||
local file="$1"
|
||||
shift
|
||||
local -a keys=("$@")
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
# Use a delimited string instead of an associative array so the script
|
||||
# works with Bash 3.2 (macOS default) which lacks `declare -A`.
|
||||
local seen=" "
|
||||
|
||||
if [[ -f "$file" ]]; then
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
local key="${line%%=*}"
|
||||
local replaced=false
|
||||
for k in "${keys[@]}"; do
|
||||
if [[ "$key" == "$k" ]]; then
|
||||
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
|
||||
seen="$seen$k "
|
||||
replaced=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$replaced" == false ]]; then
|
||||
printf '%s\n' "$line" >>"$tmp"
|
||||
fi
|
||||
done <"$file"
|
||||
fi
|
||||
|
||||
for k in "${keys[@]}"; do
|
||||
if [[ "$seen" != *" $k "* ]]; then
|
||||
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
|
||||
fi
|
||||
done
|
||||
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
upsert_env "$ENV_FILE" \
|
||||
OPENCLAW_CONFIG_DIR \
|
||||
OPENCLAW_WORKSPACE_DIR \
|
||||
OPENCLAW_GATEWAY_PORT \
|
||||
OPENCLAW_BRIDGE_PORT \
|
||||
OPENCLAW_GATEWAY_BIND \
|
||||
OPENCLAW_GATEWAY_TOKEN \
|
||||
OPENCLAW_IMAGE \
|
||||
OPENCLAW_EXTRA_MOUNTS \
|
||||
OPENCLAW_HOME_VOLUME \
|
||||
OPENCLAW_DOCKER_APT_PACKAGES \
|
||||
OPENCLAW_EXTENSIONS \
|
||||
OPENCLAW_SANDBOX \
|
||||
OPENCLAW_DOCKER_SOCKET \
|
||||
DOCKER_GID \
|
||||
OPENCLAW_INSTALL_DOCKER_CLI \
|
||||
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \
|
||||
OPENCLAW_TZ
|
||||
|
||||
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
|
||||
echo "==> Building Docker image: $IMAGE_NAME"
|
||||
docker build \
|
||||
--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \
|
||||
--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \
|
||||
--build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \
|
||||
-t "$IMAGE_NAME" \
|
||||
-f "$ROOT_DIR/Dockerfile" \
|
||||
"$ROOT_DIR"
|
||||
else
|
||||
echo "==> Pulling Docker image: $IMAGE_NAME"
|
||||
if ! docker pull "$IMAGE_NAME"; then
|
||||
echo "ERROR: Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure bind-mounted data directories are writable by the container's `node`
|
||||
# user (uid 1000). Host-created dirs inherit the host user's uid which may
|
||||
# differ, causing EACCES when the container tries to mkdir/write.
|
||||
# Running a brief root container to chown is the portable Docker idiom --
|
||||
# it works regardless of the host uid and doesn't require host-side root.
|
||||
echo ""
|
||||
echo "==> Fixing data-directory permissions"
|
||||
# Use -xdev to restrict chown to the config-dir mount only — without it,
|
||||
# the recursive chown would cross into the workspace bind mount and rewrite
|
||||
# ownership of all user project files on Linux hosts.
|
||||
# After fixing the config dir, only the OpenClaw metadata subdirectory
|
||||
# (.openclaw/) inside the workspace gets chowned, not the user's project files.
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm --user root --entrypoint sh openclaw-cli -c \
|
||||
'find /home/node/.openclaw -xdev -exec chown node:node {} +; \
|
||||
[ -d /home/node/.openclaw/workspace/.openclaw ] && chown -R node:node /home/node/.openclaw/workspace/.openclaw || true'
|
||||
|
||||
echo ""
|
||||
echo "==> Onboarding (interactive)"
|
||||
echo "Docker setup pins Gateway mode to local."
|
||||
echo "Gateway runtime bind comes from OPENCLAW_GATEWAY_BIND (default: lan)."
|
||||
echo "Current runtime bind: $OPENCLAW_GATEWAY_BIND"
|
||||
echo "Gateway token: $OPENCLAW_GATEWAY_TOKEN"
|
||||
echo "Tailscale exposure: Off (use host-level tailnet/Tailscale setup separately)."
|
||||
echo "Install Gateway daemon: No (managed by Docker Compose)"
|
||||
echo ""
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --mode local --no-install-daemon
|
||||
|
||||
echo ""
|
||||
echo "==> Docker gateway defaults"
|
||||
sync_gateway_mode_and_bind
|
||||
|
||||
echo ""
|
||||
echo "==> Control UI origin allowlist"
|
||||
ensure_control_ui_allowed_origins
|
||||
|
||||
echo ""
|
||||
echo "==> Provider setup (optional)"
|
||||
echo "WhatsApp (QR):"
|
||||
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels login"
|
||||
echo "Telegram (bot token):"
|
||||
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel telegram --token <token>"
|
||||
echo "Discord (bot token):"
|
||||
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token <token>"
|
||||
echo "Docs: https://docs.openclaw.ai/channels"
|
||||
|
||||
echo ""
|
||||
echo "==> Starting gateway"
|
||||
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
|
||||
|
||||
# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) ---
|
||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
echo ""
|
||||
echo "==> Sandbox setup"
|
||||
|
||||
# Build sandbox image if Dockerfile.sandbox exists.
|
||||
if [[ -f "$ROOT_DIR/Dockerfile.sandbox" ]]; then
|
||||
echo "Building sandbox image: openclaw-sandbox:bookworm-slim"
|
||||
docker build \
|
||||
-t "openclaw-sandbox:bookworm-slim" \
|
||||
-f "$ROOT_DIR/Dockerfile.sandbox" \
|
||||
"$ROOT_DIR"
|
||||
else
|
||||
echo "WARNING: Dockerfile.sandbox not found in $ROOT_DIR" >&2
|
||||
echo " Sandbox config will be applied but no sandbox image will be built." >&2
|
||||
echo " Agent exec may fail if the configured sandbox image does not exist." >&2
|
||||
fi
|
||||
|
||||
# Defense-in-depth: verify Docker CLI in the running image before enabling
|
||||
# sandbox. This avoids claiming sandbox is enabled when the image cannot
|
||||
# launch sandbox containers.
|
||||
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
|
||||
echo "WARNING: Docker CLI not found inside the container image." >&2
|
||||
echo " Sandbox requires Docker CLI. Rebuild with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1" >&2
|
||||
echo " or use a local build (OPENCLAW_IMAGE=openclaw:local). Skipping sandbox setup." >&2
|
||||
SANDBOX_ENABLED=""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Apply sandbox config only if prerequisites are met.
|
||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
# Mount Docker socket via a dedicated compose overlay. This overlay is
|
||||
# created only after sandbox prerequisites pass, so the socket is never
|
||||
# exposed when sandbox cannot actually run.
|
||||
if [[ -S "$DOCKER_SOCKET_PATH" ]]; then
|
||||
SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
|
||||
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
|
||||
services:
|
||||
openclaw-gateway:
|
||||
volumes:
|
||||
- ${DOCKER_SOCKET_PATH}:/var/run/docker.sock
|
||||
YAML
|
||||
if [[ -n "${DOCKER_GID:-}" ]]; then
|
||||
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
|
||||
group_add:
|
||||
- "${DOCKER_GID}"
|
||||
YAML
|
||||
fi
|
||||
COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE")
|
||||
echo "==> Sandbox: added Docker socket mount"
|
||||
else
|
||||
echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
|
||||
echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2
|
||||
SANDBOX_ENABLED=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
# Enable sandbox in OpenClaw config.
|
||||
sandbox_config_ok=true
|
||||
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
|
||||
config set agents.defaults.sandbox.mode "non-main" >/dev/null; then
|
||||
echo "WARNING: Failed to set agents.defaults.sandbox.mode" >&2
|
||||
sandbox_config_ok=false
|
||||
fi
|
||||
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
|
||||
config set agents.defaults.sandbox.scope "agent" >/dev/null; then
|
||||
echo "WARNING: Failed to set agents.defaults.sandbox.scope" >&2
|
||||
sandbox_config_ok=false
|
||||
fi
|
||||
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
|
||||
config set agents.defaults.sandbox.workspaceAccess "none" >/dev/null; then
|
||||
echo "WARNING: Failed to set agents.defaults.sandbox.workspaceAccess" >&2
|
||||
sandbox_config_ok=false
|
||||
fi
|
||||
|
||||
if [[ "$sandbox_config_ok" == true ]]; then
|
||||
echo "Sandbox enabled: mode=non-main, scope=agent, workspaceAccess=none"
|
||||
echo "Docs: https://docs.openclaw.ai/gateway/sandboxing"
|
||||
# Restart gateway with sandbox compose overlay to pick up socket mount + config.
|
||||
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
|
||||
else
|
||||
echo "WARNING: Sandbox config was partially applied. Check errors above." >&2
|
||||
echo " Skipping gateway restart to avoid exposing Docker socket without a full sandbox policy." >&2
|
||||
if ! docker compose "${BASE_COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
|
||||
config set agents.defaults.sandbox.mode "off" >/dev/null; then
|
||||
echo "WARNING: Failed to roll back agents.defaults.sandbox.mode to off" >&2
|
||||
else
|
||||
echo "Sandbox mode rolled back to off due to partial sandbox config failure."
|
||||
fi
|
||||
if [[ -n "${SANDBOX_COMPOSE_FILE:-}" ]]; then
|
||||
rm -f "$SANDBOX_COMPOSE_FILE"
|
||||
fi
|
||||
# Ensure gateway service definition is reset without sandbox overlay mount.
|
||||
docker compose "${BASE_COMPOSE_ARGS[@]}" up -d --force-recreate openclaw-gateway
|
||||
fi
|
||||
else
|
||||
# Keep reruns deterministic: if sandbox is not active for this run, reset
|
||||
# persisted sandbox mode so future execs do not require docker.sock by stale
|
||||
# config alone.
|
||||
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config set agents.defaults.sandbox.mode "off" >/dev/null; then
|
||||
echo "WARNING: Failed to reset agents.defaults.sandbox.mode to off" >&2
|
||||
fi
|
||||
if [[ -f "$ROOT_DIR/docker-compose.sandbox.yml" ]]; then
|
||||
rm -f "$ROOT_DIR/docker-compose.sandbox.yml"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Gateway running with host port mapping."
|
||||
echo "Access from tailnet devices via the host's tailnet IP."
|
||||
echo "Config: $OPENCLAW_CONFIG_DIR"
|
||||
echo "Workspace: $OPENCLAW_WORKSPACE_DIR"
|
||||
echo "Token: $OPENCLAW_GATEWAY_TOKEN"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " ${COMPOSE_HINT} logs -f openclaw-gateway"
|
||||
echo " ${COMPOSE_HINT} exec openclaw-gateway node dist/index.js health --token \"$OPENCLAW_GATEWAY_TOKEN\""
|
||||
exec "$SCRIPT_PATH" "$@"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5457}
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5549}
|
||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -730,7 +730,7 @@
|
||||
{"recordType":"path","path":"canvasHost.port","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Canvas Host Port","help":"TCP port used by the canvas host HTTP server when canvas hosting is enabled. Choose a non-conflicting port and align firewall/proxy policy accordingly.","hasChildren":false}
|
||||
{"recordType":"path","path":"canvasHost.root","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Canvas Host Root Directory","help":"Filesystem root directory served by canvas host for canvas content and static assets. Use a dedicated directory and avoid broad repo roots for least-privilege file exposure.","hasChildren":false}
|
||||
{"recordType":"path","path":"channels","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Channels","help":"Channel provider configurations plus shared defaults that control access policies, heartbeat visibility, and per-surface behavior. Keep defaults centralized and override per provider only where required.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.bluebubbles","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"BlueBubbles","help":"iMessage via the BlueBubbles mac app + REST API.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.bluebubbles","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/bluebubbles","help":"BlueBubbles channel provider configuration used for Apple messaging bridge integrations. Keep DM policy aligned with your trusted sender model in shared deployments.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.bluebubbles.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.bluebubbles.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.bluebubbles.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -818,7 +818,7 @@
|
||||
{"recordType":"path","path":"channels.bluebubbles.serverUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.bluebubbles.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.bluebubbles.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Discord","help":"very well supported right now.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/discord","help":"Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -1352,7 +1352,7 @@
|
||||
{"recordType":"path","path":"channels.discord.voice.tts.provider","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.voice.tts.summaryModel","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.voice.tts.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Feishu","help":"飞书/Lark enterprise messaging.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/feishu","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -1532,7 +1532,7 @@
|
||||
{"recordType":"path","path":"channels.feishu.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/feishu/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Google Chat","help":"Google Workspace Chat app with HTTP webhook.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/googlechat","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -1660,7 +1660,7 @@
|
||||
{"recordType":"path","path":"channels.googlechat.typingIndicator","kind":"channel","type":"string","required":false,"enumValues":["none","message","reaction"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"iMessage","help":"this is still a work in progress.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/imessage","help":"iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -1788,7 +1788,7 @@
|
||||
{"recordType":"path","path":"channels.imessage.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.service","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.irc","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"IRC","help":"classic IRC networks with DM/channel routing and pairing controls.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.irc","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/irc","help":"IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.irc.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.irc.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.irc.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -1928,7 +1928,7 @@
|
||||
{"recordType":"path","path":"channels.irc.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.irc.tls","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.irc.username","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.line","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"LINE","help":"LINE Messaging API bot for Japan/Taiwan/Thailand markets.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.line","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/line","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.line.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.line.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.line.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -1980,22 +1980,30 @@
|
||||
{"recordType":"path","path":"channels.line.secretFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.line.tokenFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.line.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Matrix","help":"open protocol; configure a homeserver + access token.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/matrix","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.accessToken","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.accounts.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.ackReactionScope","kind":"channel","type":"string","required":false,"enumValues":["group-mentions","group-all","direct","all","none","off"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.actions.channelInfo","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.actions.memberInfo","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.actions.messages","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.actions.pins","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.actions.profile","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.actions.verification","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.allowBots","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":["access","channels","network"],"label":"Matrix Allow Bot Messages","help":"Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set \"mentions\" to only accept bot messages that visibly mention this bot.","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.allowlistOnly","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.allowPrivateNetwork","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.autoJoin","kind":"channel","type":"string","required":false,"enumValues":["always","allowlist","off"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.autoJoinAllowlist","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.autoJoinAllowlist.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.avatarUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.deviceId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.deviceName","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.dm","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.dm.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -2010,6 +2018,7 @@
|
||||
{"recordType":"path","path":"channels.matrix.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.groups.*.allowBots","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.groups.*.autoReply","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -2035,11 +2044,13 @@
|
||||
{"recordType":"path","path":"channels.matrix.password.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.password.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.password.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.reactionNotifications","kind":"channel","type":"string","required":false,"enumValues":["off","own"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.replyToMode","kind":"channel","type":"string","required":false,"enumValues":["off","first","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.rooms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.allowBots","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.autoReply","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -2055,10 +2066,18 @@
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.startupVerification","kind":"channel","type":"string","required":false,"enumValues":["off","if-unverified"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.startupVerificationCooldownHours","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.textChunkLimit","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.threadBindings","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.threadBindings.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.threadBindings.idleHours","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.threadBindings.maxAgeHours","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.threadBindings.spawnAcpSessions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.threadBindings.spawnSubagentSessions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.threadReplies","kind":"channel","type":"string","required":false,"enumValues":["off","inbound","always"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.userId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost","help":"self-hosted Slack-style chat; install the plugin to enable.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.mattermost","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/mattermost","help":"Mattermost channel provider configuration for bot credentials, base URL, and message trigger modes. Keep mention/trigger rules strict in high-volume team channels.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -2086,6 +2105,11 @@
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.commands.nativeSkills","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -2130,6 +2154,11 @@
|
||||
{"recordType":"path","path":"channels.mattermost.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Config Writes","help":"Allow Mattermost to write config in response to channel events/commands (default: true).","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -2148,7 +2177,7 @@
|
||||
{"recordType":"path","path":"channels.mattermost.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Require Mention","help":"Require @mention in channels before responding (default: true).","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Microsoft Teams","help":"Bot Framework; enterprise support.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/msteams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -2236,7 +2265,7 @@
|
||||
{"recordType":"path","path":"channels.msteams.webhook","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.webhook.path","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.webhook.port","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.nextcloud-talk","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Nextcloud Talk","help":"Self-hosted chat via Nextcloud Talk webhook bots.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.nextcloud-talk","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/nextcloud-talk","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.nextcloud-talk.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.nextcloud-talk.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.nextcloud-talk.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -2352,7 +2381,7 @@
|
||||
{"recordType":"path","path":"channels.nextcloud-talk.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.nextcloud-talk.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.nextcloud-talk.webhookPublicUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.nostr","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Nostr","help":"Decentralized DMs via Nostr relays (NIP-04)","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.nostr","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/nostr","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.nostr.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.nostr.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.nostr.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -2373,7 +2402,7 @@
|
||||
{"recordType":"path","path":"channels.nostr.profile.website","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.nostr.relays","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.nostr.relays.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Signal","help":"signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.account","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -2517,7 +2546,7 @@
|
||||
{"recordType":"path","path":"channels.signal.sendReadReceipts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.startupTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack","help":"supported (Socket Mode).","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -2769,9 +2798,9 @@
|
||||
{"recordType":"path","path":"channels.slack.userToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.userTokenReadOnly","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":["auth","channels","network","security"],"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes.","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/slack/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.synology-chat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Synology Chat","help":"Connect your Synology NAS Chat to OpenClaw","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.synology-chat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/synology-chat","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.synology-chat.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram","help":"simplest way to get started — register a bot with @BotFather and get going.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -3129,7 +3158,7 @@
|
||||
{"recordType":"path","path":"channels.telegram.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.tlon","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Tlon","help":"Decentralized messaging on Urbit","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.tlon","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/tlon","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.tlon.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.tlon.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.tlon.accounts.*.allowPrivateNetwork","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -3172,7 +3201,7 @@
|
||||
{"recordType":"path","path":"channels.tlon.ship","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.tlon.showModelSignature","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.tlon.url","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.twitch","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Twitch","help":"Twitch chat integration","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.twitch","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/twitch","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.twitch.accessToken","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.twitch.accounts","kind":"channel","type":"object","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.twitch.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -3208,7 +3237,7 @@
|
||||
{"recordType":"path","path":"channels.twitch.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.twitch.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.twitch.username","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"WhatsApp","help":"works with your own number; recommend a separate phone + eSIM.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/whatsapp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.ackReaction","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -3336,7 +3365,7 @@
|
||||
{"recordType":"path","path":"channels.whatsapp.selfChatMode","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number).","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.sendReadReceipts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalo","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Zalo","help":"Vietnam-focused messaging platform with Bot API.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalo","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/zalo","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalo.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalo.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalo.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -3388,7 +3417,7 @@
|
||||
{"recordType":"path","path":"channels.zalo.webhookSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalo.webhookSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalo.webhookUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Zalo Personal","help":"Zalo personal account via QR code login.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"@openclaw/zalouser","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -3398,7 +3427,7 @@
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -3426,7 +3455,7 @@
|
||||
{"recordType":"path","path":"channels.zalouser.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -3563,7 +3592,7 @@
|
||||
{"recordType":"path","path":"gateway.channelMaxRestartsPerHour","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"Gateway Channel Max Restarts Per Hour","help":"Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.channelStaleEventThresholdMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Channel Stale Event Threshold (min)","help":"How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.controlUi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Control UI","help":"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting [\"*\"] means allow any browser origin and should be avoided outside tightly controlled local testing.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowedOrigins.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowInsecureAuth","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access","advanced","network","security"],"label":"Insecure Control UI Auth Toggle","help":"Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.controlUi.basePath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network","storage"],"label":"Control UI Base Path","help":"Optional URL prefix where the Control UI is served (e.g. /openclaw).","hasChildren":false}
|
||||
@ -3667,7 +3696,7 @@
|
||||
{"recordType":"path","path":"gateway.trustedProxies","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Trusted Proxy CIDRs","help":"CIDR/IP allowlist of upstream proxies permitted to provide forwarded client identity headers. Keep this list narrow so untrusted hops cannot impersonate users.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.trustedProxies.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"hooks","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks","help":"Inbound webhook automation surface for mapping external events into wake or agent actions in OpenClaw. Keep this locked down with explicit token/session/agent controls before exposing it beyond trusted networks.","hasChildren":true}
|
||||
{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.","hasChildren":true}
|
||||
{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.","hasChildren":true}
|
||||
{"recordType":"path","path":"hooks.allowedAgentIds.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"hooks.allowedSessionKeyPrefixes","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","storage"],"label":"Hooks Allowed Session Key Prefixes","help":"Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.","hasChildren":true}
|
||||
{"recordType":"path","path":"hooks.allowedSessionKeyPrefixes.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -3754,7 +3783,7 @@
|
||||
{"recordType":"path","path":"hooks.path","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Endpoint Path","help":"HTTP path used by the hooks endpoint (for example `/hooks`) on the gateway control server. Use a non-guessable path and combine it with token validation for defense in depth.","hasChildren":false}
|
||||
{"recordType":"path","path":"hooks.presets","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks Presets","help":"Named hook preset bundles applied at load time to seed standard mappings and behavior defaults. Keep preset usage explicit so operators can audit which automations are active.","hasChildren":true}
|
||||
{"recordType":"path","path":"hooks.presets.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false}
|
||||
{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false}
|
||||
{"recordType":"path","path":"hooks.transformsDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Transforms Directory","help":"Base directory for hook transform modules referenced by mapping transform.module paths. Use a controlled repo directory so dynamic imports remain reviewable and predictable.","hasChildren":false}
|
||||
{"recordType":"path","path":"logging","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Logging","help":"Logging behavior controls for severity, output destinations, formatting, and sensitive-data redaction. Keep levels and redaction strict enough for production while preserving useful diagnostics.","hasChildren":true}
|
||||
{"recordType":"path","path":"logging.consoleLevel","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"Console Log Level","help":"Console-specific log threshold: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\" for terminal output control. Use this to keep local console quieter while retaining richer file logging if needed.","hasChildren":false}
|
||||
@ -3976,6 +4005,7 @@
|
||||
{"recordType":"path","path":"models.providers.*.models.*.api","kind":"core","type":"string","required":false,"enumValues":["openai-completions","openai-responses","openai-codex-responses","anthropic-messages","google-generative-ai","github-copilot","bedrock-converse-stream","ollama"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.compat","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.compat.maxTokensField","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.compat.nativeWebSearchTool","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.compat.requiresAssistantAfterToolResult","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.compat.requiresMistralToolIds","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.compat.requiresOpenAiAnthropicToolPayload","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -3988,6 +4018,8 @@
|
||||
{"recordType":"path","path":"models.providers.*.models.*.compat.supportsTools","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.compat.supportsUsageInStreaming","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.compat.thinkingFormat","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.compat.toolCallArgumentsEncoding","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.compat.toolSchemaProfile","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.contextWindow","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.cost","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"models.providers.*.models.*.cost.cacheRead","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -4076,7 +4108,10 @@
|
||||
{"recordType":"path","path":"plugins.entries.bluebubbles.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.bluebubbles.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.brave","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin","help":"OpenClaw Brave plugin (plugin: brave)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.brave.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin Config","help":"Plugin-defined config payload for brave.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.brave.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin Config","help":"Plugin-defined config payload for brave.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.brave.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.brave.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Brave Search API Key","help":"Brave Search API key (fallback: BRAVE_API_KEY env var).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.brave.config.webSearch.mode","kind":"plugin","type":"string","required":false,"enumValues":["web","llm-context"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Brave Search Mode","help":"Brave Search mode: web or llm-context.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.brave.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/brave-plugin","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.brave.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.brave.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
@ -4093,6 +4128,15 @@
|
||||
{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.chutes","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider","help":"OpenClaw Chutes.ai provider plugin (plugin: chutes)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider Config","help":"Plugin-defined config payload for chutes.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/chutes-provider","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider","help":"OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider Config","help":"Plugin-defined config payload for cloudflare-ai-gateway.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/cloudflare-ai-gateway-provider","hasChildren":false}
|
||||
@ -4179,6 +4223,15 @@
|
||||
{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.fal","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/fal-provider","help":"OpenClaw fal provider plugin (plugin: fal)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.fal.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/fal-provider Config","help":"Plugin-defined config payload for fal.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.fal.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/fal-provider","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.fal.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.fal.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.fal.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.fal.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.fal.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.fal.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.feishu","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/feishu","help":"OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng) (plugin: feishu)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.feishu.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/feishu Config","help":"Plugin-defined config payload for feishu.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.feishu.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/feishu","hasChildren":false}
|
||||
@ -4189,7 +4242,10 @@
|
||||
{"recordType":"path","path":"plugins.entries.feishu.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.feishu.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin","help":"OpenClaw Firecrawl plugin (plugin: firecrawl)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin Config","help":"Plugin-defined config payload for firecrawl.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin Config","help":"Plugin-defined config payload for firecrawl.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Firecrawl Search API Key","help":"Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webSearch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Firecrawl Search Base URL","help":"Firecrawl Search base URL override.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/firecrawl-plugin","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
@ -4207,7 +4263,10 @@
|
||||
{"recordType":"path","path":"plugins.entries.github-copilot.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.github-copilot.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.google","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin","help":"OpenClaw Google plugin (plugin: google)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.google.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin Config","help":"Plugin-defined config payload for google.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.google.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin Config","help":"Plugin-defined config payload for google.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.google.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.google.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Gemini Search API Key","help":"Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.google.config.webSearch.model","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"Gemini Search Model","help":"Gemini model override for web search grounding.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.google.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/google-plugin","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.google.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.google.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
@ -4385,7 +4444,11 @@
|
||||
{"recordType":"path","path":"plugins.entries.modelstudio.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.modelstudio.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.moonshot","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider","help":"OpenClaw Moonshot provider plugin (plugin: moonshot)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.moonshot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider Config","help":"Plugin-defined config payload for moonshot.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.moonshot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider Config","help":"Plugin-defined config payload for moonshot.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.moonshot.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.moonshot.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Kimi Search API Key","help":"Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.moonshot.config.webSearch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Kimi Search Base URL","help":"Kimi base URL override.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.moonshot.config.webSearch.model","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"Kimi Search Model","help":"Kimi model override.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.moonshot.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/moonshot-provider","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.moonshot.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.moonshot.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
@ -4505,7 +4568,11 @@
|
||||
{"recordType":"path","path":"plugins.entries.openshell.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.openshell.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.perplexity","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin","help":"OpenClaw Perplexity plugin (plugin: perplexity)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.perplexity.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin Config","help":"Plugin-defined config payload for perplexity.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.perplexity.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin Config","help":"Plugin-defined config payload for perplexity.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.perplexity.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.perplexity.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Perplexity API Key","help":"Perplexity or OpenRouter API key for web search.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.perplexity.config.webSearch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Perplexity Base URL","help":"Optional Perplexity/OpenRouter chat-completions base URL override.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.perplexity.config.webSearch.model","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"Perplexity Model","help":"Optional Sonar/OpenRouter model override.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.perplexity.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/perplexity-plugin","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.perplexity.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.perplexity.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
@ -4594,6 +4661,18 @@
|
||||
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.tavily","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tavily-plugin","help":"OpenClaw Tavily plugin (plugin: tavily)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.tavily.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tavily-plugin Config","help":"Plugin-defined config payload for tavily.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.tavily.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.tavily.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Tavily API Key","help":"Tavily API key for web search and extraction (fallback: TAVILY_API_KEY env var).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.tavily.config.webSearch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Tavily Base URL","help":"Tavily API base URL override.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.tavily.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/tavily-plugin","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.tavily.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.tavily.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.tavily.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.tavily.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.tavily.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.tavily.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.telegram","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram","help":"OpenClaw Telegram channel plugin (plugin: telegram)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.telegram.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram Config","help":"Plugin-defined config payload for telegram.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.telegram.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/telegram","hasChildren":false}
|
||||
@ -4813,7 +4892,11 @@
|
||||
{"recordType":"path","path":"plugins.entries.whatsapp.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.whatsapp.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin","help":"OpenClaw xAI plugin (plugin: xai)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin Config","help":"Plugin-defined config payload for xai.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin Config","help":"Plugin-defined config payload for xai.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Grok Search API Key","help":"xAI API key for Grok web search (fallback: XAI_API_KEY env var).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.webSearch.inlineCitations","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inline Citations","help":"Include inline markdown citations in Grok responses.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.webSearch.model","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"Grok Search Model","help":"Grok model override for web search.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/xai-plugin","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.xai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
@ -5384,55 +5467,64 @@
|
||||
{"recordType":"path","path":"tools.web.fetch.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Fetch Timeout (sec)","help":"Timeout in seconds for web_fetch requests.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.userAgent","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Fetch User-Agent","help":"Override User-Agent header for web_fetch requests.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Brave Search API Key","help":"Brave Search API key (fallback: BRAVE_API_KEY env var).","hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.brave","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.brave.mode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Brave Search Mode","help":"Brave Search mode: \"web\" (URL results) or \"llm-context\" (pre-extracted page content for LLM grounding).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.brave.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.brave.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.brave.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.brave.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.brave.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.brave.mode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.brave.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.cacheTtlMinutes","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance","storage","tools"],"label":"Web Search Cache TTL (min)","help":"Cache TTL in minutes for web_search results.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Enable Web Search Tool","help":"Enable the web_search tool (requires a provider API key).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.firecrawl","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.firecrawl.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Firecrawl Search API Key","help":"Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).","hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.firecrawl.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.firecrawl.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.firecrawl.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.firecrawl.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.firecrawl.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Firecrawl Search Base URL","help":"Firecrawl Search base URL override (default: \"https://api.firecrawl.dev\").","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.firecrawl.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.firecrawl.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.gemini","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.gemini.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Gemini Search API Key","help":"Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).","hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.gemini.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.gemini.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.gemini.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.gemini.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.gemini.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models","tools"],"label":"Gemini Search Model","help":"Gemini model override (default: \"gemini-2.5-flash\").","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.gemini.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.gemini.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.grok","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.grok.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Grok Search API Key","help":"Grok (xAI) API key (fallback: XAI_API_KEY env var).","hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.grok.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.grok.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.grok.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.grok.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.grok.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.grok.inlineCitations","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.grok.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models","tools"],"label":"Grok Search Model","help":"Grok model override (default: \"grok-4-1-fast\").","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.grok.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.kimi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.kimi.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Kimi Search API Key","help":"Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).","hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.kimi.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.kimi.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.kimi.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.kimi.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.kimi.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Kimi Search Base URL","help":"Kimi base URL override (default: \"https://api.moonshot.ai/v1\").","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.kimi.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models","tools"],"label":"Kimi Search Model","help":"Kimi model override (default: \"moonshot-v1-128k\").","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.kimi.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.kimi.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.maxResults","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Search Max Results","help":"Number of results to return (1-10).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.perplexity","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.perplexity.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Perplexity API Key","help":"Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). Direct Perplexity keys default to the Search API; OpenRouter keys use Sonar chat completions.","hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.perplexity.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.search.perplexity.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.perplexity.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.perplexity.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.perplexity.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Perplexity Base URL","help":"Optional Perplexity/OpenRouter chat-completions base URL override. Setting this opts Perplexity into the legacy Sonar/OpenRouter compatibility path.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.perplexity.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models","tools"],"label":"Perplexity Model","help":"Optional Sonar/OpenRouter model override (default: \"perplexity/sonar-pro\"). Setting this opts Perplexity into the legacy chat-completions compatibility path.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Search Provider","help":"Search provider (\"brave\", \"firecrawl\", \"gemini\", \"grok\", \"kimi\", or \"perplexity\"). Auto-detected from available API keys if omitted.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.perplexity.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.perplexity.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Search Provider","help":"Search provider id. Auto-detected from available API keys if omitted.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Search Timeout (sec)","help":"Timeout in seconds for web_search requests.","hasChildren":false}
|
||||
{"recordType":"path","path":"ui","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"UI","help":"UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"ui.assistant","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Assistant Appearance","help":"Assistant display identity settings for name and avatar shown in UI surfaces. Keep these values aligned with your operator-facing persona and support expectations.","hasChildren":true}
|
||||
{"recordType":"path","path":"ui.assistant.avatar","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Assistant Avatar","help":"Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.","hasChildren":false}
|
||||
{"recordType":"path","path":"ui.assistant.name","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Assistant Name","help":"Display name shown for the assistant in UI views, chat chrome, and status contexts. Keep this stable so operators can reliably identify which assistant persona is active.","hasChildren":false}
|
||||
{"recordType":"path","path":"ui.seamColor","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Accent Color","help":"Primary accent/seam color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.","hasChildren":false}
|
||||
{"recordType":"path","path":"ui.seamColor","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Accent Color","help":"Primary accent color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.","hasChildren":false}
|
||||
{"recordType":"path","path":"update","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Updates","help":"Update-channel and startup-check behavior for keeping OpenClaw runtime versions current. Use conservative channels in production and more experimental channels only in controlled environments.","hasChildren":true}
|
||||
{"recordType":"path","path":"update.auto","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"update.auto.betaCheckIntervalHours","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Auto Update Beta Check Interval (hours)","help":"How often beta-channel checks run in hours (default: 1).","hasChildren":false}
|
||||
|
||||
@ -1 +0,0 @@
|
||||
docs.openclaw.ai
|
||||
@ -1,3 +1,11 @@
|
||||
---
|
||||
title: "Auth Credential Semantics"
|
||||
summary: "Canonical credential eligibility and resolution semantics for auth profiles"
|
||||
read_when:
|
||||
- Working on auth profile resolution or credential routing
|
||||
- Debugging model auth failures or profile order
|
||||
---
|
||||
|
||||
# Auth Credential Semantics
|
||||
|
||||
This document defines the canonical credential eligibility and resolution semantics used across:
|
||||
|
||||
@ -700,7 +700,7 @@ openclaw system event --mode now --text "Next heartbeat: check battery."
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### “Nothing runs”
|
||||
### "Nothing runs"
|
||||
|
||||
- Check cron is enabled: `cron.enabled` and `OPENCLAW_SKIP_CRON`.
|
||||
- Check the Gateway is running continuously (cron runs inside the Gateway process).
|
||||
|
||||
@ -17,7 +17,7 @@ Hooks are small scripts that run when something happens. There are two kinds:
|
||||
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Webhooks**: external HTTP webhooks that let other systems trigger work in OpenClaw. See [Webhook Hooks](/automation/webhook) or use `openclaw webhooks` for Gmail helper commands.
|
||||
|
||||
Hooks can also be bundled inside plugins; see [Plugins](/tools/plugin#plugin-hooks).
|
||||
Hooks can also be bundled inside plugins; see [Plugin hooks](/plugins/architecture#provider-runtime-hooks).
|
||||
|
||||
Common uses:
|
||||
|
||||
@ -1046,4 +1046,4 @@ node -e "import('./path/to/handler.ts').then(console.log)"
|
||||
- [CLI Reference: hooks](/cli/hooks)
|
||||
- [Bundled Hooks README](https://github.com/openclaw/openclaw/tree/main/src/hooks/bundled)
|
||||
- [Webhook Hooks](/automation/webhook)
|
||||
- [Configuration](/gateway/configuration#hooks)
|
||||
- [Configuration](/gateway/configuration-reference#hooks)
|
||||
|
||||
@ -13,7 +13,7 @@ title: "Polls"
|
||||
- Telegram
|
||||
- WhatsApp (web channel)
|
||||
- Discord
|
||||
- MS Teams (Adaptive Cards)
|
||||
- Microsoft Teams (Adaptive Cards)
|
||||
|
||||
## CLI
|
||||
|
||||
@ -37,7 +37,7 @@ openclaw message poll --channel discord --target channel:123456789 \
|
||||
openclaw message poll --channel discord --target channel:123456789 \
|
||||
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
|
||||
|
||||
# MS Teams
|
||||
# Microsoft Teams
|
||||
openclaw message poll --channel msteams --target conversation:19:abc@thread.tacv2 \
|
||||
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
|
||||
```
|
||||
@ -71,7 +71,7 @@ Params:
|
||||
- Telegram: 2-10 options. Supports forum topics via `threadId` or `:topic:` targets. Uses `durationSeconds` instead of `durationHours`, limited to 5-600 seconds. Supports anonymous and public polls.
|
||||
- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
|
||||
- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count.
|
||||
- MS Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored.
|
||||
- Microsoft Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored.
|
||||
|
||||
## Agent tool (Message)
|
||||
|
||||
|
||||
251
docs/automation/standing-orders.md
Normal file
251
docs/automation/standing-orders.md
Normal file
@ -0,0 +1,251 @@
|
||||
---
|
||||
summary: "Define permanent operating authority for autonomous agent programs"
|
||||
read_when:
|
||||
- Setting up autonomous agent workflows that run without per-task prompting
|
||||
- Defining what the agent can do independently vs. what needs human approval
|
||||
- Structuring multi-program agents with clear boundaries and escalation rules
|
||||
title: "Standing Orders"
|
||||
---
|
||||
|
||||
# Standing Orders
|
||||
|
||||
Standing orders grant your agent **permanent operating authority** for defined programs. Instead of giving individual task instructions each time, you define programs with clear scope, triggers, and escalation rules — and the agent executes autonomously within those boundaries.
|
||||
|
||||
This is the difference between telling your assistant "send the weekly report" every Friday vs. granting standing authority: "You own the weekly report. Compile it every Friday, send it, and only escalate if something looks wrong."
|
||||
|
||||
## Why Standing Orders?
|
||||
|
||||
**Without standing orders:**
|
||||
|
||||
- You must prompt the agent for every task
|
||||
- The agent sits idle between requests
|
||||
- Routine work gets forgotten or delayed
|
||||
- You become the bottleneck
|
||||
|
||||
**With standing orders:**
|
||||
|
||||
- The agent executes autonomously within defined boundaries
|
||||
- Routine work happens on schedule without prompting
|
||||
- You only get involved for exceptions and approvals
|
||||
- The agent fills idle time productively
|
||||
|
||||
## How They Work
|
||||
|
||||
Standing orders are defined in your [agent workspace](/concepts/agent-workspace) files. The recommended approach is to include them directly in `AGENTS.md` (which is auto-injected every session) so the agent always has them in context. For larger configurations, you can also place them in a dedicated file like `standing-orders.md` and reference it from `AGENTS.md`.
|
||||
|
||||
Each program specifies:
|
||||
|
||||
1. **Scope** — what the agent is authorized to do
|
||||
2. **Triggers** — when to execute (schedule, event, or condition)
|
||||
3. **Approval gates** — what requires human sign-off before acting
|
||||
4. **Escalation rules** — when to stop and ask for help
|
||||
|
||||
The agent loads these instructions every session via the workspace bootstrap files (see [Agent Workspace](/concepts/agent-workspace) for the full list of auto-injected files) and executes against them, combined with [cron jobs](/automation/cron-jobs) for time-based enforcement.
|
||||
|
||||
<Tip>
|
||||
Put standing orders in `AGENTS.md` to guarantee they're loaded every session. The workspace bootstrap automatically injects `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, and `MEMORY.md` — but not arbitrary files in subdirectories.
|
||||
</Tip>
|
||||
|
||||
## Anatomy of a Standing Order
|
||||
|
||||
```markdown
|
||||
## Program: Weekly Status Report
|
||||
|
||||
**Authority:** Compile data, generate report, deliver to stakeholders
|
||||
**Trigger:** Every Friday at 4 PM (enforced via cron job)
|
||||
**Approval gate:** None for standard reports. Flag anomalies for human review.
|
||||
**Escalation:** If data source is unavailable or metrics look unusual (>2σ from norm)
|
||||
|
||||
### Execution Steps
|
||||
|
||||
1. Pull metrics from configured sources
|
||||
2. Compare to prior week and targets
|
||||
3. Generate report in Reports/weekly/YYYY-MM-DD.md
|
||||
4. Deliver summary via configured channel
|
||||
5. Log completion to Agent/Logs/
|
||||
|
||||
### What NOT to Do
|
||||
|
||||
- Do not send reports to external parties
|
||||
- Do not modify source data
|
||||
- Do not skip delivery if metrics look bad — report accurately
|
||||
```
|
||||
|
||||
## Standing Orders + Cron Jobs
|
||||
|
||||
Standing orders define **what** the agent is authorized to do. [Cron jobs](/automation/cron-jobs) define **when** it happens. They work together:
|
||||
|
||||
```
|
||||
Standing Order: "You own the daily inbox triage"
|
||||
↓
|
||||
Cron Job (8 AM daily): "Execute inbox triage per standing orders"
|
||||
↓
|
||||
Agent: Reads standing orders → executes steps → reports results
|
||||
```
|
||||
|
||||
The cron job prompt should reference the standing order rather than duplicating it:
|
||||
|
||||
```bash
|
||||
openclaw cron create \
|
||||
--name daily-inbox-triage \
|
||||
--cron "0 8 * * 1-5" \
|
||||
--tz America/New_York \
|
||||
--timeout-seconds 300 \
|
||||
--announce \
|
||||
--channel bluebubbles \
|
||||
--to "+1XXXXXXXXXX" \
|
||||
--message "Execute daily inbox triage per standing orders. Check mail for new alerts. Parse, categorize, and persist each item. Report summary to owner. Escalate unknowns."
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Content & Social Media (Weekly Cycle)
|
||||
|
||||
```markdown
|
||||
## Program: Content & Social Media
|
||||
|
||||
**Authority:** Draft content, schedule posts, compile engagement reports
|
||||
**Approval gate:** All posts require owner review for first 30 days, then standing approval
|
||||
**Trigger:** Weekly cycle (Monday review → mid-week drafts → Friday brief)
|
||||
|
||||
### Weekly Cycle
|
||||
|
||||
- **Monday:** Review platform metrics and audience engagement
|
||||
- **Tuesday–Thursday:** Draft social posts, create blog content
|
||||
- **Friday:** Compile weekly marketing brief → deliver to owner
|
||||
|
||||
### Content Rules
|
||||
|
||||
- Voice must match the brand (see SOUL.md or brand voice guide)
|
||||
- Never identify as AI in public-facing content
|
||||
- Include metrics when available
|
||||
- Focus on value to audience, not self-promotion
|
||||
```
|
||||
|
||||
### Example 2: Finance Operations (Event-Triggered)
|
||||
|
||||
```markdown
|
||||
## Program: Financial Processing
|
||||
|
||||
**Authority:** Process transaction data, generate reports, send summaries
|
||||
**Approval gate:** None for analysis. Recommendations require owner approval.
|
||||
**Trigger:** New data file detected OR scheduled monthly cycle
|
||||
|
||||
### When New Data Arrives
|
||||
|
||||
1. Detect new file in designated input directory
|
||||
2. Parse and categorize all transactions
|
||||
3. Compare against budget targets
|
||||
4. Flag: unusual items, threshold breaches, new recurring charges
|
||||
5. Generate report in designated output directory
|
||||
6. Deliver summary to owner via configured channel
|
||||
|
||||
### Escalation Rules
|
||||
|
||||
- Single item > $500: immediate alert
|
||||
- Category > budget by 20%: flag in report
|
||||
- Unrecognizable transaction: ask owner for categorization
|
||||
- Failed processing after 2 retries: report failure, do not guess
|
||||
```
|
||||
|
||||
### Example 3: Monitoring & Alerts (Continuous)
|
||||
|
||||
```markdown
|
||||
## Program: System Monitoring
|
||||
|
||||
**Authority:** Check system health, restart services, send alerts
|
||||
**Approval gate:** Restart services automatically. Escalate if restart fails twice.
|
||||
**Trigger:** Every heartbeat cycle
|
||||
|
||||
### Checks
|
||||
|
||||
- Service health endpoints responding
|
||||
- Disk space above threshold
|
||||
- Pending tasks not stale (>24 hours)
|
||||
- Delivery channels operational
|
||||
|
||||
### Response Matrix
|
||||
|
||||
| Condition | Action | Escalate? |
|
||||
| ---------------- | ------------------------ | ------------------------ |
|
||||
| Service down | Restart automatically | Only if restart fails 2x |
|
||||
| Disk space < 10% | Alert owner | Yes |
|
||||
| Stale task > 24h | Remind owner | No |
|
||||
| Channel offline | Log and retry next cycle | If offline > 2 hours |
|
||||
```
|
||||
|
||||
## The Execute-Verify-Report Pattern
|
||||
|
||||
Standing orders work best when combined with strict execution discipline. Every task in a standing order should follow this loop:
|
||||
|
||||
1. **Execute** — Do the actual work (don't just acknowledge the instruction)
|
||||
2. **Verify** — Confirm the result is correct (file exists, message delivered, data parsed)
|
||||
3. **Report** — Tell the owner what was done and what was verified
|
||||
|
||||
```markdown
|
||||
### Execution Rules
|
||||
|
||||
- Every task follows Execute-Verify-Report. No exceptions.
|
||||
- "I'll do that" is not execution. Do it, then report.
|
||||
- "Done" without verification is not acceptable. Prove it.
|
||||
- If execution fails: retry once with adjusted approach.
|
||||
- If still fails: report failure with diagnosis. Never silently fail.
|
||||
- Never retry indefinitely — 3 attempts max, then escalate.
|
||||
```
|
||||
|
||||
This pattern prevents the most common agent failure mode: acknowledging a task without completing it.
|
||||
|
||||
## Multi-Program Architecture
|
||||
|
||||
For agents managing multiple concerns, organize standing orders as separate programs with clear boundaries:
|
||||
|
||||
```markdown
|
||||
# Standing Orders
|
||||
|
||||
## Program 1: [Domain A] (Weekly)
|
||||
|
||||
...
|
||||
|
||||
## Program 2: [Domain B] (Monthly + On-Demand)
|
||||
|
||||
...
|
||||
|
||||
## Program 3: [Domain C] (As-Needed)
|
||||
|
||||
...
|
||||
|
||||
## Escalation Rules (All Programs)
|
||||
|
||||
- [Common escalation criteria]
|
||||
- [Approval gates that apply across programs]
|
||||
```
|
||||
|
||||
Each program should have:
|
||||
|
||||
- Its own **trigger cadence** (weekly, monthly, event-driven, continuous)
|
||||
- Its own **approval gates** (some programs need more oversight than others)
|
||||
- Clear **boundaries** (the agent should know where one program ends and another begins)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do
|
||||
|
||||
- Start with narrow authority and expand as trust builds
|
||||
- Define explicit approval gates for high-risk actions
|
||||
- Include "What NOT to do" sections — boundaries matter as much as permissions
|
||||
- Combine with cron jobs for reliable time-based execution
|
||||
- Review agent logs weekly to verify standing orders are being followed
|
||||
- Update standing orders as your needs evolve — they're living documents
|
||||
|
||||
### Don't
|
||||
|
||||
- Grant broad authority on day one ("do whatever you think is best")
|
||||
- Skip escalation rules — every program needs a "when to stop and ask" clause
|
||||
- Assume the agent will remember verbal instructions — put everything in the file
|
||||
- Mix concerns in a single program — separate programs for separate domains
|
||||
- Forget to enforce with cron jobs — standing orders without triggers become suggestions
|
||||
|
||||
## Related
|
||||
|
||||
- [Cron Jobs](/automation/cron-jobs) — Schedule enforcement for standing orders
|
||||
- [Agent Workspace](/concepts/agent-workspace) — Where standing orders live, including the full list of auto-injected bootstrap files (AGENTS.md, SOUL.md, etc.)
|
||||
@ -85,7 +85,7 @@ Payload:
|
||||
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
|
||||
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
||||
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
|
||||
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session.
|
||||
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for Microsoft Teams). Defaults to the last recipient in the main session.
|
||||
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
||||
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
||||
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
|
||||
|
||||
@ -20,11 +20,21 @@ OpenClaw supports Brave Search API as a `web_search` provider.
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
brave: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
maxResults: 5,
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
@ -33,6 +43,9 @@ OpenClaw supports Brave Search API as a `web_search` provider.
|
||||
}
|
||||
```
|
||||
|
||||
Provider-specific Brave search settings now live under `plugins.entries.brave.config.webSearch.*`.
|
||||
Legacy `tools.web.search.apiKey` still loads through the compatibility shim, but it is no longer the canonical config path.
|
||||
|
||||
## Tool parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user