mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-09 15:31:18 +08:00
Compare commits
595 Commits
fix/browse
...
fix/webcha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16e6789dd5 | ||
|
|
ba99fda951 | ||
|
|
7dadd5027b | ||
|
|
f8ed48293c | ||
|
|
96a38d5aa4 | ||
|
|
c7ec237089 | ||
|
|
1ae82be55a | ||
|
|
fd782d811e | ||
|
|
a467517b2b | ||
|
|
3eec79bd6c | ||
|
|
4ba5937ef9 | ||
|
|
6fc3f504d6 | ||
|
|
b17687b775 | ||
|
|
eca242b971 | ||
|
|
4494844d17 | ||
|
|
5193189953 | ||
|
|
fbb88d5063 | ||
|
|
c0715db3c8 | ||
|
|
20c15ccc63 | ||
|
|
16fd604219 | ||
|
|
61f29830bc | ||
|
|
47736e3432 | ||
|
|
39520ad21b | ||
|
|
bd8c3230e8 | ||
|
|
ebbb572639 | ||
|
|
3b9877dee7 | ||
|
|
40e5c6a18d | ||
|
|
11e1363d2d | ||
|
|
ee646dae82 | ||
|
|
85f01cd9eb | ||
|
|
bab5d994bc | ||
|
|
2365c6c86a | ||
|
|
53ada1e9b9 | ||
|
|
b91a22a3fb | ||
|
|
2aab6dff76 | ||
|
|
980388fcf0 | ||
|
|
3e6451f2d8 | ||
|
|
2f6718b8e7 | ||
|
|
b5350bf46f | ||
|
|
0f5f20ee6b | ||
|
|
6b6af1a64f | ||
|
|
c1b37f29f0 | ||
|
|
a3b674cc98 | ||
|
|
cdc1ef85e8 | ||
|
|
1ca69c8fd7 | ||
|
|
469cd5b464 | ||
|
|
666073ee46 | ||
|
|
747902a26a | ||
|
|
61adcea68e | ||
|
|
5ee6ca13b7 | ||
|
|
71cd337137 | ||
|
|
4d04e1a41f | ||
|
|
67e3eb85d7 | ||
|
|
1b4062defd | ||
|
|
3e4dd84511 | ||
|
|
5084621f43 | ||
|
|
346d3590fb | ||
|
|
687ef2e00f | ||
|
|
1187464041 | ||
|
|
4e4a100038 | ||
|
|
ddd71bc9f6 | ||
|
|
1a7a18d0bc | ||
|
|
4e4d94cd38 | ||
|
|
f0640b0100 | ||
|
|
46df7e2421 | ||
|
|
42626648d7 | ||
|
|
17b40c4a59 | ||
|
|
d9119f0791 | ||
|
|
586f057c24 | ||
|
|
90d8b40808 | ||
|
|
d7bafae387 | ||
|
|
588fbd5b68 | ||
|
|
ef920f2f39 | ||
|
|
57e1534df8 | ||
|
|
a48a3dbdda | ||
|
|
c3d5159121 | ||
|
|
1bd20dbdb6 | ||
|
|
a2fdc3415f | ||
|
|
ced267c5cb | ||
|
|
287606e445 | ||
|
|
f26853f14c | ||
|
|
a44843507f | ||
|
|
de09ca149f | ||
|
|
503d395780 | ||
|
|
924d9e34ef | ||
|
|
f3e6578e6c | ||
|
|
e930517154 | ||
|
|
47083460ea | ||
|
|
7de4204e57 | ||
|
|
36dfd462a8 | ||
|
|
6649c22471 | ||
|
|
596621919c | ||
|
|
9657ded2e1 | ||
|
|
282b107e99 | ||
|
|
86090b0ff2 | ||
|
|
77ecef1fde | ||
|
|
53fd7f8163 | ||
|
|
1b5ac8b0b1 | ||
|
|
f6233cfa5c | ||
|
|
61be533ad4 | ||
|
|
d76ddd61ec | ||
|
|
82101b152a | ||
|
|
439a7732f4 | ||
|
|
a96b3b406a | ||
|
|
68e982ec80 | ||
|
|
d0a3743abd | ||
|
|
0d8beeb4e5 | ||
|
|
1e8afa16f0 | ||
|
|
65dc3ee76c | ||
|
|
f4682742d9 | ||
|
|
d37ad9d866 | ||
|
|
4b3d9f4fb2 | ||
|
|
6bf84ac28c | ||
|
|
051b380d38 | ||
|
|
dee7cda1ec | ||
|
|
8824565c2a | ||
|
|
d7dda4dd1a | ||
|
|
6a42d09129 | ||
|
|
fd3ca8a34c | ||
|
|
fe14be2352 | ||
|
|
e870cee542 | ||
|
|
3e9c8721fb | ||
|
|
11c397ef46 | ||
|
|
4bfbf2dfff | ||
|
|
1d0a4d1be2 | ||
|
|
d6491d8d71 | ||
|
|
6b85ec3022 | ||
|
|
3e1ec5ad8b | ||
|
|
c5ddba52d7 | ||
|
|
381bb867ac | ||
|
|
24dcd68f42 | ||
|
|
a1b4a0066b | ||
|
|
a5b81d1c13 | ||
|
|
d3dc4e54f7 | ||
|
|
dba47f349f | ||
|
|
fe4c627432 | ||
|
|
b8b8a5f314 | ||
|
|
ea3b7dfde5 | ||
|
|
32ecd6f579 | ||
|
|
dc825e59f5 | ||
|
|
500d7cb107 | ||
|
|
1234cc4c31 | ||
|
|
abec8a4f0a | ||
|
|
41bdf2df41 | ||
|
|
c20ee11348 | ||
|
|
4d19dc8671 | ||
|
|
73e08ed7b0 | ||
|
|
5868344ade | ||
|
|
abb0252a1a | ||
|
|
55f04636f3 | ||
|
|
f22fc17c78 | ||
|
|
28c88e9fa1 | ||
|
|
58ad617e64 | ||
|
|
dc2aa1e21d | ||
|
|
8fdd1d2f05 | ||
|
|
bb60687b89 | ||
|
|
a282b459b9 | ||
|
|
de77a36579 | ||
|
|
79e114a82f | ||
|
|
7c7c22d66f | ||
|
|
ec688d809f | ||
|
|
481da215b9 | ||
|
|
132794fe74 | ||
|
|
d4ec0ed3c7 | ||
|
|
2e0f5b73d1 | ||
|
|
66397c2855 | ||
|
|
e2483a5381 | ||
|
|
c703aa0fe9 | ||
|
|
3bf19d6f40 | ||
|
|
7365aefa19 | ||
|
|
7066d5e192 | ||
|
|
350d041eaf | ||
|
|
e05bcccde8 | ||
|
|
0954b6bf5f | ||
|
|
3b3e47e15d | ||
|
|
8f3eb0f7b4 | ||
|
|
0e16749f00 | ||
|
|
7eda632324 | ||
|
|
3043e68dfa | ||
|
|
36c6b63ea6 | ||
|
|
fc1787fd4b | ||
|
|
2287d1ec13 | ||
|
|
ba5ae5b4f1 | ||
|
|
a81704e622 | ||
|
|
02eeb08e04 | ||
|
|
7cbcbbc642 | ||
|
|
903e4dff35 | ||
|
|
905c3357eb | ||
|
|
f431f20c48 | ||
|
|
d9fdec12ab | ||
|
|
f25be781c4 | ||
|
|
0d8f14fed3 | ||
|
|
842a79cf99 | ||
|
|
caae34cbaf | ||
|
|
fa47f74c0f | ||
|
|
ac11f0af73 | ||
|
|
a78ec81ae6 | ||
|
|
be578b43d3 | ||
|
|
0b5d8e5b47 | ||
|
|
b9b47f5002 | ||
|
|
319b7c68a1 | ||
|
|
6200e242b2 | ||
|
|
5b5ccb0769 | ||
|
|
0743463b88 | ||
|
|
48155729fc | ||
|
|
163f5184b3 | ||
|
|
8950c59581 | ||
|
|
29dde80c3e | ||
|
|
b5102ba4f9 | ||
|
|
7ad6a04058 | ||
|
|
e0b8b80067 | ||
|
|
44183c6eb1 | ||
|
|
f9cbcfca0d | ||
|
|
5d3032b293 | ||
|
|
11adaa15a8 | ||
|
|
3cb851be90 | ||
|
|
1fa2488db1 | ||
|
|
d3cb85eaf5 | ||
|
|
d89c25d69e | ||
|
|
f257818ea5 | ||
|
|
350ac0d824 | ||
|
|
19fafed11d | ||
|
|
7253e91300 | ||
|
|
2330c71b63 | ||
|
|
477de545f9 | ||
|
|
bd4a082b73 | ||
|
|
cbd2e8eea8 | ||
|
|
807c600ad1 | ||
|
|
a1ee605494 | ||
|
|
923ff17ff3 | ||
|
|
49687d313c | ||
|
|
11dcf96628 | ||
|
|
21a1db78b3 | ||
|
|
175c770171 | ||
|
|
1212328c8d | ||
|
|
f9025c3f55 | ||
|
|
317075ef3d | ||
|
|
2c39731846 | ||
|
|
29342c37b5 | ||
|
|
cc18e43832 | ||
|
|
6545317a2c | ||
|
|
9bde7f4fde | ||
|
|
3beb1b9da9 | ||
|
|
6358aae024 | ||
|
|
55a2d12f40 | ||
|
|
99a3db6ba9 | ||
|
|
e5597a8dd4 | ||
|
|
8e259b8310 | ||
|
|
8f995dfc7a | ||
|
|
1b61269eec | ||
|
|
ef89b48785 | ||
|
|
a183656f8f | ||
|
|
f2b37f0aa9 | ||
|
|
faa4ffec03 | ||
|
|
f7b0378ccb | ||
|
|
5f19112217 | ||
|
|
8039ef7dba | ||
|
|
43f94e3ab8 | ||
|
|
8b70ba6ab8 | ||
|
|
036bd18e2a | ||
|
|
9c9ab891c2 | ||
|
|
f7c658efb9 | ||
|
|
58cde87436 | ||
|
|
8c1e9949b3 | ||
|
|
ba3fa44c5b | ||
|
|
5897eed6e9 | ||
|
|
453a1c179d | ||
|
|
76d6514ff5 | ||
|
|
6a425d189e | ||
|
|
34daed1d1e | ||
|
|
91dd89313a | ||
|
|
5f0cbd0edc | ||
|
|
ab8b8dae70 | ||
|
|
067855e623 | ||
|
|
58e9ca2fb6 | ||
|
|
61d14e8a8a | ||
|
|
2438fde6d9 | ||
|
|
7a99027ef6 | ||
|
|
42e402dfba | ||
|
|
11aa18b525 | ||
|
|
21d6d878ce | ||
|
|
8da8756f76 | ||
|
|
a4927ed8ee | ||
|
|
1f24323583 | ||
|
|
dc8a56c857 | ||
|
|
f181b7dbe6 | ||
|
|
0f1388fa15 | ||
|
|
b782ecb7eb | ||
|
|
af637deed1 | ||
|
|
73e6dc361e | ||
|
|
866bd91c65 | ||
|
|
d98a61a977 | ||
|
|
d01e04bcec | ||
|
|
5a32a66aa8 | ||
|
|
3a08e69a05 | ||
|
|
320920d523 | ||
|
|
ad12d1fbce | ||
|
|
bfb6c6290f | ||
|
|
da8a17d8de | ||
|
|
089a8785b9 | ||
|
|
e0b91067e3 | ||
|
|
d2bb04b436 | ||
|
|
4a414c5e53 | ||
|
|
da22a9113c | ||
|
|
8937c10f1f | ||
|
|
259f6543b4 | ||
|
|
3c0ec76e8e | ||
|
|
d80144f572 | ||
|
|
54eb13893f | ||
|
|
c582a54554 | ||
|
|
cceecc8bd4 | ||
|
|
00347bda75 | ||
|
|
da05395c2a | ||
|
|
e45d26b9ed | ||
|
|
16e7fc2563 | ||
|
|
479095bcfb | ||
|
|
5b63417fec | ||
|
|
6945ba189d | ||
|
|
ab0b2c21f3 | ||
|
|
f534ea9906 | ||
|
|
15677133c1 | ||
|
|
c9d0e345cb | ||
|
|
bf0653846e | ||
|
|
3de7768b11 | ||
|
|
2937fe0351 | ||
|
|
fb5d8a9cd1 | ||
|
|
2f352306fe | ||
|
|
f7765bc151 | ||
|
|
b52561bfa3 | ||
|
|
4b50018406 | ||
|
|
7003615972 | ||
|
|
eb816e0551 | ||
|
|
b1c30f0ba9 | ||
|
|
9d30159fcd | ||
|
|
9617ac9dd5 | ||
|
|
8768487aee | ||
|
|
ee0d7ba6d6 | ||
|
|
c48a0621ff | ||
|
|
b1cc8ffe9e | ||
|
|
4cd04e4652 | ||
|
|
c424836fbe | ||
|
|
a351ab2481 | ||
|
|
15226b0b83 | ||
|
|
0cf533ac61 | ||
|
|
4985c561df | ||
|
|
160dad56c4 | ||
|
|
a3c5d21b4d | ||
|
|
9a3800d8e6 | ||
|
|
39afcee864 | ||
|
|
d979eeda9f | ||
|
|
8e48f7e353 | ||
|
|
2a2e2c3630 | ||
|
|
92bf77d9a0 | ||
|
|
a3bb7a5ee5 | ||
|
|
2b088ca125 | ||
|
|
aeeb0474c6 | ||
|
|
6df36a8b35 | ||
|
|
fbd1210ec2 | ||
|
|
26b8a70a52 | ||
|
|
e391646043 | ||
|
|
e513714103 | ||
|
|
b645654923 | ||
|
|
60130203e1 | ||
|
|
c4511df283 | ||
|
|
64abf9a925 | ||
|
|
1616113170 | ||
|
|
fcec2e364d | ||
|
|
66c1da45d4 | ||
|
|
dbbd41a2ed | ||
|
|
e1bc5cad25 | ||
|
|
62d0cfeee7 | ||
|
|
a19a7f5e6e | ||
|
|
ea1fe77c83 | ||
|
|
d486b0a925 | ||
|
|
10fb632c9e | ||
|
|
4a2329e0af | ||
|
|
14baadda2c | ||
|
|
aab87ec880 | ||
|
|
a71b8d23be | ||
|
|
6c7d012320 | ||
|
|
0956b599e1 | ||
|
|
d4b20f5295 | ||
|
|
07eaeb7350 | ||
|
|
83ec545bed | ||
|
|
6add2bcc15 | ||
|
|
fbb343ab30 | ||
|
|
e1e93d932f | ||
|
|
ee68fa86b5 | ||
|
|
0958d11478 | ||
|
|
ed55b63684 | ||
|
|
31bc2cc202 | ||
|
|
c146748d7a | ||
|
|
2a98fd3d0b | ||
|
|
ce4faedad6 | ||
|
|
1ef9a2a8ea | ||
|
|
84d9b64326 | ||
|
|
99392f9868 | ||
|
|
662f389f45 | ||
|
|
3bd0505433 | ||
|
|
dde43121c0 | ||
|
|
6a5041f3ff | ||
|
|
bcb1eb2f03 | ||
|
|
842087319b | ||
|
|
5c1eb071ca | ||
|
|
4030de6c73 | ||
|
|
c9558cdcd7 | ||
|
|
740bb77c8c | ||
|
|
63734df3b0 | ||
|
|
534168a7a7 | ||
|
|
9c1312b5e4 | ||
|
|
1727279598 | ||
|
|
d52e5e1d85 | ||
|
|
c1c20491da | ||
|
|
d21cf44452 | ||
|
|
738f5d4533 | ||
|
|
a8fe8b6bf8 | ||
|
|
82f01d6081 | ||
|
|
41c8734afd | ||
|
|
cf5702233c | ||
|
|
718d418b32 | ||
|
|
16df7ef4a9 | ||
|
|
9b8e642475 | ||
|
|
8b27582509 | ||
|
|
a6489ab5e9 | ||
|
|
83c8406f01 | ||
|
|
602f6439bd | ||
|
|
1c9deeda97 | ||
|
|
fc0d374390 | ||
|
|
0ebe0480fa | ||
|
|
8ae8056622 | ||
|
|
54382a66b4 | ||
|
|
d7ae61c412 | ||
|
|
b07589642d | ||
|
|
26b8e6d510 | ||
|
|
e339c75d5d | ||
|
|
7dac9b05dd | ||
|
|
eb35fb745d | ||
|
|
b9e820b7ed | ||
|
|
aee27d0e38 | ||
|
|
34ff873a7e | ||
|
|
310dd24ce3 | ||
|
|
d4bf07d075 | ||
|
|
d3e8b17aa6 | ||
|
|
dded569626 | ||
|
|
104d32bb64 | ||
|
|
be3a62c5e0 | ||
|
|
193ad2f4f0 | ||
|
|
a0e11e63fe | ||
|
|
07b16d5ad0 | ||
|
|
67b2dde7c5 | ||
|
|
7a55a3ca07 | ||
|
|
11562c452a | ||
|
|
eb2e20c994 | ||
|
|
24a13c05b3 | ||
|
|
20c36f7e84 | ||
|
|
db7a8a6982 | ||
|
|
4a80311628 | ||
|
|
7a7eee920a | ||
|
|
33e76db12a | ||
|
|
9a68590385 | ||
|
|
031bf0c6c0 | ||
|
|
8611fd67b5 | ||
|
|
14c93d2646 | ||
|
|
1b462ed174 | ||
|
|
18f8393b6c | ||
|
|
14e4575af5 | ||
|
|
b1592457fa | ||
|
|
31c7637e0f | ||
|
|
d5ae4b8337 | ||
|
|
0dbb92dd2b | ||
|
|
17ede52a4b | ||
|
|
be65dc8acc | ||
|
|
8828418111 | ||
|
|
4dd6c7a509 | ||
|
|
d94de5c4a1 | ||
|
|
09f49cd921 | ||
|
|
87d05592ea | ||
|
|
d74bc257d8 | ||
|
|
6edb512efa | ||
|
|
c973b053a5 | ||
|
|
a229ae6c3e | ||
|
|
2fd8264ab0 | ||
|
|
b13d48987c | ||
|
|
21708f58ce | ||
|
|
1ea42ebe98 | ||
|
|
3e5762c288 | ||
|
|
c4711a9b69 | ||
|
|
ea204e65a0 | ||
|
|
14fbd0e6b6 | ||
|
|
17c434f2f3 | ||
|
|
19f5d1345c | ||
|
|
64c443ac65 | ||
|
|
b28e472fa5 | ||
|
|
0c6db05cc0 | ||
|
|
ade46d8ab7 | ||
|
|
82247f09a7 | ||
|
|
d01e82d54a | ||
|
|
93b0724025 | ||
|
|
44270c533b | ||
|
|
dec2c9e74d | ||
|
|
6135eb3353 | ||
|
|
345abf0b20 | ||
|
|
a3d2021eea | ||
|
|
e08ba063d8 | ||
|
|
998d477f5e | ||
|
|
a49afd25ea | ||
|
|
d86c1a67e0 | ||
|
|
05b84e718b | ||
|
|
07b419a0e7 | ||
|
|
12be9a08fe | ||
|
|
ee1b147631 | ||
|
|
208a9b1ad1 | ||
|
|
0f00110f5d | ||
|
|
174f2de447 | ||
|
|
db3d8d82c1 | ||
|
|
3f2848433a | ||
|
|
663c1858b8 | ||
|
|
729ddfd7c8 | ||
|
|
f39882d57e | ||
|
|
6b7d3fb011 | ||
|
|
c63c179278 | ||
|
|
dd3f7d57ee | ||
|
|
47ef180fb7 | ||
|
|
ebe54e6903 | ||
|
|
d06ee86292 | ||
|
|
f1cab9c5e5 | ||
|
|
f4c3e483fe | ||
|
|
6aa20e91d9 | ||
|
|
4b4ea5df8b | ||
|
|
a905b6dabc | ||
|
|
44c50d9a73 | ||
|
|
ed21b63bb8 | ||
|
|
e9dd6121f2 | ||
|
|
dcf8308c8f | ||
|
|
d212721df1 | ||
|
|
a469d00345 | ||
|
|
3fb0ab7435 | ||
|
|
64ac790aa8 | ||
|
|
f1cd3ea531 | ||
|
|
c5f1cf3c3b | ||
|
|
87bd6226bd | ||
|
|
9f98d2766a | ||
|
|
944abe0a6c | ||
|
|
dbc78243f4 | ||
|
|
e41f9998f7 | ||
|
|
741e74972b | ||
|
|
693f61404d | ||
|
|
3efd224ec6 | ||
|
|
b85facfb5d | ||
|
|
e23b6fb2ba | ||
|
|
d145518f94 | ||
|
|
cd18472405 | ||
|
|
2a11a20fe2 | ||
|
|
2a2a9902d9 | ||
|
|
5561a6b659 | ||
|
|
c2d41dc473 | ||
|
|
a05b8f47b1 | ||
|
|
7d600ff4e2 | ||
|
|
38bdb0d271 | ||
|
|
32475448eb | ||
|
|
8421b2e848 | ||
|
|
f2468feb86 | ||
|
|
1fe0f848df | ||
|
|
98e5851d8a | ||
|
|
cd653c55d7 | ||
|
|
32c7242974 | ||
|
|
534f436d4e | ||
|
|
234e07fcc0 | ||
|
|
9eb70d2725 | ||
|
|
2bec80cd97 | ||
|
|
dd8c76110f | ||
|
|
158709ff62 | ||
|
|
c96234b51d | ||
|
|
1184d39e1d | ||
|
|
e303b356ba | ||
|
|
22ec577d80 | ||
|
|
9b938f2bf6 | ||
|
|
c7bf54b914 | ||
|
|
c350dc8a7b | ||
|
|
b47dc73b70 | ||
|
|
050e928985 | ||
|
|
99ee26d534 | ||
|
|
8bccb0032a | ||
|
|
d06cc77f38 | ||
|
|
0d620a56e2 | ||
|
|
09748ab109 | ||
|
|
2d8b8a17ab | ||
|
|
6ea6aca5bd | ||
|
|
7b5a410b83 | ||
|
|
6e008e93be | ||
|
|
732c4f3921 | ||
|
|
910c654807 | ||
|
|
925117d277 | ||
|
|
be8930d6f9 | ||
|
|
ffa7c13c9b |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -94,7 +94,7 @@ USER.md
|
||||
!.agent/workflows/
|
||||
/local/
|
||||
package-lock.json
|
||||
.claude/settings.local.json
|
||||
.claude/
|
||||
.agents/
|
||||
.agents
|
||||
.agent/
|
||||
|
||||
@@ -6,15 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { showPagedSelectList } from "./ui/paged-select";
|
||||
|
||||
interface FileInfo {
|
||||
status: string;
|
||||
@@ -108,87 +100,17 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
};
|
||||
|
||||
// Show file picker with SelectList
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
// Top border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
// Title
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0));
|
||||
|
||||
// Build select items with colored status
|
||||
const items: SelectItem[] = files.map((f) => {
|
||||
let statusColor: string;
|
||||
switch (f.status) {
|
||||
case "M":
|
||||
statusColor = theme.fg("warning", f.status);
|
||||
break;
|
||||
case "A":
|
||||
statusColor = theme.fg("success", f.status);
|
||||
break;
|
||||
case "D":
|
||||
statusColor = theme.fg("error", f.status);
|
||||
break;
|
||||
case "?":
|
||||
statusColor = theme.fg("muted", f.status);
|
||||
break;
|
||||
default:
|
||||
statusColor = theme.fg("dim", f.status);
|
||||
}
|
||||
return {
|
||||
value: f,
|
||||
label: `${statusColor} ${f.file}`,
|
||||
};
|
||||
});
|
||||
|
||||
const visibleRows = Math.min(files.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(items, visibleRows, {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => t, // Keep existing colors
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
});
|
||||
selectList.onSelect = (item) => {
|
||||
const items = files.map((file) => ({
|
||||
value: file,
|
||||
label: `${file.status} ${file.file}`,
|
||||
}));
|
||||
await showPagedSelectList({
|
||||
ctx,
|
||||
title: " Select file to diff",
|
||||
items,
|
||||
onSelect: (item) => {
|
||||
void openSelected(item.value as FileInfo);
|
||||
};
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
// Help text
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
return {
|
||||
render: (w) => container.render(w),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
// Add paging with left/right
|
||||
if (matchesKey(data, Key.left)) {
|
||||
// Page up - clamp to 0
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
// Page down - clamp to last
|
||||
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,15 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { showPagedSelectList } from "./ui/paged-select";
|
||||
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
@@ -113,82 +105,30 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
};
|
||||
|
||||
// Show file picker with SelectList
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
// Top border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
// Title
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0));
|
||||
|
||||
// Build select items with colored operations
|
||||
const items: SelectItem[] = files.map((f) => {
|
||||
const ops: string[] = [];
|
||||
if (f.operations.has("read")) {
|
||||
ops.push(theme.fg("muted", "R"));
|
||||
}
|
||||
if (f.operations.has("write")) {
|
||||
ops.push(theme.fg("success", "W"));
|
||||
}
|
||||
if (f.operations.has("edit")) {
|
||||
ops.push(theme.fg("warning", "E"));
|
||||
}
|
||||
const opsLabel = ops.join("");
|
||||
return {
|
||||
value: f,
|
||||
label: `${opsLabel} ${f.path}`,
|
||||
};
|
||||
});
|
||||
|
||||
const visibleRows = Math.min(files.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(items, visibleRows, {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => t, // Keep existing colors
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
});
|
||||
selectList.onSelect = (item) => {
|
||||
void openSelected(item.value as FileEntry);
|
||||
};
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
// Help text
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
const items = files.map((file) => {
|
||||
const ops: string[] = [];
|
||||
if (file.operations.has("read")) {
|
||||
ops.push("R");
|
||||
}
|
||||
if (file.operations.has("write")) {
|
||||
ops.push("W");
|
||||
}
|
||||
if (file.operations.has("edit")) {
|
||||
ops.push("E");
|
||||
}
|
||||
return {
|
||||
render: (w) => container.render(w),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
// Add paging with left/right
|
||||
if (matchesKey(data, Key.left)) {
|
||||
// Page up - clamp to 0
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
// Page down - clamp to last
|
||||
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
value: file,
|
||||
label: `${ops.join("")} ${file.path}`,
|
||||
};
|
||||
});
|
||||
await showPagedSelectList({
|
||||
ctx,
|
||||
title: " Select file to open",
|
||||
items,
|
||||
onSelect: (item) => {
|
||||
void openSelected(item.value as FileEntry);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,6 +114,17 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
}
|
||||
};
|
||||
|
||||
const renderPromptMatch = (ctx: ExtensionContext, match: PromptMatch) => {
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
@@ -123,14 +134,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
renderPromptMatch(ctx, match);
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
@@ -177,14 +181,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
renderPromptMatch(ctx, match);
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
|
||||
82
.pi/extensions/ui/paged-select.ts
Normal file
82
.pi/extensions/ui/paged-select.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
type CustomUiContext = {
|
||||
ui: {
|
||||
custom: <T>(
|
||||
render: (
|
||||
tui: { requestRender: () => void },
|
||||
theme: {
|
||||
fg: (tone: string, text: string) => string;
|
||||
bold: (text: string) => string;
|
||||
},
|
||||
kb: unknown,
|
||||
done: () => void,
|
||||
) => {
|
||||
render: (width: number) => string;
|
||||
invalidate: () => void;
|
||||
handleInput: (data: string) => void;
|
||||
},
|
||||
) => Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function showPagedSelectList(params: {
|
||||
ctx: CustomUiContext;
|
||||
title: string;
|
||||
items: SelectItem[];
|
||||
onSelect: (item: SelectItem) => void;
|
||||
}): Promise<void> {
|
||||
await params.ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(params.title)), 0, 0));
|
||||
|
||||
const visibleRows = Math.min(params.items.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(params.items, visibleRows, {
|
||||
selectedPrefix: (text) => theme.fg("accent", text),
|
||||
selectedText: (text) => text,
|
||||
description: (text) => theme.fg("muted", text),
|
||||
scrollInfo: (text) => theme.fg("dim", text),
|
||||
noMatch: (text) => theme.fg("warning", text),
|
||||
});
|
||||
selectList.onSelect = (item) => params.onSelect(item);
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = params.items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
return {
|
||||
render: (width) => container.render(width),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
if (matchesKey(data, Key.left)) {
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
currentIndex = Math.min(params.items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,7 @@ Input
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (end-to-end)
|
||||
Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`.
|
||||
Goal: PR must end in GitHub state = MERGED (never CLOSED). Prefer `gh pr merge --squash`; use `--rebase` only when preserving commit history is required.
|
||||
|
||||
1. Assign PR to self:
|
||||
- `gh pr edit <PR> --add-assignee @me`
|
||||
@@ -37,8 +37,8 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
|
||||
- Implement fixes + add/adjust tests
|
||||
- Update `CHANGELOG.md` and mention `#<PR>` + `@$contrib`
|
||||
9. Decide merge strategy:
|
||||
- Rebase if we want to preserve commit history
|
||||
- Squash if we want a single clean commit
|
||||
- Squash (preferred): use when we want a single clean commit
|
||||
- Rebase: use only when we explicitly want to preserve commit history
|
||||
- If unclear, ask
|
||||
10. Full gate (BEFORE commit):
|
||||
- `pnpm lint && pnpm build && pnpm test`
|
||||
@@ -54,8 +54,8 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
|
||||
```
|
||||
|
||||
13. Merge PR (must show MERGED on GitHub):
|
||||
- Rebase: `gh pr merge <PR> --rebase`
|
||||
- Squash: `gh pr merge <PR> --squash`
|
||||
- Squash (preferred): `gh pr merge <PR> --squash`
|
||||
- Rebase (history-preserving fallback): `gh pr merge <PR> --rebase`
|
||||
- Never `gh pr close` (closing is wrong)
|
||||
14. Sync main:
|
||||
- `git checkout main`
|
||||
|
||||
403
CHANGELOG.md
403
CHANGELOG.md
@@ -6,19 +6,218 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
|
||||
- Models/MiniMax: add first-class `MiniMax-M2.5-highspeed` support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy `MiniMax-M2.5-Lightning` compatibility for existing configs.
|
||||
- Docs/Models: refresh MiniMax, Moonshot (Kimi), GLM/Z.AI model docs to align with latest defaults (`MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `moonshot/kimi-k2.5`, `zai/glm-5`) and keep Moonshot model lists synced from shared source data.
|
||||
- Memory/Ollama embeddings: add `memorySearch.provider = "ollama"` and `memorySearch.fallback = "ollama"` support, honor `models.providers.ollama` settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.
|
||||
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
|
||||
- Media understanding/audio echo: add optional `tools.media.audio.echoTranscript` + `echoFormat` to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
|
||||
- Plugin runtime/STT: add `api.runtime.stt.transcribeAudioFile(...)` so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.
|
||||
- Plugin SDK/channel extensibility: expose `channelRuntime` on `ChannelGatewayContext` so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.
|
||||
- Plugin runtime/events: expose `runtime.events.onAgentEvent` and `runtime.events.onSessionTranscriptUpdate` for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.
|
||||
- Plugin runtime/system: expose `runtime.system.requestHeartbeatNow(...)` so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.
|
||||
- Plugin hooks/session lifecycle: include `sessionKey` in `session_start`/`session_end` hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.
|
||||
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
|
||||
- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
|
||||
- Tools/PDF analysis: add a first-class `pdf` tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (`agents.defaults.pdfModel`, `pdfMaxBytesMb`, `pdfMaxPages`), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.
|
||||
- Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.
|
||||
- Zalo Personal plugin (`@openclaw/zalouser`): rebuilt channel runtime to use native `zca-js` integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
|
||||
- Telegram/DM streaming: use `sendMessageDraft` for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
|
||||
- Telegram/voice mention gating: add optional `disableAudioPreflight` on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.
|
||||
- Hooks/message lifecycle: add internal hook events `message:transcribed` and `message:preprocessed`, plus richer outbound `message:sent` context (`isGroup`, `groupId`) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.
|
||||
- Telegram/Streaming defaults: default `channels.telegram.streaming` to `partial` (from `off`) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.
|
||||
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
|
||||
- CLI/Banner taglines: add `cli.banner.taglineMode` (`random` | `default` | `off`) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.
|
||||
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
|
||||
- README/Contributors: rank contributor avatars by composite score (commits + merged PRs + code LOC), excluding docs-only LOC to prevent bulk-generated files from inflating rankings. (#23970) Thanks @tyler6204.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sessions/idle reset correctness: preserve existing `updatedAt` during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.
|
||||
- Slack/socket auth failure handling: fail fast on non-recoverable auth errors (`account_inactive`, `invalid_auth`, etc.) during startup and reconnect instead of retry-looping indefinitely, including `unable_to_socket_mode_start` error payload propagation. (#32377) Thanks @scoootscooob.
|
||||
- CLI/installer Node preflight: enforce Node.js `v22.12+` consistently in both `openclaw.mjs` runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.
|
||||
- Web UI/inline code copy fidelity: disable forced mid-token wraps on inline `<code>` spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.
|
||||
- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
|
||||
- Discord/lifecycle startup status: push an immediate `connected` status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.
|
||||
- Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel `resolveDefaultTo` fallback) when `delivery.to` is omitted. (#32364) Thanks @hclsys.
|
||||
- WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.
|
||||
- Feishu/default account resolution: always honor explicit `channels.feishu.defaultAccount` during outbound account selection (including top-level-credential setups where the preferred id is not present in `accounts`), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.
|
||||
- Gemini schema sanitization: coerce malformed JSON Schema `properties` values (`null`, arrays, primitives) to `{}` before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.
|
||||
- Models/openai-completions developer-role compatibility: force `supportsDeveloperRole=false` for non-native endpoints, treat unparseable `baseUrl` values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.
|
||||
- OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty `function_call_output.call_id` payloads in the WS conversion path to avoid OpenAI 400 errors (`Invalid 'input[n].call_id': empty string`), with regression coverage for both inbound stream normalization and outbound payload guards.
|
||||
- Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
|
||||
- CLI/Config validation and routing hardening: dedupe `openclaw config validate` failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including `--json` fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed `config get/unset` with split root options). Thanks @gumadeiras.
|
||||
- Models/config env propagation: apply `config.env.vars` before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.
|
||||
- Hooks/runtime stability: keep the internal hook handler registry on a `globalThis` singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.
|
||||
- Hooks/plugin context parity: ensure `llm_input` hooks in embedded attempts receive the same `trigger` and `channelId`-aware `hookCtx` used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.
|
||||
- Restart sentinel formatting: avoid duplicate `Reason:` lines when restart message text already matches `stats.reason`, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.
|
||||
- Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.
|
||||
- Hooks/webhook ACK compatibility: return `200` (instead of `202`) for successful `/hooks/agent` requests so providers that require `200` (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.
|
||||
- Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
|
||||
- Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
|
||||
- Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.
|
||||
- Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
|
||||
- Feishu/topic root replies: prefer `root_id` as outbound `replyTargetMessageId` when present, and parse millisecond `message_create_time` values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.
|
||||
- Feishu/DM pairing reply target: send pairing challenge replies to `chat:<chat_id>` instead of `user:<sender_open_id>` so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.
|
||||
- Feishu/Lark private DM routing: treat inbound `chat_type: "private"` as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.
|
||||
- Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (`contact:contact.base:readonly`) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)
|
||||
- Sandbox/workspace mount permissions: make primary `/workspace` bind mounts read-only whenever `workspaceAccess` is not `rw` (including `none`) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.
|
||||
- Security audit/skills workspace hardening: add `skills.workspace.symlink_escape` warning in `openclaw security audit` when workspace `skills/**/SKILL.md` resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.
|
||||
- Signal/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.
|
||||
- Discord/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.
|
||||
- Gateway/OpenAI chat completions: honor `x-openclaw-message-channel` when building `agentCommand` input for `/v1/chat/completions`, preserving caller channel identity instead of forcing `webchat`. (#30462) Thanks @bmendonca3.
|
||||
- Secrets/exec resolver timeout defaults: use provider `timeoutMs` as the default inactivity (`noOutputTimeoutMs`) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.
|
||||
- Feishu/File upload filenames: percent-encode non-ASCII/special-character `file_name` values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
|
||||
- Auto-reply/inline command cleanup: preserve newline structure when stripping inline `/status` and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.
|
||||
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
|
||||
- Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from `HTTPS_PROXY`/`HTTP_PROXY` env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
|
||||
- Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
|
||||
- Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized `kindFromMime` so mixed-case/parameterized MIME values classify consistently across message channels.
|
||||
- Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.
|
||||
- Media understanding/parakeet CLI output parsing: read `parakeet-mlx` transcripts from `--output-dir/<media-basename>.txt` when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.
|
||||
- Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.
|
||||
- OpenAI media capabilities: include `audio` in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.
|
||||
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
|
||||
- Security/Node exec approvals: revalidate approval-bound `cwd` identity immediately before execution/forwarding and fail closed with an explicit denial when `cwd` drifts after approval hardening.
|
||||
- Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for `sessions_spawn` with `runtime="acp"` by rejecting ACP spawns from sandboxed requester sessions and rejecting `sandbox="require"` for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.
|
||||
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
|
||||
- Security/fs-safe write hardening: make `writeFileWithinRoot` use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.
|
||||
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
|
||||
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
|
||||
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
|
||||
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
|
||||
- Security/Nodes camera URL downloads: bind node `camera.snap`/`camera.clip` URL payload downloads to the resolved node host, enforce fail-closed behavior when node `remoteIp` is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.
|
||||
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
|
||||
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
|
||||
- Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
|
||||
- Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.
|
||||
- Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so `HEARTBEAT_OK` noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.
|
||||
- Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing `HEARTBEAT_OK` from being delivered to users. (#32131) Thanks @adhishthite.
|
||||
- Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing `starttime` when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.
|
||||
- Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (`mtimeMs` + `sizeBytes`), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.
|
||||
- Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like `source`/`provider`), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.
|
||||
- Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so `openclaw models status` no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
|
||||
- Config/backups hardening: enforce owner-only (`0600`) permissions on rotated config backups and clean orphan `.bak.*` files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
|
||||
- Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.
|
||||
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
|
||||
- Webchat/silent token leak: filter assistant `NO_REPLY`-only transcript entries from `chat.history` responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.
|
||||
- Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like `/usr/bin/g++` and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
|
||||
- Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.
|
||||
- Hooks/after_tool_call: include embedded session context (`sessionKey`, `agentId`) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.
|
||||
- Hooks/session-scoped memory context: expose ephemeral `sessionId` in embedded plugin tool contexts and `before_tool_call`/`after_tool_call` hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across `/new` and `/reset`. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.
|
||||
- Hooks/tool-call correlation: include `runId` and `toolCallId` in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in `before_tool_call` and `after_tool_call`. (#32360) Thanks @vincentkoc.
|
||||
- Webchat/stream finalization: persist streamed assistant text when final events omit `message`, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
|
||||
- Cron/store migration: normalize legacy cron jobs with string `schedule` and top-level `command`/`timeout` fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
|
||||
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
|
||||
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
|
||||
- Tools/fsPolicy propagation: honor `tools.fs.workspaceOnly` for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.
|
||||
- Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like `node@22`) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.
|
||||
- Discord/audio preflight mentions: detect audio attachments via Discord `content_type` and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
|
||||
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
|
||||
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
|
||||
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
|
||||
- Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (`pnpm`, `bun`) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.
|
||||
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
|
||||
- Plugins/install diagnostics: reject legacy plugin package shapes without `openclaw.extensions` and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
|
||||
- Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example `diffs` -> bundled `@openclaw/diffs`), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
|
||||
- Browser/default profile selection: default `browser.defaultProfile` behavior now prefers `openclaw` (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the `chrome` relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
|
||||
- Doctor/local memory provider checks: stop false-positive local-provider warnings when `provider=local` and no explicit `modelPath` is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
|
||||
- Cron/session reaper reliability: move cron session reaper sweeps into `onTimer` `finally` and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
|
||||
- Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
|
||||
- Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
|
||||
- Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
|
||||
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
|
||||
- Gateway/Node dangerous-command parity: include `sms.send` in default onboarding node `denyCommands`, share onboarding deny defaults with the gateway dangerous-command source of truth, and include `sms.send` in phone-control `/phone arm writes` handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.
|
||||
- Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.
|
||||
- Gateway/Plugin HTTP hardening: require explicit `auth` for plugin route registration, add route ownership guards for duplicate `path+match` registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
|
||||
- Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
|
||||
- Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
|
||||
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Control UI/Legacy browser compatibility: replace `toSorted`-dependent cron suggestion sorting in `app-render` with a compatibility helper so older browsers without `Array.prototype.toSorted` no longer white-screen. (#31775) Thanks @liuxiaopai-ai.
|
||||
- Agents/Sandbox workdir mapping: map container workdir paths (for example `/workspace`) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.
|
||||
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI basePath POST handling: return 405 for `POST` on exact basePath routes (for example `/openclaw`) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.
|
||||
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
|
||||
- Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
|
||||
- Browser/Extension relay reconnect tolerance: keep `/json/version` and `/cdp` reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.
|
||||
- Browser/Extension re-announce reliability: keep relay state in `connecting` when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.
|
||||
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
|
||||
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
|
||||
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Profile attach-only override: support `browser.profiles.<name>.attachOnly` (fallback to global `browser.attachOnly`) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
|
||||
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
|
||||
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
|
||||
- Browser/CDP status accuracy: require a successful `Browser.getVersion` response over the CDP websocket (not just socket-open) before reporting `cdpReady`, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.
|
||||
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
|
||||
- Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up `PortInUseError` races after `browser start`/`open`. (#29538) Thanks @AaronWander.
|
||||
- Browser/Managed tab cap: limit loopback managed `openclaw` page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
|
||||
- Browser/CDP proxy bypass: force direct loopback agent paths and scoped `NO_PROXY` expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
|
||||
- Browser/Gateway hardening: preserve env credentials for `OPENCLAW_GATEWAY_URL` / `CLAWDBOT_GATEWAY_URL` while treating explicit `--url` as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.
|
||||
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
|
||||
- Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
|
||||
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
|
||||
- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file `starttime` with `/proc/<pid>/stat` starttime, so stale `.jsonl.lock` files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
|
||||
- Gateway/macOS LaunchAgent hardening: write `Umask=077` in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with `204` to avoid persistent `Processing...` states in Synology Chat clients. (#26635) Thanks @memphislee09-source.
|
||||
- OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit `doctor --deep`) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.
|
||||
- Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.
|
||||
- Synology Chat/reply delivery: resolve webhook usernames to Chat API `user_id` values for outbound chatbot replies, avoiding mismatches between webhook user IDs and `method=chatbot` recipient IDs in multi-account setups. (#23709) Thanks @druide67.
|
||||
- Synology Chat/gateway lifecycle: keep `startAccount` pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.
|
||||
- Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.
|
||||
- Telegram/inbound media filenames: preserve original `file_name` metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.
|
||||
- Telegram/implicit mention forum handling: exclude Telegram forum system service messages (`forum_topic_*`, `general_forum_topic_*`) from reply-chain implicit mention detection so `requireMention` does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.
|
||||
- Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
|
||||
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
|
||||
- Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.
|
||||
- Slack/session routing: keep top-level channel messages in one shared session when `replyToMode=off`, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.
|
||||
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
|
||||
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
|
||||
- Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
|
||||
- Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.
|
||||
- Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan.
|
||||
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
|
||||
- Skills/sherpa-onnx-tts: run the `sherpa-onnx-tts` bin under ESM (replace CommonJS `require` imports) and add regression coverage to prevent `require is not defined in ES module scope` startup crashes. (#31965) Thanks @bmendonca3.
|
||||
- Feishu/Run channel fallback: prefer `Provider` over `Surface` when inferring queued run `messageProvider` fallback (when `OriginatingChannel` is missing), preventing Feishu turns from being mislabeled as `webchat` in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
|
||||
- Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.
|
||||
- Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.
|
||||
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
|
||||
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
|
||||
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
|
||||
- Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)
|
||||
- Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)
|
||||
- Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)
|
||||
- Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)
|
||||
- Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)
|
||||
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
|
||||
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
|
||||
- Feishu/Send target prefixes: normalize explicit `group:`/`dm:` send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
|
||||
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
|
||||
- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
|
||||
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
|
||||
|
||||
## 2026.3.1
|
||||
|
||||
### Changes
|
||||
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.
|
||||
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
|
||||
- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
|
||||
- Telegram/DM topics: add per-DM `direct` + topic config (allowlists, `dmPolicy`, `skills`, `systemPrompt`, `requireTopic`), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.
|
||||
- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
|
||||
- Android/Nodes parity: add `system.notify`, `photos.latest`, `contacts.search`/`contacts.add`, `calendar.events`/`calendar.add`, and `motion.activity`/`motion.pedometer`, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.
|
||||
- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
|
||||
- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
|
||||
- CLI/Config: add `openclaw config file` to print the active config file path resolved from `OPENCLAW_CONFIG_PATH` or the default location. (#26256) thanks @cyb1278588254.
|
||||
- Feishu/Docx tables + uploads: add `feishu_doc` actions for Docx table creation/cell writing (`create_table`, `write_table_cells`, `create_table_with_values`) and image/file uploads (`upload_image`, `upload_file`) with stricter create/upload error handling for missing `document_id` and placeholder cleanup failures. (#20304) Thanks @xuhao1.
|
||||
- Feishu/Reactions: add inbound `im.message.reaction.created_v1` handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.
|
||||
@@ -26,7 +225,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
|
||||
- Web UI/i18n: add German (`de`) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.
|
||||
- Tools/Diffs: add a new optional `diffs` plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras.
|
||||
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
|
||||
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
|
||||
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
|
||||
- Shell env markers: set `OPENCLAW_SHELL` across shell-like runtimes (`exec`, `acp`, `acp-client`, `tui-local`) so shell startup/config rules can target OpenClaw contexts consistently, and document the markers in env/exec/acp/TUI docs. Thanks @vincentkoc.
|
||||
@@ -41,48 +239,48 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
|
||||
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
|
||||
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
|
||||
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
|
||||
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
|
||||
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
|
||||
- Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
|
||||
- Browser/Extension relay reconnect tolerance: keep `/json/version` and `/cdp` reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.
|
||||
- Browser/Extension re-announce reliability: keep relay state in `connecting` when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.
|
||||
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
|
||||
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
|
||||
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Profile attach-only override: support `browser.profiles.<name>.attachOnly` (fallback to global `browser.attachOnly`) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
|
||||
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
|
||||
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
|
||||
- Browser/Config schema: accept `browser.extraArgs` in config validation and reject invalid non-array/non-string values with explicit schema regressions coverage. (#29882) Thanks @gushiaoke.
|
||||
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
|
||||
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
|
||||
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
|
||||
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
|
||||
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
|
||||
- Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (`198.18.0.0/15`) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.
|
||||
- Feishu/Sessions announce group targets: normalize `group:` and `channel:` Feishu targets to `chat_id` routing so `sessions_send` announce delivery no longer sends group chat IDs via `user_id` API params. Fixes #31426.
|
||||
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
|
||||
- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
|
||||
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
|
||||
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
|
||||
- Gateway/Control UI origins: honor `gateway.controlUi.allowedOrigins: ["*"]` wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.
|
||||
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
|
||||
- Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.
|
||||
- CLI/Install: add an npm-link fallback to fix CLI startup `Permission denied` failures (`exit 127`) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
|
||||
- Plugins/NPM spec install: fix npm-spec plugin installs when `npm pack` output is empty by detecting newly created `.tgz` archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.
|
||||
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
|
||||
- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
|
||||
- Sessions/Internal routing: preserve established external `lastTo`/`lastChannel` routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.
|
||||
- Auto-reply/NO_REPLY: strip `NO_REPLY` token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Inbound metadata/Multi-account routing: include `account_id` in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.
|
||||
- Cron/Delivery mode none: send explicit `delivery: { mode: "none" }` from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.
|
||||
- Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
|
||||
- Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
|
||||
- CLI/Ollama config: allow `config set` for Ollama `apiKey` without predeclared provider config. (#29299) Thanks @vincentkoc.
|
||||
- Agents/Ollama: demote empty-discovery logging from `warn` to `debug` to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
|
||||
- Sandbox/Browser Docker: pass `OPENCLAW_BROWSER_NO_SANDBOX=1` to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.
|
||||
- Tools/Edit workspace boundary errors: preserve the real `Path escapes workspace root` failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Browser/Open & navigate: accept `url` as an alias parameter for `open` and `navigate`. (#29260) Thanks @vincentkoc.
|
||||
- Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false `cannot create directories` failures in sandbox write mode. (#30610) Thanks @glitch418x.
|
||||
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
|
||||
- LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.
|
||||
- Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.
|
||||
- Android/Photos permissions: declare Android 14+ selected-photo access permission (`READ_MEDIA_VISUAL_USER_SELECTED`) and align Android permission/settings paths with current minSdk behavior for more reliable permission state handling.
|
||||
- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
|
||||
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
|
||||
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
|
||||
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) Thanks @icesword0760.
|
||||
- Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (`SLACK_USER_TOKEN` env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.
|
||||
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
|
||||
- Feishu/Outbound session routing: stop assuming bare `oc_` identifiers are always group chats, honor explicit `dm:`/`group:` prefixes for `oc_` chat IDs, and default ambiguous bare `oc_` targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.
|
||||
- Feishu/Group session routing: add configurable group session scopes (`group`, `group_sender`, `group_topic`, `group_topic_sender`) with legacy `topicSessionMode=enabled` compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) Thanks @yfge.
|
||||
- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.
|
||||
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
|
||||
- Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg.
|
||||
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
|
||||
- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Feishu/Mobile video media type: treat inbound `message_type: "media"` as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.
|
||||
- Feishu/Inbound sender fallback: fall back to `sender_id.user_id` when `sender_id.open_id` is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.
|
||||
- Feishu/Reply context metadata: include inbound `parent_id` and `root_id` as `ReplyToId`/`RootMessageId` in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu.
|
||||
@@ -101,16 +299,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack/Native commands: register Slack native status as `/agentstatus` (Slack-reserved `/status`) so manifest slash command registration stays valid while text `/status` still works. Landed from contributor PR #29032 by @maloqab. Thanks @maloqab.
|
||||
- Android/Camera clip: remove `camera.clip` HTTP-upload fallback to base64 so clip transport is deterministic and fail-loud, and reject non-positive `maxWidth` values so invalid inputs fall back to the safe resize default. (#28229) Thanks @obviyus.
|
||||
- Android/Gateway canvas capability refresh: send `node.canvas.capability.refresh` with object `params` (`{}`) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.
|
||||
- Gateway/Control UI origins: honor `gateway.controlUi.allowedOrigins: ["*"]` wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.
|
||||
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
|
||||
- Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file `starttime` with `/proc/<pid>/stat` starttime, so stale `.jsonl.lock` files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
|
||||
- Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.
|
||||
- CLI/Install: add an npm-link fallback to fix CLI startup `Permission denied` failures (`exit 127`) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
|
||||
- Onboarding/Custom providers: improve verification reliability for slower local endpoints (for example Ollama) during setup. (#27380) Thanks @Sid-Qin.
|
||||
- Plugins/NPM spec install: fix npm-spec plugin installs when `npm pack` output is empty by detecting newly created `.tgz` archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.
|
||||
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
|
||||
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
|
||||
- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
|
||||
- Daemon/macOS TLS certs: default LaunchAgent service env `NODE_EXTRA_CA_CERTS` to `/etc/ssl/cert.pem` (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
|
||||
- Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.
|
||||
@@ -122,54 +311,24 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/API quota controls: add `typingIndicator` and `resolveSenderNames` config flags (top-level and per-account) so operators can disable typing reactions and sender-name lookup requests while keeping default behavior unchanged. (#10513) Thanks @BigUncle.
|
||||
- Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted `System:` context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.
|
||||
- Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.
|
||||
- Sessions/Internal routing: preserve established external `lastTo`/`lastChannel` routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.
|
||||
- Control UI/Debug log layout: render Debug Event Log payloads at full width to prevent payload JSON from being squeezed into a narrow side column. Landed from contributor PR #30978 by @stozo04. Thanks @stozo04.
|
||||
- Auto-reply/NO_REPLY: strip `NO_REPLY` token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Install/npm: fix npm global install deprecation warnings. (#28318) Thanks @vincentkoc.
|
||||
- Update/Global npm: fallback to `--omit=optional` when global `npm update` fails so optional dependency install failures no longer abort update flows. (#24896) Thanks @xinhuagu and @vincentkoc.
|
||||
- Inbound metadata/Multi-account routing: include `account_id` in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.
|
||||
- Model directives/Auth profiles: split `/model` profile suffixes at the first `@` after the last slash so email-based auth profile IDs (for example OAuth profile IDs) resolve correctly. Landed from contributor PR #30932 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Cron/Delivery mode none: send explicit `delivery: { mode: "none" }` from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.
|
||||
- Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
|
||||
- Ollama/Embedded runner base URL precedence: prioritize configured provider `baseUrl` over model defaults for embedded Ollama runs so Docker and remote-host setups avoid localhost fetch failures. (#30964) Thanks @stakeswky.
|
||||
- Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
|
||||
- CLI/Ollama config: allow `config set` for Ollama `apiKey` without predeclared provider config. (#29299) Thanks @vincentkoc.
|
||||
- Ollama/Autodiscovery: harden autodiscovery and warning behavior. (#29201) Thanks @marcodelpin and @vincentkoc.
|
||||
- Ollama/Context window: unify context window handling across discovery, merge, and OpenAI-compatible transport paths. (#29205) Thanks @Sid-Qin, @jimmielightner, and @vincentkoc.
|
||||
- Agents/Ollama: demote empty-discovery logging from `warn` to `debug` to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
|
||||
- fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.
|
||||
- Docker/Image permissions: normalize `/app/extensions`, `/app/.agent`, and `/app/.agents` to directory mode `755` and file mode `644` during image build so plugin discovery does not block inherited world-writable paths. (#30191) Fixes #30139. Thanks @edincampara.
|
||||
- OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty `baseUrl` as non-direct, honor `compat.supportsStore=false`, and auto-inject server-side compaction `context_management` for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.
|
||||
- Sandbox/Browser Docker: pass `OPENCLAW_BROWSER_NO_SANDBOX=1` to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.
|
||||
- Usage normalization: clamp negative prompt/input token values to zero (including `prompt_tokens` alias inputs) so `/usage` and TUI usage displays cannot show nonsensical negative counts. Landed from contributor PR #31211 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Secrets/Auth profiles: normalize inline SecretRef `token`/`key` values to canonical `tokenRef`/`keyRef` before persistence, and keep explicit `keyRef` precedence when inline refs are also present. Landed from contributor PR #31047 by @minupla. Thanks @minupla.
|
||||
- Tools/Edit workspace boundary errors: preserve the real `Path escapes workspace root` failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Browser/Open & navigate: accept `url` as an alias parameter for `open` and `navigate`. (#29260) Thanks @vincentkoc.
|
||||
- Codex/Usage window: label weekly usage window as `Week` instead of `Day`. (#26267) Thanks @Sid-Qin.
|
||||
- Signal/Sync message null-handling: treat `syncMessage` presence (including `null`) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Infra/fs-safe: sanitize directory-read failures so raw `EISDIR` text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.
|
||||
- Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false `cannot create directories` failures in sandbox write mode. (#30610) Thanks @glitch418x.
|
||||
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
|
||||
- Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (`198.18.0.0/15`) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
|
||||
- Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
|
||||
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
|
||||
@@ -196,6 +355,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/Application ID fallback: parse bot application IDs from token prefixes without numeric precision loss and use token fallback only on transport/timeout failures when probing `/oauth2/applications/@me`. Landed from contributor PR #29695 by @dhananjai1729. Thanks @dhananjai1729.
|
||||
- Discord/EventQueue timeout config: expose per-account `channels.discord.accounts.<id>.eventQueue.listenerTimeout` (and related queue options) so long-running handlers can avoid Carbon listener timeout drops. Landed from contributor PR #28945 by @Glucksberg. Thanks @Glucksberg.
|
||||
- CLI/Cron run exit code: return exit code `0` only when `cron run` reports `{ ok: true, ran: true }`, and `1` for non-run/error outcomes so scripting/debugging reflects actual execution status. Landed from contributor PR #31121 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Cron/Failure delivery routing: add `failureAlert.mode` (`announce|webhook`) and `failureAlert.accountId` support, plus `cron.failureDestination` and per-job `delivery.failureDestination` routing with duplicate-target suppression, best-effort skip behavior, and global+job merge semantics. Landed from contributor PR #31059 by @kesor. Thanks @kesor.
|
||||
- CLI/JSON preflight output: keep `--json` command stdout machine-readable by suppressing doctor preflight note output while still running legacy migration/config doctor flow. (#24368) Thanks @altaywtf.
|
||||
- Nodes/Screen recording guardrails: cap `nodes` tool `screen_record` `durationMs` to 5 minutes at both schema-validation and runtime invocation layers to prevent long-running blocking captures from unbounded durations. Landed from contributor PR #31106 by @BlueBirdBack. Thanks @BlueBirdBack.
|
||||
- Telegram/Empty final replies: skip outbound send for null/undefined final text payloads without media so Telegram typing indicators do not linger on `text must be non-empty` errors, with added regression coverage for undefined final payload dispatch. Landed from contributor PRs #30969 by @haosenwang1018 and #30746 by @rylena. Thanks @haosenwang1018 and @rylena.
|
||||
@@ -222,6 +382,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Audit: flag `gateway.controlUi.allowedOrigins=["*"]` as a high-risk configuration (severity based on bind exposure), and add a Feishu doc-tool warning that `owner_open_id` on `feishu_doc` create can grant document permissions.
|
||||
- Slack/download-file scoping: thread/channel-aware `download-file` actions now propagate optional scope context and reject downloads when Slack metadata definitively shows the file is outside the requested channel/thread, while preserving legacy behavior when share metadata is unavailable.
|
||||
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
- Security/Sandbox media staging: block destination symlink escapes in `stageSandboxMedia` by replacing direct destination copies with root-scoped safe writes for both local and SCP-staged attachments, preventing out-of-workspace file overwrite through `media/inbound` alias traversal. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
|
||||
- Node host/service auth env: include `OPENCLAW_GATEWAY_TOKEN` in `openclaw node install` service environments (with `CLAWDBOT_GATEWAY_TOKEN` compatibility fallback) so installed node services keep remote gateway token auth across restart/reboot. Fixes #31041. Thanks @OneStepAt4time for reporting, @byungsker, @liuxiaopai-ai, and @vincentkoc.
|
||||
- Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting.
|
||||
- Security/Workspace safe writes: harden `writeFileWithinRoot` against symlink-retarget TOCTOU races by opening existing files without truncation, creating missing files with exclusive create, deferring truncation until post-open identity+boundary validation, and removing out-of-root create artifacts on blocked races; added regression tests for truncate/create race paths. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
|
||||
@@ -585,28 +746,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting.
|
||||
- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting.
|
||||
- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.
|
||||
- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.
|
||||
- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.
|
||||
- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.
|
||||
- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.
|
||||
- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728.
|
||||
- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
|
||||
- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.
|
||||
- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr.
|
||||
- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.
|
||||
- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.
|
||||
- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.
|
||||
- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.
|
||||
- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
|
||||
- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
|
||||
- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
|
||||
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
|
||||
- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach.
|
||||
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
|
||||
- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
|
||||
- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18.
|
||||
- CLI/Memory search: accept `--query <text>` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.
|
||||
- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
|
||||
|
||||
## 2026.2.23
|
||||
@@ -660,7 +801,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/Install: when npm install returns 404 for bundled channel npm specs, fallback to bundled channel sources and complete install/enable persistence instead of failing plugin install. (#12849) Thanks @vincentkoc.
|
||||
- Gemini OAuth/Auth: resolve npm global shim install layouts while discovering Gemini CLI credentials, preventing false "Gemini CLI not found" onboarding/auth failures when shim paths are on `PATH`. (#27585) Thanks @ehgamemo and @vincentkoc.
|
||||
- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras.
|
||||
- Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn.
|
||||
- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) Thanks @steipete.
|
||||
@@ -917,6 +1057,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. Thanks @tdjackey for reporting.
|
||||
- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. Thanks @tdjackey for reporting.
|
||||
- Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared `safeFetch` so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore.
|
||||
- Security/MSTeams auth redirect scoping: strip bearer auth on redirect hops outside `authAllowHosts` and gate SharePoint Graph auth-header injection by auth allowlist to prevent token bleed across redirect targets. (#25045) Thanks @bmendonca3.
|
||||
- MSTeams/reply reliability: when Bot Framework revokes thread turn-context proxies (for example debounced flush paths), fall back to proactive messaging/typing and continue pending sends without duplicating already delivered messages. (#27224) Thanks @openperf.
|
||||
- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.
|
||||
- Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
|
||||
- CI/Tests: fix TypeScript case-table typing and lint assertion regressions so `pnpm check` passes again after Synology Chat landing. (#23012) Thanks @druide67.
|
||||
@@ -1022,8 +1164,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Config: allow `gateway.customBindHost` in strict config validation when `gateway.bind="custom"` so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420.
|
||||
- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
|
||||
- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.
|
||||
- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
|
||||
- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
|
||||
- Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.
|
||||
- Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.
|
||||
- Agents/Tool display: fix exec cwd suffix inference so `pushd ... && popd ... && <command>` does not keep stale `(in <dir>)` context in summaries. (#21925) Thanks @Lukavyi.
|
||||
@@ -1396,7 +1536,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
|
||||
- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
|
||||
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
|
||||
- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus.
|
||||
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
|
||||
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
|
||||
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
|
||||
@@ -1406,7 +1545,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
|
||||
- Discord: skip text-based exec approval forwarding in favor of Discord's component-based approval UI. Thanks @thewilloftheshadow.
|
||||
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
|
||||
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
|
||||
- Gateway/Memory: initialize QMD startup sync for every configured agent (not just the default agent), so `memory.qmd.update.onBoot` is effective across multi-agent setups. (#17663) Thanks @HenryLoenwind.
|
||||
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
|
||||
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
|
||||
@@ -1897,9 +2035,6 @@ Docs: https://docs.openclaw.ai
|
||||
- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393)
|
||||
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
|
||||
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
|
||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07.
|
||||
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
|
||||
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
|
||||
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
|
||||
- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop.
|
||||
@@ -2017,7 +2152,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
|
||||
- Security/Gateway: require `operator.approvals` for in-chat `/approve` when invoked from gateway clients. Thanks @yueyueL.
|
||||
- Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek.
|
||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
|
||||
- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)
|
||||
- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed); completion prompt now handled by install/update.
|
||||
@@ -2087,62 +2221,10 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.1.31
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: onboarding/install/i18n/exec-approvals/Control UI/exe.dev/cacheRetention updates + misc nav/typos. (#3050, #3461, #4064, #4675, #4729, #4763, #5003, #5402, #5446, #5474, #5663, #5689, #5694, #5967, #6270, #6300, #6311, #6416, #6487, #6550, #6789)
|
||||
- Telegram: use shared pairing store. (#6127) Thanks @obviyus.
|
||||
- Agents: add OpenRouter app attribution headers. Thanks @alexanderatallah.
|
||||
- Agents: add system prompt safety guardrails. (#5445) Thanks @joshp123.
|
||||
- Agents: update pi-ai to 0.50.9 and rename cacheControlTtl -> cacheRetention (with back-compat mapping).
|
||||
- Agents: extend CreateAgentSessionOptions with systemPrompt/skills/contextFiles.
|
||||
- Agents: add tool policy conformance snapshot (no runtime behavior change). (#6011)
|
||||
- Auth: update MiniMax OAuth hint + portal auth note copy.
|
||||
- Discord: inherit thread parent bindings for routing. (#3892) Thanks @aerolalit.
|
||||
- Gateway: inject timestamps into agent and chat.send messages. (#3705) Thanks @conroywhitney, @CashWilliams.
|
||||
- Gateway: require TLS 1.3 minimum for TLS listeners. (#5970) Thanks @loganaden.
|
||||
- Web UI: refine chat layout + extend session active duration.
|
||||
- CI: add formal conformance + alias consistency checks. (#5723, #5807)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning).
|
||||
- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures.
|
||||
- Plugins: validate plugin/hook install paths and reject traversal-like names.
|
||||
- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.
|
||||
- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.
|
||||
- Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014)
|
||||
- Streaming: stabilize partial streaming filters.
|
||||
- Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation.
|
||||
- Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization).
|
||||
- Tools: treat `"*"` tool allowlist entries as valid to avoid spurious unknown-entry warnings.
|
||||
- Skills: update session-logs paths from .clawdbot to .openclaw. (#4502)
|
||||
- Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach.
|
||||
- Lint: satisfy curly rule after import sorting. (#6310)
|
||||
- Process: resolve Windows `spawn()` failures for npm-family CLIs by appending `.cmd` when needed. (#5815) Thanks @thejhinvirtuoso.
|
||||
- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow.
|
||||
- Tlon: add timeout to SSE client fetch calls (CWE-400). (#5926)
|
||||
- Memory search: L2-normalize local embedding vectors to fix semantic search. (#5332)
|
||||
- Agents: align embedded runner + typings with pi-coding-agent API updates (pi 0.51.0).
|
||||
- Agents: ensure OpenRouter attribution headers apply in the embedded runner.
|
||||
- Agents: cap context window resolution for compaction safeguard. (#6187) Thanks @iamEvanYT.
|
||||
- System prompt: resolve overrides and hint using session_status for current date/time. (#1897, #1928, #2108, #3677)
|
||||
- Agents: fix Pi prompt template argument syntax. (#6543)
|
||||
- Subagents: fix announce failover race (always emit lifecycle end; timeout=0 means no-timeout). (#6621)
|
||||
- Teams: gate media auth retries.
|
||||
- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.
|
||||
- Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman.
|
||||
- TUI: prevent crash when searching with digits in the model selector.
|
||||
- Agents: wire before_tool_call plugin hook into tool execution. (#6570, #6660) Thanks @ryancnelson.
|
||||
- Browser: secure Chrome extension relay CDP sessions.
|
||||
- Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42.
|
||||
- Docker: start gateway CMD by default for container deployments. (#6635) Thanks @kaizen403.
|
||||
- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.
|
||||
- Security: sanitize WhatsApp accountId to prevent path traversal. (#4610)
|
||||
- Security: restrict MEDIA path extraction to prevent LFI. (#4930)
|
||||
- Security: validate message-tool filePath/path against sandbox root. (#6398)
|
||||
- Security: block LD*/DYLD* env overrides for host exec. (#4896) Thanks @HassanFleyah.
|
||||
- Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc.
|
||||
- Security: enforce Twitch `allowFrom` allowlist gating (deny non-allowlisted senders). Thanks @MegaManSec.
|
||||
|
||||
## 2026.1.30
|
||||
|
||||
@@ -2873,7 +2955,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -2882,7 +2963,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
|
||||
- Telegram: default reaction notifications to own.
|
||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||
@@ -2922,7 +3002,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos.
|
||||
- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos.
|
||||
- WhatsApp: default response prefix only for self-chat, using identity name when set.
|
||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
||||
- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops.
|
||||
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
|
||||
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
|
||||
@@ -3019,13 +3098,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
||||
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
|
||||
#### macOS / Apps
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
Model note: while many providers/models are supported, for the best experience and lower prompt-injection risk use the strongest latest-generation model available to you. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ These are frequently reported but are typically closed with no code change:
|
||||
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
|
||||
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
||||
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
|
||||
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
|
||||
- Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state.
|
||||
- Missing HSTS findings on default local/loopback deployments.
|
||||
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
|
||||
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
|
||||
@@ -114,6 +116,8 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
|
||||
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
|
||||
- Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
|
||||
- Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state.
|
||||
- Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive.
|
||||
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
|
||||
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
|
||||
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
|
||||
@@ -149,6 +153,8 @@ OpenClaw's security model is "personal assistant" (one trusted operator, potenti
|
||||
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.
|
||||
- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals.
|
||||
- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries.
|
||||
- Hook/webhook-driven payloads should be treated as untrusted content; keep unsafe bypass flags disabled unless doing tightly scoped debugging (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`).
|
||||
- Weak model tiers are generally easier to prompt-inject. For tool-enabled or hook-driven agents, prefer strong modern model tiers and strict tool policy (for example `tools.profile: "messaging"` or stricter), plus sandboxing where possible.
|
||||
|
||||
## Gateway and Node trust concept
|
||||
|
||||
|
||||
@@ -33,10 +33,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executor
|
||||
@@ -101,7 +98,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(params) ?: 1600
|
||||
@@ -167,7 +164,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(params) ?: true
|
||||
@@ -293,20 +290,8 @@ class CameraCaptureManager(private val context: Context) {
|
||||
return rotated
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
|
||||
private fun parseFacing(params: JsonObject?): String? {
|
||||
val value = readPrimitive(params, "facing")?.contentOrNull?.trim()?.lowercase() ?: return null
|
||||
val value = parseJsonString(params, "facing")?.trim()?.lowercase() ?: return null
|
||||
return when (value) {
|
||||
"front", "back" -> value
|
||||
else -> null
|
||||
@@ -314,31 +299,21 @@ class CameraCaptureManager(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun parseQuality(params: JsonObject?): Double? =
|
||||
readPrimitive(params, "quality")?.contentOrNull?.toDoubleOrNull()
|
||||
parseJsonDouble(params, "quality")
|
||||
|
||||
private fun parseMaxWidth(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "maxWidth")
|
||||
?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
parseJsonInt(params, "maxWidth")
|
||||
?.takeIf { it > 0 }
|
||||
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
parseJsonInt(params, "durationMs")
|
||||
|
||||
private fun parseDeviceId(params: JsonObject?): String? =
|
||||
readPrimitive(params, "deviceId")
|
||||
?.contentOrNull
|
||||
parseJsonString(params, "deviceId")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
|
||||
|
||||
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
|
||||
|
||||
@@ -21,6 +23,35 @@ fun String.toJsonString(): String {
|
||||
|
||||
fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun readJsonPrimitive(params: JsonObject?, key: String): JsonPrimitive? = params?.get(key) as? JsonPrimitive
|
||||
|
||||
fun parseJsonInt(params: JsonObject?, key: String): Int? =
|
||||
readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
|
||||
|
||||
fun parseJsonDouble(params: JsonObject?, key: String): Double? =
|
||||
readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
fun parseJsonString(params: JsonObject?, key: String): String? =
|
||||
readJsonPrimitive(params, key)?.contentOrNull
|
||||
|
||||
fun parseJsonBooleanFlag(params: JsonObject?, key: String): Boolean? {
|
||||
val value = readJsonPrimitive(params, key)?.contentOrNull?.trim()?.lowercase() ?: return null
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
|
||||
@@ -10,10 +10,7 @@ import ai.openclaw.android.ScreenCaptureRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -39,7 +36,7 @@ class ScreenRecordManager(private val context: Context) {
|
||||
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
|
||||
)
|
||||
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000)
|
||||
val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0)
|
||||
val fpsInt = fps.roundToInt().coerceIn(1, 60)
|
||||
@@ -146,38 +143,19 @@ class ScreenRecordManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
parseJsonInt(params, "durationMs")
|
||||
|
||||
private fun parseFps(params: JsonObject?): Double? =
|
||||
readPrimitive(params, "fps")?.contentOrNull?.toDoubleOrNull()
|
||||
parseJsonDouble(params, "fps")
|
||||
|
||||
private fun parseScreenIndex(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "screenIndex")?.contentOrNull?.toIntOrNull()
|
||||
parseJsonInt(params, "screenIndex")
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
|
||||
|
||||
private fun parseString(params: JsonObject?, key: String): String? =
|
||||
readPrimitive(params, key)?.contentOrNull
|
||||
parseJsonString(params, key)
|
||||
|
||||
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
|
||||
val pixels = width.toLong() * height.toLong()
|
||||
|
||||
@@ -3,12 +3,14 @@ package ai.openclaw.android.gateway
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Response
|
||||
@@ -27,6 +29,10 @@ import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private const val TEST_TIMEOUT_MS = 8_000L
|
||||
private const val CONNECT_CHALLENGE_FRAME =
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
|
||||
|
||||
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
private val tokens = mutableMapOf<String, String>()
|
||||
|
||||
@@ -37,530 +43,301 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
}
|
||||
}
|
||||
|
||||
private data class NodeHarness(
|
||||
val session: GatewaySession,
|
||||
val sessionJob: Job,
|
||||
)
|
||||
|
||||
private data class InvokeScenarioResult(
|
||||
val request: GatewaySession.InvokeRequest,
|
||||
val resultParams: JsonObject,
|
||||
)
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GatewaySessionInvokeTest {
|
||||
@Test
|
||||
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val handshakeOrigin = AtomicReference<String?>(null)
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
handshakeOrigin.compareAndSet(null, request.getHeader("Origin"))
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
val result =
|
||||
runInvokeScenario(
|
||||
invokeEventFrame =
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
|
||||
onHandshake = { request -> handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) },
|
||||
) {
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-1", req.id)
|
||||
assertEquals("node-1", req.nodeId)
|
||||
assertEquals("debug.ping", req.command)
|
||||
assertEquals("""{"ping":"pong"}""", req.paramsJson)
|
||||
assertNull(handshakeOrigin.get())
|
||||
assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
true,
|
||||
resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
assertEquals("invoke-1", result.request.id)
|
||||
assertEquals("node-1", result.request.nodeId)
|
||||
assertEquals("debug.ping", result.request.command)
|
||||
assertEquals("""{"ping":"pong"}""", result.request.paramsJson)
|
||||
assertNull(handshakeOrigin.get())
|
||||
assertEquals("invoke-1", result.resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-1", result.resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
true,
|
||||
result.resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
val result =
|
||||
runInvokeScenario(
|
||||
invokeEventFrame =
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
|
||||
) {
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-2", req.id)
|
||||
assertEquals("node-2", req.nodeId)
|
||||
assertEquals("debug.raw", req.command)
|
||||
assertEquals("""{"raw":true}""", req.paramsJson)
|
||||
assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
assertEquals("invoke-2", result.request.id)
|
||||
assertEquals("node-2", result.request.nodeId)
|
||||
assertEquals("debug.raw", result.request.command)
|
||||
assertEquals("""{"raw":true}""", result.request.paramsJson)
|
||||
assertEquals("invoke-2", result.resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-2", result.resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
val result =
|
||||
runInvokeScenario(
|
||||
invokeEventFrame =
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
|
||||
) {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
"CAMERA_PERMISSION_REQUIRED",
|
||||
resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"grant Camera permission",
|
||||
resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
assertEquals("invoke-3", result.resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-3", result.resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(false, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
"CAMERA_PERMISSION_REQUIRED",
|
||||
result.resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"grant Camera permission",
|
||||
result.resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val json = testJson()
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val refreshRequestParams = CompletableDeferred<String?>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasHostUrl":"http://127.0.0.1/__openclaw__/cap/old-cap","snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
}
|
||||
"node.canvas.capability.refresh" -> {
|
||||
if (!refreshRequestParams.isCompleted) {
|
||||
refreshRequestParams.complete(frame["params"]?.toString())
|
||||
}
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
val server =
|
||||
startGatewayServer(json) { webSocket, id, method, frame ->
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(connectResponseFrame(id, canvasHostUrl = "http://127.0.0.1/__openclaw__/cap/old-cap"))
|
||||
}
|
||||
start()
|
||||
"node.canvas.capability.refresh" -> {
|
||||
if (!refreshRequestParams.isCompleted) {
|
||||
refreshRequestParams.complete(frame["params"]?.toString())
|
||||
}
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
|
||||
)
|
||||
val harness =
|
||||
createNodeHarness(
|
||||
connected = connected,
|
||||
lastDisconnect = lastDisconnect,
|
||||
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
connectNodeSession(harness.session, server.port)
|
||||
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val refreshed = session.refreshNodeCanvasCapability(timeoutMs = 8_000)
|
||||
val refreshParamsJson = withTimeout(8_000) { refreshRequestParams.await() }
|
||||
val refreshed = harness.session.refreshNodeCanvasCapability(timeoutMs = TEST_TIMEOUT_MS)
|
||||
val refreshParamsJson = withTimeout(TEST_TIMEOUT_MS) { refreshRequestParams.await() }
|
||||
|
||||
assertEquals(true, refreshed)
|
||||
assertEquals("{}", refreshParamsJson)
|
||||
assertEquals(
|
||||
"http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
|
||||
session.currentCanvasHostUrl(),
|
||||
harness.session.currentCanvasHostUrl(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
shutdownHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
private fun testJson(): Json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun createNodeHarness(
|
||||
connected: CompletableDeferred<Unit>,
|
||||
lastDisconnect: AtomicReference<String>,
|
||||
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
|
||||
): NodeHarness {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = InMemoryDeviceAuthStore(),
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = onInvoke,
|
||||
)
|
||||
|
||||
return NodeHarness(session = session, sessionJob = sessionJob)
|
||||
}
|
||||
|
||||
private suspend fun connectNodeSession(session: GatewaySession, port: Int) {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|$port",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun awaitConnectedOrThrow(
|
||||
connected: CompletableDeferred<Unit>,
|
||||
lastDisconnect: AtomicReference<String>,
|
||||
server: MockWebServer,
|
||||
) {
|
||||
val connectedWithinTimeout =
|
||||
withTimeoutOrNull(TEST_TIMEOUT_MS) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun shutdownHarness(harness: NodeHarness, server: MockWebServer) {
|
||||
harness.session.disconnect()
|
||||
harness.sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private suspend fun runInvokeScenario(
|
||||
invokeEventFrame: String,
|
||||
onHandshake: ((RecordedRequest) -> Unit)? = null,
|
||||
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
|
||||
): InvokeScenarioResult {
|
||||
val json = testJson()
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
startGatewayServer(
|
||||
json = json,
|
||||
onHandshake = onHandshake,
|
||||
) { webSocket, id, method, frame ->
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(connectResponseFrame(id))
|
||||
webSocket.send(invokeEventFrame)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
val harness =
|
||||
createNodeHarness(
|
||||
connected = connected,
|
||||
lastDisconnect = lastDisconnect,
|
||||
) { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
onInvoke(req)
|
||||
}
|
||||
|
||||
try {
|
||||
connectNodeSession(harness.session, server.port)
|
||||
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||
val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(TEST_TIMEOUT_MS) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
return InvokeScenarioResult(request = request, resultParams = resultParams)
|
||||
} finally {
|
||||
shutdownHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectResponseFrame(id: String, canvasHostUrl: String? = null): String {
|
||||
val canvas = canvasHostUrl?.let { "\"canvasHostUrl\":\"$it\"," } ?: ""
|
||||
return """{"type":"res","id":"$id","ok":true,"payload":{$canvas"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
|
||||
}
|
||||
|
||||
private fun startGatewayServer(
|
||||
json: Json,
|
||||
onHandshake: ((RecordedRequest) -> Unit)? = null,
|
||||
onRequestFrame: (webSocket: WebSocket, id: String, method: String, frame: JsonObject) -> Unit,
|
||||
): MockWebServer =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
onHandshake?.invoke(request)
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(CONNECT_CHALLENGE_FRAME)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
onRequestFrame(webSocket, id, method, frame)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,8 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CalendarHandlerTest {
|
||||
class CalendarHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@Test
|
||||
fun handleCalendarEvents_requiresPermission() {
|
||||
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false))
|
||||
@@ -83,8 +79,6 @@ class CalendarHandlerTest {
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CALENDAR_NOT_FOUND", result.error?.code)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeCalendarDataSource(
|
||||
|
||||
@@ -9,12 +9,8 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ContactsHandlerTest {
|
||||
class ContactsHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@Test
|
||||
fun handleContactsSearch_requiresReadPermission() {
|
||||
val handler = ContactsHandler.forTesting(appContext(), FakeContactsDataSource(canRead = false))
|
||||
@@ -92,8 +88,6 @@ class ContactsHandlerTest {
|
||||
assertEquals("Grace Hopper", contact.getValue("displayName").jsonPrimitive.content)
|
||||
assertEquals(1, source.addCalls)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeContactsDataSource(
|
||||
|
||||
@@ -16,144 +16,106 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeCommandRegistryTest {
|
||||
private val coreCapabilities =
|
||||
setOf(
|
||||
OpenClawCapability.Canvas.rawValue,
|
||||
OpenClawCapability.Screen.rawValue,
|
||||
OpenClawCapability.Device.rawValue,
|
||||
OpenClawCapability.Notifications.rawValue,
|
||||
OpenClawCapability.System.rawValue,
|
||||
OpenClawCapability.AppUpdate.rawValue,
|
||||
OpenClawCapability.Photos.rawValue,
|
||||
OpenClawCapability.Contacts.rawValue,
|
||||
OpenClawCapability.Calendar.rawValue,
|
||||
)
|
||||
|
||||
private val optionalCapabilities =
|
||||
setOf(
|
||||
OpenClawCapability.Camera.rawValue,
|
||||
OpenClawCapability.Location.rawValue,
|
||||
OpenClawCapability.Sms.rawValue,
|
||||
OpenClawCapability.VoiceWake.rawValue,
|
||||
OpenClawCapability.Motion.rawValue,
|
||||
)
|
||||
|
||||
private val coreCommands =
|
||||
setOf(
|
||||
OpenClawDeviceCommand.Status.rawValue,
|
||||
OpenClawDeviceCommand.Info.rawValue,
|
||||
OpenClawDeviceCommand.Permissions.rawValue,
|
||||
OpenClawDeviceCommand.Health.rawValue,
|
||||
OpenClawNotificationsCommand.List.rawValue,
|
||||
OpenClawNotificationsCommand.Actions.rawValue,
|
||||
OpenClawSystemCommand.Notify.rawValue,
|
||||
OpenClawPhotosCommand.Latest.rawValue,
|
||||
OpenClawContactsCommand.Search.rawValue,
|
||||
OpenClawContactsCommand.Add.rawValue,
|
||||
OpenClawCalendarCommand.Events.rawValue,
|
||||
OpenClawCalendarCommand.Add.rawValue,
|
||||
"app.update",
|
||||
)
|
||||
|
||||
private val optionalCommands =
|
||||
setOf(
|
||||
OpenClawCameraCommand.Snap.rawValue,
|
||||
OpenClawCameraCommand.Clip.rawValue,
|
||||
OpenClawCameraCommand.List.rawValue,
|
||||
OpenClawLocationCommand.Get.rawValue,
|
||||
OpenClawMotionCommand.Activity.rawValue,
|
||||
OpenClawMotionCommand.Pedometer.rawValue,
|
||||
OpenClawSmsCommand.Send.rawValue,
|
||||
)
|
||||
|
||||
private val debugCommands = setOf("debug.logs", "debug.ed25519")
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_respectsFeatureAvailability() {
|
||||
val capabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = false,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags())
|
||||
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Location.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Motion.rawValue))
|
||||
assertContainsAll(capabilities, coreCapabilities)
|
||||
assertMissingAll(capabilities, optionalCapabilities)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_includesFeatureCapabilitiesWhenEnabled() {
|
||||
val capabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
NodeRuntimeFlags(
|
||||
defaultFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
voiceWakeEnabled = true,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Location.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Motion.rawValue))
|
||||
assertContainsAll(capabilities, coreCapabilities + optionalCapabilities)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_respectsFeatureAvailability() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = false,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags())
|
||||
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
|
||||
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertFalse(commands.contains("debug.logs"))
|
||||
assertFalse(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
assertContainsAll(commands, coreCommands)
|
||||
assertMissingAll(commands, optionalCommands + debugCommands)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
NodeRuntimeFlags(
|
||||
defaultFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = true,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
|
||||
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertTrue(commands.contains("debug.logs"))
|
||||
assertTrue(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
assertContainsAll(commands, coreCommands + optionalCommands + debugCommands)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -174,4 +136,31 @@ class InvokeCommandRegistryTest {
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
}
|
||||
|
||||
private fun defaultFlags(
|
||||
cameraEnabled: Boolean = false,
|
||||
locationEnabled: Boolean = false,
|
||||
smsAvailable: Boolean = false,
|
||||
voiceWakeEnabled: Boolean = false,
|
||||
motionActivityAvailable: Boolean = false,
|
||||
motionPedometerAvailable: Boolean = false,
|
||||
debugBuild: Boolean = false,
|
||||
): NodeRuntimeFlags =
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = cameraEnabled,
|
||||
locationEnabled = locationEnabled,
|
||||
smsAvailable = smsAvailable,
|
||||
voiceWakeEnabled = voiceWakeEnabled,
|
||||
motionActivityAvailable = motionActivityAvailable,
|
||||
motionPedometerAvailable = motionPedometerAvailable,
|
||||
debugBuild = debugBuild,
|
||||
)
|
||||
|
||||
private fun assertContainsAll(actual: List<String>, expected: Set<String>) {
|
||||
expected.forEach { value -> assertTrue(actual.contains(value)) }
|
||||
}
|
||||
|
||||
private fun assertMissingAll(actual: List<String>, forbidden: Set<String>) {
|
||||
forbidden.forEach { value -> assertFalse(actual.contains(value)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,8 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MotionHandlerTest {
|
||||
class MotionHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@Test
|
||||
fun handleMotionActivity_requiresPermission() =
|
||||
runTest {
|
||||
@@ -86,8 +82,6 @@ class MotionHandlerTest {
|
||||
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
|
||||
assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeMotionDataSource(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
abstract class NodeHandlerRobolectricTest {
|
||||
protected fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
@@ -10,12 +10,8 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PhotosHandlerTest {
|
||||
class PhotosHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@Test
|
||||
fun handlePhotosLatest_requiresPermission() {
|
||||
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = false))
|
||||
@@ -63,8 +59,6 @@ class PhotosHandlerTest {
|
||||
assertEquals("jpeg", first.getValue("format").jsonPrimitive.content)
|
||||
assertEquals(640, first.getValue("width").jsonPrimitive.int)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakePhotosDataSource(
|
||||
|
||||
@@ -13,14 +13,29 @@ final class PeekabooBridgeHostCoordinator {
|
||||
|
||||
private var host: PeekabooBridgeHost?
|
||||
private var services: OpenClawPeekabooBridgeServices?
|
||||
|
||||
private static let legacySocketDirectoryNames = ["clawdbot", "clawdis", "moltbot"]
|
||||
|
||||
private static var openclawSocketPath: String {
|
||||
let fileManager = FileManager.default
|
||||
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
|
||||
let directory = base.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
return directory.appendingPathComponent(PeekabooBridgeConstants.socketName, isDirectory: false).path
|
||||
return Self.makeSocketPath(for: "OpenClaw", in: base)
|
||||
}
|
||||
|
||||
private static func makeSocketPath(for directoryName: String, in baseDirectory: URL) -> String {
|
||||
baseDirectory
|
||||
.appendingPathComponent(directoryName, isDirectory: true)
|
||||
.appendingPathComponent(PeekabooBridgeConstants.socketName, isDirectory: false)
|
||||
.path
|
||||
}
|
||||
|
||||
private static var legacySocketPaths: [String] {
|
||||
let fileManager = FileManager.default
|
||||
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
|
||||
return Self.legacySocketDirectoryNames.map { Self.makeSocketPath(for: $0, in: base) }
|
||||
}
|
||||
func setEnabled(_ enabled: Bool) async {
|
||||
if enabled {
|
||||
await self.startIfNeeded()
|
||||
@@ -46,6 +61,8 @@ final class PeekabooBridgeHostCoordinator {
|
||||
}
|
||||
let allowlistedBundles: Set<String> = []
|
||||
|
||||
self.ensureLegacySocketSymlinks()
|
||||
|
||||
let services = OpenClawPeekabooBridgeServices()
|
||||
let server = PeekabooBridgeServer(
|
||||
services: services,
|
||||
@@ -67,6 +84,42 @@ final class PeekabooBridgeHostCoordinator {
|
||||
.info("PeekabooBridge host started at \(Self.openclawSocketPath, privacy: .public)")
|
||||
}
|
||||
|
||||
private func ensureLegacySocketSymlinks() {
|
||||
Self.legacySocketPaths.forEach { legacyPath in
|
||||
self.ensureLegacySocketSymlink(at: legacyPath)
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureLegacySocketSymlink(at legacyPath: String) {
|
||||
let fileManager = FileManager.default
|
||||
let legacyDirectory = (legacyPath as NSString).deletingLastPathComponent
|
||||
do {
|
||||
let directoryAttributes: [FileAttributeKey: Any] = [
|
||||
.posixPermissions: 0o700,
|
||||
]
|
||||
try fileManager.createDirectory(
|
||||
atPath: legacyDirectory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: directoryAttributes)
|
||||
let linkURL = URL(fileURLWithPath: legacyPath)
|
||||
let linkValues = try? linkURL.resourceValues(forKeys: [.isSymbolicLinkKey])
|
||||
if linkValues?.isSymbolicLink == true {
|
||||
let destination = try FileManager.default.destinationOfSymbolicLink(atPath: legacyPath)
|
||||
let destinationURL = URL(fileURLWithPath: destination, relativeTo: linkURL.deletingLastPathComponent())
|
||||
.standardizedFileURL
|
||||
if destinationURL.path == URL(fileURLWithPath: Self.openclawSocketPath).standardizedFileURL.path {
|
||||
return
|
||||
}
|
||||
try fileManager.removeItem(atPath: legacyPath)
|
||||
} else if fileManager.fileExists(atPath: legacyPath) {
|
||||
try fileManager.removeItem(atPath: legacyPath)
|
||||
}
|
||||
try fileManager.createSymbolicLink(atPath: legacyPath, withDestinationPath: Self.openclawSocketPath)
|
||||
} catch {
|
||||
self.logger.debug("Failed to create legacy PeekabooBridge socket symlink: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func currentTeamID() -> String? {
|
||||
var code: SecCode?
|
||||
guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess,
|
||||
|
||||
1
changelog/fragments/pr-21208.md
Normal file
1
changelog/fragments/pr-21208.md
Normal file
@@ -0,0 +1 @@
|
||||
- Tlon plugin: sync upstream account/settings workflows, restore SSRF-safe media + SSE fetch paths, and improve invite/approval handling reliability. (#21208) (thanks @arthyn)
|
||||
@@ -258,7 +258,9 @@ Triggered when the gateway starts:
|
||||
Triggered when messages are received or sent:
|
||||
|
||||
- **`message`**: All message events (general listener)
|
||||
- **`message:received`**: When an inbound message is received from any channel
|
||||
- **`message:received`**: When an inbound message is received from any channel. Fires early in processing before media understanding. Content may contain raw placeholders like `<media:audio>` for media attachments that haven't been processed yet.
|
||||
- **`message:transcribed`**: When a message has been fully processed, including audio transcription and link understanding. At this point, `transcript` contains the full transcript text for audio messages. Use this hook when you need access to transcribed audio content.
|
||||
- **`message:preprocessed`**: Fires for every message after all media + link understanding completes, giving hooks access to the fully enriched body (transcripts, image descriptions, link summaries) before the agent sees it.
|
||||
- **`message:sent`**: When an outbound message is successfully sent
|
||||
|
||||
#### Message Event Context
|
||||
@@ -297,6 +299,30 @@ Message events include rich context about the message:
|
||||
accountId?: string, // Provider account ID
|
||||
conversationId?: string, // Chat/conversation ID
|
||||
messageId?: string, // Message ID returned by the provider
|
||||
isGroup?: boolean, // Whether this outbound message belongs to a group/channel context
|
||||
groupId?: string, // Group/channel identifier for correlation with message:received
|
||||
}
|
||||
|
||||
// message:transcribed context
|
||||
{
|
||||
body?: string, // Raw inbound body before enrichment
|
||||
bodyForAgent?: string, // Enriched body visible to the agent
|
||||
transcript: string, // Audio transcript text
|
||||
channelId: string, // Channel (e.g., "telegram", "whatsapp")
|
||||
conversationId?: string,
|
||||
messageId?: string,
|
||||
}
|
||||
|
||||
// message:preprocessed context
|
||||
{
|
||||
body?: string, // Raw inbound body
|
||||
bodyForAgent?: string, // Final enriched body after media/link understanding
|
||||
transcript?: string, // Transcript when audio was present
|
||||
channelId: string, // Channel (e.g., "telegram", "whatsapp")
|
||||
conversationId?: string,
|
||||
messageId?: string,
|
||||
isGroup?: boolean,
|
||||
groupId?: string,
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ Mapping options (summary):
|
||||
## Responses
|
||||
|
||||
- `200` for `/hooks/wake`
|
||||
- `202` for `/hooks/agent` (async run started)
|
||||
- `200` for `/hooks/agent` (async run accepted)
|
||||
- `401` on auth failure
|
||||
- `429` after repeated auth failures from the same client (check `Retry-After`)
|
||||
- `400` on invalid payload
|
||||
|
||||
@@ -48,6 +48,7 @@ Security note:
|
||||
|
||||
- Always set a webhook password.
|
||||
- Webhook authentication is always required. OpenClaw rejects BlueBubbles webhook requests unless they include a password/guid that matches `channels.bluebubbles.password` (for example `?password=<password>` or `x-password`), regardless of loopback/proxy topology.
|
||||
- Password authentication is checked before reading/parsing full webhook bodies.
|
||||
|
||||
## Keeping Messages.app alive (VM / headless setups)
|
||||
|
||||
|
||||
@@ -41,6 +41,19 @@ Examples:
|
||||
- `agent:main:telegram:group:-1001234567890:topic:42`
|
||||
- `agent:main:discord:channel:123456:thread:987654`
|
||||
|
||||
## Main DM route pinning
|
||||
|
||||
When `session.dmScope` is `main`, direct messages may share one main session.
|
||||
To prevent the session’s `lastRoute` from being overwritten by non-owner DMs,
|
||||
OpenClaw infers a pinned owner from `allowFrom` when all of these are true:
|
||||
|
||||
- `allowFrom` has exactly one non-wildcard entry.
|
||||
- The entry can be normalized to a concrete sender ID for that channel.
|
||||
- The inbound DM sender does not match that pinned owner.
|
||||
|
||||
In that mismatch case, OpenClaw still records inbound session metadata, but it
|
||||
skips updating the main session `lastRoute`.
|
||||
|
||||
## Routing rules (how an agent is chosen)
|
||||
|
||||
Routing picks **one agent** for each inbound message:
|
||||
|
||||
@@ -944,6 +944,7 @@ Auto-join example:
|
||||
Notes:
|
||||
|
||||
- `voice.tts` overrides `messages.tts` for voice playback only.
|
||||
- Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`); non-owner speakers cannot access owner-only tools (for example `gateway` and `cron`).
|
||||
- Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it.
|
||||
- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options.
|
||||
- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset.
|
||||
|
||||
@@ -139,6 +139,8 @@ Configure your tunnel's ingress rules to only route the webhook path:
|
||||
## How it works
|
||||
|
||||
1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer <token>` header.
|
||||
- OpenClaw verifies bearer auth before reading/parsing full webhook bodies when the header is present.
|
||||
- Google Workspace Add-on requests that carry `authorizationEventObject.systemIdToken` in the body are supported via a stricter pre-auth body budget.
|
||||
2. OpenClaw verifies the token against the configured `audienceType` + `audience`:
|
||||
- `audienceType: "app-url"` → audience is your HTTPS webhook URL.
|
||||
- `audienceType: "project-number"` → audience is the Cloud project number.
|
||||
|
||||
@@ -48,6 +48,10 @@ The gateway responds to LINE’s webhook verification (GET) and inbound events (
|
||||
If you need a custom path, set `channels.line.webhookPath` or
|
||||
`channels.line.accounts.<id>.webhookPath` and update the URL accordingly.
|
||||
|
||||
Security note:
|
||||
|
||||
- LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification.
|
||||
|
||||
## Configure
|
||||
|
||||
Minimal config:
|
||||
|
||||
@@ -230,23 +230,31 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
## Feature reference
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Live stream preview (message edits)">
|
||||
OpenClaw can stream partial replies by sending a temporary Telegram message and editing it as text arrives.
|
||||
<Accordion title="Live stream preview (native drafts + message edits)">
|
||||
OpenClaw can stream partial replies in real time:
|
||||
|
||||
- direct chats: Telegram native draft streaming via `sendMessageDraft`
|
||||
- groups/topics: preview message + `editMessageText`
|
||||
|
||||
Requirement:
|
||||
|
||||
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `off`)
|
||||
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
|
||||
- `progress` maps to `partial` on Telegram (compat with cross-channel naming)
|
||||
- legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped
|
||||
|
||||
This works in direct chats and groups/topics.
|
||||
Telegram enabled `sendMessageDraft` for all bots in Bot API 9.5 (March 1, 2026).
|
||||
|
||||
For text-only replies, OpenClaw keeps the same preview message and performs a final edit in place (no second message).
|
||||
For text-only replies:
|
||||
|
||||
- DM: OpenClaw updates the draft in place (no extra preview message)
|
||||
- group/topic: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
|
||||
|
||||
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
|
||||
|
||||
Preview streaming is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming.
|
||||
|
||||
If native draft transport is unavailable/rejected, OpenClaw automatically falls back to `sendMessage` + `editMessageText`.
|
||||
|
||||
Telegram-only reasoning stream:
|
||||
|
||||
- `/reasoning stream` sends reasoning to the live preview while generating
|
||||
@@ -751,7 +759,7 @@ Primary reference:
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`; `block` is legacy preview mode compatibility).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). In DMs, `partial` uses native `sendMessageDraft` when available.
|
||||
- `channels.telegram.mediaMaxMb`: inbound Telegram media download/processing cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.
|
||||
|
||||
@@ -11,8 +11,8 @@ Tlon is a decentralized messenger built on Urbit. OpenClaw connects to your Urbi
|
||||
respond to DMs and group chat messages. Group replies require an @ mention by default and can
|
||||
be further restricted via allowlists.
|
||||
|
||||
Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback
|
||||
(URL appended to caption). Reactions, polls, and native media uploads are not supported.
|
||||
Status: supported via plugin. DMs, group mentions, thread replies, rich text formatting, and
|
||||
image uploads are supported. Reactions and polls are not yet supported.
|
||||
|
||||
## Plugin required
|
||||
|
||||
@@ -50,27 +50,38 @@ Minimal config (single account):
|
||||
ship: "~sampel-palnet",
|
||||
url: "https://your-ship-host",
|
||||
code: "lidlut-tabwed-pillex-ridrup",
|
||||
ownerShip: "~your-main-ship", // recommended: your ship, always allowed
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Private/LAN ship URLs (advanced):
|
||||
## Private/LAN ships
|
||||
|
||||
By default, OpenClaw blocks private/internal hostnames and IP ranges for this plugin (SSRF hardening).
|
||||
If your ship URL is on a private network (for example `http://192.168.1.50:8080` or `http://localhost:8080`),
|
||||
By default, OpenClaw blocks private/internal hostnames and IP ranges for SSRF protection.
|
||||
If your ship is running on a private network (localhost, LAN IP, or internal hostname),
|
||||
you must explicitly opt in:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
url: "http://localhost:8080",
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This applies to URLs like:
|
||||
|
||||
- `http://localhost:8080`
|
||||
- `http://192.168.x.x:8080`
|
||||
- `http://my-ship.local:8080`
|
||||
|
||||
⚠️ Only enable this if you trust your local network. This setting disables SSRF protections
|
||||
for requests to your ship URL.
|
||||
|
||||
## Group channels
|
||||
|
||||
Auto-discovery is enabled by default. You can also pin channels manually:
|
||||
@@ -99,7 +110,7 @@ Disable auto-discovery:
|
||||
|
||||
## Access control
|
||||
|
||||
DM allowlist (empty = allow all):
|
||||
DM allowlist (empty = no DMs allowed, use `ownerShip` for approval flow):
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -134,6 +145,56 @@ Group authorization (restricted by default):
|
||||
}
|
||||
```
|
||||
|
||||
## Owner and approval system
|
||||
|
||||
Set an owner ship to receive approval requests when unauthorized users try to interact:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
ownerShip: "~your-main-ship",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The owner ship is **automatically authorized everywhere** — DM invites are auto-accepted and
|
||||
channel messages are always allowed. You don't need to add the owner to `dmAllowlist` or
|
||||
`defaultAuthorizedShips`.
|
||||
|
||||
When set, the owner receives DM notifications for:
|
||||
|
||||
- DM requests from ships not in the allowlist
|
||||
- Mentions in channels without authorization
|
||||
- Group invite requests
|
||||
|
||||
## Auto-accept settings
|
||||
|
||||
Auto-accept DM invites (for ships in dmAllowlist):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
autoAcceptDmInvites: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Auto-accept group invites:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
autoAcceptGroupInvites: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
|
||||
Use these with `openclaw message send` or cron delivery:
|
||||
@@ -141,8 +202,75 @@ Use these with `openclaw message send` or cron delivery:
|
||||
- DM: `~sampel-palnet` or `dm/~sampel-palnet`
|
||||
- Group: `chat/~host-ship/channel` or `group:~host-ship/channel`
|
||||
|
||||
## Bundled skill
|
||||
|
||||
The Tlon plugin includes a bundled skill ([`@tloncorp/tlon-skill`](https://github.com/tloncorp/tlon-skill))
|
||||
that provides CLI access to Tlon operations:
|
||||
|
||||
- **Contacts**: get/update profiles, list contacts
|
||||
- **Channels**: list, create, post messages, fetch history
|
||||
- **Groups**: list, create, manage members
|
||||
- **DMs**: send messages, react to messages
|
||||
- **Reactions**: add/remove emoji reactions to posts and DMs
|
||||
- **Settings**: manage plugin permissions via slash commands
|
||||
|
||||
The skill is automatically available when the plugin is installed.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Feature | Status |
|
||||
| --------------- | --------------------------------------- |
|
||||
| Direct messages | ✅ Supported |
|
||||
| Groups/channels | ✅ Supported (mention-gated by default) |
|
||||
| Threads | ✅ Supported (auto-replies in thread) |
|
||||
| Rich text | ✅ Markdown converted to Tlon format |
|
||||
| Images | ✅ Uploaded to Tlon storage |
|
||||
| Reactions | ✅ Via [bundled skill](#bundled-skill) |
|
||||
| Polls | ❌ Not yet supported |
|
||||
| Native commands | ✅ Supported (owner-only by default) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run this ladder first:
|
||||
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
```
|
||||
|
||||
Common failures:
|
||||
|
||||
- **DMs ignored**: sender not in `dmAllowlist` and no `ownerShip` configured for approval flow.
|
||||
- **Group messages ignored**: channel not discovered or sender not authorized.
|
||||
- **Connection errors**: check ship URL is reachable; enable `allowPrivateNetwork` for local ships.
|
||||
- **Auth errors**: verify login code is current (codes rotate).
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
Provider options:
|
||||
|
||||
- `channels.tlon.enabled`: enable/disable channel startup.
|
||||
- `channels.tlon.ship`: bot's Urbit ship name (e.g. `~sampel-palnet`).
|
||||
- `channels.tlon.url`: ship URL (e.g. `https://sampel-palnet.tlon.network`).
|
||||
- `channels.tlon.code`: ship login code.
|
||||
- `channels.tlon.allowPrivateNetwork`: allow localhost/LAN URLs (SSRF bypass).
|
||||
- `channels.tlon.ownerShip`: owner ship for approval system (always authorized).
|
||||
- `channels.tlon.dmAllowlist`: ships allowed to DM (empty = none).
|
||||
- `channels.tlon.autoAcceptDmInvites`: auto-accept DMs from allowlisted ships.
|
||||
- `channels.tlon.autoAcceptGroupInvites`: auto-accept all group invites.
|
||||
- `channels.tlon.autoDiscoverChannels`: auto-discover group channels (default: true).
|
||||
- `channels.tlon.groupChannels`: manually pinned channel nests.
|
||||
- `channels.tlon.defaultAuthorizedShips`: ships authorized for all channels.
|
||||
- `channels.tlon.authorization.channelRules`: per-channel auth rules.
|
||||
- `channels.tlon.showModelSignature`: append model name to messages.
|
||||
|
||||
## Notes
|
||||
|
||||
- Group replies require a mention (e.g. `~your-bot-ship`) to respond.
|
||||
- Thread replies: if the inbound message is in a thread, OpenClaw replies in-thread.
|
||||
- Media: `sendMedia` falls back to text + URL (no native upload).
|
||||
- Rich text: Markdown formatting (bold, italic, code, headers, lists) is converted to Tlon's native format.
|
||||
- Images: URLs are uploaded to Tlon storage and embedded as image blocks.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration"
|
||||
summary: "Zalo personal account support via native zca-js (QR login), capabilities, and configuration"
|
||||
read_when:
|
||||
- Setting up Zalo Personal for OpenClaw
|
||||
- Debugging Zalo Personal login or message flow
|
||||
@@ -8,7 +8,7 @@ title: "Zalo Personal"
|
||||
|
||||
# Zalo Personal (unofficial)
|
||||
|
||||
Status: experimental. This integration automates a **personal Zalo account** via `zca-cli`.
|
||||
Status: experimental. This integration automates a **personal Zalo account** via native `zca-js` inside OpenClaw.
|
||||
|
||||
> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk.
|
||||
|
||||
@@ -20,19 +20,14 @@ Zalo Personal ships as a plugin and is not bundled with the core install.
|
||||
- Or from a source checkout: `openclaw plugins install ./extensions/zalouser`
|
||||
- Details: [Plugins](/tools/plugin)
|
||||
|
||||
## Prerequisite: zca-cli
|
||||
|
||||
The Gateway machine must have the `zca` binary available in `PATH`.
|
||||
|
||||
- Verify: `zca --version`
|
||||
- If missing, install zca-cli (see `extensions/zalouser/README.md` or the upstream zca-cli docs).
|
||||
No external `zca`/`openzca` CLI binary is required.
|
||||
|
||||
## Quick setup (beginner)
|
||||
|
||||
1. Install the plugin (see above).
|
||||
2. Login (QR, on the Gateway machine):
|
||||
- `openclaw channels login --channel zalouser`
|
||||
- Scan the QR code in the terminal with the Zalo mobile app.
|
||||
- Scan the QR code with the Zalo mobile app.
|
||||
3. Enable the channel:
|
||||
|
||||
```json5
|
||||
@@ -51,8 +46,9 @@ The Gateway machine must have the `zca` binary available in `PATH`.
|
||||
|
||||
## What it is
|
||||
|
||||
- Uses `zca listen` to receive inbound messages.
|
||||
- Uses `zca msg ...` to send replies (text/media/link).
|
||||
- Runs entirely in-process via `zca-js`.
|
||||
- Uses native event listeners to receive inbound messages.
|
||||
- Sends replies directly through the JS API (text/media/link).
|
||||
- Designed for “personal account” use cases where Zalo Bot API is not available.
|
||||
|
||||
## Naming
|
||||
@@ -77,7 +73,8 @@ openclaw directory groups list --channel zalouser --query "work"
|
||||
## Access control (DMs)
|
||||
|
||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
||||
`channels.zalouser.allowFrom` accepts user IDs or names. The wizard resolves names to IDs via `zca friend find` when available.
|
||||
|
||||
`channels.zalouser.allowFrom` accepts user IDs or names. During onboarding, names are resolved to IDs using the plugin's in-process contact lookup.
|
||||
|
||||
Approve via:
|
||||
|
||||
@@ -110,9 +107,31 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
### Group mention gating
|
||||
|
||||
- `channels.zalouser.groups.<group>.requireMention` controls whether group replies require a mention.
|
||||
- Resolution order: exact group id/name -> normalized group slug -> `*` -> default (`true`).
|
||||
- This applies both to allowlisted groups and open group mode.
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"*": { allow: true, requireMention: true },
|
||||
"Work Chat": { allow: true, requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-account
|
||||
|
||||
Accounts map to zca profiles. Example:
|
||||
Accounts map to `zalouser` profiles in OpenClaw state. Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -128,13 +147,26 @@ Accounts map to zca profiles. Example:
|
||||
}
|
||||
```
|
||||
|
||||
## Typing, reactions, and delivery acknowledgements
|
||||
|
||||
- OpenClaw sends a typing event before dispatching a reply (best-effort).
|
||||
- Message reaction action `react` is supported for `zalouser` in channel actions.
|
||||
- Use `remove: true` to remove a specific reaction emoji from a message.
|
||||
- Reaction semantics: [Reactions](/tools/reactions)
|
||||
- For inbound messages that include event metadata, OpenClaw sends delivered + seen acknowledgements (best-effort).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`zca` not found:**
|
||||
|
||||
- Install zca-cli and ensure it’s on `PATH` for the Gateway process.
|
||||
|
||||
**Login doesn’t stick:**
|
||||
**Login doesn't stick:**
|
||||
|
||||
- `openclaw channels status --probe`
|
||||
- Re-login: `openclaw channels logout --channel zalouser && openclaw channels login --channel zalouser`
|
||||
|
||||
**Allowlist/group name didn't resolve:**
|
||||
|
||||
- Use numeric IDs in `allowFrom`/`groups`, or exact friend/group names.
|
||||
|
||||
**Upgraded from old CLI-based setup:**
|
||||
|
||||
- Remove any old external `zca` process assumptions.
|
||||
- The channel now runs fully in OpenClaw without external CLI binaries.
|
||||
|
||||
@@ -828,7 +828,7 @@ Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `bas
|
||||
|
||||
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.
|
||||
|
||||
Preferred Anthropic auth (setup-token):
|
||||
Anthropic setup-token (supported):
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
@@ -836,6 +836,10 @@ openclaw models auth setup-token --provider anthropic
|
||||
openclaw models status
|
||||
```
|
||||
|
||||
Policy note: this is technical compatibility. Anthropic has blocked some
|
||||
subscription usage outside Claude Code in the past; verify current Anthropic
|
||||
terms before relying on setup-token in production.
|
||||
|
||||
### `models` (root)
|
||||
|
||||
`openclaw models` is an alias for `models status`.
|
||||
|
||||
@@ -77,3 +77,4 @@ Notes:
|
||||
|
||||
- `setup-token` prompts for a setup-token value (generate it with `claude setup-token` on any machine).
|
||||
- `paste-token` accepts a token string generated elsewhere or from automation.
|
||||
- Anthropic policy note: setup-token support is technical compatibility. Anthropic has blocked some subscription usage outside Claude Code in the past, so verify current terms before using it broadly.
|
||||
|
||||
@@ -48,6 +48,10 @@ Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||
Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
|
||||
specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
|
||||
|
||||
If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw
|
||||
installs the bundled plugin directly. To install an npm package with the same
|
||||
name, use an explicit scoped spec (for example `@scope/diffs`).
|
||||
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
||||
|
||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
|
||||
@@ -38,6 +38,8 @@ inside a sandbox workspace under `~/.openclaw/sandboxes`, not your host workspac
|
||||
|
||||
`openclaw onboard`, `openclaw configure`, or `openclaw setup` will create the
|
||||
workspace and seed the bootstrap files if they are missing.
|
||||
Sandbox seed copies only accept regular in-workspace files; symlink/hardlink
|
||||
aliases that resolve outside the source workspace are ignored.
|
||||
|
||||
If you already manage the workspace files yourself, you can disable bootstrap
|
||||
file creation:
|
||||
|
||||
@@ -109,6 +109,8 @@ Defaults:
|
||||
6. Otherwise memory search stays disabled until configured.
|
||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
||||
- `memorySearch.provider = "ollama"` is also supported for local/self-hosted
|
||||
Ollama embeddings (`/api/embeddings`), but it is not auto-selected.
|
||||
|
||||
Remote embeddings **require** an API key for the embedding provider. OpenClaw
|
||||
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
|
||||
@@ -116,7 +118,9 @@ variables. Codex OAuth only covers chat/completions and does **not** satisfy
|
||||
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
|
||||
`models.providers.google.apiKey`. For Voyage, use `VOYAGE_API_KEY` or
|
||||
`models.providers.voyage.apiKey`. For Mistral, use `MISTRAL_API_KEY` or
|
||||
`models.providers.mistral.apiKey`.
|
||||
`models.providers.mistral.apiKey`. Ollama typically does not require a real API
|
||||
key (a placeholder like `OLLAMA_API_KEY=ollama-local` is enough when needed by
|
||||
local policy).
|
||||
When using a custom OpenAI-compatible endpoint,
|
||||
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||
|
||||
@@ -331,7 +335,7 @@ If you don't want to set an API key, use `memorySearch.provider = "local"` or se
|
||||
|
||||
Fallbacks:
|
||||
|
||||
- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `local`, or `none`.
|
||||
- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `ollama`, `local`, or `none`.
|
||||
- The fallback provider is only used when the primary embedding provider fails.
|
||||
|
||||
Batch indexing (OpenAI + Gemini + Voyage):
|
||||
|
||||
@@ -83,6 +83,9 @@ When a profile fails due to auth/rate‑limit errors (or a timeout that looks
|
||||
like rate limiting), OpenClaw marks it in cooldown and moves to the next profile.
|
||||
Format/invalid‑request errors (for example Cloud Code Assist tool call ID
|
||||
validation failures) are treated as failover‑worthy and use the same cooldowns.
|
||||
OpenAI-compatible stop-reason errors such as `Unhandled stop reason: error`,
|
||||
`stop reason: error`, and `reason: error` are classified as timeout/failover
|
||||
signals.
|
||||
|
||||
Cooldowns use exponential backoff:
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
- Optional rotation: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`, plus `OPENCLAW_LIVE_ANTHROPIC_KEY` (single override)
|
||||
- Example model: `anthropic/claude-opus-4-6`
|
||||
- CLI: `openclaw onboard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic`
|
||||
- Policy note: setup-token support is technical compatibility; Anthropic has blocked some subscription usage outside Claude Code in the past. Verify current Anthropic terms and decide based on your risk tolerance.
|
||||
- Recommendation: Anthropic API key auth is the safer, recommended path over subscription setup-token auth.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -75,6 +77,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
- CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex`
|
||||
- Default transport is `auto` (WebSocket-first, SSE fallback)
|
||||
- Override per model via `agents.defaults.models["openai-codex/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
|
||||
- Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -121,7 +124,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
- Provider: `zai`
|
||||
- Auth: `ZAI_API_KEY`
|
||||
- Example model: `zai/glm-4.7`
|
||||
- Example model: `zai/glm-5`
|
||||
- CLI: `openclaw onboard --auth-choice zai-api-key`
|
||||
- Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*`
|
||||
|
||||
@@ -175,14 +178,20 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
|
||||
Kimi K2 model IDs:
|
||||
|
||||
{/_moonshot-kimi-k2-model-refs:start_/ && null}
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
|
||||
{/_ moonshot-kimi-k2-model-refs:start _/ && null}
|
||||
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
|
||||
- `moonshot/kimi-k2.5`
|
||||
- `moonshot/kimi-k2-0905-preview`
|
||||
- `moonshot/kimi-k2-turbo-preview`
|
||||
- `moonshot/kimi-k2-thinking`
|
||||
- `moonshot/kimi-k2-thinking-turbo`
|
||||
{/_moonshot-kimi-k2-model-refs:end_/ && null}
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
{/_ moonshot-kimi-k2-model-refs:end _/ && null}
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -307,13 +316,13 @@ Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||
|
||||
- Provider: `synthetic`
|
||||
- Auth: `SYNTHETIC_API_KEY`
|
||||
- Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.1`
|
||||
- Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.5`
|
||||
- CLI: `openclaw onboard --auth-choice synthetic-api-key`
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" } },
|
||||
defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" } },
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
@@ -322,7 +331,7 @@ Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
apiKey: "${SYNTHETIC_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [{ id: "hf:MiniMaxAI/MiniMax-M2.1", name: "MiniMax M2.1" }],
|
||||
models: [{ id: "hf:MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -396,8 +405,8 @@ Example (OpenAI‑compatible):
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
models: { "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } },
|
||||
model: { primary: "lmstudio/minimax-m2.5-gs32" },
|
||||
models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
@@ -408,8 +417,8 @@ Example (OpenAI‑compatible):
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1",
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -433,6 +442,9 @@ Notes:
|
||||
- `contextWindow: 200000`
|
||||
- `maxTokens: 8192`
|
||||
- Recommended: set explicit values that match your proxy/model limits.
|
||||
- For `api: "openai-completions"` on non-native endpoints (any non-empty `baseUrl` whose host is not `api.openai.com`), OpenClaw forces `compat.supportsDeveloperRole: false` to avoid provider 400 errors for unsupported `developer` roles.
|
||||
- If `baseUrl` is empty/omitted, OpenClaw keeps the default OpenAI behavior (which resolves to `api.openai.com`).
|
||||
- For safety, an explicit `compat.supportsDeveloperRole: true` is still overridden on non-native `openai-completions` endpoints.
|
||||
|
||||
## CLI examples
|
||||
|
||||
|
||||
@@ -28,10 +28,11 @@ Related:
|
||||
- `agents.defaults.imageModel` is used **only when** the primary model can’t accept images.
|
||||
- Per-agent defaults can override `agents.defaults.model` via `agents.list[].model` plus bindings (see [/concepts/multi-agent](/concepts/multi-agent)).
|
||||
|
||||
## Quick model picks (anecdotal)
|
||||
## Quick model policy
|
||||
|
||||
- **GLM**: a bit better for coding/tool calling.
|
||||
- **MiniMax**: better for writing and vibes.
|
||||
- Set your primary to the strongest latest-generation model available to you.
|
||||
- Use fallbacks for cost/latency-sensitive tasks and lower-stakes chat.
|
||||
- For tool-enabled agents or untrusted inputs, avoid older/weaker model tiers.
|
||||
|
||||
## Setup wizard (recommended)
|
||||
|
||||
@@ -42,8 +43,7 @@ openclaw onboard
|
||||
```
|
||||
|
||||
It can set up model + auth for common providers, including **OpenAI Code (Codex)
|
||||
subscription** (OAuth) and **Anthropic** (API key recommended; `claude
|
||||
setup-token` also supported).
|
||||
subscription** (OAuth) and **Anthropic** (API key or `claude setup-token`).
|
||||
|
||||
## Config keys (overview)
|
||||
|
||||
@@ -160,7 +160,9 @@ JSON includes `auth.oauth` (warn window + profiles) and `auth.providers`
|
||||
(effective auth per provider).
|
||||
Use `--check` for automation (exit `1` when missing/expired, `2` when expiring).
|
||||
|
||||
Preferred Anthropic auth is the Claude Code CLI setup-token (run anywhere; paste on the gateway host if needed):
|
||||
Auth choice is provider/account dependent. For always-on gateway hosts, API keys are usually the most predictable; subscription token flows are also supported.
|
||||
|
||||
Example (Anthropic setup-token):
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
|
||||
@@ -10,7 +10,9 @@ title: "OAuth"
|
||||
|
||||
# OAuth
|
||||
|
||||
OpenClaw supports “subscription auth” via OAuth for providers that offer it (notably **OpenAI Codex (ChatGPT OAuth)**). For Anthropic subscriptions, use the **setup-token** flow. This page explains:
|
||||
OpenClaw supports “subscription auth” via OAuth for providers that offer it (notably **OpenAI Codex (ChatGPT OAuth)**). For Anthropic subscriptions, use the **setup-token** flow. Anthropic subscription use outside Claude Code has been restricted for some users in the past, so treat it as a user-choice risk and verify current Anthropic policy yourself. OpenAI Codex OAuth is explicitly supported for use in external tools like OpenClaw. This page explains:
|
||||
|
||||
For Anthropic in production, API key auth is the safer recommended path over subscription setup-token auth.
|
||||
|
||||
- how the OAuth **token exchange** works (PKCE)
|
||||
- where tokens are **stored** (and why)
|
||||
@@ -54,6 +56,12 @@ For static secret refs and runtime snapshot activation behavior, see [Secrets Ma
|
||||
|
||||
## Anthropic setup-token (subscription auth)
|
||||
|
||||
<Warning>
|
||||
Anthropic setup-token support is technical compatibility, not a policy guarantee.
|
||||
Anthropic has blocked some subscription usage outside Claude Code in the past.
|
||||
Decide for yourself whether to use subscription auth, and verify Anthropic's current terms.
|
||||
</Warning>
|
||||
|
||||
Run `claude setup-token` on any machine, then paste it into OpenClaw:
|
||||
|
||||
```bash
|
||||
@@ -76,7 +84,7 @@ openclaw models status
|
||||
|
||||
OpenClaw’s interactive login flows are implemented in `@mariozechner/pi-ai` and wired into the wizards/commands.
|
||||
|
||||
### Anthropic (Claude Pro/Max) setup-token
|
||||
### Anthropic setup-token
|
||||
|
||||
Flow shape:
|
||||
|
||||
@@ -88,6 +96,8 @@ The wizard path is `openclaw onboard` → auth choice `setup-token` (Anthropic).
|
||||
|
||||
### OpenAI Codex (ChatGPT OAuth)
|
||||
|
||||
OpenAI Codex OAuth is explicitly supported for use outside the Codex CLI, including OpenClaw workflows.
|
||||
|
||||
Flow shape (PKCE):
|
||||
|
||||
1. generate PKCE verifier/challenge + random `state`
|
||||
|
||||
@@ -138,7 +138,7 @@ Legacy key migration:
|
||||
|
||||
Telegram:
|
||||
|
||||
- Uses Bot API `sendMessage` + `editMessageText`.
|
||||
- Uses Bot API `sendMessageDraft` in DMs when available, and `sendMessage` + `editMessageText` for group/topic preview updates.
|
||||
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
|
||||
- `/reasoning stream` can write reasoning to preview.
|
||||
|
||||
|
||||
@@ -462,7 +462,7 @@ const needsNonImageSanitize =
|
||||
"id": "anthropic/claude-opus-4.6",
|
||||
"name": "Anthropic: Claude Opus 4.6"
|
||||
},
|
||||
{ "id": "minimax/minimax-m2.1:free", "name": "Minimax: Minimax M2.1" }
|
||||
{ "id": "minimax/minimax-m2.5:free", "name": "Minimax: Minimax M2.5" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,23 +8,26 @@ title: "Authentication"
|
||||
|
||||
# Authentication
|
||||
|
||||
OpenClaw supports OAuth and API keys for model providers. For Anthropic
|
||||
accounts, we recommend using an **API key**. For Claude subscription access,
|
||||
use the long‑lived token created by `claude setup-token`.
|
||||
OpenClaw supports OAuth and API keys for model providers. For always-on gateway
|
||||
hosts, API keys are usually the most predictable option. Subscription/OAuth
|
||||
flows are also supported when they match your provider account model.
|
||||
|
||||
See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage
|
||||
layout.
|
||||
For SecretRef-based auth (`env`/`file`/`exec` providers), see [Secrets Management](/gateway/secrets).
|
||||
|
||||
## Recommended Anthropic setup (API key)
|
||||
## Recommended setup (API key, any provider)
|
||||
|
||||
If you’re using Anthropic directly, use an API key.
|
||||
If you’re running a long-lived gateway, start with an API key for your chosen
|
||||
provider.
|
||||
For Anthropic specifically, API key auth is the safe path and is recommended
|
||||
over subscription setup-token auth.
|
||||
|
||||
1. Create an API key in the Anthropic Console.
|
||||
1. Create an API key in your provider console.
|
||||
2. Put it on the **gateway host** (the machine running `openclaw gateway`).
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="..."
|
||||
export <PROVIDER>_API_KEY="..."
|
||||
openclaw models status
|
||||
```
|
||||
|
||||
@@ -33,7 +36,7 @@ openclaw models status
|
||||
|
||||
```bash
|
||||
cat >> ~/.openclaw/.env <<'EOF'
|
||||
ANTHROPIC_API_KEY=...
|
||||
<PROVIDER>_API_KEY=...
|
||||
EOF
|
||||
```
|
||||
|
||||
@@ -52,8 +55,8 @@ See [Help](/help) for details on env inheritance (`env.shellEnv`,
|
||||
|
||||
## Anthropic: setup-token (subscription auth)
|
||||
|
||||
For Anthropic, the recommended path is an **API key**. If you’re using a Claude
|
||||
subscription, the setup-token flow is also supported. Run it on the **gateway host**:
|
||||
If you’re using a Claude subscription, the setup-token flow is supported. Run
|
||||
it on the **gateway host**:
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
@@ -79,6 +82,12 @@ This credential is only authorized for use with Claude Code and cannot be used f
|
||||
|
||||
…use an Anthropic API key instead.
|
||||
|
||||
<Warning>
|
||||
Anthropic setup-token support is technical compatibility only. Anthropic has blocked
|
||||
some subscription usage outside Claude Code in the past. Use it only if you decide
|
||||
the policy risk is acceptable, and verify Anthropic's current terms yourself.
|
||||
</Warning>
|
||||
|
||||
Manual token entry (any provider; writes `auth-profiles.json` + updates config):
|
||||
|
||||
```bash
|
||||
@@ -164,5 +173,5 @@ is missing, rerun `claude setup-token` and paste the token again.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Claude Max or Pro subscription (for `claude setup-token`)
|
||||
- Anthropic subscription account (for `claude setup-token`)
|
||||
- Claude Code CLI installed (`claude` command available)
|
||||
|
||||
@@ -527,7 +527,13 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
|
||||
}
|
||||
```
|
||||
|
||||
### Anthropic subscription + API key, MiniMax fallback
|
||||
### Anthropic setup-token + API key, MiniMax fallback
|
||||
|
||||
<Warning>
|
||||
Anthropic setup-token usage outside Claude Code has been restricted for some
|
||||
users in the past. Treat this as user-choice risk and verify current Anthropic
|
||||
terms before depending on subscription auth.
|
||||
</Warning>
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -560,7 +566,7 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
|
||||
workspace: "~/.openclaw/workspace",
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-6",
|
||||
fallbacks: ["minimax/MiniMax-M2.1"],
|
||||
fallbacks: ["minimax/MiniMax-M2.5"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -597,7 +603,7 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
|
||||
{
|
||||
agent: {
|
||||
workspace: "~/.openclaw/workspace",
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
model: { primary: "lmstudio/minimax-m2.5-gs32" },
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
@@ -608,8 +614,8 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5 GS32",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
|
||||
@@ -825,11 +825,11 @@ Time format in system prompt. Default: `auto` (OS preference).
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-6": { alias: "opus" },
|
||||
"minimax/MiniMax-M2.1": { alias: "minimax" },
|
||||
"minimax/MiniMax-M2.5": { alias: "minimax" },
|
||||
},
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-6",
|
||||
fallbacks: ["minimax/MiniMax-M2.1"],
|
||||
fallbacks: ["minimax/MiniMax-M2.5"],
|
||||
},
|
||||
imageModel: {
|
||||
primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
|
||||
@@ -1177,6 +1177,35 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived
|
||||
- `network` defaults to `openclaw-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly want global bridge connectivity.
|
||||
- `cdpSourceRange` optionally restricts CDP ingress at the container edge to a CIDR range (for example `172.21.0.1/32`).
|
||||
- `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container.
|
||||
- Launch defaults are defined in `scripts/sandbox-browser-entrypoint.sh` and tuned for container hosts:
|
||||
- `--remote-debugging-address=127.0.0.1`
|
||||
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
|
||||
- `--user-data-dir=${HOME}/.chrome`
|
||||
- `--no-first-run`
|
||||
- `--no-default-browser-check`
|
||||
- `--disable-3d-apis`
|
||||
- `--disable-gpu`
|
||||
- `--disable-software-rasterizer`
|
||||
- `--disable-dev-shm-usage`
|
||||
- `--disable-background-networking`
|
||||
- `--disable-features=TranslateUI`
|
||||
- `--disable-breakpad`
|
||||
- `--disable-crash-reporter`
|
||||
- `--renderer-process-limit=2`
|
||||
- `--no-zygote`
|
||||
- `--metrics-recording-only`
|
||||
- `--disable-extensions` (default enabled)
|
||||
- `--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu` are
|
||||
enabled by default and can be disabled with
|
||||
`OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` if WebGL/3D usage requires it.
|
||||
- `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` re-enables extensions if your workflow
|
||||
depends on them.
|
||||
- `--renderer-process-limit=2` can be changed with
|
||||
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>`; set `0` to use Chromium's
|
||||
default process limit.
|
||||
- plus `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled.
|
||||
- Defaults are the container image baseline; use a custom browser image with a custom
|
||||
entrypoint to change container defaults.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -1587,6 +1616,8 @@ Defaults for Talk mode (macOS/iOS/Android).
|
||||
|
||||
`tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`:
|
||||
|
||||
Local onboarding defaults new local configs to `tools.profile: "messaging"` when unset (existing explicit profiles are preserved).
|
||||
|
||||
| Profile | Includes |
|
||||
| ----------- | ----------------------------------------------------------------------------------------- |
|
||||
| `minimal` | `session_status` only |
|
||||
@@ -1864,7 +1895,7 @@ Notes:
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
model: "minimax/MiniMax-M2.1",
|
||||
model: "minimax/MiniMax-M2.5",
|
||||
maxConcurrent: 1,
|
||||
runTimeoutSeconds: 900,
|
||||
archiveAfterMinutes: 60,
|
||||
@@ -1930,6 +1961,7 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model
|
||||
- `models.providers.*.baseUrl`: upstream API base URL.
|
||||
- `models.providers.*.headers`: extra static headers for proxy/tenant routing.
|
||||
- `models.providers.*.models`: explicit provider model catalog entries.
|
||||
- `models.providers.*.models.*.compat.supportsDeveloperRole`: optional compatibility hint. For `api: "openai-completions"` with a non-empty non-native `baseUrl` (host not `api.openai.com`), OpenClaw forces this to `false` at runtime. Empty/omitted `baseUrl` keeps default OpenAI behavior.
|
||||
- `models.bedrockDiscovery`: Bedrock auto-discovery settings root.
|
||||
- `models.bedrockDiscovery.enabled`: turn discovery polling on/off.
|
||||
- `models.bedrockDiscovery.region`: AWS region for discovery.
|
||||
@@ -2080,8 +2112,8 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choi
|
||||
env: { SYNTHETIC_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } },
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax M2.5" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
@@ -2093,8 +2125,8 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choi
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -2112,15 +2144,15 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw on
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="MiniMax M2.1 (direct)">
|
||||
<Accordion title="MiniMax M2.5 (direct)">
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "minimax/MiniMax-M2.1" },
|
||||
model: { primary: "minimax/MiniMax-M2.5" },
|
||||
models: {
|
||||
"minimax/MiniMax-M2.1": { alias: "Minimax" },
|
||||
"minimax/MiniMax-M2.5": { alias: "Minimax" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2133,8 +2165,8 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw on
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
|
||||
@@ -2154,7 +2186,7 @@ Set `MINIMAX_API_KEY`. Shortcut: `openclaw onboard --auth-choice minimax-api`.
|
||||
|
||||
<Accordion title="Local models (LM Studio)">
|
||||
|
||||
See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
|
||||
See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -2249,6 +2281,7 @@ See [Plugins](/tools/plugin).
|
||||
color: "#FF4500",
|
||||
// headless: false,
|
||||
// noSandbox: false,
|
||||
// extraArgs: [],
|
||||
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
// attachOnly: false,
|
||||
},
|
||||
@@ -2263,6 +2296,8 @@ See [Plugins](/tools/plugin).
|
||||
- Remote profiles are attach-only (start/stop/reset disabled).
|
||||
- Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
|
||||
- `extraArgs` appends extra launch flags to local Chromium startup (for example
|
||||
`--disable-gpu`, window sizing, or debug flags).
|
||||
|
||||
---
|
||||
|
||||
@@ -2697,6 +2732,26 @@ Notes:
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
```json5
|
||||
{
|
||||
cli: {
|
||||
banner: {
|
||||
taglineMode: "off", // random | default | off
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `cli.banner.taglineMode` controls banner tagline style:
|
||||
- `"random"` (default): rotating funny/seasonal taglines.
|
||||
- `"default"`: fixed neutral tagline (`All your chats, one OpenClaw.`).
|
||||
- `"off"`: no tagline text (banner title/version still shown).
|
||||
- To hide the entire banner (not just taglines), set env `OPENCLAW_HIDE_BANNER=1`.
|
||||
|
||||
---
|
||||
|
||||
## Wizard
|
||||
|
||||
Metadata written by CLI wizards (`onboard`, `configure`, `doctor`):
|
||||
|
||||
@@ -291,6 +291,11 @@ When validation fails:
|
||||
}
|
||||
```
|
||||
|
||||
Security note:
|
||||
- Treat all hook/webhook payload content as untrusted input.
|
||||
- Keep unsafe-content bypass flags disabled (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`) unless doing tightly scoped debugging.
|
||||
- For hook-driven agents, prefer strong modern model tiers and strict tool policy (for example messaging-only plus sandboxing where possible).
|
||||
|
||||
See [full reference](/gateway/configuration-reference#hooks) for all mapping options and Gmail integration.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -11,18 +11,18 @@ title: "Local Models"
|
||||
|
||||
Local is doable, but OpenClaw expects large context + strong defenses against prompt injection. Small cards truncate context and leak safety. Aim high: **≥2 maxed-out Mac Studios or equivalent GPU rig (~$30k+)**. A single **24 GB** GPU works only for lighter prompts with higher latency. Use the **largest / full-size model variant you can run**; aggressively quantized or “small” checkpoints raise prompt-injection risk (see [Security](/gateway/security)).
|
||||
|
||||
## Recommended: LM Studio + MiniMax M2.1 (Responses API, full-size)
|
||||
## Recommended: LM Studio + MiniMax M2.5 (Responses API, full-size)
|
||||
|
||||
Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local server (default `http://127.0.0.1:1234`), and use Responses API to keep reasoning separate from final text.
|
||||
Best current local stack. Load MiniMax M2.5 in LM Studio, enable the local server (default `http://127.0.0.1:1234`), and use Responses API to keep reasoning separate from final text.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
model: { primary: "lmstudio/minimax-m2.5-gs32" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"lmstudio/minimax-m2.1-gs32": { alias: "Minimax" },
|
||||
"lmstudio/minimax-m2.5-gs32": { alias: "Minimax" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -35,8 +35,8 @@ Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local serve
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5 GS32",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -53,7 +53,7 @@ Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local serve
|
||||
**Setup checklist**
|
||||
|
||||
- Install LM Studio: [https://lmstudio.ai](https://lmstudio.ai)
|
||||
- In LM Studio, download the **largest MiniMax M2.1 build available** (avoid “small”/heavily quantized variants), start the server, confirm `http://127.0.0.1:1234/v1/models` lists it.
|
||||
- In LM Studio, download the **largest MiniMax M2.5 build available** (avoid “small”/heavily quantized variants), start the server, confirm `http://127.0.0.1:1234/v1/models` lists it.
|
||||
- Keep the model loaded; cold-load adds startup latency.
|
||||
- Adjust `contextWindow`/`maxTokens` if your LM Studio build differs.
|
||||
- For WhatsApp, stick to Responses API so only final text is sent.
|
||||
@@ -68,11 +68,11 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-sonnet-4-5",
|
||||
fallbacks: ["lmstudio/minimax-m2.1-gs32", "anthropic/claude-opus-4-6"],
|
||||
fallbacks: ["lmstudio/minimax-m2.5-gs32", "anthropic/claude-opus-4-6"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-5": { alias: "Sonnet" },
|
||||
"lmstudio/minimax-m2.1-gs32": { alias: "MiniMax Local" },
|
||||
"lmstudio/minimax-m2.5-gs32": { alias: "MiniMax Local" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -86,8 +86,8 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5 GS32",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
|
||||
@@ -148,6 +148,40 @@ scripts/sandbox-browser-setup.sh
|
||||
By default, sandbox containers run with **no network**.
|
||||
Override with `agents.defaults.sandbox.docker.network`.
|
||||
|
||||
The bundled sandbox browser image also applies conservative Chromium startup defaults
|
||||
for containerized workloads. Current container defaults include:
|
||||
|
||||
- `--remote-debugging-address=127.0.0.1`
|
||||
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
|
||||
- `--user-data-dir=${HOME}/.chrome`
|
||||
- `--no-first-run`
|
||||
- `--no-default-browser-check`
|
||||
- `--disable-3d-apis`
|
||||
- `--disable-gpu`
|
||||
- `--disable-dev-shm-usage`
|
||||
- `--disable-background-networking`
|
||||
- `--disable-extensions`
|
||||
- `--disable-features=TranslateUI`
|
||||
- `--disable-breakpad`
|
||||
- `--disable-crash-reporter`
|
||||
- `--disable-software-rasterizer`
|
||||
- `--no-zygote`
|
||||
- `--metrics-recording-only`
|
||||
- `--renderer-process-limit=2`
|
||||
- `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled.
|
||||
- The three graphics hardening flags (`--disable-3d-apis`,
|
||||
`--disable-software-rasterizer`, `--disable-gpu`) are optional and are useful
|
||||
when containers lack GPU support. Set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0`
|
||||
if your workload requires WebGL or other 3D/browser features.
|
||||
- `--disable-extensions` is enabled by default and can be disabled with
|
||||
`OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for extension-reliant flows.
|
||||
- `--renderer-process-limit=2` is controlled by
|
||||
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>`, where `0` keeps Chromium's default.
|
||||
|
||||
If you need a different runtime profile, use a custom browser image and provide
|
||||
your own entrypoint. For local (non-container) Chromium profiles, use
|
||||
`browser.extraArgs` to append additional startup flags.
|
||||
|
||||
Security defaults:
|
||||
|
||||
- `network: "host"` is blocked.
|
||||
|
||||
@@ -224,39 +224,40 @@ When the audit prints findings, treat this as a priority order:
|
||||
|
||||
High-signal `checkId` values you will most likely see in real deployments (not exhaustive):
|
||||
|
||||
| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
|
||||
| -------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- |
|
||||
| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
|
||||
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
|
||||
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
|
||||
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
|
||||
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
|
||||
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
|
||||
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
|
||||
| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no |
|
||||
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
|
||||
| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no |
|
||||
| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no |
|
||||
| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
|
||||
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
|
||||
| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
|
||||
| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no |
|
||||
| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no |
|
||||
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
|
||||
| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
|
||||
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
|
||||
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
|
||||
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
|
||||
| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
|
||||
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
|
||||
| `security.exposure.open_groups_with_elevated` | critical | Open groups + elevated tools create high-impact prompt-injection paths | `channels.*.groupPolicy`, `tools.elevated.*` | no |
|
||||
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
|
||||
| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no |
|
||||
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
|
||||
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
|
||||
| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
|
||||
| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
|
||||
| -------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -------- |
|
||||
| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
|
||||
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
|
||||
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
|
||||
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
|
||||
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
|
||||
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
|
||||
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
|
||||
| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no |
|
||||
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
|
||||
| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no |
|
||||
| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no |
|
||||
| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
|
||||
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
|
||||
| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
|
||||
| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no |
|
||||
| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no |
|
||||
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
|
||||
| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
|
||||
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
|
||||
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
|
||||
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
|
||||
| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
|
||||
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
|
||||
| `skills.workspace.symlink_escape` | warn | Workspace `skills/**/SKILL.md` resolves outside workspace root (symlink-chain drift) | workspace `skills/**` filesystem state | no |
|
||||
| `security.exposure.open_groups_with_elevated` | critical | Open groups + elevated tools create high-impact prompt-injection paths | `channels.*.groupPolicy`, `tools.elevated.*` | no |
|
||||
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
|
||||
| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no |
|
||||
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
|
||||
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
|
||||
| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
|
||||
|
||||
## Control UI over HTTP
|
||||
|
||||
@@ -515,7 +516,7 @@ Even with strong system prompts, **prompt injection is not solved**. System prom
|
||||
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
|
||||
- Note: sandboxing is opt-in. If sandbox mode is off, exec runs on the gateway host even though tools.exec.host defaults to sandbox, and host exec does not require approvals unless you set host=gateway and configure exec approvals.
|
||||
- Limit high-risk tools (`exec`, `browser`, `web_fetch`, `web_search`) to trusted agents or explicit allowlists.
|
||||
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.6 (or the latest Opus) because it’s strong at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
|
||||
- **Model choice matters:** older/smaller/legacy models are significantly less robust against prompt injection and tool misuse. For tool-enabled agents, use the strongest latest-generation, instruction-hardened model available.
|
||||
|
||||
Red flags to treat as untrusted:
|
||||
|
||||
@@ -538,6 +539,11 @@ Guidance:
|
||||
- Only enable temporarily for tightly scoped debugging.
|
||||
- If enabled, isolate that agent (sandbox + minimal tools + dedicated session namespace).
|
||||
|
||||
Hooks risk note:
|
||||
|
||||
- Hook payloads are untrusted content, even when delivery comes from systems you control (mail/docs/web content can carry prompt injection).
|
||||
- Weak model tiers increase this risk. For hook-driven automation, prefer strong modern model tiers and keep tool policy tight (`tools.profile: "messaging"` or stricter), plus sandboxing where possible.
|
||||
|
||||
### Prompt injection does not require public DMs
|
||||
|
||||
Even if **only you** can message the bot, prompt injection can still happen via
|
||||
@@ -561,10 +567,14 @@ tool calls. Reduce the blast radius by:
|
||||
|
||||
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
|
||||
|
||||
<Warning>
|
||||
For tool-enabled agents or agents that read untrusted content, prompt-injection risk with older/smaller models is often too high. Do not run those workloads on weak model tiers.
|
||||
</Warning>
|
||||
|
||||
Recommendations:
|
||||
|
||||
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
|
||||
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
||||
- **Do not use older/weaker/smaller tiers** for tool-enabled agents or untrusted inboxes; the prompt-injection risk is too high.
|
||||
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
||||
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
|
||||
- For chat-only personal assistants with trusted input and no tools, smaller models are usually fine.
|
||||
|
||||
112
docs/help/faq.md
112
docs/help/faq.md
@@ -30,6 +30,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [How long does install and onboarding usually take?](#how-long-does-install-and-onboarding-usually-take)
|
||||
- [Installer stuck? How do I get more feedback?](#installer-stuck-how-do-i-get-more-feedback)
|
||||
- [Windows install says git not found or openclaw not recognized](#windows-install-says-git-not-found-or-openclaw-not-recognized)
|
||||
- [Windows exec output shows garbled Chinese text what should I do](#windows-exec-output-shows-garbled-chinese-text-what-should-i-do)
|
||||
- [The docs didn't answer my question - how do I get a better answer?](#the-docs-didnt-answer-my-question-how-do-i-get-a-better-answer)
|
||||
- [How do I install OpenClaw on Linux?](#how-do-i-install-openclaw-on-linux)
|
||||
- [How do I install OpenClaw on a VPS?](#how-do-i-install-openclaw-on-a-vps)
|
||||
@@ -100,6 +101,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized)
|
||||
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
|
||||
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
|
||||
- [How do I disable funny CLI taglines?](#how-do-i-disable-funny-cli-taglines)
|
||||
- [How do I enable web search (and web fetch)?](#how-do-i-enable-web-search-and-web-fetch)
|
||||
- [config.apply wiped my config. How do I recover and avoid this?](#configapply-wiped-my-config-how-do-i-recover-and-avoid-this)
|
||||
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
|
||||
@@ -146,7 +148,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||
- [Can I use GPT 5.2 for daily tasks and Codex 5.3 for coding](#can-i-use-gpt-52-for-daily-tasks-and-codex-53-for-coding)
|
||||
- [Why do I see "Model … is not allowed" and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
|
||||
- [Why do I see "Unknown model: minimax/MiniMax-M2.1"?](#why-do-i-see-unknown-model-minimaxminimaxm21)
|
||||
- [Why do I see "Unknown model: minimax/MiniMax-M2.5"?](#why-do-i-see-unknown-model-minimaxminimaxm25)
|
||||
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
|
||||
- [Are opus / sonnet / gpt built-in shortcuts?](#are-opus-sonnet-gpt-builtin-shortcuts)
|
||||
- [How do I define/override model shortcuts (aliases)?](#how-do-i-defineoverride-model-shortcuts-aliases)
|
||||
@@ -578,12 +580,40 @@ Two common Windows issues:
|
||||
npm config get prefix
|
||||
```
|
||||
|
||||
- Ensure `<prefix>\\bin` is on PATH (on most systems it is `%AppData%\\npm`).
|
||||
- Add that directory to your user PATH (no `\bin` suffix needed on Windows; on most systems it is `%AppData%\npm`).
|
||||
- Close and reopen PowerShell after updating PATH.
|
||||
|
||||
If you want the smoothest Windows setup, use **WSL2** instead of native Windows.
|
||||
Docs: [Windows](/platforms/windows).
|
||||
|
||||
### Windows exec output shows garbled Chinese text what should I do
|
||||
|
||||
This is usually a console code page mismatch on native Windows shells.
|
||||
|
||||
Symptoms:
|
||||
|
||||
- `system.run`/`exec` output renders Chinese as mojibake
|
||||
- The same command looks fine in another terminal profile
|
||||
|
||||
Quick workaround in PowerShell:
|
||||
|
||||
```powershell
|
||||
chcp 65001
|
||||
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
```
|
||||
|
||||
Then restart the Gateway and retry your command:
|
||||
|
||||
```powershell
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
If you still reproduce this on latest OpenClaw, track/report it in:
|
||||
|
||||
- [Issue #30640](https://github.com/openclaw/openclaw/issues/30640)
|
||||
|
||||
### The docs didn't answer my question how do I get a better answer
|
||||
|
||||
Use the **hackable (git) install** so you have the full source and docs locally, then ask
|
||||
@@ -659,7 +689,7 @@ Docs: [Update](/cli/update), [Updating](/install/updating).
|
||||
|
||||
`openclaw onboard` is the recommended setup path. In **local mode** it walks you through:
|
||||
|
||||
- **Model/auth setup** (Anthropic **setup-token** recommended for Claude subscriptions, OpenAI Codex OAuth supported, API keys optional, LM Studio local models supported)
|
||||
- **Model/auth setup** (provider OAuth/setup-token flows and API keys supported, plus local model options such as LM Studio)
|
||||
- **Workspace** location + bootstrap files
|
||||
- **Gateway settings** (bind/port/auth/tailscale)
|
||||
- **Providers** (WhatsApp, Telegram, Discord, Mattermost (plugin), Signal, iMessage)
|
||||
@@ -674,6 +704,10 @@ No. You can run OpenClaw with **API keys** (Anthropic/OpenAI/others) or with
|
||||
**local-only models** so your data stays on your device. Subscriptions (Claude
|
||||
Pro/Max or OpenAI Codex) are optional ways to authenticate those providers.
|
||||
|
||||
If you choose Anthropic subscription auth, decide for yourself whether to use it:
|
||||
Anthropic has blocked some subscription usage outside Claude Code in the past.
|
||||
OpenAI Codex OAuth is explicitly supported for external tools like OpenClaw.
|
||||
|
||||
Docs: [Anthropic](/providers/anthropic), [OpenAI](/providers/openai),
|
||||
[Local models](/gateway/local-models), [Models](/concepts/models).
|
||||
|
||||
@@ -683,9 +717,9 @@ Yes. You can authenticate with a **setup-token**
|
||||
instead of an API key. This is the subscription path.
|
||||
|
||||
Claude Pro/Max subscriptions **do not include an API key**, so this is the
|
||||
correct approach for subscription accounts. Important: you must verify with
|
||||
Anthropic that this usage is allowed under their subscription policy and terms.
|
||||
If you want the most explicit, supported path, use an Anthropic API key.
|
||||
technical path for subscription accounts. But this is your decision: Anthropic
|
||||
has blocked some subscription usage outside Claude Code in the past.
|
||||
If you want the clearest and safest supported path for production, use an Anthropic API key.
|
||||
|
||||
### How does Anthropic setuptoken auth work
|
||||
|
||||
@@ -705,12 +739,15 @@ Copy the token it prints, then choose **Anthropic token (paste setup-token)** in
|
||||
|
||||
Yes - via **setup-token**. OpenClaw no longer reuses Claude Code CLI OAuth tokens; use a setup-token or an Anthropic API key. Generate the token anywhere and paste it on the gateway host. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
|
||||
|
||||
Note: Claude subscription access is governed by Anthropic's terms. For production or multi-user workloads, API keys are usually the safer choice.
|
||||
Important: this is technical compatibility, not a policy guarantee. Anthropic
|
||||
has blocked some subscription usage outside Claude Code in the past.
|
||||
You need to decide whether to use it and verify Anthropic's current terms.
|
||||
For production or multi-user workloads, Anthropic API key auth is the safer, recommended choice.
|
||||
|
||||
### Why am I seeing HTTP 429 ratelimiterror from Anthropic
|
||||
|
||||
That means your **Anthropic quota/rate limit** is exhausted for the current window. If you
|
||||
use a **Claude subscription** (setup-token or Claude Code OAuth), wait for the window to
|
||||
use a **Claude subscription** (setup-token), wait for the window to
|
||||
reset or upgrade your plan. If you use an **Anthropic API key**, check the Anthropic Console
|
||||
for usage/billing and raise limits as needed.
|
||||
|
||||
@@ -734,8 +771,9 @@ OpenClaw supports **OpenAI Code (Codex)** via OAuth (ChatGPT sign-in). The wizar
|
||||
|
||||
### Do you support OpenAI subscription auth Codex OAuth
|
||||
|
||||
Yes. OpenClaw fully supports **OpenAI Code (Codex) subscription OAuth**. The onboarding wizard
|
||||
can run the OAuth flow for you.
|
||||
Yes. OpenClaw fully supports **OpenAI Code (Codex) subscription OAuth**.
|
||||
OpenAI explicitly allows subscription OAuth usage in external tools/workflows
|
||||
like OpenClaw. The onboarding wizard can run the OAuth flow for you.
|
||||
|
||||
See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard).
|
||||
|
||||
@@ -752,7 +790,7 @@ This stores OAuth tokens in auth profiles on the gateway host. Details: [Model p
|
||||
|
||||
### Is a local model OK for casual chats
|
||||
|
||||
Usually no. OpenClaw needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk - see [Security](/gateway/security).
|
||||
Usually no. OpenClaw needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.5 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk - see [Security](/gateway/security).
|
||||
|
||||
### How do I keep hosted model traffic in a specific region
|
||||
|
||||
@@ -1261,12 +1299,13 @@ It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key
|
||||
resolves, then Voyage, then Mistral. If no remote key is available, memory
|
||||
search stays disabled until you configure it. If you have a local model path
|
||||
configured and present, OpenClaw
|
||||
prefers `local`.
|
||||
prefers `local`. Ollama is supported when you explicitly set
|
||||
`memorySearch.provider = "ollama"`.
|
||||
|
||||
If you'd rather stay local, set `memorySearch.provider = "local"` (and optionally
|
||||
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
|
||||
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
|
||||
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, or local** embedding
|
||||
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, Ollama, or local** embedding
|
||||
models - see [Memory](/concepts/memory) for the setup details.
|
||||
|
||||
### Does memory persist forever What are the limits
|
||||
@@ -1429,6 +1468,25 @@ The Gateway watches the config and supports hot-reload:
|
||||
- `gateway.reload.mode: "hybrid"` (default): hot-apply safe changes, restart for critical ones
|
||||
- `hot`, `restart`, `off` are also supported
|
||||
|
||||
### How do I disable funny CLI taglines
|
||||
|
||||
Set `cli.banner.taglineMode` in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
cli: {
|
||||
banner: {
|
||||
taglineMode: "off", // random | default | off
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `off`: hides tagline text but keeps the banner title/version line.
|
||||
- `default`: uses `All your chats, one OpenClaw.` every time.
|
||||
- `random`: rotating funny/seasonal taglines (default behavior).
|
||||
- If you want no banner at all, set env `OPENCLAW_HIDE_BANNER=1`.
|
||||
|
||||
### How do I enable web search and web fetch
|
||||
|
||||
`web_fetch` works without an API key. `web_search` requires a Brave Search API
|
||||
@@ -1999,12 +2057,11 @@ Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-6`)
|
||||
|
||||
### What model do you recommend
|
||||
|
||||
**Recommended default:** `anthropic/claude-opus-4-6`.
|
||||
**Good alternative:** `anthropic/claude-sonnet-4-5`.
|
||||
**Reliable (less character):** `openai/gpt-5.2` - nearly as good as Opus, just less personality.
|
||||
**Budget:** `zai/glm-4.7`.
|
||||
**Recommended default:** use the strongest latest-generation model available in your provider stack.
|
||||
**For tool-enabled or untrusted-input agents:** prioritize model strength over cost.
|
||||
**For routine/low-stakes chat:** use cheaper fallback models and route by agent role.
|
||||
|
||||
MiniMax M2.1 has its own docs: [MiniMax](/providers/minimax) and
|
||||
MiniMax M2.5 has its own docs: [MiniMax](/providers/minimax) and
|
||||
[Local models](/gateway/local-models).
|
||||
|
||||
Rule of thumb: use the **best model you can afford** for high-stakes work, and a cheaper
|
||||
@@ -2048,8 +2105,9 @@ Docs: [Models](/concepts/models), [Configure](/cli/configure), [Config](/cli/con
|
||||
|
||||
### What do OpenClaw, Flawd, and Krill use for models
|
||||
|
||||
- **OpenClaw + Flawd:** Anthropic Opus (`anthropic/claude-opus-4-6`) - see [Anthropic](/providers/anthropic).
|
||||
- **Krill:** MiniMax M2.1 (`minimax/MiniMax-M2.1`) - see [MiniMax](/providers/minimax).
|
||||
- These deployments can differ and may change over time; there is no fixed provider recommendation.
|
||||
- Check the current runtime setting on each gateway with `openclaw models status`.
|
||||
- For security-sensitive/tool-enabled agents, use the strongest latest-generation model available.
|
||||
|
||||
### How do I switch models on the fly without restarting
|
||||
|
||||
@@ -2116,7 +2174,7 @@ Model "provider/model" is not allowed. Use /model to list available models.
|
||||
That error is returned **instead of** a normal reply. Fix: add the model to
|
||||
`agents.defaults.models`, remove the allowlist, or pick a model from `/model list`.
|
||||
|
||||
### Why do I see Unknown model minimaxMiniMaxM21
|
||||
### Why do I see Unknown model minimaxMiniMaxM25
|
||||
|
||||
This means the **provider isn't configured** (no MiniMax provider config or auth
|
||||
profile was found), so the model can't be resolved. A fix for this detection is
|
||||
@@ -2127,8 +2185,8 @@ Fix checklist:
|
||||
1. Upgrade to **2026.1.12** (or run from source `main`), then restart the gateway.
|
||||
2. Make sure MiniMax is configured (wizard or JSON), or that a MiniMax API key
|
||||
exists in env/auth profiles so the provider can be injected.
|
||||
3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.1` or
|
||||
`minimax/MiniMax-M2.1-lightning`.
|
||||
3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.5` or
|
||||
`minimax/MiniMax-M2.5-highspeed` (legacy: `minimax/MiniMax-M2.5-Lightning`).
|
||||
4. Run:
|
||||
|
||||
```bash
|
||||
@@ -2151,9 +2209,9 @@ Fallbacks are for **errors**, not "hard tasks," so use `/model` or a separate ag
|
||||
env: { MINIMAX_API_KEY: "sk-...", OPENAI_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "minimax/MiniMax-M2.1" },
|
||||
model: { primary: "minimax/MiniMax-M2.5" },
|
||||
models: {
|
||||
"minimax/MiniMax-M2.1": { alias: "minimax" },
|
||||
"minimax/MiniMax-M2.5": { alias: "minimax" },
|
||||
"openai/gpt-5.2": { alias: "gpt" },
|
||||
},
|
||||
},
|
||||
@@ -2231,8 +2289,8 @@ Z.AI (GLM models):
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "zai/glm-4.7" },
|
||||
models: { "zai/glm-4.7": {} },
|
||||
model: { primary: "zai/glm-5" },
|
||||
models: { "zai/glm-5": {} },
|
||||
},
|
||||
},
|
||||
env: { ZAI_API_KEY: "..." },
|
||||
|
||||
@@ -136,7 +136,7 @@ Live tests are split into two layers so we can isolate failures:
|
||||
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
|
||||
- Set `OPENCLAW_LIVE_MODELS=modern` (or `all`, alias for modern) to actually run this suite; otherwise it skips to keep `pnpm test:live` focused on gateway smoke
|
||||
- How to select models:
|
||||
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.1, Grok 4)
|
||||
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.5, Grok 4)
|
||||
- `OPENCLAW_LIVE_MODELS=all` is an alias for the modern allowlist
|
||||
- or `OPENCLAW_LIVE_MODELS="openai/gpt-5.2,anthropic/claude-opus-4-6,..."` (comma allowlist)
|
||||
- How to select providers:
|
||||
@@ -167,7 +167,7 @@ Live tests are split into two layers so we can isolate failures:
|
||||
- How to enable:
|
||||
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
|
||||
- How to select models:
|
||||
- Default: modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.1, Grok 4)
|
||||
- Default: modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.5, Grok 4)
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS=all` is an alias for the modern allowlist
|
||||
- Or set `OPENCLAW_LIVE_GATEWAY_MODELS="provider/model"` (or comma list) to narrow
|
||||
- How to select providers (avoid “OpenRouter everything”):
|
||||
@@ -251,7 +251,7 @@ Narrow, explicit allowlists are fastest and least flaky:
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
|
||||
- Tool calling across several providers:
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,anthropic/claude-opus-4-6,google/gemini-3-flash-preview,zai/glm-4.7,minimax/minimax-m2.1" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,anthropic/claude-opus-4-6,google/gemini-3-flash-preview,zai/glm-4.7,minimax/minimax-m2.5" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
|
||||
- Google focus (Gemini API key + Antigravity):
|
||||
- Gemini (API key): `OPENCLAW_LIVE_GATEWAY_MODELS="google/gemini-3-flash-preview" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
@@ -280,10 +280,10 @@ This is the “common models” run we expect to keep working:
|
||||
- Google (Gemini API): `google/gemini-3-pro-preview` and `google/gemini-3-flash-preview` (avoid older Gemini 2.x models)
|
||||
- Google (Antigravity): `google-antigravity/claude-opus-4-6-thinking` and `google-antigravity/gemini-3-flash`
|
||||
- Z.AI (GLM): `zai/glm-4.7`
|
||||
- MiniMax: `minimax/minimax-m2.1`
|
||||
- MiniMax: `minimax/minimax-m2.5`
|
||||
|
||||
Run gateway smoke with tools + image:
|
||||
`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,openai-codex/gpt-5.3-codex,anthropic/claude-opus-4-6,google/gemini-3-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,zai/glm-4.7,minimax/minimax-m2.1" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,openai-codex/gpt-5.3-codex,anthropic/claude-opus-4-6,google/gemini-3-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,zai/glm-4.7,minimax/minimax-m2.5" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
|
||||
### Baseline: tool calling (Read + optional Exec)
|
||||
|
||||
@@ -293,7 +293,7 @@ Pick at least one per provider family:
|
||||
- Anthropic: `anthropic/claude-opus-4-6` (or `anthropic/claude-sonnet-4-5`)
|
||||
- Google: `google/gemini-3-flash-preview` (or `google/gemini-3-pro-preview`)
|
||||
- Z.AI (GLM): `zai/glm-4.7`
|
||||
- MiniMax: `minimax/minimax-m2.1`
|
||||
- MiniMax: `minimax/minimax-m2.5`
|
||||
|
||||
Optional additional coverage (nice to have):
|
||||
|
||||
|
||||
@@ -40,6 +40,31 @@ If you see:
|
||||
`HTTP 429: rate_limit_error: Extra usage is required for long context requests`,
|
||||
go to [/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context](/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context).
|
||||
|
||||
## Plugin install fails with missing openclaw extensions
|
||||
|
||||
If install fails with `package.json missing openclaw.extensions`, the plugin package
|
||||
is using an old shape that OpenClaw no longer accepts.
|
||||
|
||||
Fix in the plugin package:
|
||||
|
||||
1. Add `openclaw.extensions` to `package.json`.
|
||||
2. Point entries at built runtime files (usually `./dist/index.js`).
|
||||
3. Republish the plugin and run `openclaw plugins install <npm-spec>` again.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@openclaw/my-plugin",
|
||||
"version": "1.2.3",
|
||||
"openclaw": {
|
||||
"extensions": ["./dist/index.js"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [/tools/plugin#distribution-npm](/tools/plugin#distribution-npm)
|
||||
|
||||
## Decision tree
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -54,7 +54,7 @@ OpenClaw is a **self-hosted gateway** that connects your favorite chat apps —
|
||||
- **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing
|
||||
- **Open source**: MIT licensed, community-driven
|
||||
|
||||
**What do you need?** Node 22+, an API key (Anthropic recommended), and 5 minutes.
|
||||
**What do you need?** Node 22+, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -64,6 +64,13 @@ Optional env vars:
|
||||
- `OPENCLAW_DOCKER_SOCKET` — override Docker socket path (default: `DOCKER_HOST=unix://...` path, else `/var/run/docker.sock`)
|
||||
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` — break-glass: allow trusted private-network
|
||||
`ws://` targets for CLI/onboarding client paths (default is loopback-only)
|
||||
- `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` — disable container browser hardening flags
|
||||
`--disable-3d-apis`, `--disable-software-rasterizer`, `--disable-gpu` when you need
|
||||
WebGL/3D compatibility.
|
||||
- `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` — keep extensions enabled when browser
|
||||
flows require them (default keeps extensions disabled in sandbox browser).
|
||||
- `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>` — set Chromium renderer process
|
||||
limit; set to `0` to skip the flag and use Chromium default behavior.
|
||||
|
||||
After it finishes:
|
||||
|
||||
@@ -672,6 +679,38 @@ Notes:
|
||||
- Browser containers default to a dedicated Docker network (`openclaw-sandbox-browser`) instead of global `bridge`.
|
||||
- Optional `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress by CIDR (for example `172.21.0.1/32`).
|
||||
- noVNC observer access is password-protected by default; OpenClaw provides a short-lived observer token URL that serves a local bootstrap page and keeps the password in URL fragment (instead of URL query).
|
||||
- Browser container startup defaults are conservative for shared/container workloads, including:
|
||||
- `--remote-debugging-address=127.0.0.1`
|
||||
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
|
||||
- `--user-data-dir=${HOME}/.chrome`
|
||||
- `--no-first-run`
|
||||
- `--no-default-browser-check`
|
||||
- `--disable-3d-apis`
|
||||
- `--disable-software-rasterizer`
|
||||
- `--disable-gpu`
|
||||
- `--disable-dev-shm-usage`
|
||||
- `--disable-background-networking`
|
||||
- `--disable-features=TranslateUI`
|
||||
- `--disable-breakpad`
|
||||
- `--disable-crash-reporter`
|
||||
- `--metrics-recording-only`
|
||||
- `--renderer-process-limit=2`
|
||||
- `--no-zygote`
|
||||
- `--disable-extensions`
|
||||
- If `agents.defaults.sandbox.browser.noSandbox` is set, `--no-sandbox` and
|
||||
`--disable-setuid-sandbox` are also appended.
|
||||
- The three graphics hardening flags above are optional. If your workload needs
|
||||
WebGL/3D, set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` to run without
|
||||
`--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu`.
|
||||
- Extension behavior is controlled by `--disable-extensions` and can be disabled
|
||||
(enables extensions) via `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for
|
||||
extension-dependent pages or extensions-heavy workflows.
|
||||
- `--renderer-process-limit=2` is also configurable with
|
||||
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT`; set `0` to let Chromium choose its
|
||||
default process limit when browser concurrency needs tuning.
|
||||
|
||||
Defaults are applied by default in the bundled image. If you need different
|
||||
Chromium flags, use a custom browser image and provide your own entrypoint.
|
||||
|
||||
Use config:
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ read_when:
|
||||
|
||||
- [flyctl CLI](https://fly.io/docs/hands-on/install-flyctl/) installed
|
||||
- Fly.io account (free tier works)
|
||||
- Model auth: Anthropic API key (or other provider keys)
|
||||
- Model auth: API key for your chosen model provider
|
||||
- Channel credentials: Discord bot token, Telegram token, etc.
|
||||
|
||||
## Beginner quick path
|
||||
|
||||
@@ -384,7 +384,7 @@ Use non-interactive flags/env vars for predictable runs.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title='Windows: "openclaw is not recognized"'>
|
||||
Run `npm config get prefix`, append `\bin`, add that directory to user PATH, then reopen PowerShell.
|
||||
Run `npm config get prefix` and add that directory to your user PATH (no `\bin` suffix needed on Windows), then reopen PowerShell.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Windows: how to get verbose installer output">
|
||||
|
||||
@@ -23,7 +23,7 @@ What I need you to do:
|
||||
1. Check if Determinate Nix is installed (if not, install it)
|
||||
2. Create a local flake at ~/code/openclaw-local using templates/agent-first/flake.nix
|
||||
3. Help me create a Telegram bot (@BotFather) and get my chat ID (@userinfobot)
|
||||
4. Set up secrets (bot token, Anthropic key) - plain files at ~/.secrets/ is fine
|
||||
4. Set up secrets (bot token, model provider API key) - plain files at ~/.secrets/ is fine
|
||||
5. Fill in the template placeholders and run home-manager switch
|
||||
6. Verify: launchd running, bot responds to messages
|
||||
|
||||
|
||||
@@ -109,6 +109,23 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
}
|
||||
```
|
||||
|
||||
### Echo transcript to chat (opt-in)
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
echoTranscript: true, // default is false
|
||||
echoFormat: '📝 "{transcript}"', // optional, supports {transcript}
|
||||
models: [{ provider: "openai", model: "gpt-4o-mini-transcribe" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Notes & limits
|
||||
|
||||
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
|
||||
@@ -117,12 +134,26 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
- Mistral setup details: [Mistral](/providers/mistral).
|
||||
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
|
||||
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
|
||||
- Tiny/empty audio files below 1024 bytes are skipped before provider/CLI transcription.
|
||||
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
|
||||
- OpenAI auto default is `gpt-4o-mini-transcribe`; set `model: "gpt-4o-transcribe"` for higher accuracy.
|
||||
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
|
||||
- Transcript is available to templates as `{{Transcript}}`.
|
||||
- `tools.media.audio.echoTranscript` is off by default; enable it to send transcript confirmation back to the originating chat before agent processing.
|
||||
- `tools.media.audio.echoFormat` customizes the echo text (placeholder: `{transcript}`).
|
||||
- CLI stdout is capped (5MB); keep CLI output concise.
|
||||
|
||||
### Proxy environment support
|
||||
|
||||
Provider-based audio transcription honors standard outbound proxy env vars:
|
||||
|
||||
- `HTTPS_PROXY`
|
||||
- `HTTP_PROXY`
|
||||
- `https_proxy`
|
||||
- `http_proxy`
|
||||
|
||||
If no proxy env vars are set, direct egress is used. If proxy config is malformed, OpenClaw logs a warning and falls back to direct fetch.
|
||||
|
||||
## Mention Detection in Groups
|
||||
|
||||
When `requireMention: true` is set for a group chat, OpenClaw now transcribes audio **before** checking for mentions. This allows voice notes to be processed even when they contain mentions.
|
||||
@@ -139,11 +170,18 @@ When `requireMention: true` is set for a group chat, OpenClaw now transcribes au
|
||||
- If transcription fails during preflight (timeout, API error, etc.), the message is processed based on text-only mention detection.
|
||||
- This ensures that mixed messages (text + audio) are never incorrectly dropped.
|
||||
|
||||
**Opt-out per Telegram group/topic:**
|
||||
|
||||
- Set `channels.telegram.groups.<chatId>.disableAudioPreflight: true` to skip preflight transcript mention checks for that group.
|
||||
- Set `channels.telegram.groups.<chatId>.topics.<threadId>.disableAudioPreflight` to override per-topic (`true` to skip, `false` to force-enable).
|
||||
- Default is `false` (preflight enabled when mention-gated conditions match).
|
||||
|
||||
**Example:** A user sends a voice note saying "Hey @Claude, what's the weather?" in a Telegram group with `requireMention: true`. The voice note is transcribed, the mention is detected, and the agent replies.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Scope rules use first-match wins. `chatType` is normalized to `direct`, `group`, or `room`.
|
||||
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
|
||||
- For `parakeet-mlx`, if you pass `--output-dir`, OpenClaw reads `<output-dir>/<media-basename>.txt` when `--output-format` is `txt` (or omitted); non-`txt` output formats fall back to stdout parsing.
|
||||
- Keep timeouts reasonable (`timeoutSeconds`, default 60s) to avoid blocking the reply queue.
|
||||
- Preflight transcription only processes the **first** audio attachment for mention detection. Additional audio is processed during the main media understanding phase.
|
||||
|
||||
@@ -40,6 +40,7 @@ If understanding fails or is disabled, **the reply flow continues** with the ori
|
||||
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
|
||||
- provider overrides (`baseUrl`, `headers`, `providerOptions`)
|
||||
- Deepgram audio options via `tools.media.audio.providerOptions.deepgram`
|
||||
- audio transcript echo controls (`echoTranscript`, default `false`; `echoFormat`)
|
||||
- optional **per‑capability `models` list** (preferred before shared models)
|
||||
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
|
||||
- `scope` (optional gating by channel/chatType/session key)
|
||||
@@ -57,6 +58,8 @@ If understanding fails or is disabled, **the reply flow continues** with the ori
|
||||
},
|
||||
audio: {
|
||||
/* optional overrides */
|
||||
echoTranscript: true,
|
||||
echoFormat: '📝 "{transcript}"',
|
||||
},
|
||||
video: {
|
||||
/* optional overrides */
|
||||
@@ -123,6 +126,7 @@ Recommended defaults:
|
||||
Rules:
|
||||
|
||||
- If media exceeds `maxBytes`, that model is skipped and the **next model is tried**.
|
||||
- Audio files smaller than **1024 bytes** are treated as empty/corrupt and skipped before provider/CLI transcription.
|
||||
- If the model returns more than `maxChars`, output is trimmed.
|
||||
- `prompt` defaults to simple “Describe the {media}.” plus the `maxChars` guidance (image/video only).
|
||||
- If `<capability>.enabled: true` but no models are configured, OpenClaw tries the
|
||||
@@ -160,6 +164,20 @@ To disable auto-detection, set:
|
||||
|
||||
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
|
||||
|
||||
### Proxy environment support (provider models)
|
||||
|
||||
When provider-based **audio** and **video** media understanding is enabled, OpenClaw
|
||||
honors standard outbound proxy environment variables for provider HTTP calls:
|
||||
|
||||
- `HTTPS_PROXY`
|
||||
- `HTTP_PROXY`
|
||||
- `https_proxy`
|
||||
- `http_proxy`
|
||||
|
||||
If no proxy env vars are set, media understanding uses direct egress.
|
||||
If the proxy value is malformed, OpenClaw logs a warning and falls back to direct
|
||||
fetch.
|
||||
|
||||
## Capabilities (optional)
|
||||
|
||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||
@@ -181,23 +199,13 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
| Audio | OpenAI, Groq, Deepgram, Google, Mistral | Provider transcription (Whisper/Deepgram/Gemini/Voxtral). |
|
||||
| Video | Google (Gemini API) | Provider video understanding. |
|
||||
|
||||
## Recommended providers
|
||||
## Model selection guidance
|
||||
|
||||
**Image**
|
||||
|
||||
- Prefer your active model if it supports images.
|
||||
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-6`, `google/gemini-3-pro-preview`.
|
||||
|
||||
**Audio**
|
||||
|
||||
- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, `deepgram/nova-3`, or `mistral/voxtral-mini-latest`.
|
||||
- CLI fallback: `whisper-cli` (whisper-cpp) or `whisper`.
|
||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
|
||||
**Video**
|
||||
|
||||
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
|
||||
- CLI fallback: `gemini` CLI (supports `read_file` on video/audio).
|
||||
- Prefer the strongest latest-generation model available for each media capability when quality and safety matter.
|
||||
- For tool-enabled agents handling untrusted inputs, avoid older/weaker media models.
|
||||
- Keep at least one fallback per capability for availability (quality model + faster/cheaper model).
|
||||
- CLI fallbacks (`whisper-cli`, `whisper`, `gemini`) are useful when provider APIs are unavailable.
|
||||
- `parakeet-mlx` note: with `--output-dir`, OpenClaw reads `<output-dir>/<media-basename>.txt` when output format is `txt` (or unspecified); non-`txt` formats fall back to stdout.
|
||||
|
||||
## Attachment policy
|
||||
|
||||
|
||||
@@ -55,6 +55,50 @@ Repair/migrate:
|
||||
openclaw doctor
|
||||
```
|
||||
|
||||
## Gateway auto-start before Windows login
|
||||
|
||||
For headless setups, ensure the full boot chain runs even when no one logs into
|
||||
Windows.
|
||||
|
||||
### 1) Keep user services running without login
|
||||
|
||||
Inside WSL:
|
||||
|
||||
```bash
|
||||
sudo loginctl enable-linger "$(whoami)"
|
||||
```
|
||||
|
||||
### 2) Install the OpenClaw gateway user service
|
||||
|
||||
Inside WSL:
|
||||
|
||||
```bash
|
||||
openclaw gateway install
|
||||
```
|
||||
|
||||
### 3) Start WSL automatically at Windows boot
|
||||
|
||||
In PowerShell as Administrator:
|
||||
|
||||
```powershell
|
||||
schtasks /create /tn "WSL Boot" /tr "wsl.exe -d Ubuntu --exec /bin/true" /sc onstart /ru SYSTEM
|
||||
```
|
||||
|
||||
Replace `Ubuntu` with your distro name from:
|
||||
|
||||
```powershell
|
||||
wsl --list --verbose
|
||||
```
|
||||
|
||||
### Verify startup chain
|
||||
|
||||
After a reboot (before Windows sign-in), check from WSL:
|
||||
|
||||
```bash
|
||||
systemctl --user is-enabled openclaw-gateway
|
||||
systemctl --user status openclaw-gateway --no-pager
|
||||
```
|
||||
|
||||
## Advanced: expose WSL services over LAN (portproxy)
|
||||
|
||||
WSL has its own virtual network. If another machine needs to reach a service
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Zalo Personal plugin: QR login + messaging via zca-cli (plugin install + channel config + CLI + tool)"
|
||||
summary: "Zalo Personal plugin: QR login + messaging via native zca-js (plugin install + channel config + tool)"
|
||||
read_when:
|
||||
- You want Zalo Personal (unofficial) support in OpenClaw
|
||||
- You are configuring or developing the zalouser plugin
|
||||
@@ -8,7 +8,7 @@ title: "Zalo Personal Plugin"
|
||||
|
||||
# Zalo Personal (plugin)
|
||||
|
||||
Zalo Personal support for OpenClaw via a plugin, using `zca-cli` to automate a normal Zalo user account.
|
||||
Zalo Personal support for OpenClaw via a plugin, using native `zca-js` to automate a normal Zalo user account.
|
||||
|
||||
> **Warning:** Unofficial automation may lead to account suspension/ban. Use at your own risk.
|
||||
|
||||
@@ -22,6 +22,8 @@ This plugin runs **inside the Gateway process**.
|
||||
|
||||
If you use a remote Gateway, install/configure it on the **machine running the Gateway**, then restart the Gateway.
|
||||
|
||||
No external `zca`/`openzca` CLI binary is required.
|
||||
|
||||
## Install
|
||||
|
||||
### Option A: install from npm
|
||||
@@ -41,14 +43,6 @@ cd ./extensions/zalouser && pnpm install
|
||||
|
||||
Restart the Gateway afterwards.
|
||||
|
||||
## Prerequisite: zca-cli
|
||||
|
||||
The Gateway machine must have `zca` on `PATH`:
|
||||
|
||||
```bash
|
||||
zca --version
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
Channel config lives under `channels.zalouser` (not `plugins.entries.*`):
|
||||
@@ -79,3 +73,5 @@ openclaw directory peers list --channel zalouser --query "name"
|
||||
Tool name: `zalouser`
|
||||
|
||||
Actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
|
||||
|
||||
Channel message actions also support `react` for message reactions.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
summary: "Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint"
|
||||
summary: "Community proxy to expose Claude subscription credentials as an OpenAI-compatible endpoint"
|
||||
read_when:
|
||||
- You want to use Claude Max subscription with OpenAI-compatible tools
|
||||
- You want a local API server that wraps Claude Code CLI
|
||||
- You want to save money by using subscription instead of API keys
|
||||
- You want to evaluate subscription-based vs API-key-based Anthropic access
|
||||
title: "Claude Max API Proxy"
|
||||
---
|
||||
|
||||
@@ -11,6 +11,12 @@ title: "Claude Max API Proxy"
|
||||
|
||||
**claude-max-api-proxy** is a community tool that exposes your Claude Max/Pro subscription as an OpenAI-compatible API endpoint. This allows you to use your subscription with any tool that supports the OpenAI API format.
|
||||
|
||||
<Warning>
|
||||
This path is technical compatibility only. Anthropic has blocked some subscription
|
||||
usage outside Claude Code in the past. You must decide for yourself whether to use
|
||||
it and verify Anthropic's current terms before relying on it.
|
||||
</Warning>
|
||||
|
||||
## Why Use This?
|
||||
|
||||
| Approach | Cost | Best For |
|
||||
@@ -18,7 +24,7 @@ title: "Claude Max API Proxy"
|
||||
| Anthropic API | Pay per token (~$15/M input, $75/M output for Opus) | Production apps, high volume |
|
||||
| Claude Max subscription | $200/month flat | Personal use, development, unlimited usage |
|
||||
|
||||
If you have a Claude Max subscription and want to use it with OpenAI-compatible tools, this proxy can save you significant money.
|
||||
If you have a Claude Max subscription and want to use it with OpenAI-compatible tools, this proxy may reduce cost for some workflows. API keys remain the clearer policy path for production use.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
@@ -13,15 +13,6 @@ default model as `provider/model`.
|
||||
|
||||
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels).
|
||||
|
||||
## Highlight: Venice (Venice AI)
|
||||
|
||||
Venice is our recommended Venice AI setup for privacy-first inference with an option to use Opus for hard tasks.
|
||||
|
||||
- Default: `venice/llama-3.3-70b`
|
||||
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
|
||||
|
||||
See [Venice AI](/providers/venice).
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Authenticate with the provider (usually via `openclaw onboard`).
|
||||
@@ -65,7 +56,7 @@ See [Venice AI](/providers/venice).
|
||||
|
||||
## Community tools
|
||||
|
||||
- [Claude Max API Proxy](/providers/claude-max-api-proxy) - Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint
|
||||
- [Claude Max API Proxy](/providers/claude-max-api-proxy) - Community proxy for Claude subscription credentials (verify Anthropic policy/terms before use)
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Use MiniMax M2.1 in OpenClaw"
|
||||
summary: "Use MiniMax M2.5 in OpenClaw"
|
||||
read_when:
|
||||
- You want MiniMax models in OpenClaw
|
||||
- You need MiniMax setup guidance
|
||||
@@ -8,15 +8,15 @@ title: "MiniMax"
|
||||
|
||||
# MiniMax
|
||||
|
||||
MiniMax is an AI company that builds the **M2/M2.1** model family. The current
|
||||
coding-focused release is **MiniMax M2.1** (December 23, 2025), built for
|
||||
MiniMax is an AI company that builds the **M2/M2.5** model family. The current
|
||||
coding-focused release is **MiniMax M2.5** (December 23, 2025), built for
|
||||
real-world complex tasks.
|
||||
|
||||
Source: [MiniMax M2.1 release note](https://www.minimax.io/news/minimax-m21)
|
||||
Source: [MiniMax M2.5 release note](https://www.minimax.io/news/minimax-m25)
|
||||
|
||||
## Model overview (M2.1)
|
||||
## Model overview (M2.5)
|
||||
|
||||
MiniMax highlights these improvements in M2.1:
|
||||
MiniMax highlights these improvements in M2.5:
|
||||
|
||||
- Stronger **multi-language coding** (Rust, Java, Go, C++, Kotlin, Objective-C, TS/JS).
|
||||
- Better **web/app development** and aesthetic output quality (including native mobile).
|
||||
@@ -27,13 +27,12 @@ MiniMax highlights these improvements in M2.1:
|
||||
Droid/Factory AI, Cline, Kilo Code, Roo Code, BlackBox).
|
||||
- Higher-quality **dialogue and technical writing** outputs.
|
||||
|
||||
## MiniMax M2.1 vs MiniMax M2.1 Lightning
|
||||
## MiniMax M2.5 vs MiniMax M2.5 Highspeed
|
||||
|
||||
- **Speed:** Lightning is the “fast” variant in MiniMax’s pricing docs.
|
||||
- **Cost:** Pricing shows the same input cost, but Lightning has higher output cost.
|
||||
- **Coding plan routing:** The Lightning back-end isn’t directly available on the MiniMax
|
||||
coding plan. MiniMax auto-routes most requests to Lightning, but falls back to the
|
||||
regular M2.1 back-end during traffic spikes.
|
||||
- **Speed:** `MiniMax-M2.5-highspeed` is the official fast tier in MiniMax docs.
|
||||
- **Cost:** MiniMax pricing lists the same input cost and a higher output cost for highspeed.
|
||||
- **Compatibility:** OpenClaw still accepts legacy `MiniMax-M2.5-Lightning` configs, but prefer
|
||||
`MiniMax-M2.5-highspeed` for new setup.
|
||||
|
||||
## Choose a setup
|
||||
|
||||
@@ -56,7 +55,7 @@ You will be prompted to select an endpoint:
|
||||
|
||||
See [MiniMax OAuth plugin README](https://github.com/openclaw/openclaw/tree/main/extensions/minimax-portal-auth) for details.
|
||||
|
||||
### MiniMax M2.1 (API key)
|
||||
### MiniMax M2.5 (API key)
|
||||
|
||||
**Best for:** hosted MiniMax with Anthropic-compatible API.
|
||||
|
||||
@@ -64,12 +63,12 @@ Configure via CLI:
|
||||
|
||||
- Run `openclaw configure`
|
||||
- Select **Model/auth**
|
||||
- Choose **MiniMax M2.1**
|
||||
- Choose **MiniMax M2.5**
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { MINIMAX_API_KEY: "sk-..." },
|
||||
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } },
|
||||
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } },
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
@@ -79,11 +78,20 @@ Configure via CLI:
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
|
||||
cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2.5-highspeed",
|
||||
name: "MiniMax M2.5 Highspeed",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
@@ -94,9 +102,10 @@ Configure via CLI:
|
||||
}
|
||||
```
|
||||
|
||||
### MiniMax M2.1 as fallback (Opus primary)
|
||||
### MiniMax M2.5 as fallback (example)
|
||||
|
||||
**Best for:** keep Opus 4.6 as primary, fail over to MiniMax M2.1.
|
||||
**Best for:** keep your strongest latest-generation model as primary, fail over to MiniMax M2.5.
|
||||
Example below uses Opus as a concrete primary; swap to your preferred latest-gen primary model.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -104,12 +113,12 @@ Configure via CLI:
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-6": { alias: "opus" },
|
||||
"minimax/MiniMax-M2.1": { alias: "minimax" },
|
||||
"anthropic/claude-opus-4-6": { alias: "primary" },
|
||||
"minimax/MiniMax-M2.5": { alias: "minimax" },
|
||||
},
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-6",
|
||||
fallbacks: ["minimax/MiniMax-M2.1"],
|
||||
fallbacks: ["minimax/MiniMax-M2.5"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -119,7 +128,7 @@ Configure via CLI:
|
||||
### Optional: Local via LM Studio (manual)
|
||||
|
||||
**Best for:** local inference with LM Studio.
|
||||
We have seen strong results with MiniMax M2.1 on powerful hardware (e.g. a
|
||||
We have seen strong results with MiniMax M2.5 on powerful hardware (e.g. a
|
||||
desktop/server) using LM Studio's local server.
|
||||
|
||||
Configure manually via `openclaw.json`:
|
||||
@@ -128,8 +137,8 @@ Configure manually via `openclaw.json`:
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
models: { "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } },
|
||||
model: { primary: "lmstudio/minimax-m2.5-gs32" },
|
||||
models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
@@ -141,8 +150,8 @@ Configure manually via `openclaw.json`:
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5 GS32",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -162,7 +171,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
|
||||
|
||||
1. Run `openclaw configure`.
|
||||
2. Select **Model/auth**.
|
||||
3. Choose **MiniMax M2.1**.
|
||||
3. Choose **MiniMax M2.5**.
|
||||
4. Pick your default model when prompted.
|
||||
|
||||
## Configuration options
|
||||
@@ -177,29 +186,31 @@ Use the interactive config wizard to set MiniMax without editing JSON:
|
||||
## Notes
|
||||
|
||||
- Model refs are `minimax/<model>`.
|
||||
- Recommended model IDs: `MiniMax-M2.5` and `MiniMax-M2.5-highspeed`.
|
||||
- Coding Plan usage API: `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` (requires a coding plan key).
|
||||
- Update pricing values in `models.json` if you need exact cost tracking.
|
||||
- Referral link for MiniMax Coding Plan (10% off): [https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link](https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link)
|
||||
- See [/concepts/model-providers](/concepts/model-providers) for provider rules.
|
||||
- Use `openclaw models list` and `openclaw models set minimax/MiniMax-M2.1` to switch.
|
||||
- Use `openclaw models list` and `openclaw models set minimax/MiniMax-M2.5` to switch.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### “Unknown model: minimax/MiniMax-M2.1”
|
||||
### “Unknown model: minimax/MiniMax-M2.5”
|
||||
|
||||
This usually means the **MiniMax provider isn’t configured** (no provider entry
|
||||
and no MiniMax auth profile/env key found). A fix for this detection is in
|
||||
**2026.1.12** (unreleased at the time of writing). Fix by:
|
||||
|
||||
- Upgrading to **2026.1.12** (or run from source `main`), then restarting the gateway.
|
||||
- Running `openclaw configure` and selecting **MiniMax M2.1**, or
|
||||
- Running `openclaw configure` and selecting **MiniMax M2.5**, or
|
||||
- Adding the `models.providers.minimax` block manually, or
|
||||
- Setting `MINIMAX_API_KEY` (or a MiniMax auth profile) so the provider can be injected.
|
||||
|
||||
Make sure the model id is **case‑sensitive**:
|
||||
|
||||
- `minimax/MiniMax-M2.1`
|
||||
- `minimax/MiniMax-M2.1-lightning`
|
||||
- `minimax/MiniMax-M2.5`
|
||||
- `minimax/MiniMax-M2.5-highspeed`
|
||||
- `minimax/MiniMax-M2.5-Lightning` (legacy)
|
||||
|
||||
Then recheck with:
|
||||
|
||||
|
||||
@@ -11,15 +11,6 @@ title: "Model Provider Quickstart"
|
||||
OpenClaw can use many LLM providers. Pick one, authenticate, then set the default
|
||||
model as `provider/model`.
|
||||
|
||||
## Highlight: Venice (Venice AI)
|
||||
|
||||
Venice is our recommended Venice AI setup for privacy-first inference with an option to use Opus for the hardest tasks.
|
||||
|
||||
- Default: `venice/llama-3.3-70b`
|
||||
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
|
||||
|
||||
See [Venice AI](/providers/venice).
|
||||
|
||||
## Quick start (two steps)
|
||||
|
||||
1. Authenticate with the provider (usually via `openclaw onboard`).
|
||||
|
||||
@@ -15,14 +15,20 @@ Kimi Coding with `kimi-coding/k2p5`.
|
||||
|
||||
Current Kimi K2 model IDs:
|
||||
|
||||
{/_moonshot-kimi-k2-ids:start_/ && null}
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
|
||||
{/_ moonshot-kimi-k2-ids:start _/ && null}
|
||||
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
|
||||
- `kimi-k2.5`
|
||||
- `kimi-k2-0905-preview`
|
||||
- `kimi-k2-turbo-preview`
|
||||
- `kimi-k2-thinking`
|
||||
- `kimi-k2-thinking-turbo`
|
||||
{/_moonshot-kimi-k2-ids:end_/ && null}
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
{/_ moonshot-kimi-k2-ids:end _/ && null}
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice moonshot-api-key
|
||||
@@ -140,3 +146,35 @@ Note: Moonshot and Kimi Coding are separate providers. Keys are not interchangea
|
||||
- If Moonshot publishes different context limits for a model, adjust
|
||||
`contextWindow` accordingly.
|
||||
- Use `https://api.moonshot.ai/v1` for the international endpoint, and `https://api.moonshot.cn/v1` for the China endpoint.
|
||||
|
||||
## Native thinking mode (Moonshot)
|
||||
|
||||
Moonshot Kimi supports binary native thinking:
|
||||
|
||||
- `thinking: { type: "enabled" }`
|
||||
- `thinking: { type: "disabled" }`
|
||||
|
||||
Configure it per model via `agents.defaults.models.<provider/model>.params`:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"moonshot/kimi-k2.5": {
|
||||
params: {
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
OpenClaw also maps runtime `/think` levels for Moonshot:
|
||||
|
||||
- `/think off` -> `thinking.type=disabled`
|
||||
- any non-off thinking level -> `thinking.type=enabled`
|
||||
|
||||
When Moonshot thinking is enabled, `tool_choice` must be `auto` or `none`. OpenClaw normalizes incompatible `tool_choice` values to `auto` for compatibility.
|
||||
|
||||
@@ -10,6 +10,7 @@ title: "OpenAI"
|
||||
|
||||
OpenAI provides developer APIs for GPT models. Codex supports **ChatGPT sign-in** for subscription
|
||||
access or **API key** sign-in for usage-based access. Codex cloud requires ChatGPT sign-in.
|
||||
OpenAI explicitly supports subscription OAuth usage in external tools/workflows like OpenClaw.
|
||||
|
||||
## Option A: OpenAI API key (OpenAI Platform)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ openclaw onboard --auth-choice synthetic-api-key
|
||||
The default model is set to:
|
||||
|
||||
```
|
||||
synthetic/hf:MiniMaxAI/MiniMax-M2.1
|
||||
synthetic/hf:MiniMaxAI/MiniMax-M2.5
|
||||
```
|
||||
|
||||
## Config example
|
||||
@@ -33,8 +33,8 @@ synthetic/hf:MiniMaxAI/MiniMax-M2.1
|
||||
env: { SYNTHETIC_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } },
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax M2.5" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
@@ -46,8 +46,8 @@ synthetic/hf:MiniMaxAI/MiniMax-M2.1
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -71,7 +71,7 @@ All models below use cost `0` (input/output/cache).
|
||||
|
||||
| Model ID | Context window | Max tokens | Reasoning | Input |
|
||||
| ------------------------------------------------------ | -------------- | ---------- | --------- | ------------ |
|
||||
| `hf:MiniMaxAI/MiniMax-M2.1` | 192000 | 65536 | false | text |
|
||||
| `hf:MiniMaxAI/MiniMax-M2.5` | 192000 | 65536 | false | text |
|
||||
| `hf:moonshotai/Kimi-K2-Thinking` | 256000 | 8192 | true | text |
|
||||
| `hf:zai-org/GLM-4.7` | 198000 | 128000 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-R1-0528` | 128000 | 8192 | false | text |
|
||||
|
||||
@@ -86,8 +86,8 @@ openclaw agent --model venice/llama-3.3-70b --message "Hello, are you working?"
|
||||
|
||||
After setup, OpenClaw shows all available Venice models. Pick based on your needs:
|
||||
|
||||
- **Default (our pick)**: `venice/llama-3.3-70b` for private, balanced performance.
|
||||
- **Best overall quality**: `venice/claude-opus-45` for hard jobs (Opus remains the strongest).
|
||||
- **Default model**: `venice/llama-3.3-70b` for private, balanced performance.
|
||||
- **High-capability option**: `venice/claude-opus-45` for hard jobs.
|
||||
- **Privacy**: Choose "private" models for fully private inference.
|
||||
- **Capability**: Choose "anonymized" models to access Claude, GPT, Gemini via Venice's proxy.
|
||||
|
||||
@@ -112,16 +112,16 @@ openclaw models list | grep venice
|
||||
|
||||
## Which Model Should I Use?
|
||||
|
||||
| Use Case | Recommended Model | Why |
|
||||
| ---------------------------- | -------------------------------- | ----------------------------------------- |
|
||||
| **General chat** | `llama-3.3-70b` | Good all-around, fully private |
|
||||
| **Best overall quality** | `claude-opus-45` | Opus remains the strongest for hard tasks |
|
||||
| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy |
|
||||
| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context |
|
||||
| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model |
|
||||
| **Uncensored** | `venice-uncensored` | No content restrictions |
|
||||
| **Fast + cheap** | `qwen3-4b` | Lightweight, still capable |
|
||||
| **Complex reasoning** | `deepseek-v3.2` | Strong reasoning, private |
|
||||
| Use Case | Recommended Model | Why |
|
||||
| ---------------------------- | -------------------------------- | ----------------------------------- |
|
||||
| **General chat** | `llama-3.3-70b` | Good all-around, fully private |
|
||||
| **High-capability option** | `claude-opus-45` | Higher quality for hard tasks |
|
||||
| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy |
|
||||
| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context |
|
||||
| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model |
|
||||
| **Uncensored** | `venice-uncensored` | No content restrictions |
|
||||
| **Fast + cheap** | `qwen3-4b` | Lightweight, still capable |
|
||||
| **Complex reasoning** | `deepseek-v3.2` | Strong reasoning, private |
|
||||
|
||||
## Available Models (25 Total)
|
||||
|
||||
@@ -158,7 +158,7 @@ openclaw models list | grep venice
|
||||
| `grok-41-fast` | Grok 4.1 Fast | 262k | Reasoning, vision |
|
||||
| `grok-code-fast-1` | Grok Code Fast 1 | 262k | Reasoning, code |
|
||||
| `kimi-k2-thinking` | Kimi K2 Thinking | 262k | Reasoning |
|
||||
| `minimax-m21` | MiniMax M2.1 | 202k | Reasoning |
|
||||
| `minimax-m21` | MiniMax M2.5 | 202k | Reasoning |
|
||||
|
||||
## Model Discovery
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ Semantic memory search uses **embedding APIs** when configured for remote provid
|
||||
- `memorySearch.provider = "gemini"` → Gemini embeddings
|
||||
- `memorySearch.provider = "voyage"` → Voyage embeddings
|
||||
- `memorySearch.provider = "mistral"` → Mistral embeddings
|
||||
- `memorySearch.provider = "ollama"` → Ollama embeddings (local/self-hosted; typically no hosted API billing)
|
||||
- Optional fallback to a remote provider if local embeddings fail
|
||||
|
||||
You can keep it local with `memorySearch.provider = "local"` (no API usage).
|
||||
|
||||
@@ -30,7 +30,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
- Full reset (also removes workspace)
|
||||
</Step>
|
||||
<Step title="Model/Auth">
|
||||
- **Anthropic API key (recommended)**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
|
||||
- **Anthropic API key**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
|
||||
- **Anthropic OAuth (Claude Code CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
|
||||
- **Anthropic token (paste setup-token)**: run `claude setup-token` on any machine, then paste the token (you can name it; blank = default).
|
||||
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
||||
@@ -44,7 +44,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- **Cloudflare AI Gateway**: prompts for Account ID, Gateway ID, and `CLOUDFLARE_AI_GATEWAY_API_KEY`.
|
||||
- More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
|
||||
- **MiniMax M2.1**: config is auto-written.
|
||||
- **MiniMax M2.5**: config is auto-written.
|
||||
- More detail: [MiniMax](/providers/minimax)
|
||||
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
|
||||
- More detail: [Synthetic](/providers/synthetic)
|
||||
@@ -52,7 +52,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
- **Kimi Coding**: config is auto-written.
|
||||
- More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
|
||||
- **Skip**: no auth configured yet.
|
||||
- Pick a default model from detected options (or enter provider/model manually).
|
||||
- Pick a default model from detected options (or enter provider/model manually). For best quality and lower prompt-injection risk, choose the strongest latest-generation model available in your provider stack.
|
||||
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
||||
- API key storage mode defaults to plaintext auth-profile values. Use `--secret-input-mode ref` to store env-backed refs instead (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`).
|
||||
- OAuth credentials live in `~/.openclaw/credentials/oauth.json`; auth profiles live in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (API keys + OAuth).
|
||||
@@ -245,6 +245,7 @@ Typical fields in `~/.openclaw/openclaw.json`:
|
||||
|
||||
- `agents.defaults.workspace`
|
||||
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
|
||||
- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved)
|
||||
- `gateway.*` (mode, bind, auth, tailscale)
|
||||
- `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals))
|
||||
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
|
||||
|
||||
@@ -34,6 +34,8 @@ Security trust model:
|
||||
|
||||
- By default, OpenClaw is a personal agent: one trusted operator boundary.
|
||||
- Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)).
|
||||
- Local onboarding now defaults new configs to `tools.profile: "messaging"` so broad runtime/filesystem tools are opt-in.
|
||||
- If hooks/webhooks or other untrusted content feeds are enabled, use a strong modern model tier and keep strict tool policy/sandboxing.
|
||||
|
||||
</Step>
|
||||
<Step title="Local vs Remote">
|
||||
|
||||
@@ -116,7 +116,7 @@ What you set:
|
||||
## Auth and model options
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Anthropic API key (recommended)">
|
||||
<Accordion title="Anthropic API key">
|
||||
Uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
|
||||
</Accordion>
|
||||
<Accordion title="Anthropic OAuth (Claude Code CLI)">
|
||||
@@ -163,7 +163,7 @@ What you set:
|
||||
Prompts for account ID, gateway ID, and `CLOUDFLARE_AI_GATEWAY_API_KEY`.
|
||||
More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway).
|
||||
</Accordion>
|
||||
<Accordion title="MiniMax M2.1">
|
||||
<Accordion title="MiniMax M2.5">
|
||||
Config is auto-written.
|
||||
More detail: [MiniMax](/providers/minimax).
|
||||
</Accordion>
|
||||
@@ -236,6 +236,7 @@ Typical fields in `~/.openclaw/openclaw.json`:
|
||||
|
||||
- `agents.defaults.workspace`
|
||||
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
|
||||
- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved)
|
||||
- `gateway.*` (mode, bind, auth, tailscale)
|
||||
- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved)
|
||||
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
|
||||
|
||||
@@ -50,6 +50,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
- Workspace default (or existing workspace)
|
||||
- Gateway port **18789**
|
||||
- Gateway auth **Token** (auto‑generated, even on loopback)
|
||||
- Tool policy default for new local setups: `tools.profile: "messaging"` (existing explicit profile is preserved)
|
||||
- DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)
|
||||
- Tailscale exposure **Off**
|
||||
- Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number)
|
||||
@@ -63,8 +64,9 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
|
||||
**Local mode (default)** walks you through these steps:
|
||||
|
||||
1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider
|
||||
1. **Model/Auth** — choose any supported provider/auth flow (API key, OAuth, or setup-token), including Custom Provider
|
||||
(OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model.
|
||||
Security note: if this agent will run tools or process webhook/hooks content, prefer the strongest latest-generation model available and keep tool policy strict. Weaker/older tiers are easier to prompt-inject.
|
||||
For non-interactive runs, `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext API key values.
|
||||
In non-interactive `ref` mode, the provider env var must be set; passing inline key flags without that env var fails fast.
|
||||
In interactive runs, choosing secret reference mode lets you point at either an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight validation before saving.
|
||||
|
||||
@@ -75,7 +75,7 @@ Thread binding support is adapter-specific. If the active channel adapter does n
|
||||
Required feature flags for thread-bound ACP:
|
||||
|
||||
- `acp.enabled=true`
|
||||
- `acp.dispatch.enabled=true`
|
||||
- `acp.dispatch.enabled` is on by default (set `false` to pause ACP dispatch)
|
||||
- Channel-adapter ACP thread-spawn flag enabled (adapter-specific)
|
||||
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
|
||||
|
||||
@@ -120,6 +120,19 @@ Interface details:
|
||||
- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy).
|
||||
- `label` (optional): operator-facing label used in session/banner text.
|
||||
|
||||
## Sandbox compatibility
|
||||
|
||||
ACP sessions currently run on the host runtime, not inside the OpenClaw sandbox.
|
||||
|
||||
Current limitations:
|
||||
|
||||
- If the requester session is sandboxed, ACP spawns are blocked.
|
||||
- Error: `Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.`
|
||||
- `sessions_spawn` with `runtime: "acp"` does not support `sandbox: "require"`.
|
||||
- Error: `sessions_spawn sandbox="require" is unsupported for runtime="acp" because ACP sessions run outside the sandbox. Use runtime="subagent" or sandbox="inherit".`
|
||||
|
||||
Use `runtime: "subagent"` when you need sandbox-enforced execution.
|
||||
|
||||
### From `/acp` command
|
||||
|
||||
Use `/acp spawn` for explicit operator control from chat when needed.
|
||||
@@ -236,6 +249,7 @@ Current acpx built-in harness aliases:
|
||||
- `codex`
|
||||
- `opencode`
|
||||
- `gemini`
|
||||
- `kimi`
|
||||
|
||||
When OpenClaw uses the acpx backend, prefer these values for `agentId` unless your acpx config defines custom agent aliases.
|
||||
|
||||
@@ -249,10 +263,11 @@ Core ACP baseline:
|
||||
{
|
||||
acp: {
|
||||
enabled: true,
|
||||
// Optional. Default is true; set false to pause ACP dispatch while keeping /acp controls.
|
||||
dispatch: { enabled: true },
|
||||
backend: "acpx",
|
||||
defaultAgent: "codex",
|
||||
allowedAgents: ["pi", "claude", "codex", "opencode", "gemini"],
|
||||
allowedAgents: ["pi", "claude", "codex", "opencode", "gemini", "kimi"],
|
||||
maxConcurrentSessions: 8,
|
||||
stream: {
|
||||
coalesceIdleMs: 300,
|
||||
@@ -403,6 +418,8 @@ Restart the gateway after changing these values.
|
||||
| `--thread here requires running /acp spawn inside an active ... thread` | `--thread here` used outside a thread context. | Move to target thread or use `--thread auto`/`off`. |
|
||||
| `Only <user-id> can rebind this thread.` | Another user owns thread binding. | Rebind as owner or use a different thread. |
|
||||
| `Thread bindings are unavailable for <channel>.` | Adapter lacks thread binding capability. | Use `--thread off` or move to supported adapter/channel. |
|
||||
| `Sandboxed sessions cannot spawn ACP sessions ...` | ACP runtime is host-side; requester session is sandboxed. | Use `runtime="subagent"` from sandboxed sessions, or run ACP spawn from a non-sandboxed session. |
|
||||
| `sessions_spawn sandbox="require" is unsupported for runtime="acp" ...` | `sandbox="require"` requested for ACP runtime. | Use `runtime="subagent"` for required sandboxing, or use ACP with `sandbox="inherit"` from a non-sandboxed session. |
|
||||
| Missing ACP metadata for bound session | Stale/deleted ACP session metadata. | Recreate with `/acp spawn`, then rebind/focus thread. |
|
||||
| `AcpRuntimeError: Permission prompt unavailable in non-interactive mode` | `permissionMode` blocks writes/exec in non-interactive ACP session. | Set `plugins.entries.acpx.config.permissionMode` to `approve-all` and restart gateway. See [Permission configuration](#permission-configuration). |
|
||||
| ACP session fails early with little output | Permission prompts are blocked by `permissionMode`/`nonInteractivePermissions`. | Check gateway logs for `AcpRuntimeError`. For full permissions, set `permissionMode=approve-all`; for graceful degradation, set `nonInteractivePermissions=deny`. |
|
||||
|
||||
@@ -97,7 +97,7 @@ Notes:
|
||||
- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility.
|
||||
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
||||
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
|
||||
- Default profile is `chrome` (extension relay). Use `defaultProfile: "openclaw"` for the managed browser.
|
||||
- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "chrome"` to opt into the Chrome extension relay.
|
||||
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.
|
||||
|
||||
|
||||
@@ -105,6 +105,11 @@ Validation and limits:
|
||||
- `title` max 1024 bytes.
|
||||
- Patch complexity cap: max 128 files and 120000 total lines.
|
||||
- `patch` and `before` or `after` together are rejected.
|
||||
- Rendered file safety limits (apply to PNG and PDF):
|
||||
- `fileQuality: "standard"`: max 8 MP (8,000,000 rendered pixels).
|
||||
- `fileQuality: "hq"`: max 14 MP (14,000,000 rendered pixels).
|
||||
- `fileQuality: "print"`: max 24 MP (24,000,000 rendered pixels).
|
||||
- PDF also has a max of 50 pages.
|
||||
|
||||
## Output details contract
|
||||
|
||||
|
||||
@@ -90,6 +90,22 @@ Notes:
|
||||
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
|
||||
- Edge TTS is not supported for telephony.
|
||||
|
||||
For STT/transcription, plugins can call:
|
||||
|
||||
```ts
|
||||
const { text } = await api.runtime.stt.transcribeAudioFile({
|
||||
filePath: "/tmp/inbound-audio.ogg",
|
||||
cfg: api.config,
|
||||
// Optional when MIME cannot be inferred reliably:
|
||||
mime: "audio/ogg",
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Uses core media-understanding audio configuration (`tools.media.audio`) and provider fallback order.
|
||||
- Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input).
|
||||
|
||||
## Discovery & precedence
|
||||
|
||||
OpenClaw scans, in order:
|
||||
|
||||
@@ -19,4 +19,5 @@ Channel notes:
|
||||
- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji.
|
||||
- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation.
|
||||
- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`).
|
||||
- **Zalo Personal (`zalouser`)**: requires non-empty `emoji`; `remove: true` removes that specific emoji reaction.
|
||||
- **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled.
|
||||
|
||||
@@ -22,6 +22,7 @@ title: "Thinking Levels"
|
||||
- Provider notes:
|
||||
- Anthropic Claude 4.6 models default to `adaptive` when no explicit thinking level is set.
|
||||
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
|
||||
- Moonshot (`moonshot/*`) maps `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
|
||||
|
||||
## Resolution order
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ user-invocable: false
|
||||
|
||||
# ACP Harness Router
|
||||
|
||||
When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
|
||||
When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini/Kimi (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
|
||||
|
||||
## Intent detection
|
||||
|
||||
@@ -39,7 +39,7 @@ Do not use:
|
||||
|
||||
- `subagents` runtime for harness control
|
||||
- `/acp` command delegation as a requirement for the user
|
||||
- PTY scraping of pi/claude/codex/opencode/gemini CLIs when `acpx` is available
|
||||
- PTY scraping of pi/claude/codex/opencode/gemini/kimi CLIs when `acpx` is available
|
||||
|
||||
## AgentId mapping
|
||||
|
||||
@@ -50,6 +50,7 @@ Use these defaults when user names a harness directly:
|
||||
- "codex" -> `agentId: "codex"`
|
||||
- "opencode" -> `agentId: "opencode"`
|
||||
- "gemini" or "gemini cli" -> `agentId: "gemini"`
|
||||
- "kimi" or "kimi cli" -> `agentId: "kimi"`
|
||||
|
||||
These defaults match current acpx built-in aliases.
|
||||
|
||||
@@ -87,7 +88,7 @@ Call:
|
||||
|
||||
## Thread spawn recovery policy
|
||||
|
||||
When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
|
||||
When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi/kimi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
|
||||
|
||||
Required behavior when ACP backend is unavailable:
|
||||
|
||||
@@ -183,6 +184,7 @@ ${ACPX_CMD} codex sessions close oc-codex-<conversationId>
|
||||
- `codex`
|
||||
- `opencode`
|
||||
- `gemini`
|
||||
- `kimi`
|
||||
|
||||
### Built-in adapter commands in acpx
|
||||
|
||||
@@ -193,6 +195,7 @@ Defaults are:
|
||||
- `codex -> npx @zed-industries/codex-acp`
|
||||
- `opencode -> npx -y opencode-ai acp`
|
||||
- `gemini -> gemini`
|
||||
- `kimi -> kimi acp`
|
||||
|
||||
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
|
||||
|
||||
|
||||
@@ -76,6 +76,28 @@ function resolveVersionFromPackage(command: string, cwd: string): string | null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveVersionCheckResult(params: {
|
||||
expectedVersion?: string;
|
||||
installedVersion: string;
|
||||
installCommand: string;
|
||||
}): AcpxVersionCheckResult {
|
||||
if (params.expectedVersion && params.installedVersion !== params.expectedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "version-mismatch",
|
||||
message: `acpx version mismatch: found ${params.installedVersion}, expected ${params.expectedVersion}`,
|
||||
expectedVersion: params.expectedVersion,
|
||||
installCommand: params.installCommand,
|
||||
installedVersion: params.installedVersion,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
version: params.installedVersion,
|
||||
expectedVersion: params.expectedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkAcpxVersion(params: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
@@ -131,21 +153,7 @@ export async function checkAcpxVersion(params: {
|
||||
if (hasExpectedVersion && isUnsupportedVersionProbe(result.stdout, result.stderr)) {
|
||||
const installedVersion = resolveVersionFromPackage(params.command, cwd);
|
||||
if (installedVersion) {
|
||||
if (expectedVersion && installedVersion !== expectedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "version-mismatch",
|
||||
message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
|
||||
expectedVersion,
|
||||
installCommand,
|
||||
installedVersion,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
version: installedVersion,
|
||||
expectedVersion,
|
||||
};
|
||||
return resolveVersionCheckResult({ expectedVersion, installedVersion, installCommand });
|
||||
}
|
||||
}
|
||||
const stderr = result.stderr.trim();
|
||||
@@ -179,22 +187,7 @@ export async function checkAcpxVersion(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (expectedVersion && installedVersion !== expectedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "version-mismatch",
|
||||
message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
|
||||
expectedVersion,
|
||||
installCommand,
|
||||
installedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
version: installedVersion,
|
||||
expectedVersion,
|
||||
};
|
||||
return resolveVersionCheckResult({ expectedVersion, installedVersion, installCommand });
|
||||
}
|
||||
|
||||
let pendingEnsure: Promise<void> | null = null;
|
||||
|
||||
@@ -14,6 +14,8 @@ export const NOOP_LOGGER = {
|
||||
};
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
let sharedMockCliScriptPath: Promise<string> | null = null;
|
||||
let logFileSequence = 0;
|
||||
|
||||
const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
@@ -263,14 +265,9 @@ export async function createMockRuntimeFixture(params?: {
|
||||
logPath: string;
|
||||
config: ResolvedAcpxPluginConfig;
|
||||
}> {
|
||||
const dir = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-acpx-runtime-test-"),
|
||||
);
|
||||
tempDirs.push(dir);
|
||||
const scriptPath = path.join(dir, "mock-acpx.cjs");
|
||||
const logPath = path.join(dir, "calls.log");
|
||||
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
const scriptPath = await ensureMockCliScriptPath();
|
||||
const dir = path.dirname(scriptPath);
|
||||
const logPath = path.join(dir, `calls-${logFileSequence++}.log`);
|
||||
process.env.MOCK_ACPX_LOG = logPath;
|
||||
|
||||
const config: ResolvedAcpxPluginConfig = {
|
||||
@@ -294,6 +291,23 @@ export async function createMockRuntimeFixture(params?: {
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureMockCliScriptPath(): Promise<string> {
|
||||
if (sharedMockCliScriptPath) {
|
||||
return await sharedMockCliScriptPath;
|
||||
}
|
||||
sharedMockCliScriptPath = (async () => {
|
||||
const dir = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-acpx-runtime-test-"),
|
||||
);
|
||||
tempDirs.push(dir);
|
||||
const scriptPath = path.join(dir, "mock-acpx.cjs");
|
||||
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
return scriptPath;
|
||||
})();
|
||||
return await sharedMockCliScriptPath;
|
||||
}
|
||||
|
||||
export async function readMockRuntimeLogEntries(
|
||||
logPath: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
@@ -310,6 +324,8 @@ export async function readMockRuntimeLogEntries(
|
||||
|
||||
export async function cleanupMockRuntimeFixtures(): Promise<void> {
|
||||
delete process.env.MOCK_ACPX_LOG;
|
||||
sharedMockCliScriptPath = null;
|
||||
logFileSequence = 0;
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
|
||||
import {
|
||||
cleanupMockRuntimeFixtures,
|
||||
@@ -10,7 +10,29 @@ import {
|
||||
} from "./runtime-internals/test-fixtures.js";
|
||||
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
||||
|
||||
afterEach(async () => {
|
||||
let sharedFixture: Awaited<ReturnType<typeof createMockRuntimeFixture>> | null = null;
|
||||
let missingCommandRuntime: AcpxRuntime | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
sharedFixture = await createMockRuntimeFixture();
|
||||
missingCommandRuntime = new AcpxRuntime(
|
||||
{
|
||||
command: "/definitely/missing/acpx",
|
||||
allowPluginLocalInstall: false,
|
||||
installCommand: "n/a",
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
strictWindowsCmdWrapper: true,
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
},
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
sharedFixture = null;
|
||||
missingCommandRuntime = null;
|
||||
await cleanupMockRuntimeFixtures();
|
||||
});
|
||||
|
||||
@@ -21,20 +43,14 @@ describe("AcpxRuntime", () => {
|
||||
createRuntime: async () => fixture.runtime,
|
||||
agentId: "codex",
|
||||
successPrompt: "contract-pass",
|
||||
errorPrompt: "trigger-error",
|
||||
includeControlChecks: false,
|
||||
assertSuccessEvents: (events) => {
|
||||
expect(events.some((event) => event.type === "done")).toBe(true);
|
||||
},
|
||||
assertErrorOutcome: ({ events, thrown }) => {
|
||||
expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
const logs = await readMockRuntimeLogEntries(fixture.logPath);
|
||||
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "status")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "set-mode")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "set")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "cancel")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "close")).toBe(true);
|
||||
});
|
||||
@@ -110,34 +126,12 @@ describe("AcpxRuntime", () => {
|
||||
expect(promptArgs).toContain("--approve-all");
|
||||
});
|
||||
|
||||
it("passes a queue-owner TTL by default to avoid long idle stalls", async () => {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:ttl-default",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
for await (const _event of runtime.runTurn({
|
||||
handle,
|
||||
text: "ttl-default",
|
||||
mode: "prompt",
|
||||
requestId: "req-ttl-default",
|
||||
})) {
|
||||
// drain
|
||||
}
|
||||
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
const prompt = logs.find((entry) => entry.kind === "prompt");
|
||||
expect(prompt).toBeDefined();
|
||||
const promptArgs = (prompt?.args as string[]) ?? [];
|
||||
const ttlFlagIndex = promptArgs.indexOf("--ttl");
|
||||
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
|
||||
});
|
||||
|
||||
it("preserves leading spaces across streamed text deltas", async () => {
|
||||
const { runtime } = await createMockRuntimeFixture();
|
||||
const runtime = sharedFixture?.runtime;
|
||||
expect(runtime).toBeDefined();
|
||||
if (!runtime) {
|
||||
throw new Error("shared runtime fixture missing");
|
||||
}
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:space",
|
||||
agent: "codex",
|
||||
@@ -158,10 +152,28 @@ describe("AcpxRuntime", () => {
|
||||
|
||||
expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
|
||||
expect(textDeltas.join("")).toBe("alpha beta gamma");
|
||||
|
||||
// Keep the default queue-owner TTL assertion on a runTurn that already exists.
|
||||
const activeLogPath = process.env.MOCK_ACPX_LOG;
|
||||
expect(activeLogPath).toBeDefined();
|
||||
const logs = await readMockRuntimeLogEntries(String(activeLogPath));
|
||||
const prompt = logs.find(
|
||||
(entry) =>
|
||||
entry.kind === "prompt" && String(entry.sessionName ?? "") === "agent:codex:acp:space",
|
||||
);
|
||||
expect(prompt).toBeDefined();
|
||||
const promptArgs = (prompt?.args as string[]) ?? [];
|
||||
const ttlFlagIndex = promptArgs.indexOf("--ttl");
|
||||
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
|
||||
});
|
||||
|
||||
it("emits done once when ACP stream repeats stop reason responses", async () => {
|
||||
const { runtime } = await createMockRuntimeFixture();
|
||||
const runtime = sharedFixture?.runtime;
|
||||
expect(runtime).toBeDefined();
|
||||
if (!runtime) {
|
||||
throw new Error("shared runtime fixture missing");
|
||||
}
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:double-done",
|
||||
agent: "codex",
|
||||
@@ -183,7 +195,11 @@ describe("AcpxRuntime", () => {
|
||||
});
|
||||
|
||||
it("maps acpx error events into ACP runtime error events", async () => {
|
||||
const { runtime } = await createMockRuntimeFixture();
|
||||
const runtime = sharedFixture?.runtime;
|
||||
expect(runtime).toBeDefined();
|
||||
if (!runtime) {
|
||||
throw new Error("shared runtime fixture missing");
|
||||
}
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:456",
|
||||
agent: "codex",
|
||||
@@ -318,28 +334,12 @@ describe("AcpxRuntime", () => {
|
||||
});
|
||||
|
||||
it("marks runtime unhealthy when command is missing", async () => {
|
||||
const runtime = new AcpxRuntime(
|
||||
{
|
||||
command: "/definitely/missing/acpx",
|
||||
allowPluginLocalInstall: false,
|
||||
installCommand: "n/a",
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
strictWindowsCmdWrapper: true,
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
},
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
|
||||
await runtime.probeAvailability();
|
||||
expect(runtime.isHealthy()).toBe(false);
|
||||
});
|
||||
|
||||
it("marks runtime healthy when command is available", async () => {
|
||||
const { runtime } = await createMockRuntimeFixture();
|
||||
await runtime.probeAvailability();
|
||||
expect(runtime.isHealthy()).toBe(true);
|
||||
expect(missingCommandRuntime).toBeDefined();
|
||||
if (!missingCommandRuntime) {
|
||||
throw new Error("missing-command runtime fixture missing");
|
||||
}
|
||||
await missingCommandRuntime.probeAvailability();
|
||||
expect(missingCommandRuntime.isHealthy()).toBe(false);
|
||||
});
|
||||
|
||||
it("logs ACPX spawn resolution once per command policy", async () => {
|
||||
@@ -368,21 +368,11 @@ describe("AcpxRuntime", () => {
|
||||
});
|
||||
|
||||
it("returns doctor report for missing command", async () => {
|
||||
const runtime = new AcpxRuntime(
|
||||
{
|
||||
command: "/definitely/missing/acpx",
|
||||
allowPluginLocalInstall: false,
|
||||
installCommand: "n/a",
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
strictWindowsCmdWrapper: true,
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
},
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
|
||||
const report = await runtime.doctor();
|
||||
expect(missingCommandRuntime).toBeDefined();
|
||||
if (!missingCommandRuntime) {
|
||||
throw new Error("missing-command runtime fixture missing");
|
||||
}
|
||||
const report = await missingCommandRuntime.doctor();
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
|
||||
expect(report.installCommand).toContain("acpx");
|
||||
|
||||
@@ -10,7 +10,7 @@ If you’re looking for **how to use BlueBubbles as an agent/tool user**, see:
|
||||
|
||||
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
|
||||
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
|
||||
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
|
||||
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register per-account route via `registerPluginHttpRoute`).
|
||||
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
|
||||
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
|
||||
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { bluebubblesPlugin } from "./src/channel.js";
|
||||
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
|
||||
import { setBlueBubblesRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
@@ -12,7 +11,6 @@ const plugin = {
|
||||
register(api: OpenClawPluginApi) {
|
||||
setBlueBubblesRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: bluebubblesPlugin });
|
||||
api.registerHttpHandler(handleBlueBubblesWebhookRequest);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
extractToolSend,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readBooleanParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
type ChannelMessageActionAdapter,
|
||||
@@ -52,23 +53,6 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
||||
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
||||
}
|
||||
|
||||
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const raw = params[key];
|
||||
if (typeof raw === "boolean") {
|
||||
return raw;
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (trimmed === "true") {
|
||||
return true;
|
||||
}
|
||||
if (trimmed === "false") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Supported action names for BlueBubbles */
|
||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
|
||||
205
extensions/bluebubbles/src/monitor-debounce.ts
Normal file
205
extensions/bluebubbles/src/monitor-debounce.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
|
||||
import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
|
||||
|
||||
/**
|
||||
* Entry type for debouncing inbound messages.
|
||||
* Captures the normalized message and its target for later combined processing.
|
||||
*/
|
||||
type BlueBubblesDebounceEntry = {
|
||||
message: NormalizedWebhookMessage;
|
||||
target: WebhookTarget;
|
||||
};
|
||||
|
||||
export type BlueBubblesDebouncer = {
|
||||
enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
|
||||
flushKey: (key: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type BlueBubblesDebounceRegistry = {
|
||||
getOrCreateDebouncer: (target: WebhookTarget) => BlueBubblesDebouncer;
|
||||
removeDebouncer: (target: WebhookTarget) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default debounce window for inbound message coalescing (ms).
|
||||
* This helps combine URL text + link preview balloon messages that BlueBubbles
|
||||
* sends as separate webhook events when no explicit inbound debounce config exists.
|
||||
*/
|
||||
const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
|
||||
|
||||
/**
|
||||
* Combines multiple debounced messages into a single message for processing.
|
||||
* Used when multiple webhook events arrive within the debounce window.
|
||||
*/
|
||||
function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
|
||||
if (entries.length === 0) {
|
||||
throw new Error("Cannot combine empty entries");
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
return entries[0].message;
|
||||
}
|
||||
|
||||
// Use the first message as the base (typically the text message)
|
||||
const first = entries[0].message;
|
||||
|
||||
// Combine text from all entries, filtering out duplicates and empty strings
|
||||
const seenTexts = new Set<string>();
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const text = entry.message.text.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
// Skip duplicate text (URL might be in both text message and balloon)
|
||||
const normalizedText = text.toLowerCase();
|
||||
if (seenTexts.has(normalizedText)) {
|
||||
continue;
|
||||
}
|
||||
seenTexts.add(normalizedText);
|
||||
textParts.push(text);
|
||||
}
|
||||
|
||||
// Merge attachments from all entries
|
||||
const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
|
||||
|
||||
// Use the latest timestamp
|
||||
const timestamps = entries
|
||||
.map((e) => e.message.timestamp)
|
||||
.filter((t): t is number => typeof t === "number");
|
||||
const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
|
||||
|
||||
// Collect all message IDs for reference
|
||||
const messageIds = entries
|
||||
.map((e) => e.message.messageId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
|
||||
// Prefer reply context from any entry that has it
|
||||
const entryWithReply = entries.find((e) => e.message.replyToId);
|
||||
|
||||
return {
|
||||
...first,
|
||||
text: textParts.join(" "),
|
||||
attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
|
||||
timestamp: latestTimestamp,
|
||||
// Use first message's ID as primary (for reply reference), but we've coalesced others
|
||||
messageId: messageIds[0] ?? first.messageId,
|
||||
// Preserve reply context if present
|
||||
replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
|
||||
replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
|
||||
replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
|
||||
// Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
|
||||
balloonBundleId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBlueBubblesDebounceMs(
|
||||
config: OpenClawConfig,
|
||||
core: BlueBubblesCoreRuntime,
|
||||
): number {
|
||||
const inbound = config.messages?.inbound;
|
||||
const hasExplicitDebounce =
|
||||
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
|
||||
if (!hasExplicitDebounce) {
|
||||
return DEFAULT_INBOUND_DEBOUNCE_MS;
|
||||
}
|
||||
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
|
||||
}
|
||||
|
||||
export function createBlueBubblesDebounceRegistry(params: {
|
||||
processMessage: (message: NormalizedWebhookMessage, target: WebhookTarget) => Promise<void>;
|
||||
}): BlueBubblesDebounceRegistry {
|
||||
const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
|
||||
|
||||
return {
|
||||
getOrCreateDebouncer: (target) => {
|
||||
const existing = targetDebouncers.get(target);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const { account, config, runtime, core } = target;
|
||||
const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
|
||||
debounceMs: resolveBlueBubblesDebounceMs(config, core),
|
||||
buildKey: (entry) => {
|
||||
const msg = entry.message;
|
||||
// Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
|
||||
// same message (e.g., text-only then text+attachment).
|
||||
//
|
||||
// For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
|
||||
// messageId than the originating text. When present, key by associatedMessageGuid
|
||||
// to keep text + balloon coalescing working.
|
||||
const balloonBundleId = msg.balloonBundleId?.trim();
|
||||
const associatedMessageGuid = msg.associatedMessageGuid?.trim();
|
||||
if (balloonBundleId && associatedMessageGuid) {
|
||||
return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
|
||||
}
|
||||
|
||||
const messageId = msg.messageId?.trim();
|
||||
if (messageId) {
|
||||
return `bluebubbles:${account.accountId}:msg:${messageId}`;
|
||||
}
|
||||
|
||||
const chatKey =
|
||||
msg.chatGuid?.trim() ??
|
||||
msg.chatIdentifier?.trim() ??
|
||||
(msg.chatId ? String(msg.chatId) : "dm");
|
||||
return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
const msg = entry.message;
|
||||
// Skip debouncing for from-me messages (they're just cached, not processed)
|
||||
if (msg.fromMe) {
|
||||
return false;
|
||||
}
|
||||
// Skip debouncing for control commands - process immediately
|
||||
if (core.channel.text.hasControlCommand(msg.text, config)) {
|
||||
return false;
|
||||
}
|
||||
// Debounce all other messages to coalesce rapid-fire webhook events
|
||||
// (e.g., text+image arriving as separate webhooks for the same messageId)
|
||||
return true;
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use target from first entry (all entries have same target due to key structure)
|
||||
const flushTarget = entries[0].target;
|
||||
|
||||
if (entries.length === 1) {
|
||||
// Single message - process normally
|
||||
await params.processMessage(entries[0].message, flushTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple messages - combine and process
|
||||
const combined = combineDebounceEntries(entries);
|
||||
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
const count = entries.length;
|
||||
const preview = combined.text.slice(0, 50);
|
||||
runtime.log?.(
|
||||
`[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
|
||||
);
|
||||
}
|
||||
|
||||
await params.processMessage(combined, flushTarget);
|
||||
},
|
||||
onError: (err) => {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
targetDebouncers.set(target, debouncer);
|
||||
return debouncer;
|
||||
},
|
||||
removeDebouncer: (target) => {
|
||||
targetDebouncers.delete(target);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -103,6 +103,7 @@ function createMockRuntime(): PluginRuntime {
|
||||
system: {
|
||||
enqueueSystemEvent:
|
||||
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
|
||||
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],
|
||||
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
|
||||
formatNativeDependencyHint: vi.fn(
|
||||
() => "",
|
||||
@@ -120,6 +121,9 @@ function createMockRuntime(): PluginRuntime {
|
||||
tts: {
|
||||
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
|
||||
},
|
||||
stt: {
|
||||
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
|
||||
},
|
||||
tools: {
|
||||
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
|
||||
createMemorySearchTool:
|
||||
@@ -271,6 +275,12 @@ function createMockRuntime(): PluginRuntime {
|
||||
imessage: {} as PluginRuntime["channel"]["imessage"],
|
||||
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
|
||||
},
|
||||
events: {
|
||||
onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"],
|
||||
onSessionTranscriptUpdate: vi.fn(
|
||||
() => () => {},
|
||||
) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"],
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: vi.fn(
|
||||
() => false,
|
||||
@@ -535,7 +545,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
// Create a request that never sends data or ends (simulates slow-loris)
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = "/bluebubbles-webhook";
|
||||
req.url = "/bluebubbles-webhook?password=test-password";
|
||||
req.headers = {};
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
@@ -558,6 +568,37 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unauthorized requests before reading the body", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = "/bluebubbles-webhook?password=wrong-token";
|
||||
req.headers = {};
|
||||
const onSpy = vi.spyOn(req, "on");
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
|
||||
});
|
||||
|
||||
it("authenticates via password query parameter", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
registerWebhookTarget,
|
||||
rejectNonPostWebhookRequest,
|
||||
requestBodyErrorToText,
|
||||
resolveSingleWebhookTarget,
|
||||
beginWebhookRequestPipelineOrReject,
|
||||
createWebhookInFlightLimiter,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
readWebhookBodyOrReject,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
resolveWebhookTargets,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
normalizeWebhookMessage,
|
||||
normalizeWebhookReaction,
|
||||
type NormalizedWebhookMessage,
|
||||
} from "./monitor-normalize.js";
|
||||
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
|
||||
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
|
||||
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
|
||||
import {
|
||||
_resetBlueBubblesShortIdState,
|
||||
@@ -24,229 +19,44 @@ import {
|
||||
DEFAULT_WEBHOOK_PATH,
|
||||
normalizeWebhookPath,
|
||||
resolveWebhookPathFromConfig,
|
||||
type BlueBubblesCoreRuntime,
|
||||
type BlueBubblesMonitorOptions,
|
||||
type WebhookTarget,
|
||||
} from "./monitor-shared.js";
|
||||
import { fetchBlueBubblesServerInfo } from "./probe.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
|
||||
/**
|
||||
* Entry type for debouncing inbound messages.
|
||||
* Captures the normalized message and its target for later combined processing.
|
||||
*/
|
||||
type BlueBubblesDebounceEntry = {
|
||||
message: NormalizedWebhookMessage;
|
||||
target: WebhookTarget;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default debounce window for inbound message coalescing (ms).
|
||||
* This helps combine URL text + link preview balloon messages that BlueBubbles
|
||||
* sends as separate webhook events when no explicit inbound debounce config exists.
|
||||
*/
|
||||
const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
|
||||
|
||||
/**
|
||||
* Combines multiple debounced messages into a single message for processing.
|
||||
* Used when multiple webhook events arrive within the debounce window.
|
||||
*/
|
||||
function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
|
||||
if (entries.length === 0) {
|
||||
throw new Error("Cannot combine empty entries");
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
return entries[0].message;
|
||||
}
|
||||
|
||||
// Use the first message as the base (typically the text message)
|
||||
const first = entries[0].message;
|
||||
|
||||
// Combine text from all entries, filtering out duplicates and empty strings
|
||||
const seenTexts = new Set<string>();
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const text = entry.message.text.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
// Skip duplicate text (URL might be in both text message and balloon)
|
||||
const normalizedText = text.toLowerCase();
|
||||
if (seenTexts.has(normalizedText)) {
|
||||
continue;
|
||||
}
|
||||
seenTexts.add(normalizedText);
|
||||
textParts.push(text);
|
||||
}
|
||||
|
||||
// Merge attachments from all entries
|
||||
const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
|
||||
|
||||
// Use the latest timestamp
|
||||
const timestamps = entries
|
||||
.map((e) => e.message.timestamp)
|
||||
.filter((t): t is number => typeof t === "number");
|
||||
const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
|
||||
|
||||
// Collect all message IDs for reference
|
||||
const messageIds = entries
|
||||
.map((e) => e.message.messageId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
|
||||
// Prefer reply context from any entry that has it
|
||||
const entryWithReply = entries.find((e) => e.message.replyToId);
|
||||
|
||||
return {
|
||||
...first,
|
||||
text: textParts.join(" "),
|
||||
attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
|
||||
timestamp: latestTimestamp,
|
||||
// Use first message's ID as primary (for reply reference), but we've coalesced others
|
||||
messageId: messageIds[0] ?? first.messageId,
|
||||
// Preserve reply context if present
|
||||
replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
|
||||
replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
|
||||
replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
|
||||
// Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
|
||||
balloonBundleId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
|
||||
type BlueBubblesDebouncer = {
|
||||
enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
|
||||
flushKey: (key: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps webhook targets to their inbound debouncers.
|
||||
* Each target gets its own debouncer keyed by a unique identifier.
|
||||
*/
|
||||
const targetDebouncers = new Map<WebhookTarget, BlueBubblesDebouncer>();
|
||||
|
||||
function resolveBlueBubblesDebounceMs(
|
||||
config: OpenClawConfig,
|
||||
core: BlueBubblesCoreRuntime,
|
||||
): number {
|
||||
const inbound = config.messages?.inbound;
|
||||
const hasExplicitDebounce =
|
||||
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
|
||||
if (!hasExplicitDebounce) {
|
||||
return DEFAULT_INBOUND_DEBOUNCE_MS;
|
||||
}
|
||||
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or retrieves a debouncer for a webhook target.
|
||||
*/
|
||||
function getOrCreateDebouncer(target: WebhookTarget) {
|
||||
const existing = targetDebouncers.get(target);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const { account, config, runtime, core } = target;
|
||||
|
||||
const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
|
||||
debounceMs: resolveBlueBubblesDebounceMs(config, core),
|
||||
buildKey: (entry) => {
|
||||
const msg = entry.message;
|
||||
// Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
|
||||
// same message (e.g., text-only then text+attachment).
|
||||
//
|
||||
// For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
|
||||
// messageId than the originating text. When present, key by associatedMessageGuid
|
||||
// to keep text + balloon coalescing working.
|
||||
const balloonBundleId = msg.balloonBundleId?.trim();
|
||||
const associatedMessageGuid = msg.associatedMessageGuid?.trim();
|
||||
if (balloonBundleId && associatedMessageGuid) {
|
||||
return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
|
||||
}
|
||||
|
||||
const messageId = msg.messageId?.trim();
|
||||
if (messageId) {
|
||||
return `bluebubbles:${account.accountId}:msg:${messageId}`;
|
||||
}
|
||||
|
||||
const chatKey =
|
||||
msg.chatGuid?.trim() ??
|
||||
msg.chatIdentifier?.trim() ??
|
||||
(msg.chatId ? String(msg.chatId) : "dm");
|
||||
return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
const msg = entry.message;
|
||||
// Skip debouncing for from-me messages (they're just cached, not processed)
|
||||
if (msg.fromMe) {
|
||||
return false;
|
||||
}
|
||||
// Skip debouncing for control commands - process immediately
|
||||
if (core.channel.text.hasControlCommand(msg.text, config)) {
|
||||
return false;
|
||||
}
|
||||
// Debounce all other messages to coalesce rapid-fire webhook events
|
||||
// (e.g., text+image arriving as separate webhooks for the same messageId)
|
||||
return true;
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use target from first entry (all entries have same target due to key structure)
|
||||
const flushTarget = entries[0].target;
|
||||
|
||||
if (entries.length === 1) {
|
||||
// Single message - process normally
|
||||
await processMessage(entries[0].message, flushTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple messages - combine and process
|
||||
const combined = combineDebounceEntries(entries);
|
||||
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
const count = entries.length;
|
||||
const preview = combined.text.slice(0, 50);
|
||||
runtime.log?.(
|
||||
`[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
|
||||
);
|
||||
}
|
||||
|
||||
await processMessage(combined, flushTarget);
|
||||
},
|
||||
onError: (err) => {
|
||||
runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
targetDebouncers.set(target, debouncer);
|
||||
return debouncer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a debouncer for a target (called during unregistration).
|
||||
*/
|
||||
function removeDebouncer(target: WebhookTarget): void {
|
||||
targetDebouncers.delete(target);
|
||||
}
|
||||
const webhookInFlightLimiter = createWebhookInFlightLimiter();
|
||||
const debounceRegistry = createBlueBubblesDebounceRegistry({ processMessage });
|
||||
|
||||
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
|
||||
const registered = registerWebhookTarget(webhookTargets, target);
|
||||
const registered = registerWebhookTargetWithPluginRoute({
|
||||
targetsByPath: webhookTargets,
|
||||
target,
|
||||
route: {
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
pluginId: "bluebubbles",
|
||||
source: "bluebubbles-webhook",
|
||||
accountId: target.account.accountId,
|
||||
log: target.runtime.log,
|
||||
handler: async (req, res) => {
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
if (!handled && !res.headersSent) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
registered.unregister();
|
||||
// Clean up debouncer when target is unregistered
|
||||
removeDebouncer(registered.target);
|
||||
debounceRegistry.removeDebouncer(registered.target);
|
||||
};
|
||||
}
|
||||
|
||||
type ReadBlueBubblesWebhookBodyResult =
|
||||
| { ok: true; value: unknown }
|
||||
| { ok: false; statusCode: number; error: string };
|
||||
|
||||
function parseBlueBubblesWebhookPayload(
|
||||
rawBody: string,
|
||||
): { ok: true; value: unknown } | { ok: false; error: string } {
|
||||
@@ -270,36 +80,6 @@ function parseBlueBubblesWebhookPayload(
|
||||
}
|
||||
}
|
||||
|
||||
async function readBlueBubblesWebhookBody(
|
||||
req: IncomingMessage,
|
||||
maxBytes: number,
|
||||
): Promise<ReadBlueBubblesWebhookBodyResult> {
|
||||
try {
|
||||
const rawBody = await readRequestBodyWithLimit(req, {
|
||||
maxBytes,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const parsed = parseBlueBubblesWebhookPayload(rawBody);
|
||||
if (!parsed.ok) {
|
||||
return { ok: false, statusCode: 400, error: parsed.error };
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (isRequestBodyLimitError(error)) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: error.statusCode,
|
||||
error: requestBodyErrorToText(error.code),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 400,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
@@ -348,137 +128,150 @@ export async function handleBlueBubblesWebhookRequest(
|
||||
}
|
||||
const { path, targets } = resolved;
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
|
||||
if (rejectNonPostWebhookRequest(req, res)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
|
||||
if (!body.ok) {
|
||||
res.statusCode = body.statusCode;
|
||||
res.end(body.error ?? "invalid payload");
|
||||
console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = asRecord(body.value) ?? {};
|
||||
const firstTarget = targets[0];
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
|
||||
);
|
||||
}
|
||||
const eventTypeRaw = payload.type;
|
||||
const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
|
||||
const allowedEventTypes = new Set([
|
||||
"new-message",
|
||||
"updated-message",
|
||||
"message-reaction",
|
||||
"reaction",
|
||||
]);
|
||||
if (eventType && !allowedEventTypes.has(eventType)) {
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (firstTarget) {
|
||||
logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const reaction = normalizeWebhookReaction(payload);
|
||||
if (
|
||||
(eventType === "updated-message" ||
|
||||
eventType === "message-reaction" ||
|
||||
eventType === "reaction") &&
|
||||
!reaction
|
||||
) {
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook ignored ${eventType || "event"} without reaction`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const message = reaction ? null : normalizeWebhookMessage(payload);
|
||||
if (!message && !reaction) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
|
||||
const headerToken =
|
||||
req.headers["x-guid"] ??
|
||||
req.headers["x-password"] ??
|
||||
req.headers["x-bluebubbles-guid"] ??
|
||||
req.headers["authorization"];
|
||||
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
||||
const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
|
||||
const token = target.account.config.password?.trim() ?? "";
|
||||
return safeEqualSecret(guid, token);
|
||||
const requestLifecycle = beginWebhookRequestPipelineOrReject({
|
||||
req,
|
||||
res,
|
||||
allowMethods: ["POST"],
|
||||
inFlightLimiter: webhookInFlightLimiter,
|
||||
inFlightKey: `${path}:${req.socket.remoteAddress ?? "unknown"}`,
|
||||
});
|
||||
|
||||
if (matchedTarget.kind === "none") {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
console.warn(
|
||||
`[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
|
||||
);
|
||||
if (!requestLifecycle.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (matchedTarget.kind === "ambiguous") {
|
||||
res.statusCode = 401;
|
||||
res.end("ambiguous webhook target");
|
||||
console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const target = matchedTarget.target;
|
||||
target.statusSink?.({ lastInboundAt: Date.now() });
|
||||
if (reaction) {
|
||||
processReaction(reaction, target).catch((err) => {
|
||||
target.runtime.error?.(
|
||||
`[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
|
||||
);
|
||||
try {
|
||||
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
|
||||
const headerToken =
|
||||
req.headers["x-guid"] ??
|
||||
req.headers["x-password"] ??
|
||||
req.headers["x-bluebubbles-guid"] ??
|
||||
req.headers["authorization"];
|
||||
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
||||
const target = resolveWebhookTargetWithAuthOrRejectSync({
|
||||
targets,
|
||||
res,
|
||||
isMatch: (target) => {
|
||||
const token = target.account.config.password?.trim() ?? "";
|
||||
return safeEqualSecret(guid, token);
|
||||
},
|
||||
});
|
||||
} else if (message) {
|
||||
// Route messages through debouncer to coalesce rapid-fire events
|
||||
// (e.g., text message + URL balloon arriving as separate webhooks)
|
||||
const debouncer = getOrCreateDebouncer(target);
|
||||
debouncer.enqueue({ message, target }).catch((err) => {
|
||||
target.runtime.error?.(
|
||||
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
|
||||
if (!target) {
|
||||
console.warn(
|
||||
`[bluebubbles] webhook rejected: status=${res.statusCode} path=${path} guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const body = await readWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
profile: "post-auth",
|
||||
invalidBodyMessage: "invalid payload",
|
||||
});
|
||||
}
|
||||
if (!body.ok) {
|
||||
console.warn(`[bluebubbles] webhook rejected: status=${res.statusCode}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (reaction) {
|
||||
const parsed = parseBlueBubblesWebhookPayload(body.value);
|
||||
if (!parsed.ok) {
|
||||
res.statusCode = 400;
|
||||
res.end(parsed.error);
|
||||
console.warn(`[bluebubbles] webhook rejected: ${parsed.error}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = asRecord(parsed.value) ?? {};
|
||||
const firstTarget = targets[0];
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
|
||||
`webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
|
||||
);
|
||||
}
|
||||
} else if (message) {
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
||||
);
|
||||
const eventTypeRaw = payload.type;
|
||||
const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
|
||||
const allowedEventTypes = new Set([
|
||||
"new-message",
|
||||
"updated-message",
|
||||
"message-reaction",
|
||||
"reaction",
|
||||
]);
|
||||
if (eventType && !allowedEventTypes.has(eventType)) {
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (firstTarget) {
|
||||
logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const reaction = normalizeWebhookReaction(payload);
|
||||
if (
|
||||
(eventType === "updated-message" ||
|
||||
eventType === "message-reaction" ||
|
||||
eventType === "reaction") &&
|
||||
!reaction
|
||||
) {
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook ignored ${eventType || "event"} without reaction`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const message = reaction ? null : normalizeWebhookMessage(payload);
|
||||
if (!message && !reaction) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
target.statusSink?.({ lastInboundAt: Date.now() });
|
||||
if (reaction) {
|
||||
processReaction(reaction, target).catch((err) => {
|
||||
target.runtime.error?.(
|
||||
`[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
} else if (message) {
|
||||
// Route messages through debouncer to coalesce rapid-fire events
|
||||
// (e.g., text message + URL balloon arriving as separate webhooks)
|
||||
const debouncer = debounceRegistry.getOrCreateDebouncer(target);
|
||||
debouncer.enqueue({ message, target }).catch((err) => {
|
||||
target.runtime.error?.(
|
||||
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
if (reaction) {
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
|
||||
);
|
||||
}
|
||||
} else if (message) {
|
||||
if (firstTarget) {
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
requestLifecycle.release();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function monitorBlueBubblesProvider(
|
||||
|
||||
44
extensions/bluebubbles/src/monitor.webhook-route.test.ts
Normal file
44
extensions/bluebubbles/src/monitor.webhook-route.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
||||
import type { WebhookTarget } from "./monitor-shared.js";
|
||||
import { registerBlueBubblesWebhookTarget } from "./monitor.js";
|
||||
|
||||
function createTarget(): WebhookTarget {
|
||||
return {
|
||||
account: { accountId: "default" } as WebhookTarget["account"],
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {},
|
||||
core: {} as WebhookTarget["core"],
|
||||
path: "/bluebubbles-webhook",
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerBlueBubblesWebhookTarget", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("registers and unregisters plugin HTTP route at path boundaries", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const unregisterA = registerBlueBubblesWebhookTarget(createTarget());
|
||||
const unregisterB = registerBlueBubblesWebhookTarget(createTarget());
|
||||
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
expect(registry.httpRoutes[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
pluginId: "bluebubbles",
|
||||
path: "/bluebubbles-webhook",
|
||||
source: "bluebubbles-webhook",
|
||||
}),
|
||||
);
|
||||
|
||||
unregisterA();
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
unregisterB();
|
||||
expect(registry.httpRoutes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import { createMockServerResponse } from "../../src/test-utils/mock-http-respons
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("diffs plugin registration", () => {
|
||||
it("registers the tool, http handler, and prompt guidance hook", () => {
|
||||
it("registers the tool, http route, and prompt guidance hook", () => {
|
||||
const registerTool = vi.fn();
|
||||
const registerHttpHandler = vi.fn();
|
||||
const registerHttpRoute = vi.fn();
|
||||
const on = vi.fn();
|
||||
|
||||
plugin.register?.({
|
||||
@@ -23,8 +23,7 @@ describe("diffs plugin registration", () => {
|
||||
},
|
||||
registerTool,
|
||||
registerHook() {},
|
||||
registerHttpHandler,
|
||||
registerHttpRoute() {},
|
||||
registerHttpRoute,
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
@@ -38,7 +37,12 @@ describe("diffs plugin registration", () => {
|
||||
});
|
||||
|
||||
expect(registerTool).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpHandler).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
});
|
||||
expect(on).toHaveBeenCalledTimes(1);
|
||||
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
|
||||
});
|
||||
@@ -47,7 +51,7 @@ describe("diffs plugin registration", () => {
|
||||
let registeredTool:
|
||||
| { execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown> }
|
||||
| undefined;
|
||||
let registeredHttpHandler:
|
||||
let registeredHttpRouteHandler:
|
||||
| ((
|
||||
req: IncomingMessage,
|
||||
res: ReturnType<typeof createMockServerResponse>,
|
||||
@@ -67,6 +71,7 @@ describe("diffs plugin registration", () => {
|
||||
},
|
||||
pluginConfig: {
|
||||
defaults: {
|
||||
mode: "view",
|
||||
theme: "light",
|
||||
background: false,
|
||||
layout: "split",
|
||||
@@ -85,10 +90,9 @@ describe("diffs plugin registration", () => {
|
||||
registeredTool = typeof tool === "function" ? undefined : tool;
|
||||
},
|
||||
registerHook() {},
|
||||
registerHttpHandler(handler) {
|
||||
registeredHttpHandler = handler as typeof registeredHttpHandler;
|
||||
registerHttpRoute(params) {
|
||||
registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler;
|
||||
},
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
@@ -109,7 +113,7 @@ describe("diffs plugin registration", () => {
|
||||
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
|
||||
);
|
||||
const res = createMockServerResponse();
|
||||
const handled = await registeredHttpHandler?.(
|
||||
const handled = await registeredHttpRouteHandler?.(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
|
||||
@@ -25,13 +25,16 @@ const plugin = {
|
||||
});
|
||||
|
||||
api.registerTool(createDiffsTool({ api, store, defaults }));
|
||||
api.registerHttpHandler(
|
||||
createDiffsHttpHandler({
|
||||
api.registerHttpRoute({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
handler: createDiffsHttpHandler({
|
||||
store,
|
||||
logger: api.logger,
|
||||
allowRemoteViewer: security.allowRemoteViewer,
|
||||
}),
|
||||
);
|
||||
});
|
||||
api.on("before_prompt_build", async () => ({
|
||||
prependContext: DIFFS_AGENT_GUIDANCE,
|
||||
}));
|
||||
|
||||
@@ -150,6 +150,16 @@ function buildImageRenderOptions(options: DiffRenderOptions): DiffRenderOptions
|
||||
};
|
||||
}
|
||||
|
||||
function buildRenderVariants(options: DiffRenderOptions): {
|
||||
viewerOptions: DiffViewerOptions;
|
||||
imageOptions: DiffViewerOptions;
|
||||
} {
|
||||
return {
|
||||
viewerOptions: buildDiffOptions(options),
|
||||
imageOptions: buildDiffOptions(buildImageRenderOptions(options)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSupportedLanguage(value?: string): SupportedLanguages | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? (normalized as SupportedLanguages) : undefined;
|
||||
@@ -298,6 +308,35 @@ function buildHtmlDocument(params: {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
type RenderedSection = {
|
||||
viewer: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
function buildRenderedSection(params: {
|
||||
viewerPrerenderedHtml: string;
|
||||
imagePrerenderedHtml: string;
|
||||
payload: Omit<DiffViewerPayload, "prerenderedHTML">;
|
||||
}): RenderedSection {
|
||||
return {
|
||||
viewer: renderDiffCard({
|
||||
prerenderedHTML: params.viewerPrerenderedHtml,
|
||||
...params.payload,
|
||||
}),
|
||||
image: renderStaticDiffCard(params.imagePrerenderedHtml),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRenderedBodies(sections: ReadonlyArray<RenderedSection>): {
|
||||
viewerBodyHtml: string;
|
||||
imageBodyHtml: string;
|
||||
} {
|
||||
return {
|
||||
viewerBodyHtml: sections.map((section) => section.viewer).join("\n"),
|
||||
imageBodyHtml: sections.map((section) => section.image).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
async function renderBeforeAfterDiff(
|
||||
input: Extract<DiffInput, { kind: "before_after" }>,
|
||||
options: DiffRenderOptions,
|
||||
@@ -314,33 +353,35 @@ async function renderBeforeAfterDiff(
|
||||
contents: input.after,
|
||||
...(lang ? { lang } : {}),
|
||||
};
|
||||
const viewerPayloadOptions = buildDiffOptions(options);
|
||||
const imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options));
|
||||
const { viewerOptions, imageOptions } = buildRenderVariants(options);
|
||||
const [viewerResult, imageResult] = await Promise.all([
|
||||
preloadMultiFileDiff({
|
||||
oldFile,
|
||||
newFile,
|
||||
options: viewerPayloadOptions,
|
||||
options: viewerOptions,
|
||||
}),
|
||||
preloadMultiFileDiff({
|
||||
oldFile,
|
||||
newFile,
|
||||
options: imagePayloadOptions,
|
||||
options: imageOptions,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
viewerBodyHtml: renderDiffCard({
|
||||
prerenderedHTML: viewerResult.prerenderedHTML,
|
||||
const section = buildRenderedSection({
|
||||
viewerPrerenderedHtml: viewerResult.prerenderedHTML,
|
||||
imagePrerenderedHtml: imageResult.prerenderedHTML,
|
||||
payload: {
|
||||
oldFile: viewerResult.oldFile,
|
||||
newFile: viewerResult.newFile,
|
||||
options: viewerPayloadOptions,
|
||||
options: viewerOptions,
|
||||
langs: buildPayloadLanguages({
|
||||
oldFile: viewerResult.oldFile,
|
||||
newFile: viewerResult.newFile,
|
||||
}),
|
||||
}),
|
||||
imageBodyHtml: renderStaticDiffCard(imageResult.prerenderedHTML),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...buildRenderedBodies([section]),
|
||||
fileCount: 1,
|
||||
};
|
||||
}
|
||||
@@ -365,36 +406,34 @@ async function renderPatchDiff(
|
||||
throw new Error(`Patch input is too large to render (max ${MAX_PATCH_TOTAL_LINES} lines).`);
|
||||
}
|
||||
|
||||
const viewerPayloadOptions = buildDiffOptions(options);
|
||||
const imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options));
|
||||
const { viewerOptions, imageOptions } = buildRenderVariants(options);
|
||||
const sections = await Promise.all(
|
||||
files.map(async (fileDiff) => {
|
||||
const [viewerResult, imageResult] = await Promise.all([
|
||||
preloadFileDiff({
|
||||
fileDiff,
|
||||
options: viewerPayloadOptions,
|
||||
options: viewerOptions,
|
||||
}),
|
||||
preloadFileDiff({
|
||||
fileDiff,
|
||||
options: imagePayloadOptions,
|
||||
options: imageOptions,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
viewer: renderDiffCard({
|
||||
prerenderedHTML: viewerResult.prerenderedHTML,
|
||||
return buildRenderedSection({
|
||||
viewerPrerenderedHtml: viewerResult.prerenderedHTML,
|
||||
imagePrerenderedHtml: imageResult.prerenderedHTML,
|
||||
payload: {
|
||||
fileDiff: viewerResult.fileDiff,
|
||||
options: viewerPayloadOptions,
|
||||
options: viewerOptions,
|
||||
langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }),
|
||||
}),
|
||||
image: renderStaticDiffCard(imageResult.prerenderedHTML),
|
||||
};
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
viewerBodyHtml: sections.map((section) => section.viewer).join("\n"),
|
||||
imageBodyHtml: sections.map((section) => section.image).join("\n"),
|
||||
...buildRenderedBodies(sections),
|
||||
fileCount: files.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -434,7 +434,6 @@ function createApi(): OpenClawPluginApi {
|
||||
},
|
||||
registerTool() {},
|
||||
registerHook() {},
|
||||
registerHttpHandler() {},
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
|
||||
@@ -187,9 +187,10 @@ export function createDiffsTool(params: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Diff ${image.format.toUpperCase()} generated at: ${artifactFile.path}\n` +
|
||||
"Use the `message` tool with `path` or `filePath` to send this file.",
|
||||
text: buildFileArtifactMessage({
|
||||
format: image.format,
|
||||
filePath: artifactFile.path,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: buildArtifactDetails({
|
||||
@@ -257,10 +258,11 @@ export function createDiffsTool(params: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Diff viewer: ${viewerUrl}\n` +
|
||||
`Diff ${image.format.toUpperCase()} generated at: ${artifactFile.path}\n` +
|
||||
"Use the `message` tool with `path` or `filePath` to send this file.",
|
||||
text: buildFileArtifactMessage({
|
||||
format: image.format,
|
||||
filePath: artifactFile.path,
|
||||
viewerUrl,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: buildArtifactDetails({
|
||||
@@ -330,6 +332,17 @@ function buildArtifactDetails(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildFileArtifactMessage(params: {
|
||||
format: DiffOutputFormat;
|
||||
filePath: string;
|
||||
viewerUrl?: string;
|
||||
}): string {
|
||||
const lines = params.viewerUrl ? [`Diff viewer: ${params.viewerUrl}`] : [];
|
||||
lines.push(`Diff ${params.format.toUpperCase()} generated at: ${params.filePath}`);
|
||||
lines.push("Use the `message` tool with `path` or `filePath` to send this file.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function renderDiffArtifactFile(params: {
|
||||
screenshotter: DiffScreenshotter;
|
||||
store: DiffArtifactStore;
|
||||
|
||||
@@ -106,39 +106,9 @@ function createToolbarButton(params: {
|
||||
}
|
||||
|
||||
function applyToolbarButtonStyles(button: HTMLButtonElement, active: boolean): void {
|
||||
button.style.display = "inline-flex";
|
||||
button.style.alignItems = "center";
|
||||
button.style.justifyContent = "center";
|
||||
button.style.width = "24px";
|
||||
button.style.height = "24px";
|
||||
button.style.padding = "0";
|
||||
button.style.margin = "0";
|
||||
button.style.border = "0";
|
||||
button.style.borderRadius = "0";
|
||||
button.style.background = "transparent";
|
||||
button.style.boxShadow = "none";
|
||||
button.style.lineHeight = "0";
|
||||
button.style.cursor = "pointer";
|
||||
button.style.overflow = "visible";
|
||||
button.style.flex = "0 0 auto";
|
||||
button.style.opacity = active ? "0.92" : "0.6";
|
||||
button.style.color =
|
||||
viewerState.theme === "dark" ? "rgba(226, 232, 240, 0.74)" : "rgba(15, 23, 42, 0.52)";
|
||||
|
||||
const svg = button.querySelector<SVGElement>("svg");
|
||||
if (!svg) {
|
||||
return;
|
||||
}
|
||||
svg.style.display = "block";
|
||||
svg.style.width = "16px";
|
||||
svg.style.height = "16px";
|
||||
svg.style.minWidth = "16px";
|
||||
svg.style.minHeight = "16px";
|
||||
svg.style.overflow = "visible";
|
||||
svg.style.flex = "0 0 auto";
|
||||
svg.style.color = "inherit";
|
||||
svg.style.fill = "currentColor";
|
||||
svg.style.pointerEvents = "none";
|
||||
button.dataset.active = String(active);
|
||||
}
|
||||
|
||||
function splitIcon(): string {
|
||||
@@ -193,11 +163,6 @@ function themeIcon(theme: DiffTheme): string {
|
||||
function createToolbar(): HTMLElement {
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "oc-diff-toolbar";
|
||||
toolbar.style.display = "inline-flex";
|
||||
toolbar.style.alignItems = "center";
|
||||
toolbar.style.gap = "6px";
|
||||
toolbar.style.marginInlineStart = "6px";
|
||||
toolbar.style.flex = "0 0 auto";
|
||||
|
||||
toolbar.append(
|
||||
createToolbarButton({
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDefaultFeishuAccountId, resolveFeishuAccount } from "./accounts.js";
|
||||
import {
|
||||
resolveDefaultFeishuAccountId,
|
||||
resolveDefaultFeishuAccountSelection,
|
||||
resolveFeishuAccount,
|
||||
} from "./accounts.js";
|
||||
|
||||
describe("resolveDefaultFeishuAccountId", () => {
|
||||
it("prefers channels.feishu.defaultAccount when configured", () => {
|
||||
@@ -33,11 +37,26 @@ describe("resolveDefaultFeishuAccountId", () => {
|
||||
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
|
||||
});
|
||||
|
||||
it("falls back to literal default account id when preferred is missing", () => {
|
||||
it("keeps configured defaultAccount even when not present in accounts map", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "router-d",
|
||||
accounts: {
|
||||
default: { appId: "cli_default", appSecret: "secret_default" },
|
||||
zeta: { appId: "cli_zeta", appSecret: "secret_zeta" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
|
||||
});
|
||||
|
||||
it("falls back to literal default account id when present", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
default: { appId: "cli_default", appSecret: "secret_default" },
|
||||
zeta: { appId: "cli_zeta", appSecret: "secret_zeta" },
|
||||
@@ -48,9 +67,59 @@ describe("resolveDefaultFeishuAccountId", () => {
|
||||
|
||||
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("default");
|
||||
});
|
||||
|
||||
it("reports selection source for configured defaults and mapped defaults", () => {
|
||||
const explicitDefaultCfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "router-d",
|
||||
accounts: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveDefaultFeishuAccountSelection(explicitDefaultCfg as never)).toEqual({
|
||||
accountId: "router-d",
|
||||
source: "explicit-default",
|
||||
});
|
||||
|
||||
const mappedDefaultCfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
default: { appId: "cli_default", appSecret: "secret_default" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveDefaultFeishuAccountSelection(mappedDefaultCfg as never)).toEqual({
|
||||
accountId: "default",
|
||||
source: "mapped-default",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFeishuAccount", () => {
|
||||
it("uses top-level credentials with configured default account id even without account map entry", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "router-d",
|
||||
appId: "top_level_app",
|
||||
appSecret: "top_level_secret",
|
||||
accounts: {
|
||||
default: { appId: "cli_default", appSecret: "secret_default" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
|
||||
expect(account.accountId).toBe("router-d");
|
||||
expect(account.selectionSource).toBe("explicit-default");
|
||||
expect(account.configured).toBe(true);
|
||||
expect(account.appId).toBe("top_level_app");
|
||||
});
|
||||
|
||||
it("uses configured default account when accountId is omitted", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -66,6 +135,7 @@ describe("resolveFeishuAccount", () => {
|
||||
|
||||
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
|
||||
expect(account.accountId).toBe("router-d");
|
||||
expect(account.selectionSource).toBe("explicit-default");
|
||||
expect(account.configured).toBe(true);
|
||||
expect(account.appId).toBe("cli_router");
|
||||
});
|
||||
@@ -85,6 +155,7 @@ describe("resolveFeishuAccount", () => {
|
||||
|
||||
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: "default" });
|
||||
expect(account.accountId).toBe("default");
|
||||
expect(account.selectionSource).toBe("explicit");
|
||||
expect(account.appId).toBe("cli_default");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco
|
||||
import type {
|
||||
FeishuConfig,
|
||||
FeishuAccountConfig,
|
||||
FeishuDefaultAccountSelectionSource,
|
||||
FeishuDomain,
|
||||
ResolvedFeishuAccount,
|
||||
} from "./types.js";
|
||||
@@ -31,20 +32,39 @@ export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
return [...ids].toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default account selection and its source.
|
||||
*/
|
||||
export function resolveDefaultFeishuAccountSelection(cfg: ClawdbotConfig): {
|
||||
accountId: string;
|
||||
source: FeishuDefaultAccountSelectionSource;
|
||||
} {
|
||||
const preferredRaw = (cfg.channels?.feishu as FeishuConfig | undefined)?.defaultAccount?.trim();
|
||||
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : undefined;
|
||||
if (preferred) {
|
||||
return {
|
||||
accountId: preferred,
|
||||
source: "explicit-default",
|
||||
};
|
||||
}
|
||||
const ids = listFeishuAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
source: "mapped-default",
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountId: ids[0] ?? DEFAULT_ACCOUNT_ID,
|
||||
source: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default account ID.
|
||||
*/
|
||||
export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
|
||||
const preferredRaw = (cfg.channels?.feishu as FeishuConfig | undefined)?.defaultAccount?.trim();
|
||||
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : undefined;
|
||||
const ids = listFeishuAccountIds(cfg);
|
||||
if (preferred && ids.includes(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
return resolveDefaultFeishuAccountSelection(cfg).accountId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,9 +131,15 @@ export function resolveFeishuAccount(params: {
|
||||
}): ResolvedFeishuAccount {
|
||||
const hasExplicitAccountId =
|
||||
typeof params.accountId === "string" && params.accountId.trim() !== "";
|
||||
const defaultSelection = hasExplicitAccountId
|
||||
? null
|
||||
: resolveDefaultFeishuAccountSelection(params.cfg);
|
||||
const accountId = hasExplicitAccountId
|
||||
? normalizeAccountId(params.accountId)
|
||||
: resolveDefaultFeishuAccountId(params.cfg);
|
||||
: (defaultSelection?.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
const selectionSource = hasExplicitAccountId
|
||||
? "explicit"
|
||||
: (defaultSelection?.source ?? "fallback");
|
||||
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
|
||||
// Base enabled state (top-level)
|
||||
@@ -131,6 +157,7 @@ export function resolveFeishuAccount(params: {
|
||||
|
||||
return {
|
||||
accountId,
|
||||
selectionSource,
|
||||
enabled,
|
||||
configured: Boolean(creds),
|
||||
name: (merged as FeishuAccountConfig).name?.trim() || undefined,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { parseFeishuMessageEvent } from "./bot.js";
|
||||
|
||||
// Helper to build a minimal FeishuMessageEvent for testing
|
||||
function makeEvent(
|
||||
chatType: "p2p" | "group",
|
||||
chatType: "p2p" | "group" | "private",
|
||||
mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
|
||||
text = "hello",
|
||||
) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user