mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-18 20:32:25 +08:00
Compare commits
601 Commits
fuzz-plugi
...
codex/mark
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c535f6d3f3 | ||
|
|
baaf6f8d1c | ||
|
|
01e7733deb | ||
|
|
66425c3406 | ||
|
|
f6d05d604b | ||
|
|
92a676fc71 | ||
|
|
4e5835c038 | ||
|
|
c4686e50c2 | ||
|
|
3764ff6b84 | ||
|
|
242995a3af | ||
|
|
4ce258ae9b | ||
|
|
b132ca0183 | ||
|
|
4c556fc09f | ||
|
|
98e05f8754 | ||
|
|
05df67dd70 | ||
|
|
30d6a53681 | ||
|
|
3c25345fd5 | ||
|
|
11d7a51844 | ||
|
|
38da14ac55 | ||
|
|
eb7ec0e620 | ||
|
|
9fe0862e4b | ||
|
|
4667b7cca2 | ||
|
|
a8b695a944 | ||
|
|
ba6af56f48 | ||
|
|
412fb4b32e | ||
|
|
6fa07e83bd | ||
|
|
889fc5fa91 | ||
|
|
9bfb81d64e | ||
|
|
08b953d111 | ||
|
|
d10427f45c | ||
|
|
c6a49588aa | ||
|
|
187cfdf385 | ||
|
|
724bdbb1bd | ||
|
|
5a6a6db65d | ||
|
|
daf2b631e0 | ||
|
|
ba9993229f | ||
|
|
021252e214 | ||
|
|
5c00de15f5 | ||
|
|
ba97b0484d | ||
|
|
e2c5e19876 | ||
|
|
b3d9bf8f55 | ||
|
|
59e8c8a166 | ||
|
|
a8019540bd | ||
|
|
55a44bb7ae | ||
|
|
2821654f38 | ||
|
|
22855ab94e | ||
|
|
10eec59169 | ||
|
|
affa47c13b | ||
|
|
d39becd739 | ||
|
|
eb8a1b6877 | ||
|
|
f3608d08b4 | ||
|
|
e6dec97e75 | ||
|
|
80e2bfbd16 | ||
|
|
a5c8558689 | ||
|
|
aca296e92b | ||
|
|
dba817386a | ||
|
|
f8d93befac | ||
|
|
887ebc95fa | ||
|
|
54ebb9d08f | ||
|
|
ce4f471206 | ||
|
|
ae606118b4 | ||
|
|
30c0c1352f | ||
|
|
94a92a78f4 | ||
|
|
e4539d2756 | ||
|
|
42f1c9e3d4 | ||
|
|
ade91600bc | ||
|
|
5fe49e3f9d | ||
|
|
518eff785e | ||
|
|
f1635142d8 | ||
|
|
ee39f5d282 | ||
|
|
17b35107a9 | ||
|
|
3d798f4e8e | ||
|
|
81dcefe261 | ||
|
|
c847c89dbb | ||
|
|
690d79b32a | ||
|
|
593d97b9ca | ||
|
|
5e88b8b5af | ||
|
|
c57c27016a | ||
|
|
1f0c7847a6 | ||
|
|
8a475b6631 | ||
|
|
d48a8e53bb | ||
|
|
845851cc78 | ||
|
|
03abdfea2c | ||
|
|
eb16425492 | ||
|
|
64fcdba480 | ||
|
|
ca0e791b4a | ||
|
|
76b69cfecb | ||
|
|
080b453592 | ||
|
|
fa4f5044f5 | ||
|
|
e144252720 | ||
|
|
646522aaa3 | ||
|
|
10c39f6da5 | ||
|
|
c07ae4e067 | ||
|
|
db0d6d750f | ||
|
|
e51e9c327c | ||
|
|
c057a31564 | ||
|
|
59cb1a2be3 | ||
|
|
ece65f4e24 | ||
|
|
266380f6c0 | ||
|
|
84914e4dc8 | ||
|
|
5f51677454 | ||
|
|
39fa88a1e4 | ||
|
|
8a42725d38 | ||
|
|
ed4f308d28 | ||
|
|
e58cba2797 | ||
|
|
088d228e71 | ||
|
|
cb55aa2ab1 | ||
|
|
1705b12dea | ||
|
|
a605c11b6f | ||
|
|
d31966726b | ||
|
|
9e45e0c9b6 | ||
|
|
c9f51ad18d | ||
|
|
a5b42a7500 | ||
|
|
0cff3edb56 | ||
|
|
fb756242be | ||
|
|
fe384065fe | ||
|
|
a8bbff2f9e | ||
|
|
cceb080869 | ||
|
|
e87e873017 | ||
|
|
7764e91417 | ||
|
|
535616c292 | ||
|
|
62eb7259c3 | ||
|
|
e5d7cf2efc | ||
|
|
ed43f9090d | ||
|
|
e634c7459e | ||
|
|
2ad26392c5 | ||
|
|
378146c9bc | ||
|
|
d67ff3e041 | ||
|
|
db2df9dd79 | ||
|
|
d43ba91710 | ||
|
|
d6145ad4c2 | ||
|
|
0c3b71ba23 | ||
|
|
d58a649a33 | ||
|
|
c41b710ae9 | ||
|
|
8c3e7eddfd | ||
|
|
09d8eae1e2 | ||
|
|
786d5c1042 | ||
|
|
fd88ce0039 | ||
|
|
b487a2dfbb | ||
|
|
1802ed180a | ||
|
|
0c790251e1 | ||
|
|
52ad1b26ef | ||
|
|
88cefa4d3f | ||
|
|
ef6e5aa961 | ||
|
|
2c04aea604 | ||
|
|
1cb93fee3e | ||
|
|
247a67320a | ||
|
|
02aee615de | ||
|
|
ba7e68b271 | ||
|
|
f3b723fd9a | ||
|
|
58a5c1e512 | ||
|
|
43212e574c | ||
|
|
95bd60001d | ||
|
|
ac206252fa | ||
|
|
7ad843234f | ||
|
|
e52c366a07 | ||
|
|
681a0863f1 | ||
|
|
ee23d27ce2 | ||
|
|
175db3e84d | ||
|
|
07693abbca | ||
|
|
27359abe70 | ||
|
|
41ea42f864 | ||
|
|
30f3fd75b1 | ||
|
|
4518c7f673 | ||
|
|
39b93679b5 | ||
|
|
8eb8eef88e | ||
|
|
8b02c78f46 | ||
|
|
a181224d0a | ||
|
|
ef8f96aeca | ||
|
|
5f143b6361 | ||
|
|
db4fb64e2f | ||
|
|
23426e4d26 | ||
|
|
6fd7ffd4c4 | ||
|
|
962cae0bf9 | ||
|
|
c2364779e0 | ||
|
|
a17b95e2dc | ||
|
|
2987e9bc82 | ||
|
|
9011a31d56 | ||
|
|
55f124ed01 | ||
|
|
2a42a0e2fe | ||
|
|
6a8090b7d8 | ||
|
|
88c1abb9b5 | ||
|
|
a9f3e35813 | ||
|
|
39b3364ae5 | ||
|
|
a9176b3e3c | ||
|
|
df4512571f | ||
|
|
ba95ba46da | ||
|
|
30678b2812 | ||
|
|
6477e3c75a | ||
|
|
69763d0d0e | ||
|
|
bd3683052d | ||
|
|
061cddc829 | ||
|
|
112a78b070 | ||
|
|
c665944276 | ||
|
|
fd883d2eb4 | ||
|
|
69583e9f15 | ||
|
|
6234092a66 | ||
|
|
d69a72f98e | ||
|
|
bfaaac79b6 | ||
|
|
bc36755609 | ||
|
|
a1223825a2 | ||
|
|
c05687aa34 | ||
|
|
d032288a77 | ||
|
|
7e71a0b4a4 | ||
|
|
187dd18674 | ||
|
|
f2e6163788 | ||
|
|
f54ee04c05 | ||
|
|
d2b8293236 | ||
|
|
10c99178c6 | ||
|
|
9fdc022ad0 | ||
|
|
bbbb3ad27b | ||
|
|
824abf5fa1 | ||
|
|
3de4e9e00f | ||
|
|
bda364fb74 | ||
|
|
0785082b8d | ||
|
|
80dd8c390e | ||
|
|
df637ed2f8 | ||
|
|
69b1b3fdd3 | ||
|
|
6ae61ffaef | ||
|
|
8b9b4ce082 | ||
|
|
75223a869d | ||
|
|
e3514e8d71 | ||
|
|
4088a58674 | ||
|
|
ae3f41f6c3 | ||
|
|
52e1d14e94 | ||
|
|
0b41911c70 | ||
|
|
c3fa7f2148 | ||
|
|
e20d87cfc3 | ||
|
|
fb94dac19d | ||
|
|
c959f82d5c | ||
|
|
8502427352 | ||
|
|
853e32fef3 | ||
|
|
bbee5e456c | ||
|
|
e4e3a8dbc4 | ||
|
|
596ee3c2a8 | ||
|
|
e836cd8b71 | ||
|
|
3f313b0ca9 | ||
|
|
7759b44638 | ||
|
|
fa3e1067a6 | ||
|
|
95eaf32b61 | ||
|
|
902c2d685c | ||
|
|
e5d1ce4f84 | ||
|
|
1f6058f495 | ||
|
|
95a84d98e4 | ||
|
|
3534f68068 | ||
|
|
9d71225d39 | ||
|
|
02043fe89b | ||
|
|
32abf56791 | ||
|
|
750bbdf09f | ||
|
|
afe95da1f7 | ||
|
|
da42fb0a81 | ||
|
|
ba65ce48a0 | ||
|
|
d7a35e7079 | ||
|
|
8016ce9999 | ||
|
|
1f9a80ca61 | ||
|
|
a21a7ee883 | ||
|
|
1d0f43a709 | ||
|
|
f1ecfbe08f | ||
|
|
301c84204d | ||
|
|
ca78f99c96 | ||
|
|
1d4f70a8cd | ||
|
|
a8113c72f6 | ||
|
|
4f48cd1413 | ||
|
|
843dfafaa8 | ||
|
|
0db557a6dc | ||
|
|
d598a239ca | ||
|
|
037cf3ed86 | ||
|
|
89203a47dd | ||
|
|
0160c650e6 | ||
|
|
d92e91373c | ||
|
|
0f4eedd32a | ||
|
|
a1a836f2bb | ||
|
|
c4a8e1be9b | ||
|
|
7a070e6ca2 | ||
|
|
904f84df05 | ||
|
|
fbb050028d | ||
|
|
fb78550cbb | ||
|
|
96e9d73a64 | ||
|
|
365b63de19 | ||
|
|
410bf91087 | ||
|
|
4d9d9d3e42 | ||
|
|
c1d56cb9b3 | ||
|
|
6b8fd7a3cd | ||
|
|
5f9926b7fd | ||
|
|
4becd8dbfe | ||
|
|
a8a2be4f33 | ||
|
|
d688f72752 | ||
|
|
19d0073e5f | ||
|
|
74eacd9742 | ||
|
|
22518f9820 | ||
|
|
3f04d320ad | ||
|
|
31420c16e1 | ||
|
|
4276ba3b60 | ||
|
|
a7b2cd5be2 | ||
|
|
1cf7ea66e5 | ||
|
|
97026eab56 | ||
|
|
6fa4e7ceb0 | ||
|
|
1fe2d34e01 | ||
|
|
2751480168 | ||
|
|
930b1fc082 | ||
|
|
ba9825795b | ||
|
|
3f5bf3ac35 | ||
|
|
8197cdcac4 | ||
|
|
38306a7695 | ||
|
|
fd7b7a09d8 | ||
|
|
1752e50eb1 | ||
|
|
92138702fb | ||
|
|
934bf883c1 | ||
|
|
42b0b53efa | ||
|
|
6d478c61cf | ||
|
|
4a48b7efe7 | ||
|
|
b51b9cbbf4 | ||
|
|
fa568259e4 | ||
|
|
1e26fa770d | ||
|
|
3693916c0c | ||
|
|
5b313c819a | ||
|
|
cafea5c3ef | ||
|
|
ab6bc8d109 | ||
|
|
db0470aece | ||
|
|
cf49d56b74 | ||
|
|
2eeabc4e12 | ||
|
|
f0af33a0ff | ||
|
|
20133a58a9 | ||
|
|
2a69d62245 | ||
|
|
14440032bd | ||
|
|
ea516f648b | ||
|
|
b71792767e | ||
|
|
7939c408cf | ||
|
|
d017bacc5a | ||
|
|
86ad6d9772 | ||
|
|
0dcb3ce86b | ||
|
|
a864715dd0 | ||
|
|
474cdce26c | ||
|
|
5917d8ba45 | ||
|
|
1bc4ba9908 | ||
|
|
6f16ee9266 | ||
|
|
2c7c7bf7f9 | ||
|
|
7580daf705 | ||
|
|
e6c50fd771 | ||
|
|
f3aae8a380 | ||
|
|
34c5d059aa | ||
|
|
eab3b1a6a2 | ||
|
|
0edb913c13 | ||
|
|
74d98e1fd7 | ||
|
|
082c443015 | ||
|
|
8340b1151c | ||
|
|
89daadd478 | ||
|
|
3d335e402a | ||
|
|
8b5a6bda51 | ||
|
|
3ac62666ed | ||
|
|
8d5a2f5fa9 | ||
|
|
0b5ead9f37 | ||
|
|
67e6f9aaba | ||
|
|
983c5a664c | ||
|
|
b5ee774d68 | ||
|
|
9676536668 | ||
|
|
441a7cf792 | ||
|
|
3a35c1e806 | ||
|
|
fbeaf41dc2 | ||
|
|
5590a45e7e | ||
|
|
9a551d49f3 | ||
|
|
fdae22dfea | ||
|
|
552fa03822 | ||
|
|
7e97b42a95 | ||
|
|
d3b9c5aa3e | ||
|
|
78172b720b | ||
|
|
9ea00cf73a | ||
|
|
a5013c5574 | ||
|
|
ec7ae4fc9a | ||
|
|
a9e6e4c5e3 | ||
|
|
92e6368860 | ||
|
|
6757a52944 | ||
|
|
e011559750 | ||
|
|
4d63f1ea8c | ||
|
|
36d1080d83 | ||
|
|
d3e8a89959 | ||
|
|
8b3c5d898a | ||
|
|
0c5b962a29 | ||
|
|
1a43a00def | ||
|
|
439904eef4 | ||
|
|
9bd5808fda | ||
|
|
32bf8712e9 | ||
|
|
2466798a08 | ||
|
|
797e503fe8 | ||
|
|
c7e238a862 | ||
|
|
0097a6fb46 | ||
|
|
8a3bda61e1 | ||
|
|
fd715e0eee | ||
|
|
f83ff78bb8 | ||
|
|
9094429658 | ||
|
|
909d521602 | ||
|
|
44cef2a792 | ||
|
|
0d4dec734d | ||
|
|
41aee0429c | ||
|
|
e1f1045d46 | ||
|
|
a98b9ceb37 | ||
|
|
b6064d1cf5 | ||
|
|
fbbf2e6237 | ||
|
|
2f2c77e192 | ||
|
|
d64c80daae | ||
|
|
db9ced7b9d | ||
|
|
11576303ab | ||
|
|
c2f5594555 | ||
|
|
df1e4177e4 | ||
|
|
a7d11dd3c7 | ||
|
|
493e4ab2f9 | ||
|
|
0f4fa29d78 | ||
|
|
f25cbad91b | ||
|
|
713d4cd355 | ||
|
|
2ed5feffef | ||
|
|
99d6f0f8c1 | ||
|
|
dcbe7e30d9 | ||
|
|
917d24f5c9 | ||
|
|
be922af1e6 | ||
|
|
bc3165647f | ||
|
|
9f36c0f00c | ||
|
|
35ee75ec6b | ||
|
|
6a14ad3189 | ||
|
|
3451c03366 | ||
|
|
5b3d73bc90 | ||
|
|
97620910ef | ||
|
|
e58cb30c44 | ||
|
|
158dd20e24 | ||
|
|
16d872f02d | ||
|
|
3edc65d397 | ||
|
|
86260867ad | ||
|
|
dd062c655c | ||
|
|
b8244deddb | ||
|
|
b1fa7f0e16 | ||
|
|
eed3735edd | ||
|
|
9d1edb4c00 | ||
|
|
d39c2051d0 | ||
|
|
d008a425c2 | ||
|
|
7fc4dd9d14 | ||
|
|
c3697c2ac1 | ||
|
|
de85fcd978 | ||
|
|
8fb987c565 | ||
|
|
8cacdce95e | ||
|
|
fd12d434ba | ||
|
|
a70b17e5cb | ||
|
|
283dff0c19 | ||
|
|
e2878dcf33 | ||
|
|
af9f15074f | ||
|
|
e7e7e4f2f1 | ||
|
|
0ba732cf5e | ||
|
|
a9120d2df6 | ||
|
|
d3106d2209 | ||
|
|
e174ddaaeb | ||
|
|
8b3a9a5617 | ||
|
|
c6fed61806 | ||
|
|
602a8e2d10 | ||
|
|
1c31afac81 | ||
|
|
5ceb45d38e | ||
|
|
ff326e9ca5 | ||
|
|
2991ae6fc9 | ||
|
|
4f12fa1d70 | ||
|
|
a73f42096e | ||
|
|
c11a3a0d78 | ||
|
|
c4520714c8 | ||
|
|
157e893b77 | ||
|
|
f260f1bc06 | ||
|
|
8cba61f985 | ||
|
|
48f2eef53b | ||
|
|
692dbb7b3f | ||
|
|
fc2e6ab07e | ||
|
|
00465096ce | ||
|
|
7ee37a45c4 | ||
|
|
102f1427e9 | ||
|
|
bf14891ff3 | ||
|
|
fc84fd8f26 | ||
|
|
6a10a55114 | ||
|
|
6e1e89cbe9 | ||
|
|
df725c5b4e | ||
|
|
0c7f9ea6be | ||
|
|
216a2daf23 | ||
|
|
a48823f18b | ||
|
|
c4d88ffc3e | ||
|
|
02e12555bb | ||
|
|
9888974144 | ||
|
|
c78717b229 | ||
|
|
d5d0090865 | ||
|
|
f407e71101 | ||
|
|
5d713e20ec | ||
|
|
c291eb6c6c | ||
|
|
36c53e66ef | ||
|
|
6810dfd575 | ||
|
|
9b40fcd056 | ||
|
|
f529df5b97 | ||
|
|
f9c86a65a6 | ||
|
|
e47f45e322 | ||
|
|
7ef4d676c9 | ||
|
|
0c2dc54eae | ||
|
|
b21b889017 | ||
|
|
6991205bd8 | ||
|
|
dfdcd2aa97 | ||
|
|
dc0cc1b7c1 | ||
|
|
25ce9fbb31 | ||
|
|
ca6fd41b95 | ||
|
|
6a6930983b | ||
|
|
a1ff03b634 | ||
|
|
4d8686c24e | ||
|
|
5802610280 | ||
|
|
51c0ca2aa6 | ||
|
|
f3a313bfd1 | ||
|
|
afa810271a | ||
|
|
11d9b2780b | ||
|
|
4a2ce15e59 | ||
|
|
c1e7449b28 | ||
|
|
49156048f0 | ||
|
|
1c212ee73f | ||
|
|
63625093a1 | ||
|
|
fb59ac217c | ||
|
|
c053b90290 | ||
|
|
fbdf593778 | ||
|
|
488b65ab87 | ||
|
|
6668eb8225 | ||
|
|
72436217ff | ||
|
|
460cf7ed75 | ||
|
|
461999c060 | ||
|
|
9cb347e4c3 | ||
|
|
1d7e5f48ed | ||
|
|
1fd2259e28 | ||
|
|
3f54d150b3 | ||
|
|
a9866a405c | ||
|
|
0b9187c780 | ||
|
|
b1ec23e05f | ||
|
|
050f0c0af6 | ||
|
|
dfeb5b81ca | ||
|
|
d9f6e03e32 | ||
|
|
fed7d1f385 | ||
|
|
0a9e594420 | ||
|
|
c1ce51546e | ||
|
|
1b928592ef | ||
|
|
12087ac9d4 | ||
|
|
00caead80a | ||
|
|
4b54a423f0 | ||
|
|
bdd6cf3d5e | ||
|
|
cb7a4239ef | ||
|
|
b226a752a1 | ||
|
|
110f7d55e3 | ||
|
|
645c7dc40b | ||
|
|
a4847297b8 | ||
|
|
4253517070 | ||
|
|
e8c126eaf2 | ||
|
|
2075d19923 | ||
|
|
9e58ef1c82 | ||
|
|
eaeccf5fdf | ||
|
|
2c0e835b48 | ||
|
|
b942a958b3 | ||
|
|
42bcf9cd0b | ||
|
|
a0fbb6cfe2 | ||
|
|
408fa6e951 | ||
|
|
671909d6d3 | ||
|
|
409f78a1ea | ||
|
|
3e592a8bd7 | ||
|
|
e895479a21 | ||
|
|
930bc9691b | ||
|
|
b9f181635f | ||
|
|
c2aaf8afec | ||
|
|
cbc5f277bb | ||
|
|
44b388f863 | ||
|
|
c0e49a2c52 | ||
|
|
c1e132195d | ||
|
|
5bd8dbd0b8 | ||
|
|
421ea1f458 | ||
|
|
1f91e97353 | ||
|
|
d4f6e0a1f2 | ||
|
|
ec2455a842 | ||
|
|
1742f3f77c | ||
|
|
5117f457bb | ||
|
|
8fe5e83462 | ||
|
|
27097bed65 | ||
|
|
1849a86dd2 | ||
|
|
5280d1d95d | ||
|
|
bcdc93d651 | ||
|
|
0751b6f2c9 | ||
|
|
7d9fae5b3a | ||
|
|
a595aba60e | ||
|
|
75645aec08 | ||
|
|
d10d71cdb6 | ||
|
|
c69a8d633d | ||
|
|
d8ebbedf45 | ||
|
|
9ed1766696 | ||
|
|
bed0fb7bad | ||
|
|
db6fc20559 | ||
|
|
1364acbe4c | ||
|
|
d2988e0248 | ||
|
|
8c8c8c8e32 | ||
|
|
8bee3be90a | ||
|
|
87d890003d | ||
|
|
aed7de306e | ||
|
|
859cb52b44 | ||
|
|
4685a84e9b | ||
|
|
f30235bed2 | ||
|
|
4f8f6c7693 | ||
|
|
055063f06b | ||
|
|
dac33c8ecb | ||
|
|
75ebf1c870 | ||
|
|
e4a32b9e8e | ||
|
|
22e3b2e94e | ||
|
|
729420c34a |
16
.github/workflows/openclaw-release-checks.yml
vendored
16
.github/workflows/openclaw-release-checks.yml
vendored
@@ -798,7 +798,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Run parity lane
|
||||
env:
|
||||
@@ -876,7 +876,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Generate parity report
|
||||
run: |
|
||||
@@ -934,7 +934,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Run runtime parity lane
|
||||
id: runtime_parity_lane
|
||||
@@ -1101,7 +1101,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Run Matrix live lane
|
||||
id: run_lane
|
||||
@@ -1199,7 +1199,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Run Telegram live lane
|
||||
id: run_lane
|
||||
@@ -1295,7 +1295,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Run Discord live lane
|
||||
id: run_lane
|
||||
@@ -1393,7 +1393,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Run WhatsApp live lane
|
||||
id: run_lane
|
||||
@@ -1488,7 +1488,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Run Slack live lane
|
||||
id: run_lane
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
63d49032a9b4dc4874a0ca17be73ecc97a2df5d1f47b4e72db34868423370558 plugin-sdk-api-baseline.json
|
||||
af79f7d711afa0a8563782b8f5cdd7e46b9aea245f5e7ebc464327a8969ed65e plugin-sdk-api-baseline.jsonl
|
||||
bdcf661ec680f79819096950295bdb04805aac9639477058d8855f294f6d8034 plugin-sdk-api-baseline.json
|
||||
6b8c92cc5a9277f90973370102fa31efb23ffd93008c3ed961d38e4a8a3073b0 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -3218,7 +3218,7 @@ describe("active-memory plugin", () => {
|
||||
testing.setSetupGraceTimeoutMsForTests(0);
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
timeoutMs: 100,
|
||||
timeoutMs: 1_000,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
hoisted.sessionStore["agent:main:memory-get-miss"] = {
|
||||
|
||||
@@ -253,6 +253,8 @@ describe("codex media understanding provider", () => {
|
||||
expect(result?.text).toBe("A red square.");
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -82,6 +82,10 @@ export function createCodexSteeringQueue(params: {
|
||||
batchedTexts.push({ text, resolve, reject });
|
||||
clearBatchTimer();
|
||||
const debounceMs = normalizeCodexSteerDebounceMs(options?.debounceMs);
|
||||
if (debounceMs === 0) {
|
||||
void flushBatch().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
batchTimer = setTimeout(() => {
|
||||
batchTimer = undefined;
|
||||
void flushBatch().catch(() => undefined);
|
||||
|
||||
@@ -9,6 +9,8 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -91,6 +93,28 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
expect(harness.abortController.signal.reason).toBe("turn_completion_idle_timeout");
|
||||
});
|
||||
|
||||
it("prefers completion idle timeout when completion and progress watches are due together", () => {
|
||||
const harness = createController();
|
||||
|
||||
harness.controller.armAttemptIdleWatch();
|
||||
harness.controller.touchActivity("request:item/tool/call:response", {
|
||||
arm: true,
|
||||
attemptProgress: true,
|
||||
attemptTimeoutMs: 10,
|
||||
});
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
expect(harness.timeouts).toMatchObject([
|
||||
{
|
||||
kind: "completion",
|
||||
idleMs: 10,
|
||||
timeoutMs: 10,
|
||||
lastActivityReason: "request:item/tool/call:response",
|
||||
},
|
||||
]);
|
||||
expect(harness.abortController.signal.reason).toBe("turn_completion_idle_timeout");
|
||||
});
|
||||
|
||||
it("clamps oversized completion idle timeouts before scheduling", () => {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const harness = createController({
|
||||
|
||||
@@ -166,6 +166,23 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
scheduleTerminalIdleWatch();
|
||||
}
|
||||
|
||||
function isCompletionIdleTimeoutDueBeforeAttempt(timeoutMs: number) {
|
||||
if (
|
||||
params.isCompleted() ||
|
||||
params.isTerminalTurnNotificationQueued() ||
|
||||
params.signal.aborted ||
|
||||
!completionIdleWatchArmed ||
|
||||
params.getActiveAppServerTurnRequests() > 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const completionTimeoutMs = completionIdleTimeoutOverrideMs ?? turnCompletionIdleTimeoutMs;
|
||||
if (completionTimeoutMs > timeoutMs) {
|
||||
return false;
|
||||
}
|
||||
return Math.max(0, Date.now() - completionLastActivityAt) >= completionTimeoutMs;
|
||||
}
|
||||
|
||||
function recordAttemptProgress(
|
||||
reason: string,
|
||||
options?: { details?: Record<string, unknown>; attemptTimeoutMs?: number },
|
||||
@@ -236,6 +253,10 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
scheduleAttemptIdleWatch();
|
||||
return;
|
||||
}
|
||||
if (isCompletionIdleTimeoutDueBeforeAttempt(timeoutMs)) {
|
||||
fireCompletionIdleTimeout();
|
||||
return;
|
||||
}
|
||||
const timeout = {
|
||||
kind: "progress" as const,
|
||||
idleMs,
|
||||
|
||||
@@ -29,8 +29,8 @@ describe("CodexAppServerClient", () => {
|
||||
|
||||
afterEach(() => {
|
||||
resetSharedCodexAppServerClientForTests();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
for (const client of clients) {
|
||||
client.close();
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ import type { CodexDynamicToolCallResponse } from "./protocol.js";
|
||||
|
||||
describe("dynamic tool execution helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps explicit dynamic tool timeouts above the default bridge deadline", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
abortAndDrainAgentHarnessRun,
|
||||
nativeHookRelayTesting,
|
||||
queueAgentHarnessMessage,
|
||||
resetAgentEventsForTest,
|
||||
@@ -30,6 +31,8 @@ const appServerHarnessWait = { interval: 1, timeout: 120_000 } as const;
|
||||
const activeAppServerAttemptsForTest = new Set<{
|
||||
abortController?: AbortController;
|
||||
promise: Promise<unknown>;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
}>();
|
||||
|
||||
type RunCodexAppServerAttemptOptions = NonNullable<
|
||||
@@ -62,6 +65,8 @@ export function runCodexAppServerAttempt(
|
||||
const entry = {
|
||||
abortController,
|
||||
promise: undefined as unknown as Promise<unknown>,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
};
|
||||
const promise = runCodexAppServerAttemptImpl(
|
||||
trackedParams,
|
||||
@@ -76,6 +81,7 @@ export function runCodexAppServerAttempt(
|
||||
}
|
||||
|
||||
async function drainActiveAppServerAttemptsForTest(): Promise<void> {
|
||||
vi.useRealTimers();
|
||||
const attempts = [...activeAppServerAttemptsForTest];
|
||||
if (attempts.length === 0) {
|
||||
return;
|
||||
@@ -83,12 +89,33 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
|
||||
for (const attempt of attempts) {
|
||||
attempt.abortController?.abort("test_cleanup");
|
||||
}
|
||||
await Promise.race([
|
||||
Promise.allSettled(attempts.map((attempt) => attempt.promise)),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 5_000);
|
||||
const drainedSessions = new Set<string>();
|
||||
const sessionDrains = attempts.flatMap((attempt) => {
|
||||
if (!attempt.sessionId || drainedSessions.has(attempt.sessionId)) {
|
||||
return [];
|
||||
}
|
||||
drainedSessions.add(attempt.sessionId);
|
||||
return [
|
||||
abortAndDrainAgentHarnessRun({
|
||||
sessionId: attempt.sessionId,
|
||||
sessionKey: attempt.sessionKey,
|
||||
settleMs: 1_000,
|
||||
forceClear: true,
|
||||
reason: "test_cleanup",
|
||||
}).catch(() => undefined),
|
||||
];
|
||||
});
|
||||
const drainResult = await Promise.race([
|
||||
Promise.allSettled([...attempts.map((attempt) => attempt.promise), ...sessionDrains]).then(
|
||||
() => "settled" as const,
|
||||
),
|
||||
new Promise<"timeout">((resolve) => {
|
||||
setTimeout(() => resolve("timeout"), 5_000);
|
||||
}),
|
||||
]);
|
||||
if (drainResult === "settled") {
|
||||
activeAppServerAttemptsForTest.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
@@ -490,8 +517,8 @@ export function setupRunAttemptTestHooks(): void {
|
||||
resetGlobalHookRunner();
|
||||
clearInternalHooks();
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
await closeCodexSandboxExecServersForTests();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
@@ -133,8 +133,8 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
afterEach(() => {
|
||||
resetSharedCodexAppServerClientForTests();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
mocks.bridgeCodexAppServerStartOptions.mockClear();
|
||||
mocks.applyCodexAppServerAuthProfile.mockClear();
|
||||
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockClear();
|
||||
|
||||
@@ -262,7 +262,12 @@ export async function createIsolatedCodexAppServerClient(
|
||||
|
||||
export function resetSharedCodexAppServerClientForTests(): void {
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const clients = collectSharedClients(state);
|
||||
state.clients.clear();
|
||||
state.leasedReleases = new WeakMap();
|
||||
for (const client of clients) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSharedCodexAppServerClient(): void {
|
||||
|
||||
@@ -186,6 +186,7 @@ describe("codex conversation turn collector", () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await assertion;
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
@@ -206,6 +207,8 @@ describe("codex conversation turn collector", () => {
|
||||
|
||||
await expect(completion).resolves.toEqual({ replyText: "" });
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2154,8 +2154,8 @@ describe("DiscordVoiceManager", () => {
|
||||
|
||||
expect(entersStateMock).toHaveBeenCalledWith(connection, "signalling", 20_000);
|
||||
expect(entersStateMock).toHaveBeenCalledWith(connection, "connecting", 20_000);
|
||||
expect(connection.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(manager.status()).toStrictEqual([]);
|
||||
await vi.waitFor(() => expect(connection.destroy).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(manager.status()).toStrictEqual([]));
|
||||
});
|
||||
|
||||
it("uses the default reconnect grace before destroying disconnected sessions", async () => {
|
||||
@@ -2175,8 +2175,8 @@ describe("DiscordVoiceManager", () => {
|
||||
|
||||
expect(entersStateMock).toHaveBeenCalledWith(connection, "signalling", 15_000);
|
||||
expect(entersStateMock).toHaveBeenCalledWith(connection, "connecting", 15_000);
|
||||
expect(connection.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(manager.status()).toStrictEqual([]);
|
||||
await vi.waitFor(() => expect(connection.destroy).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(manager.status()).toStrictEqual([]));
|
||||
});
|
||||
|
||||
it("closes realtime sessions when disconnected recovery destroys the connection", async () => {
|
||||
@@ -2201,9 +2201,9 @@ describe("DiscordVoiceManager", () => {
|
||||
expect(disconnected).toBeTypeOf("function");
|
||||
await disconnected?.();
|
||||
|
||||
expect(realtimeSessionMock.close).toHaveBeenCalledTimes(1);
|
||||
expect(connection.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(manager.status()).toStrictEqual([]);
|
||||
await vi.waitFor(() => expect(realtimeSessionMock.close).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(connection.destroy).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(manager.status()).toStrictEqual([]));
|
||||
});
|
||||
|
||||
it("closes realtime sessions when Discord destroys the connection", async () => {
|
||||
|
||||
@@ -143,7 +143,6 @@ export async function runEmbeddingOperationWithTimeout<T>(params: {
|
||||
reject(error);
|
||||
controller.abort(error);
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
const operation = params.run(controller.signal);
|
||||
|
||||
@@ -102,9 +102,10 @@ describe("memory embedding timeout abort", () => {
|
||||
});
|
||||
|
||||
it("aborts the provider operation when the timeout fires", async () => {
|
||||
vi.useFakeTimers();
|
||||
let signalSeen: AbortSignal | undefined;
|
||||
|
||||
await expect(
|
||||
const result = expect(
|
||||
runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings query timed out after 0s",
|
||||
@@ -120,12 +121,15 @@ describe("memory embedding timeout abort", () => {
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("memory embeddings query timed out after 0s");
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await result;
|
||||
|
||||
expect(signalSeen?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the timeout error when a provider abort listener rejects generically", async () => {
|
||||
await expect(
|
||||
vi.useFakeTimers();
|
||||
const result = expect(
|
||||
runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings batch timed out after 0s",
|
||||
@@ -137,6 +141,8 @@ describe("memory embedding timeout abort", () => {
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("memory embeddings batch timed out after 0s");
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await result;
|
||||
});
|
||||
|
||||
it("caps operation watchdog timers before scheduling", async () => {
|
||||
|
||||
@@ -740,6 +740,7 @@ describe("QmdMemoryManager", () => {
|
||||
});
|
||||
|
||||
it("times out collection bootstrap commands", async () => {
|
||||
vi.useFakeTimers();
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
@@ -764,7 +765,15 @@ describe("QmdMemoryManager", () => {
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
const managerPromise = createManager({ mode: "full" });
|
||||
await waitUntil(() =>
|
||||
spawnMock.mock.calls.some((call: unknown[]) => {
|
||||
const args = call[1] as string[];
|
||||
return args[0] === "collection" && args[1] === "list";
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(15);
|
||||
const { manager } = await managerPromise;
|
||||
const status = manager.status();
|
||||
expect(status.backend).toBe("qmd");
|
||||
expect(status.requestedProvider).toBe("qmd");
|
||||
@@ -4396,6 +4405,7 @@ describe("QmdMemoryManager", () => {
|
||||
});
|
||||
|
||||
it("retries boot update when qmd reports a retryable lock error", async () => {
|
||||
vi.useFakeTimers();
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
@@ -4429,26 +4439,14 @@ describe("QmdMemoryManager", () => {
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const nativeSetTimeout = globalThis.setTimeout;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation(((
|
||||
handler: TimerHandler,
|
||||
timeout?: number,
|
||||
...args: unknown[]
|
||||
) => {
|
||||
if (typeof timeout === "number" && timeout >= 500) {
|
||||
return nativeSetTimeout(handler, 1, ...args);
|
||||
}
|
||||
return nativeSetTimeout(handler, timeout, ...args);
|
||||
}) as typeof globalThis.setTimeout);
|
||||
const managerPromise = createManager({ mode: "full" });
|
||||
await waitUntil(() => updateCalls === 1);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await waitUntil(() => updateCalls === 2);
|
||||
const { manager } = await managerPromise;
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
|
||||
try {
|
||||
expect(updateCalls).toBe(2);
|
||||
await manager.close();
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
expect(updateCalls).toBe(2);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("succeeds on qmd update even when stdout exceeds the output cap", async () => {
|
||||
|
||||
@@ -1169,8 +1169,8 @@ describe("memory plugin e2e", () => {
|
||||
|
||||
test("clamps oversized auto-recall timeout timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
try {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
await expect(
|
||||
testing.runWithTimeout({
|
||||
timeoutMs: Number.MAX_SAFE_INTEGER,
|
||||
@@ -1180,14 +1180,15 @@ describe("memory plugin e2e", () => {
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back for invalid auto-recall timeout timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
try {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
await expect(
|
||||
testing.runWithTimeout({
|
||||
timeoutMs: Number.NaN,
|
||||
@@ -1197,6 +1198,7 @@ describe("memory plugin e2e", () => {
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1);
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -208,6 +208,8 @@ describe("openrouter music generation provider", () => {
|
||||
});
|
||||
|
||||
it("caps oversized OpenRouter music stream timeouts", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
try {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
@@ -231,6 +233,7 @@ describe("openrouter music generation provider", () => {
|
||||
expect(streamTimeoutMs).toBeLessThanOrEqual(MAX_TIMER_TIMEOUT_MS);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -25,11 +25,15 @@ describe("mantis visual task runtime", () => {
|
||||
let repoRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mantis-visual-task-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(repoRoot, { force: true, recursive: true });
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("records a visible browser task and keeps screenshot/video artifacts", async () => {
|
||||
|
||||
@@ -520,9 +520,12 @@ export async function runMantisVisualDriver(
|
||||
runner,
|
||||
stdio: "inherit",
|
||||
});
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, opts.settleMs ?? DEFAULT_SETTLE_MS);
|
||||
});
|
||||
const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
|
||||
if (settleMs > 0) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, settleMs);
|
||||
});
|
||||
}
|
||||
await runCommandWithExternalOutput({
|
||||
command: crabboxBin,
|
||||
outputPath: screenshotPath,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
@@ -6,6 +7,8 @@ import type { MatrixQaScenarioContext } from "./scenario-runtime-shared.js";
|
||||
|
||||
const MATRIX_SYNC_STORE_FILENAME = "bot-storage.json";
|
||||
const MATRIX_INBOUND_DEDUPE_FILENAME = "inbound-dedupe.json";
|
||||
const MATRIX_PLUGIN_ID = "matrix";
|
||||
const MATRIX_INBOUND_DEDUPE_NAMESPACE = "inbound-dedupe";
|
||||
const MATRIX_STATE_POLL_INTERVAL_MS = 100;
|
||||
|
||||
async function readJsonFile(pathname: string): Promise<unknown> {
|
||||
@@ -191,6 +194,92 @@ function hasPersistedMatrixDedupeEntry(params: {
|
||||
return params.parsed.entries.some((entry) => isRecord(entry) && entry.key === expectedKey);
|
||||
}
|
||||
|
||||
function buildMatrixInboundDedupePluginStateKey(params: {
|
||||
accountId: string;
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
}): string {
|
||||
const accountId = params.accountId.trim() || "sut";
|
||||
const roomId = params.roomId.trim();
|
||||
const eventId = params.eventId.trim();
|
||||
const digest = createHash("sha256")
|
||||
.update(accountId)
|
||||
.update("\0")
|
||||
.update(roomId)
|
||||
.update("\0")
|
||||
.update(eventId)
|
||||
.digest("hex");
|
||||
return `${accountId}:${digest}`;
|
||||
}
|
||||
|
||||
async function hasPersistedMatrixPluginStateDedupeEntry(params: {
|
||||
accountId: string;
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
stateDir: string;
|
||||
}): Promise<string | null> {
|
||||
const entryKey = buildMatrixInboundDedupePluginStateKey({
|
||||
accountId: params.accountId,
|
||||
eventId: params.eventId,
|
||||
roomId: params.roomId,
|
||||
});
|
||||
const databasePaths = await findFilesByName({
|
||||
filename: "openclaw.sqlite",
|
||||
rootDir: params.stateDir,
|
||||
maxDepth: 4,
|
||||
});
|
||||
if (databasePaths.length === 0) {
|
||||
databasePaths.push(path.join(params.stateDir, "state", "openclaw.sqlite"));
|
||||
}
|
||||
const now = Date.now();
|
||||
const isExpectedValue = (raw: unknown) => {
|
||||
if (typeof raw !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return (
|
||||
isRecord(parsed) && parsed.roomId === params.roomId && parsed.eventId === params.eventId
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const sqlite = await import("node:sqlite");
|
||||
for (const databasePath of databasePaths) {
|
||||
try {
|
||||
await fs.access(databasePath);
|
||||
const db = new sqlite.DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT entry_key AS entryKey, value_json AS valueJson
|
||||
FROM plugin_state_entries
|
||||
WHERE plugin_id = ?
|
||||
AND namespace = ?
|
||||
AND (expires_at IS NULL OR expires_at > ?)`,
|
||||
)
|
||||
.all(MATRIX_PLUGIN_ID, MATRIX_INBOUND_DEDUPE_NAMESPACE, now) as Array<{
|
||||
entryKey?: unknown;
|
||||
valueJson?: unknown;
|
||||
}>;
|
||||
if (rows.some((row) => row.entryKey === entryKey || isExpectedValue(row.valueJson))) {
|
||||
return databasePath;
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForMatrixInboundDedupeEntry(params: {
|
||||
context: MatrixQaScenarioContext;
|
||||
eventId: string;
|
||||
@@ -200,6 +289,15 @@ export async function waitForMatrixInboundDedupeEntry(params: {
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const sqlitePath = await hasPersistedMatrixPluginStateDedupeEntry({
|
||||
accountId: params.context.sutAccountId ?? "sut",
|
||||
eventId: params.eventId,
|
||||
roomId: params.roomId,
|
||||
stateDir: params.stateDir,
|
||||
});
|
||||
if (sqlitePath) {
|
||||
return sqlitePath;
|
||||
}
|
||||
const pathname = await resolveBestMatrixStateFile({
|
||||
context: params.context,
|
||||
filename: MATRIX_INBOUND_DEDUPE_FILENAME,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -60,6 +61,69 @@ const MATRIX_SUBAGENT_MISSING_HOOK_ERROR =
|
||||
"thread=true is unavailable because no channel plugin registered subagent_spawning hooks.";
|
||||
const MATRIX_QA_HOT_RELOAD_RESTART_DELAY_MS = 300_000;
|
||||
|
||||
function matrixInboundDedupePluginStateKey(params: {
|
||||
accountId: string;
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
}): string {
|
||||
const accountId = params.accountId.trim() || "sut";
|
||||
const digest = createHash("sha256")
|
||||
.update(accountId)
|
||||
.update("\0")
|
||||
.update(params.roomId.trim())
|
||||
.update("\0")
|
||||
.update(params.eventId.trim())
|
||||
.digest("hex");
|
||||
return `${accountId}:${digest}`;
|
||||
}
|
||||
|
||||
async function writeMatrixInboundDedupePluginStateEntry(params: {
|
||||
accountId: string;
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
stateRoot: string;
|
||||
}) {
|
||||
const sqlite = await import("node:sqlite");
|
||||
const databasePath = path.join(params.stateRoot, "state", "openclaw.sqlite");
|
||||
await mkdir(path.dirname(databasePath), { recursive: true });
|
||||
const db = new sqlite.DatabaseSync(databasePath);
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plugin_state_entries (
|
||||
plugin_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
entry_key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
PRIMARY KEY (plugin_id, namespace, entry_key)
|
||||
);
|
||||
`);
|
||||
db.prepare(`
|
||||
INSERT INTO plugin_state_entries (
|
||||
plugin_id, namespace, entry_key, value_json, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(plugin_id, namespace, entry_key) DO UPDATE SET
|
||||
value_json = excluded.value_json,
|
||||
created_at = excluded.created_at,
|
||||
expires_at = excluded.expires_at
|
||||
`).run(
|
||||
"matrix",
|
||||
"inbound-dedupe",
|
||||
matrixInboundDedupePluginStateKey(params),
|
||||
JSON.stringify({
|
||||
roomId: params.roomId,
|
||||
eventId: params.eventId,
|
||||
ts: Date.now(),
|
||||
}),
|
||||
Date.now(),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function requireMatrixQaScenario(id: string): (typeof MATRIX_QA_SCENARIOS)[number] {
|
||||
const scenario = MATRIX_QA_SCENARIOS.find((entry) => entry.id === id);
|
||||
if (!scenario) {
|
||||
@@ -1958,7 +2022,6 @@ describe("matrix live qa scenarios", () => {
|
||||
const accountDir = path.join(stateRoot, "matrix", "accounts", "sut", "server", "token");
|
||||
const staleSyncRoomId = "!stale-sync:matrix-qa.test";
|
||||
const syncStorePath = path.join(accountDir, "bot-storage.json");
|
||||
const dedupeStorePath = path.join(accountDir, "inbound-dedupe.json");
|
||||
await mkdir(accountDir, { recursive: true });
|
||||
await writeTestJsonFile(path.join(accountDir, "storage-meta.json"), {
|
||||
accountId: "sut",
|
||||
@@ -1983,14 +2046,11 @@ describe("matrix live qa scenarios", () => {
|
||||
const kind = token.includes("STALE_SYNC_DEDUPE_FRESH") ? "fresh" : "first";
|
||||
callOrder.push(`wait:${kind}`);
|
||||
if (kind === "first") {
|
||||
await writeTestJsonFile(dedupeStorePath, {
|
||||
version: 1,
|
||||
entries: [
|
||||
{
|
||||
key: `${staleSyncRoomId}|$first-trigger`,
|
||||
ts: Date.now(),
|
||||
},
|
||||
],
|
||||
await writeMatrixInboundDedupePluginStateEntry({
|
||||
accountId: "runtime-default",
|
||||
eventId: "$first-trigger",
|
||||
roomId: staleSyncRoomId,
|
||||
stateRoot,
|
||||
});
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["slack"],
|
||||
"channelConfigs": {
|
||||
"slack": {
|
||||
"label": "Slack",
|
||||
"description": "Slack channel, DM, command, and app event integration.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"channelEnvVars": {
|
||||
"slack": ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"]
|
||||
},
|
||||
|
||||
@@ -998,11 +998,15 @@ describe("web auto-reply connection", () => {
|
||||
const firstPattern = escapeRegExp(firstTimestamp);
|
||||
const secondPattern = escapeRegExp(secondTimestamp);
|
||||
expect(firstArgs.Body).toMatch(
|
||||
new RegExp(`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${firstPattern}\\] \\[openclaw\\] first`),
|
||||
new RegExp(
|
||||
`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${firstPattern}\\] \\+1: \\[openclaw\\] first`,
|
||||
),
|
||||
);
|
||||
expect(firstArgs.Body).not.toContain("second");
|
||||
expect(secondArgs.Body).toMatch(
|
||||
new RegExp(`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${secondPattern}\\] \\[openclaw\\] second`),
|
||||
new RegExp(
|
||||
`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${secondPattern}\\] \\+1: \\[openclaw\\] second`,
|
||||
),
|
||||
);
|
||||
expect(secondArgs.Body).not.toContain("first");
|
||||
expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "./test-helpers.js";
|
||||
import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -151,17 +150,6 @@ describe("web auto-reply last-route", () => {
|
||||
to: "+1000",
|
||||
accountId: "default",
|
||||
});
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "WhatsApp",
|
||||
from: "+1000",
|
||||
timestamp: now,
|
||||
body: "hello",
|
||||
chatType: "direct",
|
||||
sender: {
|
||||
e164: "+1000",
|
||||
id: "+1000",
|
||||
},
|
||||
});
|
||||
expect(ctx).toMatchObject({
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
@@ -178,7 +166,7 @@ describe("web auto-reply last-route", () => {
|
||||
SenderE164: "+1000",
|
||||
SenderId: "+1000",
|
||||
RawBody: "hello",
|
||||
Body: body,
|
||||
Body: expect.stringMatching(/^\[WhatsApp \+1000 .+\] \+1000: hello$/),
|
||||
BodyForAgent: "hello",
|
||||
CommandBody: "hello",
|
||||
Timestamp: now,
|
||||
|
||||
@@ -201,16 +201,29 @@ function resolveSenderLabelMock(sender?: TestInboundEnvelopeParams["sender"]) {
|
||||
return display || idPart || undefined;
|
||||
}
|
||||
|
||||
function resolveDirectEnvelopeBodyLabelMock(from?: string) {
|
||||
const label = sanitizeEnvelopeHeaderPart(from?.trim() || "");
|
||||
const idMarkerIndex = label.search(/\s+id:/i);
|
||||
if (idMarkerIndex > 0) {
|
||||
const displayLabel = label.slice(0, idMarkerIndex).trim();
|
||||
return displayLabel.includes(":") ? "(sender)" : displayLabel;
|
||||
}
|
||||
return label.includes(":") ? "(sender)" : label;
|
||||
}
|
||||
|
||||
function formatInboundEnvelopeMock(params: TestInboundEnvelopeParams) {
|
||||
const chatType = normalizeLowercaseStringOrEmpty(params.chatType);
|
||||
const isDirect = !chatType || chatType === "direct";
|
||||
const sender = params.senderLabel?.trim() || resolveSenderLabelMock(params.sender);
|
||||
const directSender = resolveDirectEnvelopeBodyLabelMock(params.from);
|
||||
const body =
|
||||
isDirect && params.fromMe
|
||||
? `(self): ${params.body}`
|
||||
: !isDirect && sender
|
||||
? `${sanitizeEnvelopeHeaderPart(sender)}: ${params.body}`
|
||||
: params.body;
|
||||
: isDirect && directSender
|
||||
? `${directSender}: ${params.body}`
|
||||
: !isDirect && sender
|
||||
? `${sanitizeEnvelopeHeaderPart(sender)}: ${params.body}`
|
||||
: params.body;
|
||||
const parts = [sanitizeEnvelopeHeaderPart(params.channel?.trim() || "Channel")];
|
||||
const from = params.from?.trim();
|
||||
if (from) {
|
||||
|
||||
@@ -28,13 +28,18 @@ import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
|
||||
import { resolveConnectChallengeTimeoutMs, resolveSafeTimeoutDelayMs } from "./timeouts.js";
|
||||
|
||||
export type DeviceIdentity = {
|
||||
/** Stable gateway device id associated with this keypair. */
|
||||
deviceId: string;
|
||||
/** PEM private key used by host deps to sign device-auth payloads. */
|
||||
privateKeyPem: string;
|
||||
/** PEM public key sent to the gateway during device pairing/auth. */
|
||||
publicKeyPem: string;
|
||||
};
|
||||
|
||||
export type DeviceAuthTokenRecord = {
|
||||
/** Stored device bearer token returned by the gateway. */
|
||||
token?: string;
|
||||
/** Scopes granted to the stored token; reused only when still sufficient. */
|
||||
scopes?: string[];
|
||||
};
|
||||
|
||||
@@ -306,8 +311,11 @@ type Pending = {
|
||||
};
|
||||
|
||||
export type GatewayClientRequestOptions = {
|
||||
/** Wait for an accepted response followed by a final response. */
|
||||
expectFinal?: boolean;
|
||||
/** Per-request timeout; null disables request timeout scheduling. */
|
||||
timeoutMs?: number | null;
|
||||
/** Cancels the request and removes its pending response handler. */
|
||||
signal?: AbortSignal;
|
||||
/** Called once for expectFinal requests after an accepted response, before the final result. */
|
||||
onAccepted?: (payload: unknown) => void;
|
||||
@@ -355,11 +363,15 @@ const DEFAULT_GATEWAY_CLIENT_URL = "ws://127.0.0.1:18789";
|
||||
const DEFAULT_CLIENT_VERSION = "0.0.0";
|
||||
|
||||
export type GatewayReconnectPausedInfo = {
|
||||
/** WebSocket close code that paused reconnect attempts. */
|
||||
code: number;
|
||||
/** Raw close reason supplied by the gateway/socket. */
|
||||
reason: string;
|
||||
/** Structured connect-error detail code when the close came from gateway auth/startup. */
|
||||
detailCode: string | null;
|
||||
};
|
||||
|
||||
/** Error wrapper for gateway response frames that preserves retry metadata for callers. */
|
||||
export class GatewayClientRequestError extends Error {
|
||||
readonly gatewayCode: string;
|
||||
readonly details?: unknown;
|
||||
@@ -397,8 +409,10 @@ export function isGatewayConnectAssemblyError(value: unknown): value is Error {
|
||||
);
|
||||
}
|
||||
|
||||
/** Construction options for GatewayClient connections, auth, protocol bounds, and callbacks. */
|
||||
export type GatewayClientOptions = {
|
||||
url?: string; // ws://127.0.0.1:18789
|
||||
/** Client-side watchdog for receiving the connect challenge. */
|
||||
connectChallengeTimeoutMs?: number;
|
||||
/** @deprecated Use connectChallengeTimeoutMs. */
|
||||
connectDelayMs?: number;
|
||||
@@ -450,6 +464,7 @@ export const GATEWAY_CLOSE_CODE_HINTS: Readonly<Record<number, string>> = {
|
||||
1013: "try again later",
|
||||
};
|
||||
|
||||
/** Returns the short operator-facing description for common gateway close codes. */
|
||||
export function describeGatewayCloseCode(code: number): string | undefined {
|
||||
return GATEWAY_CLOSE_CODE_HINTS[code];
|
||||
}
|
||||
@@ -490,6 +505,8 @@ export function resolveGatewayClientConnectChallengeTimeoutMs(
|
||||
"connectChallengeTimeoutMs" | "connectDelayMs" | "preauthHandshakeTimeoutMs"
|
||||
>,
|
||||
): number {
|
||||
// Keep the legacy connectDelayMs alias feeding the same clamp path until the
|
||||
// public option is removed; explicit challenge timeout still wins.
|
||||
return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts), {
|
||||
configuredTimeoutMs: opts.preauthHandshakeTimeoutMs,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Normalizes optional device metadata before it becomes part of a signed auth
|
||||
* payload.
|
||||
*/
|
||||
export function normalizeDeviceMetadataForAuth(value?: string | null): string {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
@@ -6,25 +10,38 @@ export function normalizeDeviceMetadataForAuth(value?: string | null): string {
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
// Preserve the gateway's historical ASCII-only case fold; locale-sensitive
|
||||
// lowercasing would change existing signatures for non-ASCII device names.
|
||||
return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32));
|
||||
}
|
||||
|
||||
type DeviceAuthPayloadParams = {
|
||||
/** Stable device id paired with the gateway. */
|
||||
deviceId: string;
|
||||
/** Client application id, such as the desktop or mobile client. */
|
||||
clientId: string;
|
||||
/** Gateway client mode included in the signed payload. */
|
||||
clientMode: string;
|
||||
/** Requested gateway role for the authenticated device. */
|
||||
role: string;
|
||||
/** Ordered scope list; order is signature-significant. */
|
||||
scopes: string[];
|
||||
/** Signing timestamp in epoch milliseconds. */
|
||||
signedAtMs: number;
|
||||
/** Optional bootstrap token; null/undefined still reserves the v2/v3 field. */
|
||||
token?: string | null;
|
||||
/** Per-request nonce included to prevent replay. */
|
||||
nonce: string;
|
||||
};
|
||||
|
||||
type DeviceAuthPayloadV3Params = DeviceAuthPayloadParams & {
|
||||
/** Optional normalized platform metadata appended after the v2 fields. */
|
||||
platform?: string | null;
|
||||
/** Optional normalized device-family metadata appended after platform. */
|
||||
deviceFamily?: string | null;
|
||||
};
|
||||
|
||||
/** Builds the canonical v2 device-auth string that the gateway verifies byte-for-byte. */
|
||||
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
|
||||
const scopes = params.scopes.join(",");
|
||||
const token = params.token ?? "";
|
||||
@@ -41,6 +58,7 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string
|
||||
].join("|");
|
||||
}
|
||||
|
||||
/** Builds the canonical v3 device-auth string with normalized platform/family metadata. */
|
||||
export function buildDeviceAuthPayloadV3(params: DeviceAuthPayloadV3Params): string {
|
||||
const scopes = params.scopes.join(",");
|
||||
const token = params.token ?? "";
|
||||
|
||||
@@ -2,19 +2,29 @@ import { resolveFiniteTimeoutDelayMs } from "./timeouts.js";
|
||||
|
||||
/** Readiness probe outcome with timing data for diagnosing event-loop stalls. */
|
||||
export type EventLoopReadyResult = {
|
||||
/** True when enough consecutive timer checks stayed below the drift threshold. */
|
||||
ready: boolean;
|
||||
/** Wall-clock time spent in the readiness probe. */
|
||||
elapsedMs: number;
|
||||
/** Largest observed timer drift across all checks. */
|
||||
maxDriftMs: number;
|
||||
/** Number of scheduled timer checks that fired before completion. */
|
||||
checks: number;
|
||||
/** True when the supplied AbortSignal stopped the probe before readiness or timeout. */
|
||||
aborted: boolean;
|
||||
};
|
||||
|
||||
/** Controls how aggressively the client waits for low-drift timer checks before starting IO. */
|
||||
export type EventLoopReadyOptions = {
|
||||
/** Maximum wall-clock time to wait before reporting not ready. */
|
||||
maxWaitMs?: number;
|
||||
/** Delay between drift samples; clamped to safe Node timer bounds. */
|
||||
intervalMs?: number;
|
||||
/** Maximum acceptable timer drift for a sample to count as ready. */
|
||||
driftThresholdMs?: number;
|
||||
/** Number of low-drift samples required before the event loop is considered ready. */
|
||||
consecutiveReadyChecks?: number;
|
||||
/** Cancels the probe without starting client IO. */
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
@@ -104,6 +114,8 @@ export async function waitForEventLoopReady(
|
||||
if (driftMs > driftThresholdMs) {
|
||||
readyChecks = 0;
|
||||
} else {
|
||||
// Require consecutive low-drift samples so one lucky timer after a
|
||||
// blocked loop does not start IO while the process is still saturated.
|
||||
readyChecks += 1;
|
||||
}
|
||||
if (readyChecks >= consecutiveReadyChecks) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { resolveConnectChallengeTimeoutMs } from "./timeouts.js";
|
||||
|
||||
export type GatewayClientStartable = {
|
||||
/** Starts the underlying gateway connection after readiness succeeds. */
|
||||
start(): void;
|
||||
};
|
||||
|
||||
@@ -17,11 +18,14 @@ export type EventLoopReadyWaiter = (
|
||||
|
||||
/** Timeout and abort controls for delaying client start until the loop can process IO. */
|
||||
export type GatewayClientStartReadinessOptions = {
|
||||
/** Explicit readiness wait cap; wins over client connection timeout settings. */
|
||||
timeoutMs?: number;
|
||||
/** Client connection settings used to derive a readiness cap when timeoutMs is absent. */
|
||||
clientOptions?: Pick<
|
||||
GatewayClientOptions,
|
||||
"connectChallengeTimeoutMs" | "connectDelayMs" | "preauthHandshakeTimeoutMs"
|
||||
>;
|
||||
/** Cancels readiness without starting the client. */
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
@@ -33,6 +37,8 @@ function resolveGatewayClientStartReadinessTimeoutMs(
|
||||
}
|
||||
const clientOptions = options.clientOptions ?? {};
|
||||
const timeoutOverride =
|
||||
// Prefer the challenge watchdog over the older connectDelayMs alias so
|
||||
// readiness stays aligned with the server-side preauth handshake window.
|
||||
typeof clientOptions.connectChallengeTimeoutMs === "number" &&
|
||||
Number.isFinite(clientOptions.connectChallengeTimeoutMs)
|
||||
? clientOptions.connectChallengeTimeoutMs
|
||||
@@ -55,6 +61,8 @@ export async function startGatewayClientWithReadinessWait(
|
||||
maxWaitMs: resolveGatewayClientStartReadinessTimeoutMs(options),
|
||||
signal: options.signal,
|
||||
});
|
||||
// The readiness waiter can race with abort delivery; gate start on both the
|
||||
// returned state and the current signal so aborted startup remains side-effect-free.
|
||||
if (readiness.ready && !readiness.aborted && options.signal?.aborted !== true) {
|
||||
client.start();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
function parseStrictPositiveInteger(value: string): number | undefined {
|
||||
const trimmed = value.trim();
|
||||
// Env overrides accept only decimal integers so units/decimals do not
|
||||
// silently truncate into a shorter timeout.
|
||||
if (!/^\+?\d+$/u.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -19,6 +21,8 @@ export const MAX_CONNECT_CHALLENGE_TIMEOUT_MS = DEFAULT_PREAUTH_HANDSHAKE_TIMEOU
|
||||
/** Clamps arbitrary timer delays to Node's safe range and an optional floor. */
|
||||
export function resolveSafeTimeoutDelayMs(delayMs: number, opts?: { minMs?: number }): number {
|
||||
const rawMinMs = opts?.minMs ?? 1;
|
||||
// Clamp the floor first; callers can opt into immediate timers with minMs=0,
|
||||
// but invalid floors still fall back to the nonzero default timeout guard.
|
||||
const minMs = Math.min(
|
||||
MAX_SAFE_TIMEOUT_DELAY_MS,
|
||||
Math.max(0, Number.isFinite(rawMinMs) ? Math.floor(rawMinMs) : 1),
|
||||
@@ -59,6 +63,8 @@ export function clampConnectChallengeTimeoutMs(
|
||||
timeoutMs: number,
|
||||
maxTimeoutMs = MAX_CONNECT_CHALLENGE_TIMEOUT_MS,
|
||||
): number {
|
||||
// Keep the upper bound at least as large as the watchdog floor so callers
|
||||
// cannot invert the clamp range with an undersized configured server timeout.
|
||||
return Math.max(
|
||||
MIN_CONNECT_CHALLENGE_TIMEOUT_MS,
|
||||
Math.min(Math.max(MIN_CONNECT_CHALLENGE_TIMEOUT_MS, maxTimeoutMs), timeoutMs),
|
||||
@@ -105,6 +111,8 @@ export function resolveConnectChallengeTimeoutMs(
|
||||
}
|
||||
const envOverride = getConnectChallengeTimeoutMsFromEnv(params?.env);
|
||||
if (envOverride !== undefined) {
|
||||
// Explicit client overrides are allowed to exceed the server-derived cap
|
||||
// for tests and slow environments; still apply the lower watchdog floor.
|
||||
return clampConnectChallengeTimeoutMs(envOverride, Math.max(maxTimeoutMs, envOverride));
|
||||
}
|
||||
return clampConnectChallengeTimeoutMs(configuredPreauthTimeoutMs, maxTimeoutMs);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { scanFenceSpans, type FenceScanState, type FenceSpan } from "./fences.js";
|
||||
|
||||
/** Incremental inline-code scanner state carried between streamed chunks. */
|
||||
export type InlineCodeState = {
|
||||
/** True when a previous chunk opened a backtick run that has not closed yet. */
|
||||
open: boolean;
|
||||
/** Backtick run length required to close the current inline-code span. */
|
||||
ticks: number;
|
||||
};
|
||||
|
||||
@@ -21,7 +24,7 @@ type CodeSpanIndex = {
|
||||
isInside: (index: number) => boolean;
|
||||
};
|
||||
|
||||
/** Builds a lookup for fenced and inline code spans while preserving scanner state. */
|
||||
/** Builds a zero-based code-region lookup for fenced and inline spans, plus next scanner state. */
|
||||
export function buildCodeSpanIndex(
|
||||
text: string,
|
||||
inlineState?: InlineCodeState,
|
||||
@@ -59,6 +62,7 @@ function parseInlineCodeSpans(
|
||||
while (i < text.length) {
|
||||
const fence = findFenceSpanAtInclusive(fenceSpans, i);
|
||||
if (fence) {
|
||||
// Fenced code owns its full range; inline backticks inside it must not change state.
|
||||
i = fence.end;
|
||||
continue;
|
||||
}
|
||||
@@ -91,6 +95,7 @@ function parseInlineCodeSpans(
|
||||
}
|
||||
|
||||
if (open) {
|
||||
// Treat an unfinished span as code through chunk end so partial tags stay protected.
|
||||
spans.push([openStart, text.length]);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ type MarkdownToken = {
|
||||
level?: number;
|
||||
};
|
||||
|
||||
/** Style categories tracked as ranges over rendered plaintext. */
|
||||
export type MarkdownStyle =
|
||||
| "bold"
|
||||
| "italic"
|
||||
@@ -37,19 +38,23 @@ export type MarkdownStyle =
|
||||
| "spoiler"
|
||||
| "blockquote";
|
||||
|
||||
/** Half-open style range in `MarkdownIR.text`; `end` is exclusive. */
|
||||
export type MarkdownStyleSpan = {
|
||||
start: number;
|
||||
end: number;
|
||||
style: MarkdownStyle;
|
||||
/** Fence language info for code blocks when markdown-it provided one. */
|
||||
language?: string;
|
||||
};
|
||||
|
||||
/** Half-open link-label range in `MarkdownIR.text` with the original href. */
|
||||
export type MarkdownLinkSpan = {
|
||||
start: number;
|
||||
end: number;
|
||||
href: string;
|
||||
};
|
||||
|
||||
/** Plaintext markdown projection plus style/link ranges into that text. */
|
||||
export type MarkdownIR = {
|
||||
text: string;
|
||||
styles: MarkdownStyleSpan[];
|
||||
@@ -68,11 +73,13 @@ function createStyleSpan(params: MarkdownStyleSpan): MarkdownStyleSpan {
|
||||
return span;
|
||||
}
|
||||
|
||||
/** Parsed table text after markdown inline rendering has been applied per cell. */
|
||||
export type MarkdownTableData = {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
};
|
||||
|
||||
/** Table metadata collected for block-mode rendering with the placeholder location. */
|
||||
export type MarkdownTableMeta = MarkdownTableData & {
|
||||
placeholderOffset: number;
|
||||
};
|
||||
@@ -116,10 +123,15 @@ type RenderState = RenderTarget & {
|
||||
};
|
||||
|
||||
export type MarkdownParseOptions = {
|
||||
/** Enable markdown-it linkify conversion. Default: true. */
|
||||
linkify?: boolean;
|
||||
/** Interpret paired `||` text delimiters as spoiler style spans. Default: false. */
|
||||
enableSpoilers?: boolean;
|
||||
/** Whether headings should become bold spans or plain text. Default: none. */
|
||||
headingStyle?: "none" | "bold";
|
||||
/** Text prefix inserted at each blockquote open before applying blockquote style. */
|
||||
blockquotePrefix?: string;
|
||||
/** Enable markdown-it autolinks. Default: true unless explicitly false. */
|
||||
autolink?: boolean;
|
||||
/** How to render tables (off|bullets|code|block). Default: off. */
|
||||
tableMode?: MarkdownTableMode;
|
||||
@@ -966,6 +978,7 @@ function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number):
|
||||
return sliced;
|
||||
}
|
||||
|
||||
/** Slices IR text and rebases overlapping style/link spans into the returned range. */
|
||||
export function sliceMarkdownIR(ir: MarkdownIR, start: number, end: number): MarkdownIR {
|
||||
return {
|
||||
text: ir.text.slice(start, end),
|
||||
@@ -974,10 +987,12 @@ export function sliceMarkdownIR(ir: MarkdownIR, start: number, end: number): Mar
|
||||
};
|
||||
}
|
||||
|
||||
/** Parses markdown into plaintext plus style/link ranges. */
|
||||
export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR {
|
||||
return markdownToIRWithMeta(markdown, options).ir;
|
||||
}
|
||||
|
||||
/** Parses markdown into IR and returns table-detection metadata for table-aware callers. */
|
||||
export function markdownToIRWithMeta(
|
||||
markdown: string,
|
||||
options: MarkdownParseOptions = {},
|
||||
@@ -1040,6 +1055,7 @@ export function markdownToIRWithMeta(
|
||||
};
|
||||
}
|
||||
|
||||
/** Chunks IR text at readable boundaries and rebases style/link spans per chunk. */
|
||||
export function chunkMarkdownIR(ir: MarkdownIR, limit: number): MarkdownIR[] {
|
||||
if (!ir.text) {
|
||||
return [];
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from "./ir.js";
|
||||
|
||||
/** Opening/closing marker pair used when rendering one Markdown style span. */
|
||||
export type RenderStyleMarker = {
|
||||
open: string | ((span: MarkdownStyleSpan) => string);
|
||||
close: string;
|
||||
};
|
||||
|
||||
/** Optional marker overrides keyed by Markdown style. */
|
||||
export type RenderStyleMap = Partial<Record<MarkdownStyle, RenderStyleMarker>>;
|
||||
|
||||
/** Rendered link wrapper coordinates and markers returned by link builders. */
|
||||
export type RenderLink = {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -14,6 +17,7 @@ export type RenderLink = {
|
||||
close: string;
|
||||
};
|
||||
|
||||
/** Rendering hooks for escaping text, styles, and optional link wrappers. */
|
||||
export type RenderOptions = {
|
||||
styleMarkers: RenderStyleMap;
|
||||
escapeText: (text: string) => string;
|
||||
@@ -46,6 +50,7 @@ function sortStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** Renders Markdown IR by applying caller-provided style/link markers. */
|
||||
export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions): string {
|
||||
const text = ir.text ?? "";
|
||||
if (!text) {
|
||||
@@ -104,7 +109,7 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
|
||||
}
|
||||
|
||||
const points = [...boundaries].toSorted((a, b) => a - b);
|
||||
// Unified stack for both styles and links, tracking close string and end position
|
||||
// Links and styles share one stack so overlapping spans close in one LIFO order.
|
||||
const stack: { close: string; end: number }[] = [];
|
||||
type OpeningItem =
|
||||
| { end: number; open: string; close: string; kind: "link"; index: number }
|
||||
@@ -121,7 +126,7 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
|
||||
for (let i = 0; i < points.length; i += 1) {
|
||||
const pos = points[i];
|
||||
|
||||
// Close ALL elements (styles and links) in LIFO order at this position
|
||||
// Close every element ending here before opening new same-position spans.
|
||||
while (stack.length && stack[stack.length - 1]?.end === pos) {
|
||||
const item = stack.pop();
|
||||
if (item) {
|
||||
|
||||
@@ -10,11 +10,12 @@ const MARKDOWN_STYLE_MARKERS = {
|
||||
code_block: { open: "```\n", close: "```" },
|
||||
} as const;
|
||||
|
||||
/** Converts markdown tables into the configured plaintext/code rendering mode. */
|
||||
/** Converts markdown tables into the configured plaintext/code mode while preserving links. */
|
||||
export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode): string {
|
||||
if (!markdown || mode === "off") {
|
||||
return markdown;
|
||||
}
|
||||
// External "block" mode shares the code renderer when callers want inline replacement text.
|
||||
const effectiveMode = mode === "block" ? "code" : mode;
|
||||
const { ir, hasTables } = markdownToIRWithMeta(markdown, {
|
||||
linkify: false,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
/** Table rendering modes shared by markdown parsing and table conversion helpers. */
|
||||
export type MarkdownTableMode = "off" | "bullets" | "code" | "block";
|
||||
|
||||
@@ -50,6 +50,7 @@ export function asSafeIntegerInRange(
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Normalizes numeric string tokens while rejecting whitespace-only input. */
|
||||
function normalizeNumericString(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
@@ -366,6 +367,8 @@ export function resolveExpiresAtMsFromDurationOrEpoch(
|
||||
return resolveExpiresAtMsFromDurationSeconds(parsed, { nowMs: opts.nowMs });
|
||||
}
|
||||
const absoluteMillisecondsThreshold = opts.absoluteMillisecondsThreshold ?? 1_000_000_000_000;
|
||||
// Values below this threshold are treated as epoch seconds; larger values are
|
||||
// already millisecond timestamps and must fit JavaScript Date bounds.
|
||||
if (parsed < absoluteMillisecondsThreshold) {
|
||||
return resolveExpiresAtMsFromEpochSeconds(parsed);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ const INCLUDE_RAW = parseBooleanEnv({
|
||||
name: "OPENCLAW_PROMPT_INCLUDE_RAW",
|
||||
raw: process.env.OPENCLAW_PROMPT_INCLUDE_RAW,
|
||||
});
|
||||
const KEEP_TMP = parseBooleanEnv({
|
||||
fallback: false,
|
||||
name: "OPENCLAW_PROMPT_KEEP_TMP",
|
||||
raw: process.env.OPENCLAW_PROMPT_KEEP_TMP,
|
||||
});
|
||||
const CLAUDE_BIN = process.env.CLAUDE_BIN?.trim() || "claude";
|
||||
const NODE_BIN = process.env.OPENCLAW_NODE_BIN?.trim() || process.execPath;
|
||||
const TIMEOUT_MS = parseStrictIntegerOption({
|
||||
@@ -88,7 +93,7 @@ type PromptResult = {
|
||||
error?: string;
|
||||
matchedExtraUsage400: boolean;
|
||||
capture?: CaptureSummary;
|
||||
tmpDir: string;
|
||||
tmpDir?: string;
|
||||
};
|
||||
|
||||
type ProxyCapture = {
|
||||
@@ -106,6 +111,17 @@ type TokenSource = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
type StoppableGatewayChild = {
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
kill(signal: NodeJS.Signals): boolean;
|
||||
once(event: "exit", listener: () => void): unknown;
|
||||
};
|
||||
|
||||
type ClosableLogFile = {
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
function toHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value.join(", ") : value;
|
||||
}
|
||||
@@ -168,6 +184,17 @@ function matchesExtraUsage400(...parts: Array<string | undefined>): boolean {
|
||||
.includes("third-party apps now draw from your extra usage");
|
||||
}
|
||||
|
||||
function promptProbeTmpResult(tmpDir: string, keepTmp = KEEP_TMP): Pick<PromptResult, "tmpDir"> {
|
||||
return keepTmp ? { tmpDir } : {};
|
||||
}
|
||||
|
||||
async function cleanupPromptProbeTmpDir(tmpDir: string, keepTmp = KEEP_TMP): Promise<void> {
|
||||
if (keepTmp) {
|
||||
return;
|
||||
}
|
||||
await fs.rm(tmpDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
function isSetupToken(value: string): boolean {
|
||||
return value.startsWith("sk-ant-oat01-");
|
||||
}
|
||||
@@ -411,45 +438,50 @@ async function runDirectPrompt(prompt: string): Promise<PromptResult> {
|
||||
? await startAnthropicProxy({ port: proxyPort, upstreamBaseUrl: "https://api.anthropic.com" })
|
||||
: undefined;
|
||||
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
const child = spawn(CLAUDE_BIN, [...DIRECT_CLAUDE_ARGS, prompt, USER_PROMPT], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
...(proxyPort ? { ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}` } : {}),
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_API_KEY_OLD: "",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.on("data", (chunk) => stdout.push(String(chunk)));
|
||||
child.stderr.on("data", (chunk) => stderr.push(String(chunk)));
|
||||
const exit = await withTimeout(
|
||||
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
}),
|
||||
TIMEOUT_MS,
|
||||
() => {
|
||||
child.kill("SIGKILL");
|
||||
return { code: null, signal: "SIGKILL" as NodeJS.Signals };
|
||||
},
|
||||
);
|
||||
await proxy?.stop().catch(() => {});
|
||||
const joinedStdout = stdout.join("");
|
||||
const joinedStderr = stderr.join("");
|
||||
return {
|
||||
prompt,
|
||||
ok: exit.code === 0 && !matchesExtraUsage400(joinedStdout, joinedStderr),
|
||||
transport: "direct",
|
||||
exitCode: exit.code,
|
||||
signal: exit.signal,
|
||||
stdout: redactForDevToolLog(joinedStdout.trim()) || undefined,
|
||||
stderr: redactForDevToolLog(joinedStderr.trim()) || undefined,
|
||||
matchedExtraUsage400: matchesExtraUsage400(joinedStdout, joinedStderr),
|
||||
capture: summarizeCapture(proxy?.getLastCapture(), prompt),
|
||||
tmpDir,
|
||||
};
|
||||
try {
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
const child = spawn(CLAUDE_BIN, [...DIRECT_CLAUDE_ARGS, prompt, USER_PROMPT], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
...(proxyPort ? { ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}` } : {}),
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_API_KEY_OLD: "",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.on("data", (chunk) => stdout.push(String(chunk)));
|
||||
child.stderr.on("data", (chunk) => stderr.push(String(chunk)));
|
||||
const exit = await withTimeout(
|
||||
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
}),
|
||||
TIMEOUT_MS,
|
||||
() => {
|
||||
child.kill("SIGKILL");
|
||||
return { code: null, signal: "SIGKILL" as NodeJS.Signals };
|
||||
},
|
||||
);
|
||||
const joinedStdout = stdout.join("");
|
||||
const joinedStderr = stderr.join("");
|
||||
return {
|
||||
prompt,
|
||||
ok: exit.code === 0 && !matchesExtraUsage400(joinedStdout, joinedStderr),
|
||||
transport: "direct",
|
||||
exitCode: exit.code,
|
||||
signal: exit.signal,
|
||||
stdout: redactForDevToolLog(joinedStdout.trim()) || undefined,
|
||||
stderr: redactForDevToolLog(joinedStderr.trim()) || undefined,
|
||||
matchedExtraUsage400: matchesExtraUsage400(joinedStdout, joinedStderr),
|
||||
capture: summarizeCapture(proxy?.getLastCapture(), prompt),
|
||||
...promptProbeTmpResult(tmpDir),
|
||||
};
|
||||
} finally {
|
||||
await proxy?.stop().catch(() => {});
|
||||
await cleanupPromptProbeTmpDir(tmpDir).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function startGatewayProcess(params: {
|
||||
@@ -490,25 +522,47 @@ async function startGatewayProcess(params: {
|
||||
child.stdout.on("data", (chunk) => void logFile.appendFile(chunk));
|
||||
child.stderr.on("data", (chunk) => void logFile.appendFile(chunk));
|
||||
return {
|
||||
async stop() {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGINT");
|
||||
}
|
||||
const exited = await withTimeout(
|
||||
new Promise<boolean>((resolve) => {
|
||||
child.once("exit", () => resolve(true));
|
||||
}),
|
||||
1_500,
|
||||
() => false,
|
||||
);
|
||||
if (!exited && !child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
await logFile.close();
|
||||
async stop(): Promise<boolean> {
|
||||
return await stopGatewayPromptChild(child, logFile);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function stopGatewayPromptChild(
|
||||
child: StoppableGatewayChild,
|
||||
logFile: ClosableLogFile,
|
||||
sigintTimeoutMs = 1_500,
|
||||
sigkillTimeoutMs = 1_500,
|
||||
): Promise<boolean> {
|
||||
let exited = child.exitCode !== null || child.signalCode !== null;
|
||||
const exitPromise = exited
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => {
|
||||
child.once("exit", () => {
|
||||
exited = true;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
if (!exited) {
|
||||
child.kill("SIGINT");
|
||||
}
|
||||
const exitedAfterSigint = await withTimeout(
|
||||
exitPromise.then(() => true),
|
||||
sigintTimeoutMs,
|
||||
() => false,
|
||||
);
|
||||
if (!exitedAfterSigint && !exited) {
|
||||
child.kill("SIGKILL");
|
||||
await withTimeout(
|
||||
exitPromise.then(() => true),
|
||||
sigkillTimeoutMs,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
await logFile.close();
|
||||
return exited;
|
||||
}
|
||||
|
||||
async function waitForGatewayReady(url: string, token: string): Promise<void> {
|
||||
const deadline = Date.now() + 45_000;
|
||||
let lastError = "gateway start timeout";
|
||||
@@ -544,80 +598,81 @@ async function runGatewayPrompt(prompt: string): Promise<PromptResult> {
|
||||
ENABLE_CAPTURE && proxyPort
|
||||
? await startAnthropicProxy({ port: proxyPort, upstreamBaseUrl: "https://api.anthropic.com" })
|
||||
: undefined;
|
||||
let gateway: Awaited<ReturnType<typeof startGatewayProcess>> | undefined;
|
||||
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.mkdir(bundledPluginsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
gateway: {
|
||||
mode: "local",
|
||||
controlUi: { enabled: false },
|
||||
tailscale: { mode: "off" },
|
||||
},
|
||||
discovery: {
|
||||
mdns: { mode: "off" },
|
||||
wideArea: { enabled: false },
|
||||
},
|
||||
...(proxyPort
|
||||
? {
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: `http://127.0.0.1:${proxyPort}`,
|
||||
api: "anthropic-messages",
|
||||
models: [],
|
||||
try {
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.mkdir(bundledPluginsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
gateway: {
|
||||
mode: "local",
|
||||
controlUi: { enabled: false },
|
||||
tailscale: { mode: "off" },
|
||||
},
|
||||
discovery: {
|
||||
mdns: { mode: "off" },
|
||||
wideArea: { enabled: false },
|
||||
},
|
||||
...(proxyPort
|
||||
? {
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: `http://127.0.0.1:${proxyPort}`,
|
||||
api: "anthropic-messages",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
auth: {
|
||||
profiles: { [tokenSource.profileId]: { provider: "anthropic", mode: "token" } },
|
||||
order: { anthropic: [tokenSource.profileId] },
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
heartbeat: {
|
||||
includeSystemPromptSection: false,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
auth: {
|
||||
profiles: { [tokenSource.profileId]: { provider: "anthropic", mode: "token" } },
|
||||
order: { anthropic: [tokenSource.profileId] },
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
heartbeat: {
|
||||
includeSystemPromptSection: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "auth-profiles.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
[tokenSource.profileId]: {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: tokenSource.token,
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "auth-profiles.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
[tokenSource.profileId]: {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: tokenSource.token,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const gateway = await startGatewayProcess({
|
||||
port,
|
||||
gatewayToken,
|
||||
configPath,
|
||||
stateDir,
|
||||
agentDir,
|
||||
bundledPluginsDir,
|
||||
logPath,
|
||||
});
|
||||
try {
|
||||
gateway = await startGatewayProcess({
|
||||
port,
|
||||
gatewayToken,
|
||||
configPath,
|
||||
stateDir,
|
||||
agentDir,
|
||||
bundledPluginsDir,
|
||||
logPath,
|
||||
});
|
||||
const url = `ws://127.0.0.1:${port}`;
|
||||
await waitForGatewayReady(url, gatewayToken);
|
||||
const agentRes = await callGateway({
|
||||
@@ -644,7 +699,7 @@ async function runGatewayPrompt(prompt: string): Promise<PromptResult> {
|
||||
error: redactForDevToolLog(`missing runId: ${JSON.stringify(agentRes)}`),
|
||||
matchedExtraUsage400: false,
|
||||
capture: summarizeCapture(proxy?.getLastCapture(), prompt),
|
||||
tmpDir,
|
||||
...promptProbeTmpResult(tmpDir),
|
||||
};
|
||||
}
|
||||
const waitRes = await callGateway({
|
||||
@@ -672,11 +727,14 @@ async function runGatewayPrompt(prompt: string): Promise<PromptResult> {
|
||||
: redactForDevToolLog(waitRes.error || logTail || "agent.wait failed"),
|
||||
matchedExtraUsage400: matched400,
|
||||
capture: summarizeCapture(proxy?.getLastCapture(), prompt),
|
||||
tmpDir,
|
||||
...promptProbeTmpResult(tmpDir),
|
||||
};
|
||||
} finally {
|
||||
await gateway.stop().catch(() => {});
|
||||
const gatewayStopped = (await gateway?.stop().catch(() => false)) ?? true;
|
||||
await proxy?.stop().catch(() => {});
|
||||
if (gatewayStopped) {
|
||||
await cleanupPromptProbeTmpDir(tmpDir).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,8 +764,11 @@ async function main() {
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
cleanupPromptProbeTmpDir,
|
||||
matchesExtraUsage400,
|
||||
promptProbeTmpResult,
|
||||
resolveAnthropicUpstreamUrl,
|
||||
stopGatewayPromptChild,
|
||||
summarizeCapture,
|
||||
summarizeText,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { accessSync, chmodSync, constants, existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
accessSync,
|
||||
chmodSync,
|
||||
constants,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
@@ -36,6 +44,7 @@ const LINTABLE_CORE_PATH_RE = /^(?:src|ui|packages)\/.+\.[cm]?[jt]sx?$/u;
|
||||
const CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
|
||||
/^(?:scripts|test\/scripts)\/|^\.github\/workflows\/ci\.yml$/u;
|
||||
let corepackPnpmShimDir;
|
||||
let corepackPnpmShimCleanupRegistered = false;
|
||||
|
||||
export function createChangedCheckChildEnv(baseEnv = process.env) {
|
||||
const resolvedBaseEnv = resolveLocalHeavyCheckEnv(baseEnv);
|
||||
@@ -464,9 +473,27 @@ function ensureCorepackPnpmShimDir() {
|
||||
chmodSync(pnpmPath, 0o755);
|
||||
writeFileSync(path.join(dir, "pnpm.cmd"), "@echo off\r\ncorepack pnpm %*\r\n", "utf8");
|
||||
corepackPnpmShimDir = dir;
|
||||
registerCorepackPnpmShimCleanup();
|
||||
return dir;
|
||||
}
|
||||
|
||||
function registerCorepackPnpmShimCleanup() {
|
||||
if (corepackPnpmShimCleanupRegistered) {
|
||||
return;
|
||||
}
|
||||
corepackPnpmShimCleanupRegistered = true;
|
||||
process.once("exit", cleanupCorepackPnpmShimDir);
|
||||
}
|
||||
|
||||
export function cleanupCorepackPnpmShimDir() {
|
||||
if (!corepackPnpmShimDir) {
|
||||
return;
|
||||
}
|
||||
const dir = corepackPnpmShimDir;
|
||||
corepackPnpmShimDir = undefined;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function runCommand(command, timings) {
|
||||
const startedAt = performance.now();
|
||||
console.error(`\n[check:changed] ${command.name}`);
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from "./deadcode-unused-files.allowlist.mjs";
|
||||
|
||||
const KNIP_VERSION = "6.8.0";
|
||||
export const KNIP_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
export const KNIP_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
||||
const KNIP_ARGS = [
|
||||
"--config",
|
||||
"config/knip.config.ts",
|
||||
@@ -108,18 +110,28 @@ export function formatUnusedFileComparison(comparison) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function runKnipUnusedFiles() {
|
||||
const result = spawnSync(
|
||||
function spawnErrorCode(error) {
|
||||
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
||||
}
|
||||
|
||||
export function runKnipUnusedFiles(params = {}) {
|
||||
const run = params.spawnSyncCommand ?? spawnSync;
|
||||
const result = run(
|
||||
"pnpm",
|
||||
["--config.minimum-release-age=0", "dlx", `knip@${KNIP_VERSION}`, ...KNIP_ARGS],
|
||||
{
|
||||
encoding: "utf8",
|
||||
killSignal: "SIGTERM",
|
||||
maxBuffer: params.maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: params.timeoutMs ?? KNIP_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
return {
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
errorCode: spawnErrorCode(result.error),
|
||||
errorMessage: result.error?.message,
|
||||
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
};
|
||||
}
|
||||
@@ -144,6 +156,18 @@ export function checkUnusedFiles(
|
||||
|
||||
function main() {
|
||||
const result = runKnipUnusedFiles();
|
||||
if (result.errorCode || result.status === null) {
|
||||
console.error(
|
||||
`deadcode unused-file scan failed: ${result.errorCode ?? result.signal ?? "unknown"}${
|
||||
result.errorMessage ? `: ${result.errorMessage}` : ""
|
||||
}`,
|
||||
);
|
||||
if (result.output) {
|
||||
console.error(result.output);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const check = checkUnusedFiles(result.output);
|
||||
if (!check.ok) {
|
||||
if (check.message) {
|
||||
|
||||
@@ -137,9 +137,22 @@ function runStep(name, command, args, options = {}, params = {}) {
|
||||
stdio: "inherit",
|
||||
...options,
|
||||
});
|
||||
const status = result.status ?? (result.signal ? 1 : 0);
|
||||
console.error(`[gateway-cpu] ${status === 0 ? "pass" : "fail"} ${name}`);
|
||||
return { name, status, signal: result.signal ?? null };
|
||||
const error =
|
||||
result.error instanceof Error
|
||||
? result.error.message
|
||||
: result.error
|
||||
? String(result.error)
|
||||
: null;
|
||||
const status = result.error ? 1 : (result.status ?? (result.signal ? 1 : 0));
|
||||
console.error(
|
||||
`[gateway-cpu] ${status === 0 ? "pass" : "fail"} ${name}${error ? `: ${error}` : ""}`,
|
||||
);
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
signal: result.signal ?? null,
|
||||
...(error ? { error } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function pnpmCommand(args, params = {}) {
|
||||
|
||||
@@ -404,7 +404,7 @@ async function allocateLoopbackPort() {
|
||||
const { port } = address;
|
||||
server.close((closeErr) => {
|
||||
if (closeErr) {
|
||||
reject(closeErr);
|
||||
reject(closeErr instanceof Error ? closeErr : new Error(String(closeErr)));
|
||||
return;
|
||||
}
|
||||
resolve(port);
|
||||
@@ -484,100 +484,142 @@ function parseTimingFile(timeFilePath) {
|
||||
};
|
||||
}
|
||||
|
||||
async function runTimedWatch(options, outputDir) {
|
||||
export async function runTimedWatch(options, outputDir, deps = {}) {
|
||||
const allocatePort = deps.allocateLoopbackPort ?? allocateLoopbackPort;
|
||||
const parseTiming = deps.parseTimingFile ?? parseTimingFile;
|
||||
const readCpuMs = deps.readProcessTreeCpuMs ?? readProcessTreeCpuMs;
|
||||
const sleepMs = deps.sleep ?? sleep;
|
||||
const spawnCommand = deps.spawn ?? spawn;
|
||||
const stopChild = deps.stopTimedWatchChild ?? stopTimedWatchChild;
|
||||
const waitReady = deps.waitForGatewayReady ?? waitForGatewayReady;
|
||||
const pidFilePath = path.join(outputDir, "watch.pid");
|
||||
const timeFilePath = path.join(outputDir, "watch.time.log");
|
||||
const isolatedHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-watch-"));
|
||||
fs.writeFileSync(path.join(outputDir, "watch.home.txt"), `${isolatedHomeDir}\n`, "utf8");
|
||||
const stdoutPath = path.join(outputDir, "watch.stdout.log");
|
||||
const stderrPath = path.join(outputDir, "watch.stderr.log");
|
||||
for (const stalePath of [pidFilePath, timeFilePath, stdoutPath, stderrPath]) {
|
||||
removePathIfExists(stalePath);
|
||||
}
|
||||
const port = await allocateLoopbackPort();
|
||||
fs.writeFileSync(path.join(outputDir, "watch.port.txt"), `${String(port)}\n`, "utf8");
|
||||
const { command, args, env } = buildTimedWatchCommand(
|
||||
pidFilePath,
|
||||
timeFilePath,
|
||||
isolatedHomeDir,
|
||||
port,
|
||||
);
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
let buildDetection = { buffer: "", triggered: false, reason: null };
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
const next = appendBoundedWatchLog(stdout, chunk);
|
||||
stdout = next.text;
|
||||
stdoutTruncated ||= next.truncated;
|
||||
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const next = appendBoundedWatchLog(stderr, chunk);
|
||||
stderr = next.text;
|
||||
stderrTruncated ||= next.truncated;
|
||||
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
|
||||
});
|
||||
|
||||
let spawnError = null;
|
||||
const spawnErrorExit = new Promise((resolve) => {
|
||||
child.once("error", (error) => {
|
||||
spawnError = error;
|
||||
resolve({ code: null, signal: null, error: error.message });
|
||||
});
|
||||
});
|
||||
|
||||
let watchPid = null;
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (fs.existsSync(pidFilePath)) {
|
||||
watchPid = Number(fs.readFileSync(pidFilePath, "utf8").trim());
|
||||
break;
|
||||
try {
|
||||
const stdoutPath = path.join(outputDir, "watch.stdout.log");
|
||||
const stderrPath = path.join(outputDir, "watch.stderr.log");
|
||||
for (const stalePath of [pidFilePath, timeFilePath, stdoutPath, stderrPath]) {
|
||||
removePathIfExists(stalePath);
|
||||
}
|
||||
await sleep(100);
|
||||
const port = await allocatePort();
|
||||
fs.writeFileSync(path.join(outputDir, "watch.port.txt"), `${String(port)}\n`, "utf8");
|
||||
const { command, args, env } = buildTimedWatchCommand(
|
||||
pidFilePath,
|
||||
timeFilePath,
|
||||
isolatedHomeDir,
|
||||
port,
|
||||
);
|
||||
const child = spawnCommand(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
let buildDetection = { buffer: "", triggered: false, reason: null };
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
const next = appendBoundedWatchLog(stdout, chunk);
|
||||
stdout = next.text;
|
||||
stdoutTruncated ||= next.truncated;
|
||||
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const next = appendBoundedWatchLog(stderr, chunk);
|
||||
stderr = next.text;
|
||||
stderrTruncated ||= next.truncated;
|
||||
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
|
||||
});
|
||||
|
||||
let spawnError = null;
|
||||
const spawnErrorExit = new Promise((resolve) => {
|
||||
child.once("error", (error) => {
|
||||
spawnError = error;
|
||||
resolve({ code: null, signal: null, error: error.message });
|
||||
});
|
||||
});
|
||||
const raceSpawnError = async (operation) =>
|
||||
await Promise.race([
|
||||
Promise.resolve(operation).then((value) => ({ type: "value", value })),
|
||||
spawnErrorExit.then((value) => ({ type: "spawn-error", value })),
|
||||
]);
|
||||
|
||||
let watchPid = null;
|
||||
let exit = null;
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (fs.existsSync(pidFilePath)) {
|
||||
watchPid = Number(fs.readFileSync(pidFilePath, "utf8").trim());
|
||||
break;
|
||||
}
|
||||
const waitResult = await raceSpawnError(sleepMs(100));
|
||||
if (waitResult.type === "spawn-error") {
|
||||
exit = waitResult.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let readyBeforeWindow = false;
|
||||
let idleCpuStartMs = null;
|
||||
let idleCpuEndMs = null;
|
||||
if (!exit) {
|
||||
const readyResult = await raceSpawnError(
|
||||
waitReady(() => `${stdout}\n${stderr}`, options.readyTimeoutMs),
|
||||
);
|
||||
if (readyResult.type === "spawn-error") {
|
||||
exit = readyResult.value;
|
||||
} else {
|
||||
readyBeforeWindow = readyResult.value;
|
||||
}
|
||||
}
|
||||
if (!exit && readyBeforeWindow && options.readySettleMs > 0) {
|
||||
const settleResult = await raceSpawnError(sleepMs(options.readySettleMs));
|
||||
if (settleResult.type === "spawn-error") {
|
||||
exit = settleResult.value;
|
||||
}
|
||||
}
|
||||
if (!exit) {
|
||||
idleCpuStartMs = watchPid ? readCpuMs(watchPid) : null;
|
||||
const windowResult = await raceSpawnError(sleepMs(options.windowMs));
|
||||
if (windowResult.type === "spawn-error") {
|
||||
exit = windowResult.value;
|
||||
} else {
|
||||
idleCpuEndMs = watchPid ? readCpuMs(watchPid) : null;
|
||||
}
|
||||
}
|
||||
if (!exit) {
|
||||
const stopResult = await raceSpawnError(stopChild(child, watchPid, options));
|
||||
exit = stopResult.value;
|
||||
}
|
||||
|
||||
fs.writeFileSync(stdoutPath, formatCapturedWatchLog(stdout, stdoutTruncated), "utf8");
|
||||
fs.writeFileSync(stderrPath, formatCapturedWatchLog(stderr, stderrTruncated), "utf8");
|
||||
const timingFileMissing = !fs.existsSync(timeFilePath);
|
||||
const timing = timingFileMissing
|
||||
? { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN }
|
||||
: parseTiming(timeFilePath);
|
||||
|
||||
return {
|
||||
exit,
|
||||
spawnError: spawnError ? spawnError.message : null,
|
||||
timingFileMissing,
|
||||
timing,
|
||||
readyBeforeWindow,
|
||||
idleCpuMs:
|
||||
idleCpuStartMs == null || idleCpuEndMs == null
|
||||
? null
|
||||
: Math.max(0, idleCpuEndMs - idleCpuStartMs),
|
||||
stdoutPath,
|
||||
stderrPath,
|
||||
timeFilePath,
|
||||
watchTriggeredBuild: buildDetection.triggered,
|
||||
watchBuildReason: buildDetection.reason,
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(isolatedHomeDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
const readyBeforeWindow = await waitForGatewayReady(
|
||||
() => `${stdout}\n${stderr}`,
|
||||
options.readyTimeoutMs,
|
||||
);
|
||||
if (readyBeforeWindow && options.readySettleMs > 0) {
|
||||
await sleep(options.readySettleMs);
|
||||
}
|
||||
const idleCpuStartMs = watchPid ? readProcessTreeCpuMs(watchPid) : null;
|
||||
await sleep(options.windowMs);
|
||||
const idleCpuEndMs = watchPid ? readProcessTreeCpuMs(watchPid) : null;
|
||||
|
||||
const exit = await Promise.race([stopTimedWatchChild(child, watchPid, options), spawnErrorExit]);
|
||||
fs.writeFileSync(stdoutPath, formatCapturedWatchLog(stdout, stdoutTruncated), "utf8");
|
||||
fs.writeFileSync(stderrPath, formatCapturedWatchLog(stderr, stderrTruncated), "utf8");
|
||||
const timingFileMissing = !fs.existsSync(timeFilePath);
|
||||
const timing = timingFileMissing
|
||||
? { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN }
|
||||
: parseTimingFile(timeFilePath);
|
||||
|
||||
return {
|
||||
exit,
|
||||
spawnError: spawnError ? spawnError.message : null,
|
||||
timingFileMissing,
|
||||
timing,
|
||||
readyBeforeWindow,
|
||||
idleCpuMs:
|
||||
idleCpuStartMs == null || idleCpuEndMs == null
|
||||
? null
|
||||
: Math.max(0, idleCpuEndMs - idleCpuStartMs),
|
||||
stdoutPath,
|
||||
stderrPath,
|
||||
timeFilePath,
|
||||
watchTriggeredBuild: buildDetection.triggered,
|
||||
watchBuildReason: buildDetection.reason,
|
||||
};
|
||||
}
|
||||
|
||||
export async function stopTimedWatchChild(child, watchPid, options, deps = {}) {
|
||||
|
||||
@@ -2,6 +2,53 @@
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const DEFAULT_GITHUB_REPOSITORY = "openclaw/openclaw";
|
||||
const RUN_JOBS_PAGE_SIZE = 20;
|
||||
const RUN_JOBS_MAX_PAGES = 25;
|
||||
const GH_JSON_RETRY_DELAYS_MS = [1_000, 3_000, 6_000];
|
||||
|
||||
function sleepSync(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function parseJsonCommand(command, args, options = {}) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt <= GH_JSON_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||
try {
|
||||
return JSON.parse(
|
||||
execFileSync(command, args, {
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const retryable = /HTTP 5\d\d|Server Error|ETIMEDOUT|ECONNRESET|EAI_AGAIN/u.test(message);
|
||||
if (!retryable || attempt === GH_JSON_RETRY_DELAYS_MS.length) {
|
||||
throw error;
|
||||
}
|
||||
sleepSync(GH_JSON_RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function normalizeRunJob(job) {
|
||||
return {
|
||||
completedAt: job.completedAt ?? job.completed_at ?? null,
|
||||
conclusion: job.conclusion ?? "",
|
||||
databaseId: job.databaseId ?? job.id,
|
||||
name: job.name,
|
||||
startedAt: job.startedAt ?? job.started_at ?? null,
|
||||
status: job.status ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function collectRunJobsFromPages(pages) {
|
||||
return pages.flatMap((page) => (Array.isArray(page.jobs) ? page.jobs.map(normalizeRunJob) : []));
|
||||
}
|
||||
|
||||
function parseTime(value) {
|
||||
if (!value || value === "0001-01-01T00:00:00Z") {
|
||||
return null;
|
||||
@@ -216,15 +263,37 @@ function listRecentSuccessfulCiRuns(limit) {
|
||||
}
|
||||
|
||||
function loadRun(runId) {
|
||||
return JSON.parse(
|
||||
execFileSync(
|
||||
"gh",
|
||||
["run", "view", runId, "--json", "status,conclusion,createdAt,updatedAt,jobs"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
),
|
||||
);
|
||||
const run = parseJsonCommand("gh", [
|
||||
"run",
|
||||
"view",
|
||||
runId,
|
||||
"--json",
|
||||
"status,conclusion,createdAt,updatedAt",
|
||||
]);
|
||||
const repository = process.env.GITHUB_REPOSITORY || DEFAULT_GITHUB_REPOSITORY;
|
||||
const pages = [];
|
||||
let totalCount = null;
|
||||
for (let page = 1; page <= RUN_JOBS_MAX_PAGES; page += 1) {
|
||||
const payload = parseJsonCommand("gh", [
|
||||
"api",
|
||||
"-X",
|
||||
"GET",
|
||||
`repos/${repository}/actions/runs/${runId}/jobs?per_page=${RUN_JOBS_PAGE_SIZE}&page=${page}`,
|
||||
]);
|
||||
pages.push(payload);
|
||||
const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
|
||||
totalCount = typeof payload.total_count === "number" ? payload.total_count : totalCount;
|
||||
if (
|
||||
jobs.length === 0 ||
|
||||
(totalCount !== null && collectRunJobsFromPages(pages).length >= totalCount)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
jobs: collectRunJobsFromPages(pages),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeJobs(run) {
|
||||
|
||||
@@ -51,6 +51,10 @@ type TelegramFlowResult = {
|
||||
previewUpdates: number;
|
||||
};
|
||||
|
||||
function toError(value: unknown): Error {
|
||||
return value instanceof Error ? value : new Error(String(value));
|
||||
}
|
||||
|
||||
type TelegramThinkingFinalDeps = {
|
||||
createDraftStream?: (params: {
|
||||
accountId?: string;
|
||||
@@ -310,15 +314,35 @@ export async function runTelegramThinkingFinalFlow(
|
||||
});
|
||||
const wait = deps.sleep ?? sleep;
|
||||
|
||||
for (const update of thinkingUpdates) {
|
||||
stream.update(formatReasoningMessage(update));
|
||||
await stream.flush();
|
||||
if (delayMs > 0) {
|
||||
await wait(delayMs);
|
||||
let previewStarted = false;
|
||||
let flowError: unknown;
|
||||
try {
|
||||
for (const update of thinkingUpdates) {
|
||||
previewStarted = true;
|
||||
stream.update(formatReasoningMessage(update));
|
||||
await stream.flush();
|
||||
if (delayMs > 0) {
|
||||
await wait(delayMs);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
flowError = error;
|
||||
}
|
||||
let cleanupError: unknown;
|
||||
if (previewStarted) {
|
||||
try {
|
||||
await stream.clear();
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
}
|
||||
if (flowError) {
|
||||
throw toError(flowError);
|
||||
}
|
||||
if (cleanupError) {
|
||||
throw toError(cleanupError);
|
||||
}
|
||||
|
||||
await stream.clear();
|
||||
const final = await (deps.sendFinal ?? sendTelegramFinal)({
|
||||
accountId: options.accountId,
|
||||
cfg: options.cfg,
|
||||
@@ -350,19 +374,39 @@ export async function runTelegramWorkingFinalFlow(
|
||||
let previewUpdates = 0;
|
||||
let lastPreviewText = "";
|
||||
const updateIntervalMs = delayMs > 0 ? delayMs : 1_000;
|
||||
for (let elapsedMs = 0; elapsedMs < durationMs; elapsedMs += updateIntervalMs) {
|
||||
const previewText = formatWorkingProgressPreview(elapsedMs);
|
||||
if (previewText !== lastPreviewText) {
|
||||
await draft.update(previewText);
|
||||
lastPreviewText = previewText;
|
||||
previewUpdates += 1;
|
||||
let draftStarted = false;
|
||||
let flowError: unknown;
|
||||
try {
|
||||
for (let elapsedMs = 0; elapsedMs < durationMs; elapsedMs += updateIntervalMs) {
|
||||
const previewText = formatWorkingProgressPreview(elapsedMs);
|
||||
if (previewText !== lastPreviewText) {
|
||||
draftStarted = true;
|
||||
await draft.update(previewText);
|
||||
lastPreviewText = previewText;
|
||||
previewUpdates += 1;
|
||||
}
|
||||
if (delayMs > 0 && elapsedMs + updateIntervalMs < durationMs) {
|
||||
await wait(delayMs);
|
||||
}
|
||||
}
|
||||
if (delayMs > 0 && elapsedMs + updateIntervalMs < durationMs) {
|
||||
await wait(delayMs);
|
||||
} catch (error) {
|
||||
flowError = error;
|
||||
}
|
||||
let cleanupError: unknown;
|
||||
if (draftStarted) {
|
||||
try {
|
||||
draft.stop();
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
}
|
||||
if (flowError) {
|
||||
throw toError(flowError);
|
||||
}
|
||||
if (cleanupError) {
|
||||
throw toError(cleanupError);
|
||||
}
|
||||
|
||||
draft.stop();
|
||||
const final = await (deps.sendFinal ?? sendTelegramFinal)({
|
||||
accountId: options.accountId,
|
||||
cfg: options.cfg,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
MIN_CLIENT_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -12,69 +13,94 @@ function writeStderrLine(message: string): void {
|
||||
process.stderr.write(`${message}\n`);
|
||||
}
|
||||
|
||||
const { get: getArg } = createArgReader();
|
||||
const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL;
|
||||
const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
|
||||
if (!urlRaw || !token) {
|
||||
function writeUsage(): void {
|
||||
writeStderrLine(
|
||||
"Usage: bun scripts/dev/gateway-smoke.ts --url <wss://host[:port]> --token <gateway.auth.token>\n" +
|
||||
"Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = resolveGatewayUrl(urlRaw);
|
||||
const { request, waitOpen, close } = createGatewayWsClient({
|
||||
type GatewaySmokeClient = ReturnType<typeof createGatewayWsClient>;
|
||||
|
||||
type GatewaySmokeDeps = {
|
||||
createClient?: typeof createGatewayWsClient;
|
||||
stderr?: (message: string) => void;
|
||||
stdout?: (message: string) => void;
|
||||
};
|
||||
|
||||
export async function runGatewaySmoke(
|
||||
input: { token: string; urlRaw: string },
|
||||
deps: GatewaySmokeDeps = {},
|
||||
): Promise<number> {
|
||||
const url = resolveGatewayUrl(input.urlRaw);
|
||||
const createClient = deps.createClient ?? createGatewayWsClient;
|
||||
const stderr = deps.stderr ?? writeStderrLine;
|
||||
const stdout = deps.stdout ?? writeStdoutLine;
|
||||
const client: GatewaySmokeClient = createClient({
|
||||
url: url.toString(),
|
||||
onEvent: (evt) => {
|
||||
// Ignore noisy connect handshakes.
|
||||
void evt;
|
||||
},
|
||||
});
|
||||
const { request, waitOpen, close } = client;
|
||||
|
||||
await waitOpen();
|
||||
try {
|
||||
await waitOpen();
|
||||
|
||||
// Match iOS "operator" session defaults: token auth, no device identity.
|
||||
const connectRes = await request("connect", {
|
||||
minProtocol: MIN_CLIENT_PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "openclaw-ios",
|
||||
displayName: "openclaw gateway smoke test",
|
||||
version: "dev",
|
||||
platform: "dev",
|
||||
mode: "ui",
|
||||
instanceId: "openclaw-dev-smoke",
|
||||
},
|
||||
locale: "en-US",
|
||||
userAgent: "gateway-smoke",
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.admin"],
|
||||
caps: [],
|
||||
auth: { token },
|
||||
});
|
||||
// Match iOS "operator" session defaults: token auth, no device identity.
|
||||
const connectRes = await request("connect", {
|
||||
minProtocol: MIN_CLIENT_PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "openclaw-ios",
|
||||
displayName: "openclaw gateway smoke test",
|
||||
version: "dev",
|
||||
platform: "dev",
|
||||
mode: "ui",
|
||||
instanceId: "openclaw-dev-smoke",
|
||||
},
|
||||
locale: "en-US",
|
||||
userAgent: "gateway-smoke",
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.admin"],
|
||||
caps: [],
|
||||
auth: { token: input.token },
|
||||
});
|
||||
|
||||
if (!connectRes.ok) {
|
||||
writeStderrLine(`connect failed: ${String(connectRes.error)}`);
|
||||
process.exit(2);
|
||||
if (!connectRes.ok) {
|
||||
stderr(`connect failed: ${String(connectRes.error)}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const healthRes = await request("health");
|
||||
if (!healthRes.ok) {
|
||||
stderr(`health failed: ${String(healthRes.error)}`);
|
||||
return 3;
|
||||
}
|
||||
|
||||
const historyRes = await request("chat.history", { sessionKey: "main" }, 15000);
|
||||
if (!historyRes.ok) {
|
||||
stderr(`chat.history failed: ${String(historyRes.error)}`);
|
||||
return 4;
|
||||
}
|
||||
|
||||
stdout("ok: connected + health + chat.history");
|
||||
return 0;
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
|
||||
const healthRes = await request("health");
|
||||
if (!healthRes.ok) {
|
||||
writeStderrLine(`health failed: ${String(healthRes.error)}`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const historyRes = await request("chat.history", { sessionKey: "main" }, 15000);
|
||||
if (!historyRes.ok) {
|
||||
writeStderrLine(`chat.history failed: ${String(historyRes.error)}`);
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
writeStdoutLine("ok: connected + health + chat.history");
|
||||
close();
|
||||
}
|
||||
|
||||
await main();
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
const { get: getArg } = createArgReader();
|
||||
const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL;
|
||||
const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
|
||||
if (!urlRaw || !token) {
|
||||
writeUsage();
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.exitCode = await runGatewaySmoke({ token, urlRaw });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,18 +108,26 @@ export function createGatewayWsClient(params: {
|
||||
|
||||
const waitOpen = () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(
|
||||
() => reject(new Error("ws open timeout")),
|
||||
params.openTimeoutMs ?? 8000,
|
||||
);
|
||||
ws.once("open", () => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(t);
|
||||
ws.off("open", onOpen);
|
||||
ws.off("error", onError);
|
||||
};
|
||||
const onOpen = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
});
|
||||
ws.once("error", (err) => {
|
||||
clearTimeout(t);
|
||||
};
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
};
|
||||
const t = setTimeout(() => {
|
||||
cleanup();
|
||||
ws.terminate();
|
||||
reject(new Error("ws open timeout"));
|
||||
}, params.openTimeoutMs ?? 8000);
|
||||
ws.once("open", onOpen);
|
||||
ws.once("error", onError);
|
||||
});
|
||||
|
||||
ws.on("message", (data) => {
|
||||
|
||||
@@ -275,47 +275,66 @@ export function sanitizeDocsConfigForEnglishOnly(value) {
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
function prepareMirroredDocsDir(sourceDir = DOCS_DIR) {
|
||||
/**
|
||||
* @param {string} [sourceDir]
|
||||
* @param {{
|
||||
* resolveClawHubRepoPathImpl?: typeof resolveClawHubRepoPath;
|
||||
* syncClawHubDocsTreeImpl?: typeof syncClawHubDocsTree;
|
||||
* }} [options]
|
||||
*/
|
||||
export function prepareMirroredDocsDir(sourceDir = DOCS_DIR, options = {}) {
|
||||
const sourceRoot = path.resolve(sourceDir);
|
||||
if (sourceRoot !== path.resolve(DOCS_DIR)) {
|
||||
return { dir: sourceRoot, mirroredClawHub: false, cleanup: () => {} };
|
||||
}
|
||||
|
||||
const clawhubRepo = resolveClawHubRepoPath("", { required: false });
|
||||
const resolveClawHubRepoPathImpl = options.resolveClawHubRepoPathImpl ?? resolveClawHubRepoPath;
|
||||
const syncClawHubDocsTreeImpl = options.syncClawHubDocsTreeImpl ?? syncClawHubDocsTree;
|
||||
const clawhubRepo = resolveClawHubRepoPathImpl("", { required: false });
|
||||
if (!clawhubRepo) {
|
||||
return { dir: sourceRoot, mirroredClawHub: false, cleanup: () => {} };
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docs-link-audit-"));
|
||||
fs.cpSync(sourceRoot, tempDir, { recursive: true });
|
||||
syncClawHubDocsTree(tempDir, { repoPath: clawhubRepo, required: false });
|
||||
return {
|
||||
dir: tempDir,
|
||||
mirroredClawHub: true,
|
||||
cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),
|
||||
};
|
||||
try {
|
||||
fs.cpSync(sourceRoot, tempDir, { recursive: true });
|
||||
syncClawHubDocsTreeImpl(tempDir, { repoPath: clawhubRepo, required: false });
|
||||
return {
|
||||
dir: tempDir,
|
||||
mirroredClawHub: true,
|
||||
cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),
|
||||
};
|
||||
} catch (error) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareAnchorAuditDocsDir(sourceDir = DOCS_DIR) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docs-anchor-audit-"));
|
||||
fs.cpSync(sourceDir, tempDir, { recursive: true });
|
||||
try {
|
||||
fs.cpSync(sourceDir, tempDir, { recursive: true });
|
||||
|
||||
for (const entry of fs.readdirSync(tempDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
for (const entry of fs.readdirSync(tempDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (!isGeneratedTranslatedDoc(`${entry.name}/`)) {
|
||||
continue;
|
||||
}
|
||||
fs.rmSync(path.join(tempDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
if (!isGeneratedTranslatedDoc(`${entry.name}/`)) {
|
||||
continue;
|
||||
}
|
||||
fs.rmSync(path.join(tempDir, entry.name), { recursive: true, force: true });
|
||||
|
||||
const docsJsonPath = path.join(tempDir, "docs.json");
|
||||
const docsConfig = JSON.parse(fs.readFileSync(docsJsonPath, "utf8"));
|
||||
const sanitized = sanitizeDocsConfigForEnglishOnly(docsConfig);
|
||||
fs.writeFileSync(docsJsonPath, `${JSON.stringify(sanitized, null, 2)}\n`, "utf8");
|
||||
|
||||
return tempDir;
|
||||
} catch (error) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const docsJsonPath = path.join(tempDir, "docs.json");
|
||||
const docsConfig = JSON.parse(fs.readFileSync(docsJsonPath, "utf8"));
|
||||
const sanitized = sanitizeDocsConfigForEnglishOnly(docsConfig);
|
||||
fs.writeFileSync(docsJsonPath, `${JSON.stringify(sanitized, null, 2)}\n`, "utf8");
|
||||
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/** @param {string} version */
|
||||
@@ -528,6 +547,11 @@ export function auditDocsLinks(options = {}) {
|
||||
* platform?: NodeJS.Platform;
|
||||
* spawnSyncImpl?: typeof spawnSync;
|
||||
* prepareAnchorAuditDocsDirImpl?: (sourceDir?: string) => string;
|
||||
* prepareMirroredDocsDirImpl?: (sourceDir?: string) => {
|
||||
* dir: string;
|
||||
* mirroredClawHub: boolean;
|
||||
* cleanup: () => void;
|
||||
* };
|
||||
* cleanupAnchorAuditDocsDirImpl?: (dir: string) => void;
|
||||
* }} [options]
|
||||
*/
|
||||
@@ -540,10 +564,12 @@ export function runDocsLinkAuditCli(options = {}) {
|
||||
const cleanupAnchorAuditDocsDirImpl =
|
||||
options.cleanupAnchorAuditDocsDirImpl ??
|
||||
((dir) => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const mirroredDocsDir = prepareMirroredDocsDir(DOCS_DIR);
|
||||
const anchorDocsDir = prepareAnchorAuditDocsDirImpl(mirroredDocsDir.dir);
|
||||
const prepareMirroredDocsDirImpl = options.prepareMirroredDocsDirImpl ?? prepareMirroredDocsDir;
|
||||
const mirroredDocsDir = prepareMirroredDocsDirImpl(DOCS_DIR);
|
||||
let anchorDocsDir;
|
||||
|
||||
try {
|
||||
anchorDocsDir = prepareAnchorAuditDocsDirImpl(mirroredDocsDir.dir);
|
||||
// Use the npm Mintlify package explicitly. Some developer machines also
|
||||
// have the Swift Package Manager tool named `mint` on PATH, and that
|
||||
// binary exits with "command 'broken-links' not found".
|
||||
@@ -565,7 +591,9 @@ export function runDocsLinkAuditCli(options = {}) {
|
||||
|
||||
return result.status ?? 1;
|
||||
} finally {
|
||||
cleanupAnchorAuditDocsDirImpl(anchorDocsDir);
|
||||
if (anchorDocsDir) {
|
||||
cleanupAnchorAuditDocsDirImpl(anchorDocsDir);
|
||||
}
|
||||
mirroredDocsDir.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ const DEFAULT_PORT = 19000 + Math.floor(Math.random() * 1000);
|
||||
const LOG_SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
const LOG_SCAN_MAX_LINE_CHARS = 16 * 1024;
|
||||
const LOG_TAIL_BYTES = 256 * 1024;
|
||||
const POSIX_PROCESS_SNAPSHOT_ARGS = ["-ww", "-axo", "pid=,ppid=,rss=,pcpu=,command="];
|
||||
const ERROR_LOG_DENY_PATTERNS = [
|
||||
/\buncaught exception\b/iu,
|
||||
/\bunhandled rejection\b/iu,
|
||||
@@ -1034,7 +1035,7 @@ async function samplePosixProcessWithDescendants(pid, run) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await run("ps", ["-axo", "pid=,ppid=,rss=,pcpu=,command="], {
|
||||
const { stdout } = await run("ps", POSIX_PROCESS_SNAPSHOT_ARGS, {
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
const rows = parsePosixProcessRows(stdout);
|
||||
@@ -1054,7 +1055,7 @@ async function samplePosixProcessTree(pid, run, commandLineNeedles) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await run("ps", ["-axo", "pid=,ppid=,rss=,pcpu=,command="], {
|
||||
const { stdout } = await run("ps", POSIX_PROCESS_SNAPSHOT_ARGS, {
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
const rows = parsePosixProcessRows(stdout);
|
||||
|
||||
@@ -15,6 +15,10 @@ const LOG_SCAN_BYTES = readPositiveInt(
|
||||
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_LOG_SCAN_BYTES,
|
||||
256 * 1024,
|
||||
);
|
||||
const GATEWAY_LOG_CAPTURE_BYTES = readPositiveInt(
|
||||
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_GATEWAY_LOG_BYTES,
|
||||
16 * 1024 * 1024,
|
||||
);
|
||||
const WATCHDOG_MS = readPositiveInt(process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_WATCHDOG_MS, 1000);
|
||||
const READY_TIMEOUT_MS = readPositiveInt(
|
||||
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS,
|
||||
@@ -33,14 +37,28 @@ const HTTP_PROBE_TIMEOUT_MS = readPositiveInt(
|
||||
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_HTTP_MS,
|
||||
5000,
|
||||
);
|
||||
const GATEWAY_TEARDOWN_GRACE_MS = readPositiveInt(
|
||||
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_GRACE_MS,
|
||||
10000,
|
||||
);
|
||||
const GATEWAY_TEARDOWN_KILL_GRACE_MS = readPositiveInt(
|
||||
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_KILL_GRACE_MS,
|
||||
1000,
|
||||
);
|
||||
const GATEWAY_READY_LOG_NEEDLE = Buffer.from("[gateway] ready");
|
||||
const READY_OFFSET_LOG_NEEDLES = [
|
||||
GATEWAY_READY_LOG_NEEDLE,
|
||||
Buffer.from("listening on ws://"),
|
||||
Buffer.from("[gateway] http server listening"),
|
||||
];
|
||||
const GATEWAY_LOG_TRUNCATED_NEEDLE = "[gateway log truncated after ";
|
||||
const FORBIDDEN_POST_READY_DEPS_WORK = [/\b(?:npm|pnpm|yarn|corepack) install\b/iu];
|
||||
const PACKAGE_MANAGER_PROCESS_BASENAME = /^(?:npm|pnpm|yarn|corepack)(?:$|[.-])/u;
|
||||
const PROCESS_SNAPSHOT_ARGS = ["-ww", "-eo", "pid=,ppid=,args="];
|
||||
const isolatedStateRoots = new WeakMap();
|
||||
const activeGatewayChildren = new Set();
|
||||
const parentSignalHandlers = new Map();
|
||||
let gatewayExitCleanupInstalled = false;
|
||||
|
||||
function readPositiveInt(raw, fallback) {
|
||||
const text = String(raw ?? "").trim();
|
||||
@@ -241,7 +259,7 @@ export function activateSmokePlugin(config, pluginId, channels = []) {
|
||||
const allow = Array.isArray(config.plugins?.allow)
|
||||
? Array.from(new Set([...config.plugins.allow, pluginId].filter(isNonEmptyString)))
|
||||
: undefined;
|
||||
const channelConfig = { ...(config.channels ?? {}) };
|
||||
const channelConfig = { ...config.channels };
|
||||
for (const channel of channels) {
|
||||
channelConfig[channel] = {
|
||||
...(typeof channelConfig[channel] === "object" && channelConfig[channel] !== null
|
||||
@@ -268,6 +286,28 @@ export function activateSmokePlugin(config, pluginId, channels = []) {
|
||||
};
|
||||
}
|
||||
|
||||
function channelActivationEnvName(channel) {
|
||||
return `${channel
|
||||
.replace(/[^a-z0-9]+/giu, "_")
|
||||
.replace(/^_+|_+$/gu, "")
|
||||
.toUpperCase()}_RUNTIME_SMOKE`;
|
||||
}
|
||||
|
||||
export function withManifestChannelActivationEnv(env, channels = []) {
|
||||
const nextEnv = { ...env };
|
||||
for (const channel of channels) {
|
||||
if (!isNonEmptyString(channel)) {
|
||||
continue;
|
||||
}
|
||||
const key = channelActivationEnvName(channel);
|
||||
if (key === "_RUNTIME_SMOKE") {
|
||||
continue;
|
||||
}
|
||||
nextEnv[key] ??= "1";
|
||||
}
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
function buildPluginPlan(manifest) {
|
||||
const contracts =
|
||||
manifest.contracts && typeof manifest.contracts === "object" ? manifest.contracts : {};
|
||||
@@ -319,6 +359,44 @@ function formatCapturedOutput(label, buffer) {
|
||||
return `${prefix}${buffer.text}`;
|
||||
}
|
||||
|
||||
function createBoundedGatewayLog(logPath) {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
const fd = fs.openSync(logPath, "w");
|
||||
let bytes = 0;
|
||||
let closed = false;
|
||||
let truncated = false;
|
||||
const marker = Buffer.from(
|
||||
`\n[gateway log truncated after ${String(GATEWAY_LOG_CAPTURE_BYTES)} bytes]\n`,
|
||||
);
|
||||
return {
|
||||
append(chunk) {
|
||||
if (closed || truncated) {
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
const remaining = GATEWAY_LOG_CAPTURE_BYTES - bytes;
|
||||
if (buffer.length <= remaining) {
|
||||
fs.writeSync(fd, buffer);
|
||||
bytes += buffer.length;
|
||||
return;
|
||||
}
|
||||
if (remaining > 0) {
|
||||
fs.writeSync(fd, buffer.subarray(0, remaining));
|
||||
}
|
||||
fs.writeSync(fd, marker);
|
||||
bytes = GATEWAY_LOG_CAPTURE_BYTES;
|
||||
truncated = true;
|
||||
},
|
||||
close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
fs.closeSync(fd);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function runCommand(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { timeoutMs = COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
|
||||
@@ -384,8 +462,8 @@ export function runCommand(command, args, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function startGateway(params) {
|
||||
const log = fs.openSync(params.logPath, "w");
|
||||
export function startGateway(params) {
|
||||
const log = createBoundedGatewayLog(params.logPath);
|
||||
const child = childProcess.spawn(
|
||||
"node",
|
||||
[
|
||||
@@ -405,11 +483,15 @@ function startGateway(params) {
|
||||
OPENCLAW_SKIP_CHANNELS: params.skipChannels ? "1" : "0",
|
||||
OPENCLAW_SKIP_PROVIDERS: "0",
|
||||
},
|
||||
stdio: ["ignore", log, log],
|
||||
detached: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
},
|
||||
);
|
||||
fs.closeSync(log);
|
||||
child.stdout?.on("data", (chunk) => log.append(chunk));
|
||||
child.stderr?.on("data", (chunk) => log.append(chunk));
|
||||
child.once("error", () => log.close());
|
||||
child.once("close", () => log.close());
|
||||
trackGatewayChild(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
@@ -417,17 +499,109 @@ export function hasChildExited(child) {
|
||||
return child.exitCode !== null || (child.signalCode ?? null) !== null;
|
||||
}
|
||||
|
||||
function trackGatewayChild(child) {
|
||||
activeGatewayChildren.add(child);
|
||||
const untrack = () => {
|
||||
if (!processTreeIsAlive(child)) {
|
||||
activeGatewayChildren.delete(child);
|
||||
}
|
||||
};
|
||||
child.once("error", untrack);
|
||||
child.once("close", untrack);
|
||||
installGatewayParentCleanup();
|
||||
}
|
||||
|
||||
function installGatewayParentCleanup() {
|
||||
if (!gatewayExitCleanupInstalled) {
|
||||
gatewayExitCleanupInstalled = true;
|
||||
process.once("exit", () => {
|
||||
cleanupActiveGatewayChildren("SIGTERM");
|
||||
});
|
||||
}
|
||||
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
||||
if (parentSignalHandlers.has(signal)) {
|
||||
continue;
|
||||
}
|
||||
const handler = () => {
|
||||
cleanupActiveGatewayChildren(signal);
|
||||
for (const [registeredSignal, registeredHandler] of parentSignalHandlers) {
|
||||
process.off(registeredSignal, registeredHandler);
|
||||
}
|
||||
parentSignalHandlers.clear();
|
||||
process.kill(process.pid, signal);
|
||||
};
|
||||
parentSignalHandlers.set(signal, handler);
|
||||
process.once(signal, handler);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupActiveGatewayChildren(signal) {
|
||||
for (const child of activeGatewayChildren) {
|
||||
signalGateway(child, signal);
|
||||
if (process.platform !== "win32") {
|
||||
signalGateway(child, "SIGKILL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopGateway(child) {
|
||||
if (!child || hasChildExited(child)) {
|
||||
if (!child || !processTreeIsAlive(child)) {
|
||||
return;
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
const started = Date.now();
|
||||
while (!hasChildExited(child) && Date.now() - started < 10000) {
|
||||
await delay(100);
|
||||
const waitForExit = async (ms) => {
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
if (!processTreeIsAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
return !processTreeIsAlive(child);
|
||||
};
|
||||
|
||||
signalGateway(child, "SIGTERM");
|
||||
if (await waitForExit(GATEWAY_TEARDOWN_GRACE_MS)) {
|
||||
return;
|
||||
}
|
||||
if (!hasChildExited(child)) {
|
||||
child.kill("SIGKILL");
|
||||
signalGateway(child, "SIGKILL");
|
||||
await waitForExit(GATEWAY_TEARDOWN_KILL_GRACE_MS);
|
||||
}
|
||||
|
||||
function processTreeIsAlive(child) {
|
||||
if (!child || typeof child.pid !== "number") {
|
||||
return !hasChildExited(child);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return !hasChildExited(child);
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function signalGateway(child, signal) {
|
||||
if (process.platform !== "win32" && typeof child.pid === "number") {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error?.code === "ESRCH") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +833,7 @@ async function smokePlugin(pluginId, pluginDir, requiresConfig, pluginIndex, plu
|
||||
activateSmokePlugin(readConfig(), pluginId, plan.channels),
|
||||
port,
|
||||
);
|
||||
const env = withManifestChannelActivationEnv(process.env, plan.channels);
|
||||
if (plan.speechProviders[0]) {
|
||||
const provider = plan.speechProviders[0];
|
||||
config.messages = {
|
||||
@@ -682,7 +857,7 @@ async function smokePlugin(pluginId, pluginDir, requiresConfig, pluginIndex, plu
|
||||
entrypoint,
|
||||
port,
|
||||
logPath,
|
||||
env: process.env,
|
||||
env,
|
||||
skipChannels: plan.channels.length === 0,
|
||||
});
|
||||
try {
|
||||
@@ -690,12 +865,12 @@ async function smokePlugin(pluginId, pluginDir, requiresConfig, pluginIndex, plu
|
||||
await assertBaseGatewayProbes({
|
||||
entrypoint,
|
||||
port,
|
||||
env: process.env,
|
||||
env,
|
||||
pluginId,
|
||||
allowDegradedReadyz: plan.channels.length > 0,
|
||||
});
|
||||
await runManifestProbes(plan, { entrypoint, port, env: process.env, pluginId });
|
||||
await runWatchdog({ child, logPath, port, entrypoint, env: process.env, pluginId });
|
||||
await runManifestProbes(plan, { entrypoint, port, env, pluginId });
|
||||
await runWatchdog({ child, logPath, port, entrypoint, env, pluginId });
|
||||
console.log(`Runtime smoke passed for ${pluginId}`);
|
||||
} catch (error) {
|
||||
console.error(tailFile(logPath));
|
||||
@@ -842,6 +1017,7 @@ async function runWatchdog(options) {
|
||||
);
|
||||
}
|
||||
await retryRpcCall("health", {}, options);
|
||||
assertGatewayLogNotTruncated(options.logPath);
|
||||
assertNoPostReadyRuntimeDepsWork(options.logPath, readyOffset);
|
||||
await assertNoPackageManagerChildren(options.child.pid);
|
||||
}
|
||||
@@ -850,6 +1026,16 @@ export function findReadyLogOffset(logPath) {
|
||||
return findFirstNeedleOffset(logPath, READY_OFFSET_LOG_NEEDLES);
|
||||
}
|
||||
|
||||
export function assertGatewayLogNotTruncated(logPath) {
|
||||
if (readFileTail(logPath).includes(GATEWAY_LOG_TRUNCATED_NEEDLE)) {
|
||||
throw new Error(
|
||||
`gateway log exceeded ${String(
|
||||
GATEWAY_LOG_CAPTURE_BYTES,
|
||||
)} bytes; runtime smoke cannot validate complete post-ready output`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNoPostReadyRuntimeDepsWork(logPath, readyOffset) {
|
||||
let stat;
|
||||
try {
|
||||
@@ -878,28 +1064,81 @@ export function assertNoPostReadyRuntimeDepsWork(logPath, readyOffset) {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNoPackageManagerChildren(pid) {
|
||||
function commandIncludesPackageManager(args) {
|
||||
return String(args ?? "")
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.some((token) =>
|
||||
PACKAGE_MANAGER_PROCESS_BASENAME.test(
|
||||
path.basename(token.replace(/^['"]|['"]$/gu, "")).toLowerCase(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parseProcessSnapshot(stdout) {
|
||||
const processes = [];
|
||||
for (const line of String(stdout ?? "").split("\n")) {
|
||||
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/u.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
processes.push({
|
||||
args: match[3],
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
});
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
export function findPackageManagerDescendants(psOutput, rootPid) {
|
||||
const root = Number(rootPid);
|
||||
if (!Number.isInteger(root) || root <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const childrenByParent = new Map();
|
||||
for (const processInfo of parseProcessSnapshot(psOutput)) {
|
||||
const list = childrenByParent.get(processInfo.ppid) ?? [];
|
||||
list.push(processInfo);
|
||||
childrenByParent.set(processInfo.ppid, list);
|
||||
}
|
||||
|
||||
const matches = [];
|
||||
const pending = [...(childrenByParent.get(root) ?? [])];
|
||||
const seen = new Set();
|
||||
while (pending.length > 0) {
|
||||
const current = pending.shift();
|
||||
if (!current || seen.has(current.pid)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current.pid);
|
||||
if (commandIncludesPackageManager(current.args)) {
|
||||
matches.push(current);
|
||||
}
|
||||
pending.push(...(childrenByParent.get(current.pid) ?? []));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export async function assertNoPackageManagerChildren(pid) {
|
||||
if (!pid || process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await runCommand("pgrep", [
|
||||
"-P",
|
||||
String(pid),
|
||||
"-af",
|
||||
"npm|pnpm|yarn|corepack",
|
||||
]);
|
||||
if (stdout.trim()) {
|
||||
const { stdout } = await runCommand("ps", PROCESS_SNAPSHOT_ARGS);
|
||||
const packageManagerDescendants = findPackageManagerDescendants(stdout, pid);
|
||||
if (packageManagerDescendants.length > 0) {
|
||||
const formatted = packageManagerDescendants
|
||||
.map((entry) => `${entry.pid} ${entry.args}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`package manager child process still running under gateway ${pid}:\n${stdout}`,
|
||||
`package manager descendant process still running under gateway ${pid}:\n${formatted}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
console.log("Runtime deps child-process watchdog skipped: pgrep unavailable");
|
||||
return;
|
||||
}
|
||||
if (error instanceof Error && error.message.includes("failed with 1")) {
|
||||
console.log("Runtime deps child-process watchdog skipped: ps unavailable");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { spawn } from "@lydell/node-pty";
|
||||
import { readPositiveIntEnv } from "./env-limits.mjs";
|
||||
|
||||
const [logPath, command, ...args] = process.argv.slice(2);
|
||||
const OUTPUT_MAX_BYTES = readPositiveIntEnv("OPENCLAW_E2E_PTY_OUTPUT_MAX_BYTES", 16 * 1024 * 1024);
|
||||
const FORCE_KILL_MS = readPositiveIntEnv("OPENCLAW_E2E_PTY_FORCE_KILL_MS", 5_000);
|
||||
|
||||
if (!logPath || !command) {
|
||||
console.error("usage: run-with-pty.mjs <log-path> <command> [args...]");
|
||||
@@ -21,15 +23,48 @@ const pty = spawn(command, args, {
|
||||
});
|
||||
|
||||
let exiting = false;
|
||||
let forwardedSignal = null;
|
||||
let forceKillTimer = null;
|
||||
const outputLimitMarker = `\n[run-with-pty output truncated after ${OUTPUT_MAX_BYTES} bytes]\n`;
|
||||
const outputState = {
|
||||
bytes: 0,
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
function writeCappedOutput(data) {
|
||||
if (outputState.truncated) {
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.from(data);
|
||||
const remainingBytes = OUTPUT_MAX_BYTES - outputState.bytes;
|
||||
if (buffer.byteLength <= remainingBytes) {
|
||||
outputState.bytes += buffer.byteLength;
|
||||
log.write(buffer);
|
||||
process.stdout.write(buffer);
|
||||
return;
|
||||
}
|
||||
if (remainingBytes > 0) {
|
||||
const head = buffer.subarray(0, remainingBytes);
|
||||
log.write(head);
|
||||
process.stdout.write(head);
|
||||
}
|
||||
outputState.bytes = OUTPUT_MAX_BYTES;
|
||||
outputState.truncated = true;
|
||||
log.write(outputLimitMarker);
|
||||
process.stdout.write(outputLimitMarker);
|
||||
}
|
||||
|
||||
pty.onData((data) => {
|
||||
log.write(data);
|
||||
process.stdout.write(data);
|
||||
writeCappedOutput(data);
|
||||
});
|
||||
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
exiting = true;
|
||||
clearTimeout(forceKillTimer);
|
||||
log.end(() => {
|
||||
if (forwardedSignal) {
|
||||
process.exit(signalExitCode(forwardedSignal));
|
||||
}
|
||||
if (typeof exitCode === "number") {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
@@ -41,10 +76,28 @@ process.stdin.on("data", (chunk) => {
|
||||
pty.write(chunk.toString("utf8"));
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
if (!exiting) {
|
||||
forwardedSignal ??= signal;
|
||||
pty.kill(signal);
|
||||
forceKillTimer ??= setTimeout(() => {
|
||||
pty.kill("SIGKILL");
|
||||
}, FORCE_KILL_MS);
|
||||
forceKillTimer.unref?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function signalExitCode(signal) {
|
||||
switch (signal) {
|
||||
case "SIGHUP":
|
||||
return 129;
|
||||
case "SIGINT":
|
||||
return 130;
|
||||
case "SIGTERM":
|
||||
return 143;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { buildCmdExeCommandLine } from "../../../windows-cmd-helpers.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args.shift();
|
||||
export const CONFIG_COMMAND_TIMEOUT_MS = 120_000;
|
||||
export const CONFIG_COMMAND_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
function option(name, fallback) {
|
||||
const index = args.indexOf(name);
|
||||
@@ -217,21 +219,34 @@ export function resolveUpgradeSurvivorOpenClawCommand(argv, params = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function runOpenClaw(step) {
|
||||
function errorCode(error) {
|
||||
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
||||
}
|
||||
|
||||
export function runUpgradeSurvivorOpenClawStep(step, params = {}) {
|
||||
const invocation = resolveUpgradeSurvivorOpenClawCommand(step.argv);
|
||||
const result = spawnSync(invocation.command, invocation.args, {
|
||||
const run = params.spawnSyncCommand ?? spawnSync;
|
||||
const timeoutMs = params.timeoutMs ?? CONFIG_COMMAND_TIMEOUT_MS;
|
||||
const maxBuffer = params.maxBufferBytes ?? CONFIG_COMMAND_MAX_BUFFER_BYTES;
|
||||
const result = run(invocation.command, invocation.args, {
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
killSignal: "SIGTERM",
|
||||
maxBuffer,
|
||||
shell: invocation.shell,
|
||||
timeout: timeoutMs,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
});
|
||||
const code = errorCode(result.error);
|
||||
return {
|
||||
id: step.id,
|
||||
intent: step.intent,
|
||||
command: invocation.commandLabel,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
ok: result.status === 0,
|
||||
ok: result.status === 0 && !result.error,
|
||||
errorCode: code,
|
||||
errorMessage: result.error?.message ? tail(result.error.message) : undefined,
|
||||
stdout: tail(result.stdout),
|
||||
stderr: tail(result.stderr),
|
||||
};
|
||||
@@ -268,11 +283,12 @@ function applyRecipe() {
|
||||
if (!adaptedStep) {
|
||||
continue;
|
||||
}
|
||||
const outcome = runOpenClaw(adaptedStep);
|
||||
const outcome = runUpgradeSurvivorOpenClawStep(adaptedStep);
|
||||
summary.steps.push(outcome);
|
||||
writeJson(summaryPath, summary);
|
||||
if (!outcome.ok) {
|
||||
throw new Error(`baseline config recipe failed at ${step.id}`);
|
||||
const detail = outcome.errorCode ?? outcome.signal ?? outcome.status ?? "unknown";
|
||||
throw new Error(`baseline config recipe failed at ${step.id}: ${detail}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
maybeApprovePendingBridgePairing,
|
||||
waitFor,
|
||||
} from "./mcp-channels-harness.ts";
|
||||
import { createMcpClientTempState } from "./mcp-client-temp-state.ts";
|
||||
|
||||
function summarizeSessionRows(rows: Array<Record<string, unknown>> | undefined) {
|
||||
return (rows ?? []).map((entry) => ({
|
||||
@@ -92,6 +93,7 @@ async function main() {
|
||||
|
||||
const gateway = await connectGateway({ url: gatewayUrl, token: gatewayToken });
|
||||
let mcpHandle: Awaited<ReturnType<typeof connectMcpClient>> | undefined;
|
||||
const mcpTempState = createMcpClientTempState({ gatewayToken });
|
||||
|
||||
try {
|
||||
const gatewayConversation = await waitForGatewaySeededConversation(gateway);
|
||||
@@ -108,14 +110,17 @@ async function main() {
|
||||
mcpHandle = await connectMcpClient({
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
tempState: mcpTempState,
|
||||
});
|
||||
let mcp = mcpHandle.client;
|
||||
|
||||
if (await maybeApprovePendingBridgePairing(gateway)) {
|
||||
await Promise.allSettled([mcp.close(), mcpHandle.transport.close()]);
|
||||
mcpHandle.cleanup();
|
||||
mcpHandle = await connectMcpClient({
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
tempState: mcpTempState,
|
||||
});
|
||||
mcp = mcpHandle.client;
|
||||
}
|
||||
@@ -397,10 +402,13 @@ async function main() {
|
||||
) + "\n",
|
||||
);
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
...(mcpHandle ? [mcpHandle.client.close(), mcpHandle.transport.close()] : []),
|
||||
gateway.close(),
|
||||
]);
|
||||
const closeTasks: Array<Promise<unknown>> = [gateway.close()];
|
||||
if (mcpHandle) {
|
||||
closeTasks.push(mcpHandle.client.close(), mcpHandle.transport.close());
|
||||
}
|
||||
await Promise.allSettled(closeTasks);
|
||||
mcpHandle?.cleanup();
|
||||
mcpTempState.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// The mounted test harness imports packaged dist modules so bridge assertions run
|
||||
// against the OpenClaw npm tarball installed in the functional image.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import process from "node:process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
@@ -14,6 +13,7 @@ import { formatErrorMessage } from "../../dist/infra/errors.js";
|
||||
import { rawDataToString } from "../../dist/infra/ws.js";
|
||||
import { readStringValue } from "../../dist/normalization-core/string-coerce.js";
|
||||
import { readMcpChannelLimits } from "./mcp-channel-limits.ts";
|
||||
import { createMcpClientTempState, type McpClientTempState } from "./mcp-client-temp-state.ts";
|
||||
import { connectMcpWithTimeout } from "./mcp-connect-timeout.ts";
|
||||
import { waitForWebSocketOpen } from "./mcp-websocket-open.ts";
|
||||
|
||||
@@ -43,6 +43,7 @@ export type GatewayRpcClient = {
|
||||
|
||||
export type McpClientHandle = {
|
||||
client: Client;
|
||||
cleanup(): void;
|
||||
transport: StdioClientTransport;
|
||||
rawMessages: unknown[];
|
||||
};
|
||||
@@ -310,11 +311,11 @@ function isRetryableGatewayConnectError(error: Error): boolean {
|
||||
export async function connectMcpClient(params: {
|
||||
gatewayUrl: string;
|
||||
gatewayToken: string;
|
||||
tempState?: McpClientTempState;
|
||||
}): Promise<McpClientHandle> {
|
||||
const tokenDir = "/tmp/openclaw-mcp-client";
|
||||
const tokenFile = `${tokenDir}/gateway.token`;
|
||||
mkdirSync(tokenDir, { recursive: true });
|
||||
writeFileSync(tokenFile, `${params.gatewayToken}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
const ownsTempState = !params.tempState;
|
||||
const tempState =
|
||||
params.tempState ?? createMcpClientTempState({ gatewayToken: params.gatewayToken });
|
||||
const transport = new StdioClientTransport({
|
||||
command: "node",
|
||||
args: [
|
||||
@@ -324,7 +325,7 @@ export async function connectMcpClient(params: {
|
||||
"--url",
|
||||
params.gatewayUrl,
|
||||
"--token-file",
|
||||
tokenFile,
|
||||
tempState.tokenFile,
|
||||
"--claude-channel-mode",
|
||||
"on",
|
||||
],
|
||||
@@ -332,7 +333,7 @@ export async function connectMcpClient(params: {
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1",
|
||||
OPENCLAW_STATE_DIR: "/tmp/openclaw-mcp-client",
|
||||
OPENCLAW_STATE_DIR: tempState.stateDir,
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
@@ -345,8 +346,21 @@ export async function connectMcpClient(params: {
|
||||
});
|
||||
|
||||
const client = new Client({ name: "docker-mcp-channels", version: "1.0.0" });
|
||||
await connectMcpWithTimeout(client, transport, MCP_CONNECT_TIMEOUT_MS);
|
||||
return { client, transport, rawMessages };
|
||||
try {
|
||||
await connectMcpWithTimeout(client, transport, MCP_CONNECT_TIMEOUT_MS);
|
||||
return {
|
||||
client,
|
||||
cleanup: ownsTempState ? tempState.cleanup : () => {},
|
||||
transport,
|
||||
rawMessages,
|
||||
};
|
||||
} catch (error) {
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
if (ownsTempState) {
|
||||
tempState.cleanup();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function maybeApprovePendingBridgePairing(
|
||||
|
||||
29
scripts/e2e/mcp-client-temp-state.ts
Normal file
29
scripts/e2e/mcp-client-temp-state.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type McpClientTempState = {
|
||||
cleanup: () => void;
|
||||
root: string;
|
||||
stateDir: string;
|
||||
tokenFile: string;
|
||||
};
|
||||
|
||||
export function createMcpClientTempState(params: {
|
||||
gatewayToken: string;
|
||||
tempRoot?: string;
|
||||
}): McpClientTempState {
|
||||
const root = mkdtempSync(path.join(params.tempRoot ?? tmpdir(), "openclaw-mcp-client-"));
|
||||
const stateDir = path.join(root, "state");
|
||||
const tokenFile = path.join(root, "gateway.token");
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(tokenFile, `${params.gatewayToken}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
return {
|
||||
cleanup: () => {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
},
|
||||
root,
|
||||
stateDir,
|
||||
tokenFile,
|
||||
};
|
||||
}
|
||||
@@ -12,8 +12,12 @@ export interface GuestExecOptions {
|
||||
export interface WindowsBackgroundPowerShellOptions {
|
||||
append?: (chunk: string | Uint8Array) => void;
|
||||
beforeLaunchAttempt?: () => void;
|
||||
completedLogDrainGraceMs?: number;
|
||||
label: string;
|
||||
logChunkBytes?: number;
|
||||
onLaunchRetry?: (message: string) => void;
|
||||
pollIntervalMs?: number;
|
||||
runCommand?: typeof run;
|
||||
script: string;
|
||||
timeoutMs: number;
|
||||
vmName: string;
|
||||
@@ -52,6 +56,13 @@ export async function runWindowsBackgroundPowerShell(
|
||||
options: WindowsBackgroundPowerShellOptions,
|
||||
): Promise<void> {
|
||||
const append = options.append;
|
||||
const completedLogDrainGraceMs = Math.max(
|
||||
1,
|
||||
Math.floor(options.completedLogDrainGraceMs ?? 30_000),
|
||||
);
|
||||
const logChunkBytes = Math.max(1, Math.floor(options.logChunkBytes ?? 1024 * 1024));
|
||||
const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 5_000));
|
||||
const runCommand = options.runCommand ?? run;
|
||||
const safeLabel = options.label.replaceAll(/[^A-Za-z0-9_-]/g, "-");
|
||||
const nonce = `${safeLabel}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
const fileBase = `openclaw-parallels-${nonce}`;
|
||||
@@ -59,7 +70,8 @@ export async function runWindowsBackgroundPowerShell(
|
||||
$scriptPath = "$base.ps1"
|
||||
$logPath = "$base.log"
|
||||
$donePath = "$base.done"
|
||||
$exitPath = "$base.exit"`;
|
||||
$exitPath = "$base.exit"
|
||||
$pidPath = "$base.pid"`;
|
||||
const payload = `$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
${pathsScript}
|
||||
@@ -74,7 +86,7 @@ ${options.script}
|
||||
} finally {
|
||||
Set-Content -Path $donePath -Value 'done' -Encoding UTF8
|
||||
}`;
|
||||
const writeScript = run(
|
||||
const writeScript = runCommand(
|
||||
"prlctl",
|
||||
[
|
||||
"exec",
|
||||
@@ -86,7 +98,7 @@ ${options.script}
|
||||
"Bypass",
|
||||
"-EncodedCommand",
|
||||
encodePowerShell(`${pathsScript}
|
||||
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue
|
||||
[System.IO.File]::WriteAllText($scriptPath, [Console]::In.ReadToEnd(), [System.Text.UTF8Encoding]::new($false))
|
||||
if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not written" }`),
|
||||
],
|
||||
@@ -99,81 +111,102 @@ if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not wr
|
||||
);
|
||||
}
|
||||
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
let launched = false;
|
||||
let lastLaunchStatus = 0;
|
||||
for (let attempt = 1; attempt <= 5 && Date.now() < deadline; attempt++) {
|
||||
options.beforeLaunchAttempt?.();
|
||||
const launch = run(
|
||||
"prlctl",
|
||||
[
|
||||
"exec",
|
||||
options.vmName,
|
||||
"--current-user",
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-EncodedCommand",
|
||||
encodePowerShell(`${pathsScript}
|
||||
Start-Process -FilePath powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath)
|
||||
let doneSeen = false;
|
||||
try {
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
let launched = false;
|
||||
let lastLaunchStatus = 0;
|
||||
for (let attempt = 1; attempt <= 5 && Date.now() < deadline; attempt++) {
|
||||
options.beforeLaunchAttempt?.();
|
||||
const launch = runCommand(
|
||||
"prlctl",
|
||||
[
|
||||
"exec",
|
||||
options.vmName,
|
||||
"--current-user",
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-EncodedCommand",
|
||||
encodePowerShell(`${pathsScript}
|
||||
$process = Start-Process -FilePath powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath) -PassThru
|
||||
Set-Content -Path $pidPath -Value $process.Id -Encoding UTF8
|
||||
'started'`),
|
||||
],
|
||||
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
|
||||
);
|
||||
appendOutput(append, launch);
|
||||
if (launch.status === 0 && launch.stdout.includes("started")) {
|
||||
launched = true;
|
||||
break;
|
||||
}
|
||||
lastLaunchStatus = launch.status;
|
||||
if (launch.status === 0 || launch.status === 124) {
|
||||
const materialized = waitForWindowsBackgroundMaterialized({
|
||||
append,
|
||||
deadline,
|
||||
pathsScript,
|
||||
vmName: options.vmName,
|
||||
});
|
||||
if (materialized) {
|
||||
],
|
||||
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
|
||||
);
|
||||
appendOutput(append, launch);
|
||||
if (launch.status === 0 && launch.stdout.includes("started")) {
|
||||
launched = true;
|
||||
break;
|
||||
}
|
||||
options.onLaunchRetry?.(
|
||||
`${options.label} launch retry ${attempt}: background log/done file did not materialize`,
|
||||
lastLaunchStatus = launch.status;
|
||||
if (launch.status === 0 || launch.status === 124) {
|
||||
const materialized = waitForWindowsBackgroundMaterialized({
|
||||
append,
|
||||
deadline,
|
||||
pathsScript,
|
||||
runCommand,
|
||||
vmName: options.vmName,
|
||||
});
|
||||
if (materialized) {
|
||||
launched = true;
|
||||
break;
|
||||
}
|
||||
options.onLaunchRetry?.(
|
||||
`${options.label} launch retry ${attempt}: background log/done file did not materialize`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (launch.stdout.includes("restoring") || launch.stderr.includes("restoring")) {
|
||||
options.onLaunchRetry?.(`${options.label} launch retry ${attempt}: VM is still restoring`);
|
||||
await sleep(5_000);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`${options.label} background launch failed with exit code ${launch.status}`);
|
||||
}
|
||||
if (!launched) {
|
||||
throw new Error(
|
||||
`${options.label} background launch failed with exit code ${lastLaunchStatus}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (launch.stdout.includes("restoring") || launch.stderr.includes("restoring")) {
|
||||
options.onLaunchRetry?.(`${options.label} launch retry ${attempt}: VM is still restoring`);
|
||||
await sleep(5_000);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`${options.label} background launch failed with exit code ${launch.status}`);
|
||||
}
|
||||
if (!launched) {
|
||||
throw new Error(`${options.label} background launch failed with exit code ${lastLaunchStatus}`);
|
||||
}
|
||||
|
||||
let lastLogOffset = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const poll = run(
|
||||
"prlctl",
|
||||
[
|
||||
"exec",
|
||||
options.vmName,
|
||||
"--current-user",
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-EncodedCommand",
|
||||
encodePowerShell(`${pathsScript}
|
||||
let lastLogOffset = 0;
|
||||
let completedLogDrainDeadline = 0;
|
||||
const activeDeadline = () => (doneSeen ? completedLogDrainDeadline : deadline);
|
||||
while (Date.now() < activeDeadline()) {
|
||||
const poll = runCommand(
|
||||
"prlctl",
|
||||
[
|
||||
"exec",
|
||||
options.vmName,
|
||||
"--current-user",
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-EncodedCommand",
|
||||
encodePowerShell(`${pathsScript}
|
||||
$offset = ${lastLogOffset}
|
||||
if (Test-Path $logPath) {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($logPath)
|
||||
if ($bytes.Length -gt $offset) {
|
||||
"__OPENCLAW_LOG_OFFSET__:$($bytes.Length)"
|
||||
[System.Text.Encoding]::UTF8.GetString($bytes, $offset, $bytes.Length - $offset)
|
||||
$stream = [System.IO.File]::Open($logPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||
try {
|
||||
$length = $stream.Length
|
||||
"__OPENCLAW_LOG_LENGTH__:$length"
|
||||
if ($length -gt $offset) {
|
||||
[void]$stream.Seek($offset, [System.IO.SeekOrigin]::Begin)
|
||||
$count = [int][Math]::Min($length - $offset, ${logChunkBytes})
|
||||
$buffer = New-Object byte[] $count
|
||||
$read = $stream.Read($buffer, 0, $count)
|
||||
if ($read -gt 0) {
|
||||
$nextOffset = $offset + $read
|
||||
"__OPENCLAW_LOG_OFFSET__:$nextOffset"
|
||||
[System.Text.Encoding]::UTF8.GetString($buffer, 0, $read)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
if (Test-Path $donePath) {
|
||||
@@ -183,37 +216,53 @@ if (Test-Path $donePath) {
|
||||
if ($backgroundExit -ne '0') { exit 23 }
|
||||
exit 0
|
||||
}`),
|
||||
],
|
||||
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
|
||||
);
|
||||
appendOutput(append, poll);
|
||||
const offsetMatch = poll.stdout.match(/__OPENCLAW_LOG_OFFSET__:(\d+)/);
|
||||
if (offsetMatch) {
|
||||
lastLogOffset = Number(offsetMatch[1]);
|
||||
}
|
||||
if (poll.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) {
|
||||
const exitMatch = poll.stdout.match(/__OPENCLAW_BACKGROUND_EXIT__:(\S+)/);
|
||||
const backgroundExit = exitMatch?.[1] ?? "0";
|
||||
if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) {
|
||||
throw new Error(`${options.label} failed`);
|
||||
],
|
||||
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
|
||||
);
|
||||
appendOutput(append, poll);
|
||||
const offsetMatch = poll.stdout.match(/__OPENCLAW_LOG_OFFSET__:(\d+)/);
|
||||
if (offsetMatch) {
|
||||
lastLogOffset = Number(offsetMatch[1]);
|
||||
}
|
||||
cleanupWindowsBackground(options.vmName, pathsScript);
|
||||
return;
|
||||
const lengthMatch = poll.stdout.match(/__OPENCLAW_LOG_LENGTH__:(\d+)/);
|
||||
const logLength = lengthMatch ? Number(lengthMatch[1]) : lastLogOffset;
|
||||
if (poll.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) {
|
||||
doneSeen = true;
|
||||
completedLogDrainDeadline ||= Date.now() + completedLogDrainGraceMs;
|
||||
if (lastLogOffset < logLength) {
|
||||
await sleep(Math.min(pollIntervalMs, 100));
|
||||
continue;
|
||||
}
|
||||
const exitMatch = poll.stdout.match(/__OPENCLAW_BACKGROUND_EXIT__:(\S+)/);
|
||||
const backgroundExit = exitMatch?.[1] ?? "0";
|
||||
if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) {
|
||||
throw new Error(`${options.label} failed`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
await sleep(5_000);
|
||||
if (doneSeen) {
|
||||
throw new Error(`${options.label} completed but log drain timed out`);
|
||||
}
|
||||
throw new Error(`${options.label} timed out`);
|
||||
} finally {
|
||||
cleanupWindowsBackground(options.vmName, pathsScript, runCommand, {
|
||||
stopProcessTree: !doneSeen,
|
||||
});
|
||||
}
|
||||
throw new Error(`${options.label} timed out`);
|
||||
}
|
||||
|
||||
function waitForWindowsBackgroundMaterialized(params: {
|
||||
append?: (chunk: string | Uint8Array) => void;
|
||||
deadline: number;
|
||||
pathsScript: string;
|
||||
runCommand: typeof run;
|
||||
vmName: string;
|
||||
}): boolean {
|
||||
const materializeDeadline = Math.min(Date.now() + 45_000, params.deadline);
|
||||
while (Date.now() < materializeDeadline) {
|
||||
const result = run(
|
||||
const result = params.runCommand(
|
||||
"prlctl",
|
||||
[
|
||||
"exec",
|
||||
@@ -239,8 +288,28 @@ if ((Test-Path $logPath) -or (Test-Path $donePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function cleanupWindowsBackground(vmName: string, pathsScript: string): void {
|
||||
run(
|
||||
function cleanupWindowsBackground(
|
||||
vmName: string,
|
||||
pathsScript: string,
|
||||
runCommand: typeof run,
|
||||
options: { stopProcessTree: boolean },
|
||||
): void {
|
||||
const stopProcessTree = options.stopProcessTree
|
||||
? `function Stop-OpenClawBackgroundProcessTree([int]$ProcessId) {
|
||||
Get-CimInstance Win32_Process -Filter "ParentProcessId=$ProcessId" -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
Stop-OpenClawBackgroundProcessTree ([int]$_.ProcessId)
|
||||
}
|
||||
Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if (Test-Path $pidPath) {
|
||||
$backgroundPid = (Get-Content -Path $pidPath -Raw).Trim()
|
||||
if ($backgroundPid) {
|
||||
Stop-OpenClawBackgroundProcessTree ([int]$backgroundPid)
|
||||
}
|
||||
}
|
||||
`
|
||||
: "";
|
||||
runCommand(
|
||||
"prlctl",
|
||||
[
|
||||
"exec",
|
||||
@@ -252,7 +321,8 @@ function cleanupWindowsBackground(vmName: string, pathsScript: string): void {
|
||||
"Bypass",
|
||||
"-EncodedCommand",
|
||||
encodePowerShell(`${pathsScript}
|
||||
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue`),
|
||||
${stopProcessTree}
|
||||
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue`),
|
||||
],
|
||||
{ check: false, quiet: true, timeoutMs: 30_000 },
|
||||
);
|
||||
|
||||
@@ -81,20 +81,54 @@ export async function startHostServer(input: {
|
||||
hostIp: input.hostIp,
|
||||
port: actualPort,
|
||||
stop: async () => {
|
||||
child.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
child.once("exit", () => resolve());
|
||||
setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 2_000).unref();
|
||||
});
|
||||
await stopHostServerChild(child);
|
||||
},
|
||||
urlFor: (filePath) =>
|
||||
`http://${input.hostIp}:${actualPort}/${encodeURIComponent(path.basename(filePath))}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopHostServerChild(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
terminateTimeoutMs = 2_000,
|
||||
killTimeoutMs = 1_500,
|
||||
): Promise<boolean> {
|
||||
if (child.exitCode != null) {
|
||||
return true;
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
if (await waitForChildExit(child, terminateTimeoutMs)) {
|
||||
return true;
|
||||
}
|
||||
child.kill("SIGKILL");
|
||||
return await waitForChildExit(child, killTimeoutMs);
|
||||
}
|
||||
|
||||
async function waitForChildExit(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
if (child.exitCode != null) {
|
||||
return true;
|
||||
}
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const onExit = () => settle(true);
|
||||
const timeout = setTimeout(() => settle(child.exitCode != null), timeoutMs);
|
||||
timeout.unref();
|
||||
function settle(exited: boolean): void {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
child.off("exit", onExit);
|
||||
resolve(exited);
|
||||
}
|
||||
child.once("exit", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHostServer(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
port: number,
|
||||
@@ -160,4 +194,5 @@ async function delay(ms: number): Promise<void> {
|
||||
|
||||
export const testing = {
|
||||
appendBoundedOutput,
|
||||
stopHostServerChild,
|
||||
};
|
||||
|
||||
@@ -363,7 +363,8 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
|
||||
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
|
||||
await this.phases.phase(name, timeoutSeconds, fn);
|
||||
|
||||
private remainingPhaseTimeoutMs = (): number | undefined => this.phases.remainingTimeoutMs();
|
||||
private remainingPhaseTimeoutMs = (fallbackMs?: number): number | undefined =>
|
||||
this.phases.remainingTimeoutMs(fallbackMs);
|
||||
|
||||
private logGuestPreflight(): void {
|
||||
this.guestBash(String.raw`set -euo pipefail
|
||||
@@ -406,11 +407,17 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
|
||||
say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`);
|
||||
run("prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], {
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(),
|
||||
});
|
||||
if (this.snapshot.state === "poweroff") {
|
||||
waitForVmStatus(this.options.vmName, "stopped", 180);
|
||||
waitForVmStatus(this.options.vmName, "stopped", 180, {
|
||||
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
|
||||
});
|
||||
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
||||
run("prlctl", ["start", this.options.vmName], { quiet: true });
|
||||
run("prlctl", ["start", this.options.vmName], {
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
|
||||
});
|
||||
}
|
||||
this.waitForGuestReady();
|
||||
}
|
||||
|
||||
@@ -565,8 +565,8 @@ class MacosSmoke {
|
||||
await this.phases.phase(name, timeoutSeconds, fn);
|
||||
}
|
||||
|
||||
private remainingPhaseTimeoutMs(): number | undefined {
|
||||
return this.phases.remainingTimeoutMs();
|
||||
private remainingPhaseTimeoutMs(fallbackMs?: number): number | undefined {
|
||||
return this.phases.remainingTimeoutMs(fallbackMs);
|
||||
}
|
||||
|
||||
private async phaseReturns(
|
||||
@@ -653,6 +653,7 @@ exec node "$entry" ${argv}`,
|
||||
run("prlctl", ["exec", this.options.vmName, "/usr/bin/stat", "-f", "%Su", "/dev/console"], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
|
||||
})
|
||||
.stdout.trim()
|
||||
.replaceAll("\r", "")
|
||||
@@ -671,6 +672,7 @@ exec node "$entry" ${argv}`,
|
||||
{
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
|
||||
},
|
||||
).stdout.replaceAll("\r", "");
|
||||
for (const line of users.split("\n")) {
|
||||
@@ -700,7 +702,7 @@ exec node "$entry" ${argv}`,
|
||||
`/Users/${user}`,
|
||||
"NFSHomeDirectory",
|
||||
],
|
||||
{ check: false, quiet: true },
|
||||
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(30_000) },
|
||||
).stdout.replaceAll("\r", "");
|
||||
const match = /^NFSHomeDirectory:\s+(.+)$/m.exec(output);
|
||||
return match?.[1]?.trim() || `/Users/${user}`;
|
||||
@@ -713,7 +715,7 @@ exec node "$entry" ${argv}`,
|
||||
const result = run(
|
||||
"prlctl",
|
||||
["snapshot-switch", this.options.vmName, "--id", this.snapshot.id, "--skip-resume"],
|
||||
{ check: false, quiet: true, timeoutMs: 360_000 },
|
||||
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(360_000) },
|
||||
);
|
||||
this.log(result.stdout);
|
||||
this.log(result.stderr);
|
||||
@@ -725,10 +727,17 @@ exec node "$entry" ${argv}`,
|
||||
const status = run("prlctl", ["status", this.options.vmName], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(60_000),
|
||||
}).stdout;
|
||||
if (status.includes(" running") || status.includes(" suspended")) {
|
||||
run("prlctl", ["stop", this.options.vmName, "--kill"], { check: false, quiet: true });
|
||||
waitForVmStatus(this.options.vmName, "stopped", 360);
|
||||
run("prlctl", ["stop", this.options.vmName, "--kill"], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
|
||||
});
|
||||
waitForVmStatus(this.options.vmName, "stopped", 360, {
|
||||
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
|
||||
});
|
||||
}
|
||||
run("sleep", ["3"], { quiet: true });
|
||||
}
|
||||
@@ -738,15 +747,23 @@ exec node "$entry" ${argv}`,
|
||||
const status = run("prlctl", ["status", this.options.vmName], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: 60_000,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(60_000),
|
||||
}).stdout;
|
||||
if (this.snapshot.state === "poweroff" || status.includes(" stopped")) {
|
||||
waitForVmStatus(this.options.vmName, "stopped", 360);
|
||||
waitForVmStatus(this.options.vmName, "stopped", 360, {
|
||||
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
|
||||
});
|
||||
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
||||
run("prlctl", ["start", this.options.vmName], { quiet: true });
|
||||
run("prlctl", ["start", this.options.vmName], {
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
|
||||
});
|
||||
} else if (status.includes(" suspended")) {
|
||||
say(`Resume restored snapshot ${this.snapshot.name}`);
|
||||
run("prlctl", ["start", this.options.vmName], { quiet: true });
|
||||
run("prlctl", ["start", this.options.vmName], {
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
|
||||
});
|
||||
}
|
||||
this.waitForCurrentUser();
|
||||
}
|
||||
|
||||
@@ -67,6 +67,12 @@ interface UpdateJobContext {
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
interface SpawnLoggedOptions {
|
||||
timeoutKillGraceMs?: number;
|
||||
timeoutLabel?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface NpmUpdateSummary {
|
||||
packageSpec: string;
|
||||
updateTarget: string;
|
||||
@@ -104,6 +110,173 @@ const windowsVm = "Windows 11";
|
||||
const linuxVmDefault = "Ubuntu 26.04";
|
||||
const updateTimeoutSeconds = readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S", 1200);
|
||||
const updateCleanupBackstopMs = 60_000;
|
||||
const freshLaneTimeoutKillGraceMs = readPositiveIntEnv(
|
||||
"OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_KILL_GRACE_MS",
|
||||
2_000,
|
||||
);
|
||||
const activeLoggedChildren = new Set<ReturnType<typeof spawn>>();
|
||||
const loggedParentSignalHandlers = new Map<NodeJS.Signals, () => void>();
|
||||
let loggedExitCleanupInstalled = false;
|
||||
|
||||
export function freshLaneTimeoutMs(platform: Platform): number {
|
||||
const defaultSeconds = platform === "windows" ? 90 * 60 : 75 * 60;
|
||||
return readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_S", defaultSeconds) * 1000;
|
||||
}
|
||||
|
||||
export function spawnLoggedCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
logPath: string,
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
onOutput: (text: string) => void = () => undefined,
|
||||
options: SpawnLoggedOptions = {},
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
writeFileSync(logPath, "", "utf8");
|
||||
const child = spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
detached: process.platform !== "win32",
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
trackLoggedChild(child);
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
let forceKillTimer: NodeJS.Timeout | undefined;
|
||||
const append = (text: string) => {
|
||||
appendFileSync(logPath, text, "utf8");
|
||||
onOutput(text);
|
||||
};
|
||||
const timeoutMs = options.timeoutMs ?? 0;
|
||||
const timeoutTimer =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
append(
|
||||
`\n[${options.timeoutLabel ?? `${command} ${args.join(" ")}`} timed out after ${timeoutMs}ms]\n`,
|
||||
);
|
||||
signalLoggedChild(child, "SIGTERM");
|
||||
forceKillTimer = setTimeout(
|
||||
() => signalLoggedChild(child, "SIGKILL"),
|
||||
options.timeoutKillGraceMs ?? freshLaneTimeoutKillGraceMs,
|
||||
);
|
||||
}, timeoutMs)
|
||||
: undefined;
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
append(chunk.toString("utf8"));
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
append(chunk.toString("utf8"));
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutTimer);
|
||||
clearTimeout(forceKillTimer);
|
||||
untrackLoggedChild(child);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutTimer);
|
||||
clearTimeout(forceKillTimer);
|
||||
if (timedOut && loggedProcessTreeIsAlive(child)) {
|
||||
signalLoggedChild(child, "SIGKILL");
|
||||
}
|
||||
untrackLoggedChild(child);
|
||||
resolve(timedOut ? 124 : (code ?? 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function trackLoggedChild(child: ReturnType<typeof spawn>) {
|
||||
activeLoggedChildren.add(child);
|
||||
child.once("close", () => {
|
||||
if (!loggedProcessTreeIsAlive(child)) {
|
||||
activeLoggedChildren.delete(child);
|
||||
}
|
||||
});
|
||||
child.once("error", () => {
|
||||
if (!loggedProcessTreeIsAlive(child)) {
|
||||
activeLoggedChildren.delete(child);
|
||||
}
|
||||
});
|
||||
installLoggedParentCleanup();
|
||||
}
|
||||
|
||||
function untrackLoggedChild(child: ReturnType<typeof spawn>) {
|
||||
if (!loggedProcessTreeIsAlive(child)) {
|
||||
activeLoggedChildren.delete(child);
|
||||
}
|
||||
}
|
||||
|
||||
function installLoggedParentCleanup() {
|
||||
if (!loggedExitCleanupInstalled) {
|
||||
loggedExitCleanupInstalled = true;
|
||||
process.once("exit", () => cleanupActiveLoggedChildren("SIGTERM"));
|
||||
}
|
||||
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) {
|
||||
if (loggedParentSignalHandlers.has(signal)) {
|
||||
continue;
|
||||
}
|
||||
const handler = () => {
|
||||
cleanupActiveLoggedChildren(signal);
|
||||
for (const [registeredSignal, registeredHandler] of loggedParentSignalHandlers) {
|
||||
process.off(registeredSignal, registeredHandler);
|
||||
}
|
||||
loggedParentSignalHandlers.clear();
|
||||
process.kill(process.pid, signal);
|
||||
};
|
||||
loggedParentSignalHandlers.set(signal, handler);
|
||||
process.once(signal, handler);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupActiveLoggedChildren(signal: NodeJS.Signals) {
|
||||
for (const child of activeLoggedChildren) {
|
||||
signalLoggedChild(child, signal);
|
||||
if (process.platform !== "win32") {
|
||||
signalLoggedChild(child, "SIGKILL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loggedProcessTreeIsAlive(child: ReturnType<typeof spawn>): boolean {
|
||||
if (process.platform === "win32" || typeof child.pid !== "number") {
|
||||
return child.exitCode === null && child.signalCode === null;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error instanceof Error && "code" in error && error.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
function signalLoggedChild(child: ReturnType<typeof spawn>, signal: NodeJS.Signals) {
|
||||
if (process.platform !== "win32" && typeof child.pid === "number") {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ESRCH") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return `Usage: bash scripts/e2e/parallels-npm-update-smoke.sh [options]
|
||||
@@ -238,7 +411,7 @@ function parseOpenClawPackageSpecVersion(spec: string): string {
|
||||
return resolveOpenClawRegistryVersion(value) || "";
|
||||
}
|
||||
|
||||
class NpmUpdateSmoke {
|
||||
export class NpmUpdateSmoke {
|
||||
private auth: ProviderAuth;
|
||||
private windowsAuth: ProviderAuth;
|
||||
private runDir = "";
|
||||
@@ -250,7 +423,7 @@ class NpmUpdateSmoke {
|
||||
private harnessCheckoutVersion = "";
|
||||
private harnessTargetFamily = "";
|
||||
private hostIp = "";
|
||||
private server: HostServer | null = null;
|
||||
protected server: HostServer | null = null;
|
||||
private artifact: PackageArtifact | null = null;
|
||||
private freshTargetSpec = "";
|
||||
private startedAt = Date.now();
|
||||
@@ -282,52 +455,60 @@ class NpmUpdateSmoke {
|
||||
|
||||
async run(): Promise<void> {
|
||||
this.startedAt = Date.now();
|
||||
this.runDir = await makeTempDir("openclaw-parallels-npm-update.");
|
||||
this.tgzDir = await makeTempDir("openclaw-parallels-npm-update-tgz.");
|
||||
this.runDir = await this.makeRunTempDir("openclaw-parallels-npm-update.");
|
||||
this.tgzDir = await this.makeRunTempDir("openclaw-parallels-npm-update-tgz.");
|
||||
try {
|
||||
this.latestVersion = resolveLatestVersion();
|
||||
this.packageSpec = this.options.packageSpec || `openclaw@${this.latestVersion}`;
|
||||
this.currentHead = run("git", ["rev-parse", "HEAD"], { quiet: true }).stdout.trim();
|
||||
this.currentHeadShort = run("git", ["rev-parse", "--short=7", "HEAD"], {
|
||||
quiet: true,
|
||||
}).stdout.trim();
|
||||
this.harnessCheckoutVersion = readHarnessCheckoutVersion();
|
||||
this.hostIp = resolveHostIp(this.options.hostIp ?? "");
|
||||
this.configurePublishedTargets();
|
||||
this.assertPublishedTargetMatchesHarnessCheckout();
|
||||
|
||||
if (this.options.platforms.has("linux")) {
|
||||
this.linuxVm = resolveUbuntuVmName(linuxVmDefault);
|
||||
}
|
||||
this.preflightRegistryUpdateTarget();
|
||||
|
||||
say(`Run fresh npm baseline: ${this.packageSpec}`);
|
||||
say(`Platforms: ${[...this.options.platforms].join(",")}`);
|
||||
say(`Run dir: ${this.runDir}`);
|
||||
await this.runFreshBaselines();
|
||||
|
||||
await this.prepareUpdateTarget();
|
||||
say(`Run same-guest openclaw update to ${this.updateTargetEffective}`);
|
||||
await this.runSameGuestUpdates();
|
||||
|
||||
if (this.freshTargetSpec) {
|
||||
say(`Run fresh target npm install: ${this.freshTargetSpec}`);
|
||||
await this.runFreshTargetInstalls();
|
||||
}
|
||||
|
||||
const summaryPath = await this.writeSummary();
|
||||
if (this.options.json) {
|
||||
process.stdout.write(await readFile(summaryPath, "utf8"));
|
||||
} else {
|
||||
say(`Run dir: ${this.runDir}`);
|
||||
process.stdout.write(await readFile(summaryPath, "utf8"));
|
||||
}
|
||||
await this.runSteps();
|
||||
} finally {
|
||||
await this.server?.stop().catch(() => undefined);
|
||||
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
protected async makeRunTempDir(prefix: string): Promise<string> {
|
||||
return await makeTempDir(prefix);
|
||||
}
|
||||
|
||||
protected async runSteps(): Promise<void> {
|
||||
this.latestVersion = resolveLatestVersion();
|
||||
this.packageSpec = this.options.packageSpec || `openclaw@${this.latestVersion}`;
|
||||
this.currentHead = run("git", ["rev-parse", "HEAD"], { quiet: true }).stdout.trim();
|
||||
this.currentHeadShort = run("git", ["rev-parse", "--short=7", "HEAD"], {
|
||||
quiet: true,
|
||||
}).stdout.trim();
|
||||
this.harnessCheckoutVersion = readHarnessCheckoutVersion();
|
||||
this.hostIp = resolveHostIp(this.options.hostIp ?? "");
|
||||
this.configurePublishedTargets();
|
||||
this.assertPublishedTargetMatchesHarnessCheckout();
|
||||
|
||||
if (this.options.platforms.has("linux")) {
|
||||
this.linuxVm = resolveUbuntuVmName(linuxVmDefault);
|
||||
}
|
||||
this.preflightRegistryUpdateTarget();
|
||||
|
||||
say(`Run fresh npm baseline: ${this.packageSpec}`);
|
||||
say(`Platforms: ${[...this.options.platforms].join(",")}`);
|
||||
say(`Run dir: ${this.runDir}`);
|
||||
await this.runFreshBaselines();
|
||||
|
||||
await this.prepareUpdateTarget();
|
||||
say(`Run same-guest openclaw update to ${this.updateTargetEffective}`);
|
||||
await this.runSameGuestUpdates();
|
||||
|
||||
if (this.freshTargetSpec) {
|
||||
say(`Run fresh target npm install: ${this.freshTargetSpec}`);
|
||||
await this.runFreshTargetInstalls();
|
||||
}
|
||||
|
||||
const summaryPath = await this.writeSummary();
|
||||
if (this.options.json) {
|
||||
process.stdout.write(await readFile(summaryPath, "utf8"));
|
||||
} else {
|
||||
say(`Run dir: ${this.runDir}`);
|
||||
process.stdout.write(await readFile(summaryPath, "utf8"));
|
||||
}
|
||||
}
|
||||
|
||||
private async runFreshBaselines(): Promise<void> {
|
||||
const jobs: Job[] = [];
|
||||
if (this.options.platforms.has("macos")) {
|
||||
@@ -432,8 +613,16 @@ class NpmUpdateSmoke {
|
||||
rerunCommand: this.formatRerun("bash", args, env),
|
||||
startedAt,
|
||||
};
|
||||
job.promise = this.spawnLogged("bash", args, logPath, env, (text) =>
|
||||
this.noteJobOutput(job, text),
|
||||
job.promise = this.spawnLogged(
|
||||
"bash",
|
||||
args,
|
||||
logPath,
|
||||
env,
|
||||
(text) => this.noteJobOutput(job, text),
|
||||
{
|
||||
timeoutLabel: `${label} ${phase}`,
|
||||
timeoutMs: freshLaneTimeoutMs(platform),
|
||||
},
|
||||
).finally(() => {
|
||||
job.durationMs = Date.now() - job.startedAt;
|
||||
job.done = true;
|
||||
@@ -636,29 +825,9 @@ class NpmUpdateSmoke {
|
||||
logPath: string,
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
onOutput: (text: string) => void = () => undefined,
|
||||
options: SpawnLoggedOptions = {},
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
writeFileSync(logPath, "", "utf8");
|
||||
const child = spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString("utf8");
|
||||
appendFileSync(logPath, text, "utf8");
|
||||
onOutput(text);
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString("utf8");
|
||||
appendFileSync(logPath, text, "utf8");
|
||||
onOutput(text);
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
return spawnLoggedCommand(command, args, logPath, env, onOutput, options);
|
||||
}
|
||||
|
||||
private async monitorJobs(label: string, jobs: Job[]): Promise<void> {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { die, run, say, warn } from "./host-command.ts";
|
||||
|
||||
const PRLCTL_STATUS_TIMEOUT_MS = 30_000;
|
||||
const PRLCTL_TRANSITION_TIMEOUT_MS = 120_000;
|
||||
|
||||
interface PrlctlVmListItem {
|
||||
name?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface WaitForVmStatusOptions {
|
||||
probeTimeoutMs?: () => number | undefined;
|
||||
}
|
||||
|
||||
export function listVmNames(): string[] {
|
||||
return listVms()
|
||||
.map((item) => (item.name ?? "").trim())
|
||||
@@ -15,12 +22,18 @@ export function vmStatus(vmName: string): string {
|
||||
return listVms().find((vm) => vm.name === vmName)?.status || "missing";
|
||||
}
|
||||
|
||||
export function waitForVmStatus(vmName: string, expected: string, timeoutSeconds: number): void {
|
||||
export function waitForVmStatus(
|
||||
vmName: string,
|
||||
expected: string,
|
||||
timeoutSeconds: number,
|
||||
options: WaitForVmStatusOptions = {},
|
||||
): void {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const status = run("prlctl", ["status", vmName], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: options.probeTimeoutMs?.() ?? PRLCTL_STATUS_TIMEOUT_MS,
|
||||
}).stdout;
|
||||
if (status.includes(` ${expected}`)) {
|
||||
return;
|
||||
@@ -39,10 +52,16 @@ export function ensureVmRunning(vmName: string, timeoutSeconds = 180): void {
|
||||
}
|
||||
if (status === "stopped") {
|
||||
say(`Start ${vmName} before update phase`);
|
||||
run("prlctl", ["start", vmName], { quiet: true });
|
||||
run("prlctl", ["start", vmName], {
|
||||
quiet: true,
|
||||
timeoutMs: PRLCTL_TRANSITION_TIMEOUT_MS,
|
||||
});
|
||||
} else if (status === "suspended" || status === "paused") {
|
||||
say(`Resume ${vmName} before update phase`);
|
||||
run("prlctl", ["resume", vmName], { quiet: true });
|
||||
run("prlctl", ["resume", vmName], {
|
||||
quiet: true,
|
||||
timeoutMs: PRLCTL_TRANSITION_TIMEOUT_MS,
|
||||
});
|
||||
} else if (status === "missing") {
|
||||
die(`VM not found before update phase: ${vmName}`);
|
||||
}
|
||||
@@ -79,7 +98,10 @@ export function resolveUbuntuVmName(requested: string, explicit = false): string
|
||||
|
||||
function listVms(): PrlctlVmListItem[] {
|
||||
return JSON.parse(
|
||||
run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout,
|
||||
run("prlctl", ["list", "--all", "--json"], {
|
||||
quiet: true,
|
||||
timeoutMs: PRLCTL_STATUS_TIMEOUT_MS,
|
||||
}).stdout,
|
||||
) as PrlctlVmListItem[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { say, warn } from "./host-command.ts";
|
||||
|
||||
export const PHASE_LOG_TAIL_MAX_BYTES = 512 * 1024;
|
||||
|
||||
function appendTextTail(current: string, chunk: string, maxBytes: number): string {
|
||||
const text = chunk.endsWith("\n") ? chunk : `${chunk}\n`;
|
||||
const combined = `${current}${text}`;
|
||||
if (Buffer.byteLength(combined) <= maxBytes) {
|
||||
return combined;
|
||||
}
|
||||
const marker = `[phase log tail truncated to last ${maxBytes} bytes]\n`;
|
||||
const tailBytes = Math.max(0, maxBytes - Buffer.byteLength(marker));
|
||||
const tail = Buffer.from(combined).subarray(-tailBytes).toString("utf8");
|
||||
return `${marker}${tail}`;
|
||||
}
|
||||
|
||||
export class PhaseRunner {
|
||||
private logText = "";
|
||||
private logTail = "";
|
||||
private currentLogPath: string | undefined;
|
||||
private deadlineMs = 0;
|
||||
private timings: Array<{
|
||||
durationMs: number;
|
||||
@@ -13,13 +29,18 @@ export class PhaseRunner {
|
||||
timeoutSeconds: number;
|
||||
}> = [];
|
||||
|
||||
constructor(private runDir: string) {}
|
||||
constructor(
|
||||
private runDir: string,
|
||||
private logTailMaxBytes = PHASE_LOG_TAIL_MAX_BYTES,
|
||||
) {}
|
||||
|
||||
async phase(name: string, timeoutSeconds: number, fn: () => Promise<void> | void): Promise<void> {
|
||||
const logPath = path.join(this.runDir, `${name}.log`);
|
||||
say(name);
|
||||
this.logText = "";
|
||||
this.logTail = "";
|
||||
this.currentLogPath = logPath;
|
||||
this.deadlineMs = Date.now() + timeoutSeconds * 1000;
|
||||
await writeFile(logPath, "", "utf8");
|
||||
const startedAt = Date.now();
|
||||
let status: "pass" | "fail" = "fail";
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
@@ -31,13 +52,11 @@ export class PhaseRunner {
|
||||
});
|
||||
try {
|
||||
await Promise.race([Promise.resolve(fn()), timeout]);
|
||||
await writeFile(logPath, this.logText, "utf8");
|
||||
status = "pass";
|
||||
} catch (error) {
|
||||
await writeFile(logPath, this.logText, "utf8").catch(() => undefined);
|
||||
warn(`${name} failed`);
|
||||
warn(`log tail: ${logPath}`);
|
||||
process.stderr.write(this.logText.split("\n").slice(-80).join("\n"));
|
||||
process.stderr.write(this.logTail.split("\n").slice(-80).join("\n"));
|
||||
process.stderr.write("\n");
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -52,6 +71,7 @@ export class PhaseRunner {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.currentLogPath = undefined;
|
||||
this.deadlineMs = 0;
|
||||
}
|
||||
}
|
||||
@@ -84,10 +104,11 @@ export class PhaseRunner {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
this.logText += text;
|
||||
if (!text.endsWith("\n")) {
|
||||
this.logText += "\n";
|
||||
const line = text.endsWith("\n") ? text : `${text}\n`;
|
||||
if (this.currentLogPath) {
|
||||
appendFileSync(this.currentLogPath, line, "utf8");
|
||||
}
|
||||
this.logTail = appendTextTail(this.logTail, line, this.logTailMaxBytes);
|
||||
}
|
||||
|
||||
private async writeTimings(): Promise<void> {
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { parsePositiveInt, readPositiveIntEnv } from "./env-limits.ts";
|
||||
import { die, run } from "./host-command.ts";
|
||||
import type { Mode, Platform, Provider, ProviderAuth } from "./types.ts";
|
||||
|
||||
type ResolveLatestVersionDeps = {
|
||||
createTempDir?: typeof mkdtempSync;
|
||||
removeDir?: typeof rmSync;
|
||||
runCommand?: typeof run;
|
||||
tempDir?: typeof tmpdir;
|
||||
writeFile?: typeof writeFileSync;
|
||||
};
|
||||
|
||||
export function parseBoolEnv(value: string | undefined): boolean {
|
||||
return /^(1|true|yes|on)$/i.test(value ?? "");
|
||||
}
|
||||
@@ -192,21 +200,26 @@ export function parsePlatformList(value: string): Set<Platform> {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveLatestVersion(versionOverride = ""): string {
|
||||
export function resolveLatestVersion(
|
||||
versionOverride = "",
|
||||
deps: ResolveLatestVersionDeps = {},
|
||||
): string {
|
||||
if (versionOverride) {
|
||||
return versionOverride;
|
||||
}
|
||||
return run(
|
||||
"npm",
|
||||
[
|
||||
"view",
|
||||
"openclaw",
|
||||
"version",
|
||||
"--userconfig",
|
||||
mkdtempSync(path.join(tmpdir(), "openclaw-npm-")),
|
||||
],
|
||||
{
|
||||
const createTempDir = deps.createTempDir ?? mkdtempSync;
|
||||
const removeDir = deps.removeDir ?? rmSync;
|
||||
const runCommand = deps.runCommand ?? run;
|
||||
const resolveTempDir = deps.tempDir ?? tmpdir;
|
||||
const writeFile = deps.writeFile ?? writeFileSync;
|
||||
const userConfigDir = createTempDir(path.join(resolveTempDir(), "openclaw-npm-"));
|
||||
const userConfigPath = path.join(userConfigDir, "npmrc");
|
||||
try {
|
||||
writeFile(userConfigPath, "", "utf8");
|
||||
return runCommand("npm", ["view", "openclaw", "version", "--userconfig", userConfigPath], {
|
||||
quiet: true,
|
||||
},
|
||||
).stdout.trim();
|
||||
}).stdout.trim();
|
||||
} finally {
|
||||
removeDir(userConfigDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { die, run } from "./host-command.ts";
|
||||
import type { SnapshotInfo } from "./types.ts";
|
||||
|
||||
const SNAPSHOT_LIST_TIMEOUT_MS = 120_000;
|
||||
|
||||
export function resolveSnapshot(vmName: string, hint: string): SnapshotInfo {
|
||||
const output = run("prlctl", ["snapshot-list", vmName, "--json"], { quiet: true }).stdout;
|
||||
const output = run("prlctl", ["snapshot-list", vmName, "--json"], {
|
||||
quiet: true,
|
||||
timeoutMs: SNAPSHOT_LIST_TIMEOUT_MS,
|
||||
}).stdout;
|
||||
const payload = JSON.parse(output) as Record<string, { name?: string; state?: string }>;
|
||||
let best: SnapshotInfo | null = null;
|
||||
let bestScore = -1;
|
||||
|
||||
@@ -449,6 +449,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
||||
{
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(),
|
||||
},
|
||||
);
|
||||
this.log(result.stdout);
|
||||
@@ -469,9 +470,14 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
||||
}
|
||||
this.waitForVmNotRestoring(240);
|
||||
if (this.snapshot.state === "poweroff") {
|
||||
waitForVmStatus(this.options.vmName, "stopped", 240);
|
||||
waitForVmStatus(this.options.vmName, "stopped", 240, {
|
||||
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
|
||||
});
|
||||
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
|
||||
run("prlctl", ["start", this.options.vmName], { quiet: true });
|
||||
run("prlctl", ["start", this.options.vmName], {
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +487,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
||||
const status = run("prlctl", ["status", this.options.vmName], {
|
||||
check: false,
|
||||
quiet: true,
|
||||
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
|
||||
}).stdout;
|
||||
if (!status.includes(" restoring")) {
|
||||
return;
|
||||
|
||||
@@ -847,6 +847,19 @@ function spawnDaemon(params: {
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForChildExit(child: ChildProcess) {
|
||||
return new Promise<number | null>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", resolve);
|
||||
});
|
||||
}
|
||||
|
||||
export function readLogTail(logPath: string, maxBytes = LOG_READY_TAIL_BYTES): string {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
@@ -1011,50 +1024,122 @@ function writeSutConfig(params: {
|
||||
return { configPath, stateDir, tempRoot, workspace };
|
||||
}
|
||||
|
||||
async function startLocalSut(params: {
|
||||
gatewayPort: number;
|
||||
groupId: string;
|
||||
mockResponseText: string;
|
||||
mockPort: number;
|
||||
outputDir: string;
|
||||
sutToken: string;
|
||||
testerId: string;
|
||||
repoRoot: string;
|
||||
type StartLocalSutDeps = {
|
||||
createGatewaySpawnSpec?: typeof createOpenClawGatewaySpawnSpec;
|
||||
drainUpdates?: typeof drainSutUpdates;
|
||||
spawnLoggedCommand?: typeof spawnLogged;
|
||||
waitForOutputReady?: typeof waitForOutput;
|
||||
writeConfig?: typeof writeSutConfig;
|
||||
};
|
||||
|
||||
export async function startLocalSut(
|
||||
params: {
|
||||
gatewayPort: number;
|
||||
groupId: string;
|
||||
mockResponseText: string;
|
||||
mockPort: number;
|
||||
outputDir: string;
|
||||
sutToken: string;
|
||||
testerId: string;
|
||||
repoRoot: string;
|
||||
},
|
||||
deps: StartLocalSutDeps = {},
|
||||
) {
|
||||
const drainUpdates = deps.drainUpdates ?? drainSutUpdates;
|
||||
const writeConfig = deps.writeConfig ?? writeSutConfig;
|
||||
const spawnLoggedCommand = deps.spawnLoggedCommand ?? spawnLogged;
|
||||
const waitForOutputReady = deps.waitForOutputReady ?? waitForOutput;
|
||||
const createGatewaySpawnSpec = deps.createGatewaySpawnSpec ?? createOpenClawGatewaySpawnSpec;
|
||||
let gateway: ReturnType<typeof spawnLogged> | undefined;
|
||||
let mock: ReturnType<typeof spawnLogged> | undefined;
|
||||
try {
|
||||
const drained = await drainUpdates(params.sutToken);
|
||||
const config = writeConfig(params);
|
||||
const requestLog = path.join(params.outputDir, "mock-openai-requests.ndjson");
|
||||
mock = spawnLoggedCommand("node", ["scripts/e2e/mock-openai-server.mjs"], {
|
||||
cwd: params.repoRoot,
|
||||
env: mockServerEnv({ ...params, requestLog }),
|
||||
});
|
||||
await waitForOutputReady(
|
||||
mock.child,
|
||||
/mock-openai listening/u,
|
||||
() => mock.output,
|
||||
"mock-openai",
|
||||
10_000,
|
||||
);
|
||||
const gatewaySpec = createGatewaySpawnSpec({
|
||||
env: gatewayEnv({ ...config, sutToken: params.sutToken }),
|
||||
gatewayPort: params.gatewayPort,
|
||||
repoRoot: params.repoRoot,
|
||||
});
|
||||
gateway = spawnLoggedCommand(gatewaySpec.command, gatewaySpec.args, gatewaySpec.options);
|
||||
await waitForOutputReady(
|
||||
gateway.child,
|
||||
/\[gateway\] ready/u,
|
||||
() => gateway.output,
|
||||
"gateway",
|
||||
60_000,
|
||||
);
|
||||
return {
|
||||
...config,
|
||||
drained,
|
||||
gateway: gateway.child,
|
||||
get gatewayLog() {
|
||||
return gateway.output;
|
||||
},
|
||||
mock: mock.child,
|
||||
get mockLog() {
|
||||
return mock.output;
|
||||
},
|
||||
requestLog,
|
||||
};
|
||||
} catch (error) {
|
||||
killTree(gateway?.child);
|
||||
killTree(mock?.child);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordProbeVideo(params: {
|
||||
crabboxBin: string;
|
||||
cwd: string;
|
||||
durationSeconds: number;
|
||||
leaseId: string;
|
||||
outputPath: string;
|
||||
provider: string;
|
||||
runProbe: () => Promise<void>;
|
||||
startDelayMs?: number;
|
||||
target: string;
|
||||
}) {
|
||||
const drained = await drainSutUpdates(params.sutToken);
|
||||
const config = writeSutConfig(params);
|
||||
const requestLog = path.join(params.outputDir, "mock-openai-requests.ndjson");
|
||||
const mock = spawnLogged("node", ["scripts/e2e/mock-openai-server.mjs"], {
|
||||
cwd: params.repoRoot,
|
||||
env: mockServerEnv({ ...params, requestLog }),
|
||||
});
|
||||
await waitForOutput(
|
||||
mock.child,
|
||||
/mock-openai listening/u,
|
||||
() => mock.output,
|
||||
"mock-openai",
|
||||
10_000,
|
||||
);
|
||||
const gatewaySpec = createOpenClawGatewaySpawnSpec({
|
||||
env: gatewayEnv({ ...config, sutToken: params.sutToken }),
|
||||
gatewayPort: params.gatewayPort,
|
||||
repoRoot: params.repoRoot,
|
||||
});
|
||||
const gateway = spawnLogged(gatewaySpec.command, gatewaySpec.args, gatewaySpec.options);
|
||||
await waitForOutput(gateway.child, /\[gateway\] ready/u, () => gateway.output, "gateway", 60_000);
|
||||
return {
|
||||
...config,
|
||||
drained,
|
||||
gateway: gateway.child,
|
||||
get gatewayLog() {
|
||||
return gateway.output;
|
||||
},
|
||||
mock: mock.child,
|
||||
get mockLog() {
|
||||
return mock.output;
|
||||
},
|
||||
requestLog,
|
||||
};
|
||||
let recording: ChildProcess | undefined;
|
||||
try {
|
||||
recording = spawn(
|
||||
params.crabboxBin,
|
||||
[
|
||||
"artifacts",
|
||||
"video",
|
||||
"--provider",
|
||||
params.provider,
|
||||
"--target",
|
||||
params.target,
|
||||
"--id",
|
||||
params.leaseId,
|
||||
"--duration",
|
||||
`${params.durationSeconds}s`,
|
||||
"--output",
|
||||
params.outputPath,
|
||||
],
|
||||
{ cwd: params.cwd, stdio: "inherit" },
|
||||
);
|
||||
await sleep(params.startDelayMs ?? 3_000);
|
||||
await params.runProbe();
|
||||
const recordCode = await waitForChildExit(recording);
|
||||
if (recordCode !== 0) {
|
||||
throw new Error(`Crabbox recording failed with exit code ${recordCode ?? "unknown"}.`);
|
||||
}
|
||||
} finally {
|
||||
killTree(recording);
|
||||
}
|
||||
}
|
||||
|
||||
async function startLocalSutDaemon(params: {
|
||||
@@ -2512,34 +2597,18 @@ async function main() {
|
||||
await sshRun(root, inspect, `bash ${REMOTE_ROOT}/authorize-desktop.sh`);
|
||||
await sshRun(root, inspect, `bash ${REMOTE_ROOT}/select-desktop-chat.sh`);
|
||||
const videoPath = path.join(outputDir, "telegram-user-crabbox-proof.mp4");
|
||||
const recording = spawn(
|
||||
opts.crabboxBin,
|
||||
[
|
||||
"artifacts",
|
||||
"video",
|
||||
"--provider",
|
||||
opts.provider,
|
||||
"--target",
|
||||
opts.target,
|
||||
"--id",
|
||||
leaseId,
|
||||
"--duration",
|
||||
`${opts.recordSeconds}s`,
|
||||
"--output",
|
||||
videoPath,
|
||||
],
|
||||
{ cwd: root, stdio: "inherit" },
|
||||
);
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 3_000);
|
||||
await recordProbeVideo({
|
||||
crabboxBin: opts.crabboxBin,
|
||||
cwd: root,
|
||||
durationSeconds: opts.recordSeconds,
|
||||
leaseId,
|
||||
outputPath: videoPath,
|
||||
provider: opts.provider,
|
||||
runProbe: async () => {
|
||||
await sshRun(root, inspect, `bash ${REMOTE_ROOT}/remote-probe.sh`);
|
||||
},
|
||||
target: opts.target,
|
||||
});
|
||||
await sshRun(root, inspect, `bash ${REMOTE_ROOT}/remote-probe.sh`);
|
||||
const recordCode = await new Promise<number | null>((resolve) => {
|
||||
recording.on("exit", resolve);
|
||||
});
|
||||
if (recordCode !== 0) {
|
||||
throw new Error(`Crabbox recording failed with exit code ${recordCode ?? "unknown"}.`);
|
||||
}
|
||||
const motionVideoPath = path.join(outputDir, "telegram-user-crabbox-proof-motion.mp4");
|
||||
const motionGifPath = path.join(outputDir, "telegram-user-crabbox-proof-motion.gif");
|
||||
summary.mediaPreview = await createMotionPreview({
|
||||
|
||||
@@ -410,6 +410,16 @@
|
||||
"systemImage": "number",
|
||||
"markdownCapable": true
|
||||
},
|
||||
"channelConfigs": {
|
||||
"slack": {
|
||||
"label": "Slack",
|
||||
"description": "Slack channel, DM, command, and app event integration.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/slack",
|
||||
"defaultChoice": "npm",
|
||||
|
||||
@@ -40,14 +40,26 @@ function parseQaLabUpArgs(argv: readonly string[]) {
|
||||
|
||||
export const qaLabUpTesting = {
|
||||
parseQaLabUpArgs,
|
||||
runQaLabUp,
|
||||
usage,
|
||||
};
|
||||
|
||||
async function main(argv: readonly string[]): Promise<number> {
|
||||
type QaLabRuntime = typeof import("../extensions/qa-lab/src/cli.runtime.ts");
|
||||
|
||||
type QaLabUpDeps = {
|
||||
loadRuntime?: () => Promise<Pick<QaLabRuntime, "runQaDockerUpCommand">>;
|
||||
writeStdout?: (text: string) => void;
|
||||
};
|
||||
|
||||
async function loadQaLabRuntime(): Promise<Pick<QaLabRuntime, "runQaDockerUpCommand">> {
|
||||
return await import("../extensions/qa-lab/src/cli.runtime.ts");
|
||||
}
|
||||
|
||||
async function runQaLabUp(argv: readonly string[], deps: QaLabUpDeps = {}): Promise<number> {
|
||||
const values = parseQaLabUpArgs(argv);
|
||||
|
||||
if (values.help) {
|
||||
process.stdout.write(usage());
|
||||
(deps.writeStdout ?? ((text: string) => process.stdout.write(text)))(usage());
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -62,7 +74,7 @@ async function main(argv: readonly string[]): Promise<number> {
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const { runQaDockerUpCommand } = await import("../extensions/qa-lab/src/cli.runtime.ts");
|
||||
const { runQaDockerUpCommand } = await (deps.loadRuntime ?? loadQaLabRuntime)();
|
||||
|
||||
await runQaDockerUpCommand({
|
||||
outputDir: values["output-dir"],
|
||||
@@ -77,6 +89,10 @@ async function main(argv: readonly string[]): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<number> {
|
||||
return await runQaLabUp(argv);
|
||||
}
|
||||
|
||||
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
|
||||
main(process.argv.slice(2)).then(
|
||||
(code) => {
|
||||
|
||||
@@ -867,12 +867,38 @@ async function stopDockerContainer(name: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function startDockerOtelCollector(receiverPort: number) {
|
||||
const collectorPort = await reserveLocalPort();
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), "openclaw-otel-collector-"));
|
||||
type StartDockerOtelCollectorDeps = {
|
||||
mkdtemp?: typeof mkdtemp;
|
||||
platform?: NodeJS.Platform;
|
||||
randomUUID?: typeof randomUUID;
|
||||
reserveLocalPort?: typeof reserveLocalPort;
|
||||
rm?: typeof rm;
|
||||
spawn?: typeof spawn;
|
||||
stopDockerContainer?: typeof stopDockerContainer;
|
||||
tmpdir?: typeof tmpdir;
|
||||
waitForLocalPort?: typeof waitForLocalPort;
|
||||
writeFile?: typeof writeFile;
|
||||
};
|
||||
|
||||
async function startDockerOtelCollector(
|
||||
receiverPort: number,
|
||||
deps: StartDockerOtelCollectorDeps = {},
|
||||
) {
|
||||
const reservePort = deps.reserveLocalPort ?? reserveLocalPort;
|
||||
const makeTempDir = deps.mkdtemp ?? mkdtemp;
|
||||
const writeConfigFile = deps.writeFile ?? writeFile;
|
||||
const spawnProcess = deps.spawn ?? spawn;
|
||||
const waitForPort = deps.waitForLocalPort ?? waitForLocalPort;
|
||||
const stopContainer = deps.stopDockerContainer ?? stopDockerContainer;
|
||||
const removePath = deps.rm ?? rm;
|
||||
const makeUuid = deps.randomUUID ?? randomUUID;
|
||||
const osTmpdir = deps.tmpdir ?? tmpdir;
|
||||
|
||||
const collectorPort = await reservePort();
|
||||
const tempDir = await makeTempDir(path.join(osTmpdir(), "openclaw-otel-collector-"));
|
||||
const configPath = path.join(tempDir, "collector.yaml");
|
||||
const containerName = `openclaw-otel-smoke-${randomUUID()}`;
|
||||
const useHostNetwork = process.platform === "linux";
|
||||
const containerName = `openclaw-otel-smoke-${makeUuid()}`;
|
||||
const useHostNetwork = (deps.platform ?? process.platform) === "linux";
|
||||
const collectorEndpoint = useHostNetwork ? `127.0.0.1:${collectorPort}` : "0.0.0.0:4318";
|
||||
const receiverEndpoint = useHostNetwork
|
||||
? `http://127.0.0.1:${receiverPort}`
|
||||
@@ -897,7 +923,7 @@ service:
|
||||
receivers: [otlp]
|
||||
exporters: [otlphttp/openclaw]
|
||||
`;
|
||||
await writeFile(configPath, config, "utf8");
|
||||
await writeConfigFile(configPath, config, "utf8");
|
||||
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
@@ -916,7 +942,7 @@ service:
|
||||
DEFAULT_DOCKER_COLLECTOR_IMAGE,
|
||||
"--config=/etc/otelcol/config.yaml",
|
||||
];
|
||||
const child = spawn("docker", dockerArgs, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const child = spawnProcess("docker", dockerArgs, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
child.stdout?.on("data", (chunk) => stdout.push(String(chunk)));
|
||||
child.stderr?.on("data", (chunk) => stderr.push(String(chunk)));
|
||||
child.on("error", (err) => {
|
||||
@@ -927,13 +953,22 @@ service:
|
||||
exitCode = code ?? 1;
|
||||
});
|
||||
|
||||
await waitForLocalPort(collectorPort, 60_000, () => {
|
||||
if (exitCode === null) {
|
||||
return "";
|
||||
try {
|
||||
await waitForPort(collectorPort, 60_000, () => {
|
||||
if (exitCode === null) {
|
||||
return "";
|
||||
}
|
||||
const output = [...stdout, ...stderr].join("").trim();
|
||||
return `OpenTelemetry Collector exited before readiness (code=${exitCode})${output ? `:\n${output}` : ""}`;
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await stopContainer(containerName);
|
||||
} finally {
|
||||
await removePath(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
const output = [...stdout, ...stderr].join("").trim();
|
||||
return `OpenTelemetry Collector exited before readiness (code=${exitCode})${output ? `:\n${output}` : ""}`;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
port: collectorPort,
|
||||
@@ -943,8 +978,8 @@ service:
|
||||
return tailText([...stdout, ...stderr].join("").trim(), COLLECTOR_OUTPUT_TAIL_BYTES);
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
await stopDockerContainer(containerName);
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
await stopContainer(containerName);
|
||||
await removePath(tempDir, { force: true, recursive: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1524,6 +1559,7 @@ export const testing = {
|
||||
parseArgs,
|
||||
readPositiveIntegerEnv,
|
||||
readRequestBody,
|
||||
startDockerOtelCollector,
|
||||
terminateChildTree,
|
||||
waitForChild,
|
||||
};
|
||||
|
||||
@@ -90,6 +90,22 @@ const EXTENSION_ACTIVE_MEMORY_VITEST_CONFIG =
|
||||
const EXTENSION_ACPX_VITEST_CONFIG = "test/vitest/vitest.extension-acpx.config.ts";
|
||||
const EXTENSION_BROWSER_VITEST_CONFIG = "test/vitest/vitest.extension-browser.config.ts";
|
||||
const EXTENSION_CODEX_VITEST_CONFIG = "test/vitest/vitest.extension-codex.config.ts";
|
||||
const EXTENSION_CODEX_APP_SERVER_ATTEMPT_VITEST_CONFIG =
|
||||
"test/vitest/vitest.extension-codex-app-server-attempt.config.ts";
|
||||
const EXTENSION_CODEX_APP_SERVER_ATTEMPT_EXTRA_VITEST_CONFIG =
|
||||
"test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts";
|
||||
const EXTENSION_CODEX_APP_SERVER_ATTEMPT_LIGHT_VITEST_CONFIG =
|
||||
"test/vitest/vitest.extension-codex-app-server-attempt-light.config.ts";
|
||||
const EXTENSION_CODEX_APP_SERVER_ATTEMPT_SUPPORT_VITEST_CONFIG =
|
||||
"test/vitest/vitest.extension-codex-app-server-attempt-support.config.ts";
|
||||
const EXTENSION_CODEX_APP_SERVER_RUNTIME_VITEST_CONFIG =
|
||||
"test/vitest/vitest.extension-codex-app-server-runtime.config.ts";
|
||||
const EXTENSION_CODEX_APP_SERVER_SUPPORT_VITEST_CONFIG =
|
||||
"test/vitest/vitest.extension-codex-app-server-support.config.ts";
|
||||
const EXTENSION_CODEX_APP_SERVER_TOOLS_VITEST_CONFIG =
|
||||
"test/vitest/vitest.extension-codex-app-server-tools.config.ts";
|
||||
const EXTENSION_CODEX_SURFACE_VITEST_CONFIG =
|
||||
"test/vitest/vitest.extension-codex-surface.config.ts";
|
||||
const EXTENSION_CHANNELS_VITEST_CONFIG = "test/vitest/vitest.extension-channels.config.ts";
|
||||
const EXTENSION_DIFFS_VITEST_CONFIG = "test/vitest/vitest.extension-diffs.config.ts";
|
||||
const EXTENSION_DISCORD_VITEST_CONFIG = "test/vitest/vitest.extension-discord.config.ts";
|
||||
@@ -148,6 +164,14 @@ const FULL_SUITE_CONFIG_WEIGHT = new Map([
|
||||
[AGENTS_SUPPORT_VITEST_CONFIG, 168],
|
||||
[AGENTS_TOOLS_VITEST_CONFIG, 167],
|
||||
[EXTENSION_CODEX_VITEST_CONFIG, 168],
|
||||
[EXTENSION_CODEX_APP_SERVER_ATTEMPT_VITEST_CONFIG, 168],
|
||||
[EXTENSION_CODEX_APP_SERVER_ATTEMPT_EXTRA_VITEST_CONFIG, 118],
|
||||
[EXTENSION_CODEX_APP_SERVER_ATTEMPT_LIGHT_VITEST_CONFIG, 82],
|
||||
[EXTENSION_CODEX_APP_SERVER_ATTEMPT_SUPPORT_VITEST_CONFIG, 80],
|
||||
[EXTENSION_CODEX_APP_SERVER_RUNTIME_VITEST_CONFIG, 88],
|
||||
[EXTENSION_CODEX_APP_SERVER_TOOLS_VITEST_CONFIG, 78],
|
||||
[EXTENSION_CODEX_APP_SERVER_SUPPORT_VITEST_CONFIG, 72],
|
||||
[EXTENSION_CODEX_SURFACE_VITEST_CONFIG, 68],
|
||||
[EXTENSION_VOICE_CALL_VITEST_CONFIG, 169],
|
||||
[EXTENSIONS_VITEST_CONFIG, 168],
|
||||
[EXTENSION_PROVIDER_OPENAI_VITEST_CONFIG, 167],
|
||||
@@ -455,7 +479,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
],
|
||||
[
|
||||
"scripts/mcp-code-mode-gateway-e2e.ts",
|
||||
["test/scripts/mcp-code-mode-gateway-client.test.ts", "test/scripts/session-log-mentions.test.ts"],
|
||||
[
|
||||
"test/scripts/mcp-code-mode-gateway-client.test.ts",
|
||||
"test/scripts/session-log-mentions.test.ts",
|
||||
],
|
||||
],
|
||||
["scripts/dependency-changes-report.mjs", ["test/scripts/dependency-changes-report.test.ts"]],
|
||||
[
|
||||
|
||||
@@ -14,6 +14,7 @@ const WATCH_RESTARTABLE_CHILD_SIGNALS = new Set(["SIGTERM"]);
|
||||
const WATCH_IGNORED_PATH_SEGMENTS = new Set([".git", "dist", "node_modules"]);
|
||||
const WATCH_LOCK_WAIT_MS = 5_000;
|
||||
const WATCH_LOCK_POLL_MS = 100;
|
||||
const WATCH_SHUTDOWN_KILL_GRACE_MS = 5_000;
|
||||
const WATCH_LOCK_DIR = path.join(".local", "watch-node");
|
||||
const AUTO_DOCTOR_DISABLE_VALUES = new Set(["0", "false", "no", "off"]);
|
||||
|
||||
@@ -292,12 +293,17 @@ export async function runWatchMain(params = {}) {
|
||||
let watcher = null;
|
||||
let lockHandle = null;
|
||||
let autoDoctorAttempted = false;
|
||||
let shutdownExitCode = null;
|
||||
let shutdownKillTimer = null;
|
||||
|
||||
const settle = (code) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (shutdownKillTimer) {
|
||||
clearTimeout(shutdownKillTimer);
|
||||
}
|
||||
if (onSigInt) {
|
||||
deps.process.off("SIGINT", onSigInt);
|
||||
}
|
||||
@@ -309,6 +315,30 @@ export async function runWatchMain(params = {}) {
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
const requestShutdown = (code) => {
|
||||
shuttingDown = true;
|
||||
shutdownExitCode = code;
|
||||
if (!watchProcess || typeof watchProcess.kill !== "function") {
|
||||
settle(code);
|
||||
return;
|
||||
}
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
shutdownKillTimer ??= setTimeout(() => {
|
||||
shutdownKillTimer = null;
|
||||
if (watchProcess && typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill("SIGKILL");
|
||||
}
|
||||
}, WATCH_SHUTDOWN_KILL_GRACE_MS);
|
||||
};
|
||||
|
||||
const settleIfShuttingDown = () => {
|
||||
if (!shuttingDown || shutdownExitCode === null) {
|
||||
return false;
|
||||
}
|
||||
settle(shutdownExitCode);
|
||||
return true;
|
||||
};
|
||||
|
||||
const startRunner = () => {
|
||||
watchProcess = deps.spawn(deps.process.execPath, buildRunnerArgs(deps.args), {
|
||||
cwd: deps.cwd,
|
||||
@@ -322,7 +352,10 @@ export async function runWatchMain(params = {}) {
|
||||
});
|
||||
watchProcess.on("exit", (exitCode, exitSignal) => {
|
||||
watchProcess = null;
|
||||
if (shuttingDown) {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (settleIfShuttingDown()) {
|
||||
return;
|
||||
}
|
||||
if (restartRequested || shouldRestartAfterChildExit(exitCode, exitSignal)) {
|
||||
@@ -339,11 +372,7 @@ export async function runWatchMain(params = {}) {
|
||||
};
|
||||
|
||||
const handleWatcherError = () => {
|
||||
shuttingDown = true;
|
||||
if (watchProcess && typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
}
|
||||
settle(1);
|
||||
requestShutdown(1);
|
||||
};
|
||||
|
||||
const rejectWatcherStartupError = (err) => {
|
||||
@@ -396,7 +425,10 @@ export async function runWatchMain(params = {}) {
|
||||
});
|
||||
watchProcess.on("exit", (exitCode, exitSignal) => {
|
||||
watchProcess = null;
|
||||
if (shuttingDown) {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (settleIfShuttingDown()) {
|
||||
return;
|
||||
}
|
||||
if (exitCode === 0 && !exitSignal) {
|
||||
@@ -450,18 +482,10 @@ export async function runWatchMain(params = {}) {
|
||||
};
|
||||
|
||||
const onSigInt = () => {
|
||||
shuttingDown = true;
|
||||
if (watchProcess && typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
}
|
||||
settle(130);
|
||||
requestShutdown(130);
|
||||
};
|
||||
const onSigTerm = () => {
|
||||
shuttingDown = true;
|
||||
if (watchProcess && typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
}
|
||||
settle(143);
|
||||
requestShutdown(143);
|
||||
};
|
||||
|
||||
deps.process.on("SIGINT", onSigInt);
|
||||
|
||||
@@ -35,6 +35,22 @@ type ResolvePnpmCommandOptions = {
|
||||
};
|
||||
|
||||
const COMMAND_OUTPUT_MAX_CHARS = 512 * 1024;
|
||||
type ReproLog = (message: string) => void;
|
||||
type RunCommand = typeof runCommand;
|
||||
|
||||
type RunZaiFallbackReproDeps = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
error?: ReproLog;
|
||||
log?: ReproLog;
|
||||
mkdtemp?: typeof fs.mkdtemp;
|
||||
mkdir?: typeof fs.mkdir;
|
||||
randomUUID?: typeof randomUUID;
|
||||
readFile?: typeof fs.readFile;
|
||||
rm?: typeof fs.rm;
|
||||
runCommand?: RunCommand;
|
||||
warn?: ReproLog;
|
||||
writeFile?: typeof fs.writeFile;
|
||||
};
|
||||
|
||||
function resolveEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
||||
@@ -81,20 +97,20 @@ export function resolveZaiFallbackPnpmCommand(
|
||||
return command;
|
||||
}
|
||||
|
||||
function pickAnthropicEnv(): { type: "oauth" | "api"; value: string } | null {
|
||||
const oauth = process.env.ANTHROPIC_OAUTH_TOKEN?.trim();
|
||||
function pickAnthropicEnv(env: NodeJS.ProcessEnv): { type: "oauth" | "api"; value: string } | null {
|
||||
const oauth = env.ANTHROPIC_OAUTH_TOKEN?.trim();
|
||||
if (oauth) {
|
||||
return { type: "oauth", value: oauth };
|
||||
}
|
||||
const api = process.env.ANTHROPIC_API_KEY?.trim();
|
||||
const api = env.ANTHROPIC_API_KEY?.trim();
|
||||
if (api) {
|
||||
return { type: "api", value: api };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickZaiKey(): string | null {
|
||||
return process.env.ZAI_API_KEY?.trim() ?? process.env.Z_AI_API_KEY?.trim() ?? null;
|
||||
function pickZaiKey(env: NodeJS.ProcessEnv): string | null {
|
||||
return env.ZAI_API_KEY?.trim() ?? env.Z_AI_API_KEY?.trim() ?? null;
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
@@ -143,97 +159,116 @@ async function runCommand(
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const anthropic = pickAnthropicEnv();
|
||||
const zaiKey = pickZaiKey();
|
||||
export async function runZaiFallbackRepro(deps: RunZaiFallbackReproDeps = {}): Promise<number> {
|
||||
const env = deps.env ?? process.env;
|
||||
const log = deps.log ?? console.log;
|
||||
const warn = deps.warn ?? console.warn;
|
||||
const error = deps.error ?? console.error;
|
||||
const mkdtemp = deps.mkdtemp ?? fs.mkdtemp;
|
||||
const mkdir = deps.mkdir ?? fs.mkdir;
|
||||
const readFile = deps.readFile ?? fs.readFile;
|
||||
const rm = deps.rm ?? fs.rm;
|
||||
const writeFile = deps.writeFile ?? fs.writeFile;
|
||||
const run = deps.runCommand ?? runCommand;
|
||||
const createUuid = deps.randomUUID ?? randomUUID;
|
||||
const anthropic = pickAnthropicEnv(env);
|
||||
const zaiKey = pickZaiKey(env);
|
||||
if (!anthropic) {
|
||||
console.error("Missing ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY.");
|
||||
process.exit(1);
|
||||
error("Missing ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY.");
|
||||
return 1;
|
||||
}
|
||||
if (!zaiKey) {
|
||||
console.error("Missing ZAI_API_KEY or Z_AI_API_KEY.");
|
||||
process.exit(1);
|
||||
error("Missing ZAI_API_KEY or Z_AI_API_KEY.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zai-fallback-"));
|
||||
const baseDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zai-fallback-"));
|
||||
const stateDir = path.join(baseDir, "state");
|
||||
const configPath = path.join(baseDir, "openclaw.json");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
try {
|
||||
await mkdir(stateDir, { recursive: true });
|
||||
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-6",
|
||||
fallbacks: ["zai/glm-4.7"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4-6": {},
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"zai/glm-4.7": {},
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-6",
|
||||
fallbacks: ["zai/glm-4.7"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4-6": {},
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"zai/glm-4.7": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||
};
|
||||
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||
|
||||
const sessionId = process.env.OPENCLAW_ZAI_FALLBACK_SESSION_ID ?? randomUUID();
|
||||
const sessionId = env.OPENCLAW_ZAI_FALLBACK_SESSION_ID ?? createUuid();
|
||||
|
||||
const baseEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
ZAI_API_KEY: zaiKey,
|
||||
Z_AI_API_KEY: "",
|
||||
};
|
||||
const baseEnv: NodeJS.ProcessEnv = {
|
||||
...env,
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
ZAI_API_KEY: zaiKey,
|
||||
Z_AI_API_KEY: "",
|
||||
};
|
||||
|
||||
const envValidAnthropic: NodeJS.ProcessEnv = {
|
||||
...baseEnv,
|
||||
ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? anthropic.value : "",
|
||||
ANTHROPIC_API_KEY: anthropic.type === "api" ? anthropic.value : "",
|
||||
};
|
||||
const envValidAnthropic: NodeJS.ProcessEnv = {
|
||||
...baseEnv,
|
||||
ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? anthropic.value : "",
|
||||
ANTHROPIC_API_KEY: anthropic.type === "api" ? anthropic.value : "",
|
||||
};
|
||||
|
||||
const envInvalidAnthropic: NodeJS.ProcessEnv = {
|
||||
...baseEnv,
|
||||
ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? "invalid" : "",
|
||||
ANTHROPIC_API_KEY: anthropic.type === "api" ? "invalid" : "",
|
||||
};
|
||||
const envInvalidAnthropic: NodeJS.ProcessEnv = {
|
||||
...baseEnv,
|
||||
ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? "invalid" : "",
|
||||
ANTHROPIC_API_KEY: anthropic.type === "api" ? "invalid" : "",
|
||||
};
|
||||
|
||||
console.log("== Run 1: create tool history (primary only)");
|
||||
const toolPrompt =
|
||||
"Use the exec tool to create a file named zai-fallback-tool.txt with the content tool-ok. " +
|
||||
"Then use the read tool to display the file contents. Reply with just the file contents.";
|
||||
const run1 = await runCommand(
|
||||
"run1",
|
||||
["openclaw", "agent", "--local", "--session-id", sessionId, "--message", toolPrompt],
|
||||
envValidAnthropic,
|
||||
);
|
||||
if (run1.code !== 0) {
|
||||
process.exit(run1.code ?? 1);
|
||||
log("== Run 1: create tool history (primary only)");
|
||||
const toolPrompt =
|
||||
"Use the exec tool to create a file named zai-fallback-tool.txt with the content tool-ok. " +
|
||||
"Then use the read tool to display the file contents. Reply with just the file contents.";
|
||||
const run1 = await run(
|
||||
"run1",
|
||||
["openclaw", "agent", "--local", "--session-id", sessionId, "--message", toolPrompt],
|
||||
envValidAnthropic,
|
||||
);
|
||||
if (run1.code !== 0) {
|
||||
return run1.code ?? 1;
|
||||
}
|
||||
|
||||
const sessionFile = path.join(stateDir, "agents", "main", "sessions", `${sessionId}.jsonl`);
|
||||
const transcript = await readFile(sessionFile, "utf8").catch(() => "");
|
||||
if (!transcript.includes('"toolResult"')) {
|
||||
warn("Warning: no toolResult entries detected in session history.");
|
||||
}
|
||||
|
||||
log("== Run 2: force auth failover to Z.AI");
|
||||
const followupPrompt =
|
||||
"What is the content of zai-fallback-tool.txt? Reply with just the contents.";
|
||||
const run2 = await run(
|
||||
"run2",
|
||||
["openclaw", "agent", "--local", "--session-id", sessionId, "--message", followupPrompt],
|
||||
envInvalidAnthropic,
|
||||
);
|
||||
|
||||
if (run2.code === 0) {
|
||||
log("PASS: fallback succeeded.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
error("FAIL: fallback failed.");
|
||||
return run2.code ?? 1;
|
||||
} finally {
|
||||
await rm(baseDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
const sessionFile = path.join(stateDir, "agents", "main", "sessions", `${sessionId}.jsonl`);
|
||||
const transcript = await fs.readFile(sessionFile, "utf8").catch(() => "");
|
||||
if (!transcript.includes('"toolResult"')) {
|
||||
console.warn("Warning: no toolResult entries detected in session history.");
|
||||
}
|
||||
|
||||
console.log("== Run 2: force auth failover to Z.AI");
|
||||
const followupPrompt =
|
||||
"What is the content of zai-fallback-tool.txt? Reply with just the contents.";
|
||||
const run2 = await runCommand(
|
||||
"run2",
|
||||
["openclaw", "agent", "--local", "--session-id", sessionId, "--message", followupPrompt],
|
||||
envInvalidAnthropic,
|
||||
);
|
||||
|
||||
if (run2.code === 0) {
|
||||
console.log("PASS: fallback succeeded.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("FAIL: fallback failed.");
|
||||
process.exit(run2.code ?? 1);
|
||||
async function main() {
|
||||
process.exitCode = await runZaiFallbackRepro();
|
||||
}
|
||||
|
||||
function isCliEntrypoint() {
|
||||
|
||||
@@ -28,6 +28,11 @@ function workspacePathsOverlap(left: string, right: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find other configured agents whose workspaces overlap the target deletion
|
||||
* workspace. Deletion callers use this to avoid removing shared parent/child
|
||||
* directories that still belong to another agent.
|
||||
*/
|
||||
export function findOverlappingWorkspaceAgentIds(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
|
||||
@@ -5,6 +5,11 @@ import { normalizeProviderId } from "./model-selection.js";
|
||||
|
||||
const CLAUDE_CLI_BACKEND_ID = "claude-cli";
|
||||
|
||||
/**
|
||||
* Hash CLI-session reuse inputs before persisting them into session metadata.
|
||||
* The stored value is only an equality token, so prompt/cwd/MCP inputs are not
|
||||
* written back into the session store in plaintext.
|
||||
*/
|
||||
export function hashCliSessionText(value: string | undefined): string | undefined {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
if (!trimmed) {
|
||||
@@ -13,6 +18,11 @@ export function hashCliSessionText(value: string | undefined): string | undefine
|
||||
return crypto.createHash("sha256").update(trimmed).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stored CLI session binding for a provider. New structured
|
||||
* bindings win, older provider-id maps are still read, and the legacy
|
||||
* Claude-only field is retained as a final migration fallback.
|
||||
*/
|
||||
export function getCliSessionBinding(
|
||||
entry: SessionEntry | undefined,
|
||||
provider: string,
|
||||
@@ -51,6 +61,7 @@ export function getCliSessionBinding(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Return only the reusable CLI session id for callers that do not need invalidation metadata. */
|
||||
export function getCliSessionId(
|
||||
entry: SessionEntry | undefined,
|
||||
provider: string,
|
||||
@@ -58,10 +69,19 @@ export function getCliSessionId(
|
||||
return getCliSessionBinding(entry, provider)?.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a CLI session id without reuse metadata. Prefer `setCliSessionBinding`
|
||||
* when the caller can also persist auth, prompt, cwd, or MCP hashes.
|
||||
*/
|
||||
export function setCliSessionId(entry: SessionEntry, provider: string, sessionId: string): void {
|
||||
setCliSessionBinding(entry, provider, { sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a provider-scoped CLI session binding in all currently supported
|
||||
* session-store shapes. The duplicate legacy writes keep older readers working
|
||||
* while structured bindings carry the invalidation inputs for newer runtimes.
|
||||
*/
|
||||
export function setCliSessionBinding(
|
||||
entry: SessionEntry,
|
||||
provider: string,
|
||||
@@ -109,6 +129,11 @@ export function setCliSessionBinding(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear one provider's CLI session binding across structured and legacy fields.
|
||||
* Other providers' bindings stay intact so a model switch only invalidates the
|
||||
* backend that actually failed or changed reuse conditions.
|
||||
*/
|
||||
export function clearCliSession(entry: SessionEntry, provider: string): void {
|
||||
const normalized = normalizeProviderId(provider);
|
||||
if (entry.cliSessionBindings?.[normalized] !== undefined) {
|
||||
@@ -126,12 +151,18 @@ export function clearCliSession(entry: SessionEntry, provider: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear every persisted CLI session binding from a session entry. */
|
||||
export function clearAllCliSessions(entry: SessionEntry): void {
|
||||
entry.cliSessionBindings = undefined;
|
||||
entry.cliSessionIds = undefined;
|
||||
entry.claudeCliSessionId = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a stored CLI session can be reused under the current run
|
||||
* inputs. Auth, system prompt, cwd, and MCP changes invalidate the session
|
||||
* unless the binding was explicitly marked `forceReuse`.
|
||||
*/
|
||||
export function resolveCliSessionReuse(params: {
|
||||
binding?: CliSessionBinding;
|
||||
authProfileId?: string;
|
||||
@@ -163,6 +194,8 @@ export function resolveCliSessionReuse(params: {
|
||||
const currentMcpResumeHash = normalizeOptionalString(params.mcpResumeHash);
|
||||
const storedAuthProfileId = normalizeOptionalString(binding?.authProfileId);
|
||||
const storedAuthEpoch = normalizeOptionalString(binding?.authEpoch);
|
||||
// Versioned auth epochs let a rotated profile keep reuse when the underlying
|
||||
// auth material is known to be unchanged, avoiding unnecessary CLI restarts.
|
||||
const hasMatchingVersionedAuthEpoch =
|
||||
binding?.authEpochVersion === params.authEpochVersion &&
|
||||
storedAuthEpoch !== undefined &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
import { scanOpenRouterModels } from "./model-scan.js";
|
||||
@@ -15,8 +15,14 @@ function createFetchFixture(payload: unknown): typeof fetch {
|
||||
}
|
||||
|
||||
describe("scanOpenRouterModels", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("lists free models without probing", async () => {
|
||||
@@ -108,6 +114,7 @@ describe("scanOpenRouterModels", () => {
|
||||
});
|
||||
|
||||
it("applies the scan timeout to the OpenRouter catalog request", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchImpl: typeof fetch = async (_input, init) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
const signal = typeof init === "object" && init ? init.signal : undefined;
|
||||
@@ -120,13 +127,16 @@ describe("scanOpenRouterModels", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
const scan = expect(
|
||||
scanOpenRouterModels({
|
||||
fetchImpl,
|
||||
probe: false,
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).rejects.toThrow(/catalog aborted/);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await scan;
|
||||
});
|
||||
|
||||
it("caps oversized scan timeouts before scheduling catalog aborts", async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ const pluginRegistryMocks = vi.hoisted(() => {
|
||||
loadPluginManifestRegistryForInstalledIndex: loadManifestRegistry,
|
||||
loadPluginManifestRegistryForPluginRegistry: loadManifestRegistry,
|
||||
loadPluginRegistrySnapshot: vi.fn(() => ({ plugins: [] })),
|
||||
resolveInstalledManifestRegistryIndexFingerprint: vi.fn(() => "test-index"),
|
||||
loadPluginMetadataSnapshot: vi.fn((params: unknown) => {
|
||||
const registry = loadManifestRegistry(params) ?? { plugins: [], diagnostics: [] };
|
||||
return {
|
||||
@@ -26,6 +27,8 @@ const pluginRegistryMocks = vi.hoisted(() => {
|
||||
vi.mock("../plugins/manifest-registry-installed.js", () => ({
|
||||
loadPluginManifestRegistryForInstalledIndex:
|
||||
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex,
|
||||
resolveInstalledManifestRegistryIndexFingerprint:
|
||||
pluginRegistryMocks.resolveInstalledManifestRegistryIndexFingerprint,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/plugin-registry.js", () => ({
|
||||
@@ -38,13 +41,104 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
|
||||
loadPluginMetadataSnapshot: pluginRegistryMocks.loadPluginMetadataSnapshot,
|
||||
}));
|
||||
|
||||
import {
|
||||
clearCurrentPluginMetadataSnapshot,
|
||||
setCurrentPluginMetadataSnapshot,
|
||||
} from "../plugins/current-plugin-metadata-snapshot.js";
|
||||
import type { InstalledPluginIndexRecord } from "../plugins/installed-plugin-index.js";
|
||||
import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js";
|
||||
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
|
||||
import {
|
||||
resetProviderAuthAliasMapCacheForTest,
|
||||
resolveProviderIdForAuth,
|
||||
} from "./provider-auth-aliases.js";
|
||||
|
||||
function createPluginManifestRecord(
|
||||
plugin: Partial<PluginManifestRecord> & Pick<PluginManifestRecord, "id" | "origin">,
|
||||
): PluginManifestRecord {
|
||||
return {
|
||||
channels: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
rootDir: `/plugins/${plugin.id}`,
|
||||
source: `/plugins/${plugin.id}`,
|
||||
manifestPath: `/plugins/${plugin.id}/.codex-plugin/plugin.json`,
|
||||
...plugin,
|
||||
};
|
||||
}
|
||||
|
||||
function createInstalledPluginIndexRecord(
|
||||
plugin: PluginManifestRecord,
|
||||
): InstalledPluginIndexRecord {
|
||||
return {
|
||||
pluginId: plugin.id,
|
||||
manifestPath: plugin.manifestPath,
|
||||
manifestHash: `${plugin.id}:manifest`,
|
||||
rootDir: plugin.rootDir,
|
||||
origin: plugin.origin,
|
||||
enabled: true,
|
||||
enabledByDefault: true,
|
||||
startup: {
|
||||
sidecar: false,
|
||||
memory: false,
|
||||
deferConfiguredChannelFullLoadUntilAfterListen: false,
|
||||
agentHarnesses: [],
|
||||
},
|
||||
compat: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginMetadataSnapshot(params: {
|
||||
config?: Parameters<typeof resolveInstalledPluginIndexPolicyHash>[0];
|
||||
plugins: readonly PluginManifestRecord[];
|
||||
}): PluginMetadataSnapshot {
|
||||
const policyHash = resolveInstalledPluginIndexPolicyHash(params.config);
|
||||
return {
|
||||
policyHash,
|
||||
index: {
|
||||
version: 1,
|
||||
hostContractVersion: "test",
|
||||
compatRegistryVersion: "test",
|
||||
migrationVersion: 1,
|
||||
policyHash,
|
||||
generatedAtMs: 1,
|
||||
installRecords: {},
|
||||
plugins: params.plugins.map((plugin) => createInstalledPluginIndexRecord(plugin)),
|
||||
diagnostics: [],
|
||||
},
|
||||
registryDiagnostics: [],
|
||||
manifestRegistry: { plugins: [...params.plugins], diagnostics: [] },
|
||||
plugins: params.plugins,
|
||||
diagnostics: [],
|
||||
byPluginId: new Map(params.plugins.map((plugin) => [plugin.id, plugin])),
|
||||
normalizePluginId: (pluginId) => pluginId,
|
||||
owners: {
|
||||
channels: new Map(),
|
||||
channelConfigs: new Map(),
|
||||
providers: new Map(),
|
||||
modelCatalogProviders: new Map(),
|
||||
cliBackends: new Map(),
|
||||
setupProviders: new Map(),
|
||||
commandAliases: new Map(),
|
||||
contracts: new Map(),
|
||||
},
|
||||
metrics: {
|
||||
registrySnapshotMs: 0,
|
||||
manifestRegistryMs: 0,
|
||||
ownerMapsMs: 0,
|
||||
totalMs: 0,
|
||||
indexPluginCount: params.plugins.length,
|
||||
manifestPluginCount: params.plugins.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider auth aliases", () => {
|
||||
beforeEach(() => {
|
||||
clearCurrentPluginMetadataSnapshot();
|
||||
resetProviderAuthAliasMapCacheForTest();
|
||||
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReset();
|
||||
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReset();
|
||||
@@ -54,9 +148,9 @@ describe("provider auth aliases", () => {
|
||||
});
|
||||
|
||||
it("treats deprecated auth choice ids as provider auth aliases", () => {
|
||||
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({
|
||||
const metadataSnapshot = createPluginMetadataSnapshot({
|
||||
plugins: [
|
||||
{
|
||||
createPluginManifestRecord({
|
||||
id: "openai",
|
||||
origin: "bundled",
|
||||
providerAuthChoices: [
|
||||
@@ -67,49 +161,52 @@ describe("provider auth aliases", () => {
|
||||
deprecatedChoiceIds: ["codex-cli", "openai-chatgpt-import"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
expect(resolveProviderIdForAuth("codex-cli")).toBe("openai");
|
||||
expect(resolveProviderIdForAuth("openai-chatgpt-import")).toBe("openai");
|
||||
expect(resolveProviderIdForAuth("openai")).toBe("openai");
|
||||
expect(resolveProviderIdForAuth("codex-cli", { metadataSnapshot })).toBe("openai");
|
||||
expect(resolveProviderIdForAuth("openai-chatgpt-import", { metadataSnapshot })).toBe("openai");
|
||||
expect(resolveProviderIdForAuth("openai", { metadataSnapshot })).toBe("openai");
|
||||
});
|
||||
|
||||
it("does not reuse aliases across env-resolved plugin roots", () => {
|
||||
const config = {};
|
||||
const env = {
|
||||
HOME: "/home/one",
|
||||
OPENCLAW_HOME: undefined,
|
||||
} as NodeJS.ProcessEnv;
|
||||
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry
|
||||
.mockReturnValueOnce({
|
||||
setCurrentPluginMetadataSnapshot(
|
||||
createPluginMetadataSnapshot({
|
||||
config,
|
||||
plugins: [
|
||||
{
|
||||
createPluginManifestRecord({
|
||||
id: "one",
|
||||
origin: "global",
|
||||
providerAuthAliases: { fixture: "provider-one" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
}),
|
||||
{ config, env },
|
||||
);
|
||||
|
||||
expect(resolveProviderIdForAuth("fixture", { config, env })).toBe("provider-one");
|
||||
env.HOME = "/home/two";
|
||||
setCurrentPluginMetadataSnapshot(
|
||||
createPluginMetadataSnapshot({
|
||||
config,
|
||||
plugins: [
|
||||
{
|
||||
createPluginManifestRecord({
|
||||
id: "two",
|
||||
origin: "global",
|
||||
providerAuthAliases: { fixture: "provider-two" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
expect(resolveProviderIdForAuth("fixture", { config: {}, env })).toBe("provider-one");
|
||||
env.HOME = "/home/two";
|
||||
expect(resolveProviderIdForAuth("fixture", { config: {}, env })).toBe("provider-two");
|
||||
expect(pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledTimes(
|
||||
2,
|
||||
}),
|
||||
{ config, env },
|
||||
);
|
||||
|
||||
expect(resolveProviderIdForAuth("fixture", { config, env })).toBe("provider-two");
|
||||
});
|
||||
|
||||
it("uses caller-provided metadata snapshots without loading plugin metadata", () => {
|
||||
|
||||
@@ -5,6 +5,11 @@ import { ensureCustomApiRegistered } from "./custom-api-registry.js";
|
||||
import { createTransportAwareStreamFnForModel } from "./provider-transport-stream.js";
|
||||
import type { StreamFn } from "./runtime/index.js";
|
||||
|
||||
/**
|
||||
* Resolve and register the stream function for a concrete model. Provider
|
||||
* plugin streams win, transport-aware built-ins are the fallback, and successful
|
||||
* resolution updates the custom API registry for downstream runtime dispatch.
|
||||
*/
|
||||
export function registerProviderStreamForModel<TApi extends Api>(params: {
|
||||
model: Model<TApi>;
|
||||
cfg?: OpenClawConfig;
|
||||
|
||||
@@ -6,6 +6,7 @@ const hoisted = vi.hoisted(() => ({
|
||||
getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">(
|
||||
() => "default",
|
||||
),
|
||||
getActivePluginRegistryWorkspaceDir: vi.fn<() => string | undefined>(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({
|
||||
@@ -18,6 +19,7 @@ vi.mock("../plugins/runtime/standalone-runtime-registry-loader.js", () => ({
|
||||
|
||||
vi.mock("../plugins/runtime.js", () => ({
|
||||
getActivePluginRuntimeSubagentMode: hoisted.getActivePluginRuntimeSubagentMode,
|
||||
getActivePluginRegistryWorkspaceDir: hoisted.getActivePluginRegistryWorkspaceDir,
|
||||
}));
|
||||
|
||||
describe("ensureRuntimePluginsLoaded", () => {
|
||||
@@ -30,6 +32,8 @@ describe("ensureRuntimePluginsLoaded", () => {
|
||||
hoisted.ensureStandaloneRuntimePluginRegistryLoaded.mockReturnValue(undefined);
|
||||
hoisted.getActivePluginRuntimeSubagentMode.mockReset();
|
||||
hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("default");
|
||||
hoisted.getActivePluginRegistryWorkspaceDir.mockReset();
|
||||
hoisted.getActivePluginRegistryWorkspaceDir.mockReturnValue(undefined);
|
||||
vi.resetModules();
|
||||
({ ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js"));
|
||||
});
|
||||
|
||||
@@ -827,7 +827,6 @@ describe("Tool Search", () => {
|
||||
}, 5_000);
|
||||
|
||||
it("aborts already-started bridged calls when code mode times out", async () => {
|
||||
testing.setToolSearchMinCodeTimeoutMsForTest(100);
|
||||
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
|
||||
const target = pluginTool("fake_abort_on_timeout", "Long-running target tool");
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
@@ -860,7 +859,7 @@ describe("Tool Search", () => {
|
||||
|
||||
const config = {
|
||||
tools: {
|
||||
toolSearch: { enabled: true, mode: "code", codeTimeoutMs: 100 },
|
||||
toolSearch: { enabled: true, mode: "code", codeTimeoutMs: 1_000 },
|
||||
},
|
||||
} as never;
|
||||
applyToolSearchCatalog({
|
||||
|
||||
@@ -24,40 +24,27 @@ import type {
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
|
||||
function resolveEffectiveToolLabel(tool: AnyAgentTool): string {
|
||||
const toolName = readEffectiveToolName(tool);
|
||||
const rawLabel = normalizeOptionalString(readEffectiveToolField(tool, "label")) ?? "";
|
||||
const rawLabel = normalizeOptionalString(tool.label) ?? "";
|
||||
if (
|
||||
rawLabel &&
|
||||
normalizeLowercaseStringOrEmpty(rawLabel) !== normalizeLowercaseStringOrEmpty(toolName)
|
||||
normalizeLowercaseStringOrEmpty(rawLabel) !== normalizeLowercaseStringOrEmpty(tool.name)
|
||||
) {
|
||||
return rawLabel;
|
||||
}
|
||||
return resolveToolDisplay({ name: toolName }).title;
|
||||
return resolveToolDisplay({ name: tool.name }).title;
|
||||
}
|
||||
|
||||
function resolveRawToolDescription(tool: AnyAgentTool): string {
|
||||
return normalizeOptionalString(readEffectiveToolField(tool, "description")) ?? "";
|
||||
return normalizeOptionalString(tool.description) ?? "";
|
||||
}
|
||||
|
||||
function summarizeToolDescription(tool: AnyAgentTool): string {
|
||||
return summarizeToolDescriptionText({
|
||||
rawDescription: resolveRawToolDescription(tool),
|
||||
displaySummary: normalizeOptionalString(readEffectiveToolField(tool, "displaySummary")),
|
||||
displaySummary: tool.displaySummary,
|
||||
});
|
||||
}
|
||||
|
||||
function readEffectiveToolField(tool: AnyAgentTool, field: string): unknown {
|
||||
try {
|
||||
return (tool as Record<string, unknown>)[field];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readEffectiveToolName(tool: AnyAgentTool, fallback = "tool"): string {
|
||||
return normalizeOptionalString(readEffectiveToolField(tool, "name")) ?? fallback;
|
||||
}
|
||||
|
||||
function resolveEffectiveToolSource(
|
||||
tool: AnyAgentTool,
|
||||
fallbackTool?: AnyAgentTool,
|
||||
@@ -144,10 +131,7 @@ function buildReadableRawToolsByName(
|
||||
for (let index = 0; index < toolCount; index += 1) {
|
||||
try {
|
||||
const tool = tools[index];
|
||||
const toolName = readEffectiveToolName(tool, "");
|
||||
if (toolName) {
|
||||
toolsByName.set(toolName, tool);
|
||||
}
|
||||
toolsByName.set(tool.name, tool);
|
||||
} catch {
|
||||
// Unreadable entries are reported by the schema projection diagnostics.
|
||||
}
|
||||
@@ -184,15 +168,14 @@ export function buildEffectiveToolInventoryEntries(
|
||||
|
||||
return disambiguateLabels(
|
||||
tools
|
||||
.map((tool, index) => {
|
||||
const toolName = readEffectiveToolName(tool, `tool[${index}]`);
|
||||
const source = resolveEffectiveToolSource(tool, rawToolsByName.get(toolName));
|
||||
.map((tool) => {
|
||||
const source = resolveEffectiveToolSource(tool, rawToolsByName.get(tool.name));
|
||||
const metadata = source.pluginId
|
||||
? pluginToolMetadata.get(buildPluginToolMetadataKey(source.pluginId, toolName))
|
||||
? pluginToolMetadata.get(buildPluginToolMetadataKey(source.pluginId, tool.name))
|
||||
: undefined;
|
||||
return Object.assign(
|
||||
{
|
||||
id: toolName,
|
||||
id: tool.name,
|
||||
label:
|
||||
normalizeOptionalString(metadata?.displayName) ?? resolveEffectiveToolLabel(tool),
|
||||
description:
|
||||
|
||||
@@ -861,32 +861,6 @@ describe("resolveEffectiveToolInventory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to raw description when displaySummary is unreadable", async () => {
|
||||
const tool = mockTool({
|
||||
name: "cron",
|
||||
label: "Cron",
|
||||
description: "Long raw description\n\nACTIONS:\n- status",
|
||||
});
|
||||
Object.defineProperty(tool, "displaySummary", {
|
||||
get() {
|
||||
throw new Error("display summary exploded");
|
||||
},
|
||||
});
|
||||
const { resolveEffectiveToolInventory: resolveEffectiveToolInventoryItem } = await loadHarness({
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
const result = resolveEffectiveToolInventoryItem({ cfg: {} });
|
||||
|
||||
expect(result.groups[0]?.tools[0]).toEqual({
|
||||
id: "cron",
|
||||
label: "Cron",
|
||||
description: "Long raw description",
|
||||
rawDescription: "Long raw description\n\nACTIONS:\n- status",
|
||||
source: "core",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a sanitized summary for multi-line raw descriptions", async () => {
|
||||
const { resolveEffectiveToolInventory: resolveEffectiveToolInventoryCandidate } =
|
||||
await loadHarness({
|
||||
|
||||
@@ -1758,6 +1758,9 @@ describe("createImageGenerateTool", () => {
|
||||
const defaultLoadOptions = mockCallArg(webMedia.loadWebMedia, 0, "loadWebMedia", 1);
|
||||
expect(defaultLoadUrl).toBe("http://198.18.0.153/reference.png");
|
||||
expect(requireRecord(defaultLoadOptions, "loadWebMedia options").ssrfPolicy).toBeUndefined();
|
||||
expect(requireRecord(defaultLoadOptions, "loadWebMedia options").readIdleTimeoutMs).toBe(
|
||||
120_000,
|
||||
);
|
||||
|
||||
const tool = requireImageGenerateTool(
|
||||
createImageGenerateTool({
|
||||
@@ -1782,6 +1785,9 @@ describe("createImageGenerateTool", () => {
|
||||
expect(requireRecord(configuredLoadOptions, "loadWebMedia options").ssrfPolicy).toEqual({
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
});
|
||||
expect(requireRecord(configuredLoadOptions, "loadWebMedia options").readIdleTimeoutMs).toBe(
|
||||
120_000,
|
||||
);
|
||||
expect(mockCallArg(generateImage, 1, "generateImage").ssrfPolicy).toEqual({
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
hasGenerationToolAvailability,
|
||||
normalizeMediaReferenceInputs,
|
||||
readGenerationTimeoutMs,
|
||||
REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS,
|
||||
resolveRemoteMediaSsrfPolicy,
|
||||
resolveCapabilityModelConfigForTool,
|
||||
resolveGenerateAction,
|
||||
@@ -618,6 +619,7 @@ async function loadReferenceImages(params: {
|
||||
maxBytes: params.maxBytes,
|
||||
localRoots,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
...(isHttpUrl ? { readIdleTimeoutMs: REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS } : {}),
|
||||
});
|
||||
if (media.kind !== "image") {
|
||||
throw new ToolInputError(`Unsupported media type: ${media.kind}`);
|
||||
|
||||
@@ -1981,6 +1981,46 @@ describe("image tool implicit imageModel config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the shared remote read idle timeout when loading remote image references", async () => {
|
||||
const fetch = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({ content: "ok", base_resp: { status_code: 0, status_msg: "" } }),
|
||||
),
|
||||
);
|
||||
global.fetch = withFetchPreconnect(fetch);
|
||||
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
|
||||
const loadWebMedia = vi.fn(async () => ({
|
||||
buffer: Buffer.from(ONE_PIXEL_PNG_B64, "base64"),
|
||||
contentType: "image/png",
|
||||
kind: "image" as const,
|
||||
}));
|
||||
installImageUnderstandingProviderDeps([minimaxProvider, moonshotProvider], {
|
||||
loadImageWebMediaRuntime: async () => ({
|
||||
loadWebMedia,
|
||||
optimizeImageBufferForWebMedia: async ({ buffer, contentType, fileName }) => ({
|
||||
buffer,
|
||||
contentType: contentType ?? "image/png",
|
||||
kind: "image",
|
||||
fileName,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
const tool = createRequiredImageTool({
|
||||
config: createMinimaxImageConfig(),
|
||||
agentDir,
|
||||
});
|
||||
|
||||
await expectImageToolExecOk(tool, "https://example.test/reference.png");
|
||||
|
||||
expect(loadWebMedia).toHaveBeenCalledTimes(1);
|
||||
const [, options] = fetchCallAt(loadWebMedia, 0);
|
||||
expect((options as { readIdleTimeoutMs?: number }).readIdleTimeoutMs).toBe(120_000);
|
||||
});
|
||||
});
|
||||
|
||||
it("sandboxes image paths like the read tool", async () => {
|
||||
await withTempSandboxState(async ({ agentDir, sandboxRoot }) => {
|
||||
await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8");
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
import {
|
||||
applyImageModelConfigDefaults,
|
||||
buildTextToolResult,
|
||||
REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS,
|
||||
resolveMediaToolInboundRoots,
|
||||
resolveMediaToolLocalRoots,
|
||||
resolveRemoteMediaSsrfPolicy,
|
||||
@@ -90,6 +91,7 @@ type ImageToolLoadWebMediaOptions = {
|
||||
localRoots?: readonly string[] | "any";
|
||||
inboundRoots?: readonly string[];
|
||||
ssrfPolicy?: ReturnType<typeof resolveRemoteMediaSsrfPolicy>;
|
||||
readIdleTimeoutMs?: number;
|
||||
};
|
||||
|
||||
type ImageWebMediaRuntime = {
|
||||
@@ -974,6 +976,7 @@ export function createImageTool(options?: {
|
||||
localRoots: mediaLocalRoots,
|
||||
inboundRoots: mediaInboundRoots,
|
||||
ssrfPolicy: remoteMediaSsrfPolicy,
|
||||
...(isHttpUrl ? { readIdleTimeoutMs: REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS } : {}),
|
||||
imageCompression,
|
||||
});
|
||||
if (media.kind !== "image") {
|
||||
|
||||
@@ -70,6 +70,8 @@ type TaskRunDetailHandle = {
|
||||
runId: string;
|
||||
};
|
||||
|
||||
export const REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS = 120_000;
|
||||
|
||||
export function applyImageModelConfigDefaults(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
imageModelConfig: ImageModelConfig,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { coerceImageModelConfig, type ImageModelConfig } from "./image-tool.help
|
||||
import {
|
||||
applyImageModelConfigDefaults,
|
||||
buildTextToolResult,
|
||||
REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS,
|
||||
resolveModelFromRegistry,
|
||||
resolveMediaToolLocalRoots,
|
||||
resolveModelRuntimeApiKey,
|
||||
@@ -54,7 +55,6 @@ const DEFAULT_PROMPT = "Analyze this PDF document.";
|
||||
const DEFAULT_MAX_PDFS = 10;
|
||||
const DEFAULT_MAX_BYTES_MB = 10;
|
||||
const DEFAULT_MAX_PAGES = 20;
|
||||
const PDF_REMOTE_READ_IDLE_TIMEOUT_MS = 120_000;
|
||||
|
||||
const PDF_MIN_TEXT_CHARS = 200;
|
||||
const PDF_MAX_PIXELS = 4_000_000;
|
||||
@@ -457,7 +457,7 @@ export function createPdfTool(options?: {
|
||||
: await loadWebMediaRaw(resolvedPathInfo.resolved, {
|
||||
maxBytes,
|
||||
localRoots,
|
||||
...(isHttpUrl ? { readIdleTimeoutMs: PDF_REMOTE_READ_IDLE_TIMEOUT_MS } : {}),
|
||||
...(isHttpUrl ? { readIdleTimeoutMs: REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS } : {}),
|
||||
ssrfPolicy: remoteMediaSsrfPolicy,
|
||||
});
|
||||
|
||||
|
||||
@@ -1515,6 +1515,30 @@ describe("createVideoGenerateTool", () => {
|
||||
expect(call.inputImages?.[1]?.role).toBe("last_frame");
|
||||
});
|
||||
|
||||
it("passes direct remote reference URLs to the provider without local media loading", async () => {
|
||||
mockVideoPluginProvider({
|
||||
imageToVideo: { enabled: true, maxInputImages: 1 },
|
||||
});
|
||||
const loadWebMedia = vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({
|
||||
kind: "image",
|
||||
buffer: Buffer.from("image"),
|
||||
contentType: "image/png",
|
||||
});
|
||||
const generateSpy = mockSavedVideoResult();
|
||||
const tool = createVideoPluginTool();
|
||||
|
||||
await tool.execute("call-1", {
|
||||
prompt: "lobster",
|
||||
image: "https://example.test/reference.png",
|
||||
});
|
||||
|
||||
expect(loadWebMedia).not.toHaveBeenCalled();
|
||||
const call = firstMockCallArg(generateSpy) as {
|
||||
inputImages?: Array<{ url?: string }>;
|
||||
};
|
||||
expect(call.inputImages).toEqual([{ url: "https://example.test/reference.png" }]);
|
||||
});
|
||||
|
||||
it("passes web_fetch SSRF policy when loading reference assets", async () => {
|
||||
mockVideoPluginProvider({
|
||||
imageToVideo: { enabled: true, maxInputImages: 1 },
|
||||
|
||||
@@ -6,10 +6,6 @@ import { isRecord } from "../utils.js";
|
||||
import { asBoolean } from "../utils/boolean.js";
|
||||
import type { ChannelAccountSnapshot } from "./plugins/types.core.js";
|
||||
|
||||
// Read-only status commands project a safe subset of account fields into snapshots
|
||||
// so renderers can preserve "configured but unavailable" state without touching
|
||||
// strict runtime-only credential helpers.
|
||||
|
||||
const CREDENTIAL_STATUS_KEYS = [
|
||||
"tokenStatus",
|
||||
"botTokenStatus",
|
||||
@@ -33,6 +29,8 @@ function readNullableNumber(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): number | null | undefined {
|
||||
// Preserve explicit null timestamps; status callers use null to distinguish
|
||||
// "known empty" from an omitted/unsupported field.
|
||||
if (record[key] === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -57,6 +55,7 @@ function readCredentialStatus(record: Record<string, unknown>, key: CredentialSt
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Infers configured state from any credential status field on an account snapshot-like object. */
|
||||
export function resolveConfiguredFromCredentialStatuses(account: unknown): boolean | undefined {
|
||||
const record = isRecord(account) ? account : null;
|
||||
if (!record) {
|
||||
@@ -70,6 +69,8 @@ export function resolveConfiguredFromCredentialStatuses(account: unknown): boole
|
||||
}
|
||||
sawCredentialStatus = true;
|
||||
if (status !== "missing") {
|
||||
// Any configured credential is enough for coarse account presence; callers
|
||||
// that require every credential use resolveConfiguredFromRequiredCredentialStatuses.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -92,12 +93,15 @@ export function resolveConfiguredFromRequiredCredentialStatuses(
|
||||
}
|
||||
sawCredentialStatus = true;
|
||||
if (status === "missing") {
|
||||
// Required-credential checks are all-or-nothing so multi-token accounts
|
||||
// do not appear configured when one mandatory credential is absent.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return sawCredentialStatus ? true : undefined;
|
||||
}
|
||||
|
||||
/** Returns true when a credential exists but is unavailable to the current process. */
|
||||
export function hasConfiguredUnavailableCredentialStatus(account: unknown): boolean {
|
||||
const record = isRecord(account) ? account : null;
|
||||
if (!record) {
|
||||
@@ -108,6 +112,7 @@ export function hasConfiguredUnavailableCredentialStatus(account: unknown): bool
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns true when an account snapshot exposes an actual credential or available status. */
|
||||
export function hasResolvedCredentialValue(account: unknown): boolean {
|
||||
const record = isRecord(account) ? account : null;
|
||||
if (!record) {
|
||||
@@ -120,6 +125,7 @@ export function hasResolvedCredentialValue(account: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Projects non-secret credential source/status fields into a channel account snapshot. */
|
||||
export function projectCredentialSnapshotFields(
|
||||
account: unknown,
|
||||
): Pick<
|
||||
@@ -143,6 +149,8 @@ export function projectCredentialSnapshotFields(
|
||||
const appTokenSource = normalizeOptionalString(record.appTokenSource);
|
||||
const signingSecretSource = normalizeOptionalString(record.signingSecretSource);
|
||||
|
||||
// Only expose source/status metadata. Raw credential fields are intentionally
|
||||
// omitted here because channel snapshots are safe to display in status output.
|
||||
return {
|
||||
...(tokenSource ? { tokenSource } : {}),
|
||||
...(botTokenSource ? { botTokenSource } : {}),
|
||||
@@ -166,6 +174,7 @@ export function projectCredentialSnapshotFields(
|
||||
};
|
||||
}
|
||||
|
||||
/** Projects a safe read-only account snapshot, redacting URL credentials and raw secrets. */
|
||||
export function projectSafeChannelAccountSnapshotFields(
|
||||
account: unknown,
|
||||
): Partial<ChannelAccountSnapshot> {
|
||||
@@ -232,6 +241,7 @@ export function projectSafeChannelAccountSnapshotFields(
|
||||
? { allowFrom: readStringArray(record, "allowFrom") }
|
||||
: {}),
|
||||
...projectCredentialSnapshotFields(account),
|
||||
// Status output may display base URLs, but embedded credentials must never leak.
|
||||
...(baseUrl ? { baseUrl: stripUrlUserInfo(baseUrl) } : {}),
|
||||
...(readBoolean(record, "allowUnmentionedGroups") !== undefined
|
||||
? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") }
|
||||
|
||||
@@ -2,12 +2,14 @@ export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions"
|
||||
|
||||
export type WhatsAppAckReactionMode = "always" | "mentions" | "never";
|
||||
|
||||
/** Pending ack reaction plus the provider callback needed to remove it after a reply. */
|
||||
export type AckReactionHandle = {
|
||||
ackReactionPromise: Promise<boolean>;
|
||||
ackReactionValue: string;
|
||||
remove: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Channel-neutral facts used to decide whether an inbound message gets an ack reaction. */
|
||||
export type AckReactionGateParams = {
|
||||
scope: AckReactionScope | undefined;
|
||||
isDirect: boolean;
|
||||
@@ -19,6 +21,7 @@ export type AckReactionGateParams = {
|
||||
shouldBypassMention?: boolean;
|
||||
};
|
||||
|
||||
/** Apply channel-neutral ack reaction scope rules before a provider sends an emoji. */
|
||||
export function shouldAckReaction(params: AckReactionGateParams): boolean {
|
||||
const scope = params.scope ?? "group-mentions";
|
||||
if (scope === "off" || scope === "none") {
|
||||
@@ -48,6 +51,7 @@ export function shouldAckReaction(params: AckReactionGateParams): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Adapt WhatsApp's direct/group knobs onto the shared ack reaction gate. */
|
||||
export function shouldAckReactionForWhatsApp(params: {
|
||||
emoji: string;
|
||||
isDirect: boolean;
|
||||
@@ -84,6 +88,7 @@ export function shouldAckReactionForWhatsApp(params: {
|
||||
});
|
||||
}
|
||||
|
||||
/** Start sending an ack reaction and retain enough state for optional cleanup. */
|
||||
export function createAckReactionHandle(params: {
|
||||
ackReactionValue: string;
|
||||
send: () => Promise<void>;
|
||||
@@ -115,6 +120,7 @@ export function createAckReactionHandle(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove an ack reaction only after the send path confirmed it was applied. */
|
||||
export function removeAckReactionAfterReply(params: {
|
||||
removeAfterReply: boolean;
|
||||
ackReactionPromise: Promise<boolean> | null;
|
||||
@@ -139,6 +145,7 @@ export function removeAckReactionAfterReply(params: {
|
||||
});
|
||||
}
|
||||
|
||||
/** Convenience wrapper for removing a stored ack reaction handle after reply delivery. */
|
||||
export function removeAckReactionHandleAfterReply(params: {
|
||||
removeAfterReply: boolean;
|
||||
ackReaction: AckReactionHandle | null | undefined;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
|
||||
/** Prefix used in allow-from entries that delegate membership to an access group. */
|
||||
export const ACCESS_GROUP_ALLOW_FROM_PREFIX = "accessGroup:";
|
||||
|
||||
/** Parses an access-group allow-from entry and returns the referenced group name. */
|
||||
export function parseAccessGroupAllowFromEntry(entry: string): string | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed.startsWith(ACCESS_GROUP_ALLOW_FROM_PREFIX)) {
|
||||
@@ -11,11 +13,14 @@ export function parseAccessGroupAllowFromEntry(entry: string): string | null {
|
||||
return name.length > 0 ? name : null;
|
||||
}
|
||||
|
||||
/** Merges configured and pairing-store DM allowlists according to the active DM policy. */
|
||||
export function mergeDmAllowFromSources(params: {
|
||||
allowFrom?: Array<string | number>;
|
||||
storeAllowFrom?: Array<string | number>;
|
||||
dmPolicy?: string;
|
||||
}): string[] {
|
||||
// Explicit allowlist/open policy owns the effective list; pairing-store entries only supplement
|
||||
// pairing/default policies so old approved users do not override a stricter configured list.
|
||||
const storeEntries =
|
||||
params.dmPolicy === "allowlist" || params.dmPolicy === "open"
|
||||
? []
|
||||
@@ -23,6 +28,7 @@ export function mergeDmAllowFromSources(params: {
|
||||
return normalizeStringEntries([...(params.allowFrom ?? []), ...storeEntries]);
|
||||
}
|
||||
|
||||
/** Resolves group allow-from entries with optional fallback to the generic allowFrom list. */
|
||||
export function resolveGroupAllowFromSources(params: {
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
@@ -40,6 +46,7 @@ export function resolveGroupAllowFromSources(params: {
|
||||
return normalizeStringEntries(scoped);
|
||||
}
|
||||
|
||||
/** Returns the first defined value without treating null/false/empty string as missing. */
|
||||
export function firstDefined<T>(...values: Array<T | undefined>) {
|
||||
for (const value of values) {
|
||||
if (value !== undefined) {
|
||||
@@ -49,6 +56,7 @@ export function firstDefined<T>(...values: Array<T | undefined>) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Checks a normalized sender id against a compiled allowlist summary. */
|
||||
export function isSenderIdAllowed(
|
||||
allow: { entries: string[]; hasWildcard: boolean; hasEntries: boolean },
|
||||
senderId: string | undefined,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
/** Candidate class that matched an allowlist entry. */
|
||||
export type AllowlistMatchSource =
|
||||
| "wildcard"
|
||||
| "id"
|
||||
@@ -15,23 +16,32 @@ export type AllowlistMatchSource =
|
||||
| "slug"
|
||||
| "localpart";
|
||||
|
||||
/** Allowlist decision plus optional match metadata for diagnostics. */
|
||||
export type AllowlistMatch<TSource extends string = AllowlistMatchSource> = {
|
||||
/** Whether the candidate was allowed. */
|
||||
allowed: boolean;
|
||||
/** Config entry or wildcard that matched. */
|
||||
matchKey?: string;
|
||||
/** Candidate source that matched the config entry. */
|
||||
matchSource?: TSource;
|
||||
};
|
||||
|
||||
/** Precompiled allowlist for repeated candidate checks. */
|
||||
export type CompiledAllowlist = {
|
||||
/** Normalized allowlist entries. */
|
||||
set: ReadonlySet<string>;
|
||||
/** Whether the wildcard entry allows every candidate. */
|
||||
wildcard: boolean;
|
||||
};
|
||||
|
||||
/** Formats match metadata for compact logs and tests. */
|
||||
export function formatAllowlistMatchMeta(
|
||||
match?: { matchKey?: string; matchSource?: string } | null,
|
||||
): string {
|
||||
return `matchKey=${match?.matchKey ?? "none"} matchSource=${match?.matchSource ?? "none"}`;
|
||||
}
|
||||
|
||||
/** Compiles already-normalized allowlist entries into a lookup set. */
|
||||
export function compileAllowlist(entries: ReadonlyArray<string>): CompiledAllowlist {
|
||||
const set = new Set(entries.filter(Boolean));
|
||||
return {
|
||||
@@ -48,6 +58,7 @@ function compileSimpleAllowlist(entries: ReadonlyArray<string | number>): Compil
|
||||
);
|
||||
}
|
||||
|
||||
/** Checks candidates in order, returning the first exact allowlist match. */
|
||||
export function resolveAllowlistCandidates<TSource extends string>(params: {
|
||||
compiledAllowlist: CompiledAllowlist;
|
||||
candidates: Array<{ value?: string; source: TSource }>;
|
||||
@@ -67,6 +78,7 @@ export function resolveAllowlistCandidates<TSource extends string>(params: {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
/** Resolves an allowlist decision with wildcard taking precedence over candidate checks. */
|
||||
export function resolveCompiledAllowlistMatch<TSource extends string>(params: {
|
||||
compiledAllowlist: CompiledAllowlist;
|
||||
candidates: Array<{ value?: string; source: TSource }>;
|
||||
@@ -80,6 +92,7 @@ export function resolveCompiledAllowlistMatch<TSource extends string>(params: {
|
||||
return resolveAllowlistCandidates(params);
|
||||
}
|
||||
|
||||
/** Compiles an allowlist and resolves it against ordered candidate values. */
|
||||
export function resolveAllowlistMatchByCandidates<TSource extends string>(params: {
|
||||
allowList: ReadonlyArray<string>;
|
||||
candidates: Array<{ value?: string; source: TSource }>;
|
||||
@@ -90,12 +103,14 @@ export function resolveAllowlistMatchByCandidates<TSource extends string>(params
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves the common id/name allowlist shape used by channel sender checks. */
|
||||
export function resolveAllowlistMatchSimple(params: {
|
||||
allowFrom: ReadonlyArray<string | number>;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
allowNameMatching?: boolean;
|
||||
}): AllowlistMatch<"wildcard" | "id" | "name"> {
|
||||
// Compile from the current array contents so in-place config edits are visible immediately.
|
||||
const allowFrom = compileSimpleAllowlist(params.allowFrom);
|
||||
|
||||
if (allowFrom.set.size === 0) {
|
||||
@@ -111,6 +126,7 @@ export function resolveAllowlistMatchSimple(params: {
|
||||
compiledAllowlist: allowFrom,
|
||||
candidates: [
|
||||
{ value: senderId, source: "id" },
|
||||
// Name matching is opt-in because display names can be mutable or ambiguous.
|
||||
...(params.allowNameMatching === true && senderName
|
||||
? ([{ value: senderName, source: "name" as const }] satisfies Array<{
|
||||
value?: string;
|
||||
|
||||
@@ -30,6 +30,7 @@ function dedupeAllowlistEntries(entries: string[]): string[] {
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/** Appends resolved ids to an allowlist while preserving first-seen casing/order. */
|
||||
export function mergeAllowlist(params: {
|
||||
existing?: Array<string | number>;
|
||||
additions: string[];
|
||||
@@ -37,6 +38,7 @@ export function mergeAllowlist(params: {
|
||||
return dedupeAllowlistEntries([...mapAllowFromEntries(params.existing), ...params.additions]);
|
||||
}
|
||||
|
||||
/** Builds resolved/unresolved summaries plus id additions from resolver output. */
|
||||
export function buildAllowlistResolutionSummary<T extends AllowlistUserResolutionLike>(
|
||||
resolvedUsers: T[],
|
||||
opts?: { formatResolved?: (entry: T) => string; formatUnresolved?: (entry: T) => string },
|
||||
@@ -93,6 +95,7 @@ export function canonicalizeAllowlistWithResolvedIds<
|
||||
return dedupeAllowlistEntries(canonicalized);
|
||||
}
|
||||
|
||||
/** Rewrites nested `users` arrays in channel config entries after allowlist resolution. */
|
||||
export function patchAllowlistUsersInConfigEntries<
|
||||
T extends AllowlistUserResolutionLike,
|
||||
TEntries extends Record<string, unknown>,
|
||||
@@ -110,6 +113,7 @@ export function patchAllowlistUsersInConfigEntries<
|
||||
if (!Array.isArray(users) || users.length === 0) {
|
||||
continue;
|
||||
}
|
||||
// Merge keeps user-facing aliases; canonicalize replaces aliases with stable ids when possible.
|
||||
const resolvedUsers =
|
||||
params.strategy === "canonicalize"
|
||||
? canonicalizeAllowlistWithResolvedIds({
|
||||
@@ -131,6 +135,7 @@ export function patchAllowlistUsersInConfigEntries<
|
||||
return nextEntries as TEntries;
|
||||
}
|
||||
|
||||
/** Collects resolvable user aliases from one config entry, excluding wildcard entries. */
|
||||
export function addAllowlistUserEntriesFromConfigEntry(target: Set<string>, entry: unknown): void {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return;
|
||||
@@ -147,6 +152,7 @@ export function addAllowlistUserEntriesFromConfigEntry(target: Set<string>, entr
|
||||
}
|
||||
}
|
||||
|
||||
/** Logs compact allowlist resolution mapping output when there is anything to report. */
|
||||
export function summarizeMapping(
|
||||
label: string,
|
||||
mapping: string[],
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
|
||||
|
||||
/** Source of the config entry selected for a channel target. */
|
||||
export type ChannelMatchSource = "direct" | "parent" | "wildcard";
|
||||
|
||||
/** Match result retaining direct, parent, and wildcard candidates for diagnostics. */
|
||||
export type ChannelEntryMatch<T> = {
|
||||
/** Entry selected for the effective config result. */
|
||||
entry?: T;
|
||||
/** Config key for the selected entry. */
|
||||
key?: string;
|
||||
/** Wildcard fallback entry, retained even when a direct match wins. */
|
||||
wildcardEntry?: T;
|
||||
/** Config key for the wildcard fallback entry. */
|
||||
wildcardKey?: string;
|
||||
/** Parent conversation entry, retained when direct target matching falls back. */
|
||||
parentEntry?: T;
|
||||
/** Config key for the parent conversation entry. */
|
||||
parentKey?: string;
|
||||
/** Key that should be reported to callers as the effective match. */
|
||||
matchKey?: string;
|
||||
/** Precedence source that produced the effective match. */
|
||||
matchSource?: ChannelMatchSource;
|
||||
};
|
||||
|
||||
/** Copies match metadata onto a resolved config result. */
|
||||
export function applyChannelMatchMeta<
|
||||
TResult extends { matchKey?: string; matchSource?: ChannelMatchSource },
|
||||
>(result: TResult, match: ChannelEntryMatch<unknown>): TResult {
|
||||
@@ -24,6 +35,7 @@ export function applyChannelMatchMeta<
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Resolves the matched entry into a config result while preserving match metadata. */
|
||||
export function resolveChannelMatchConfig<
|
||||
TEntry,
|
||||
TResult extends { matchKey?: string; matchSource?: ChannelMatchSource },
|
||||
@@ -34,6 +46,7 @@ export function resolveChannelMatchConfig<
|
||||
return applyChannelMatchMeta(resolveEntry(match.entry), match);
|
||||
}
|
||||
|
||||
/** Normalizes user-visible channel names into lowercase slug keys. */
|
||||
export function normalizeChannelSlug(value: string): string {
|
||||
return normalizeLowercaseStringOrEmpty(value)
|
||||
.replace(/^#/, "")
|
||||
@@ -41,10 +54,12 @@ export function normalizeChannelSlug(value: string): string {
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Builds deduped key candidates while dropping blank/nullish entries. */
|
||||
export function buildChannelKeyCandidates(...keys: Array<string | undefined | null>): string[] {
|
||||
return normalizeUniqueSingleOrTrimmedStringList(keys);
|
||||
}
|
||||
|
||||
/** Finds direct and wildcard entries without applying parent fallback precedence. */
|
||||
export function resolveChannelEntryMatch<T>(params: {
|
||||
entries?: Record<string, T>;
|
||||
keys: string[];
|
||||
@@ -61,12 +76,15 @@ export function resolveChannelEntryMatch<T>(params: {
|
||||
break;
|
||||
}
|
||||
if (params.wildcardKey && Object.hasOwn(entries, params.wildcardKey)) {
|
||||
// Keep wildcard metadata even when a direct entry exists so diagnostics can
|
||||
// explain the fallback that would have applied.
|
||||
match.wildcardEntry = entries[params.wildcardKey];
|
||||
match.wildcardKey = params.wildcardKey;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/** Resolves channel config by direct match, normalized direct match, parent match, then wildcard. */
|
||||
export function resolveChannelEntryMatchWithFallback<T>(params: {
|
||||
entries?: Record<string, T>;
|
||||
keys: string[];
|
||||
@@ -86,11 +104,15 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
|
||||
|
||||
const normalizeKey = params.normalizeKey;
|
||||
if (normalizeKey) {
|
||||
// Normalized direct matching lets display names and ids converge before parent/wildcard
|
||||
// fallback can broaden the selected config.
|
||||
const normalizedKeys = params.keys.map((key) => normalizeKey(key)).filter(Boolean);
|
||||
if (normalizedKeys.length > 0) {
|
||||
for (const [entryKey, entry] of Object.entries(params.entries ?? {})) {
|
||||
const normalizedEntry = normalizeKey(entryKey);
|
||||
if (normalizedEntry && normalizedKeys.includes(normalizedEntry)) {
|
||||
// Preserve the original configured key as matchKey; callers surface it
|
||||
// in status/debug output instead of the normalized comparison key.
|
||||
return {
|
||||
...direct,
|
||||
entry,
|
||||
@@ -118,6 +140,7 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
|
||||
};
|
||||
}
|
||||
if (normalizeKey) {
|
||||
// Normalized parent keys keep thread/channel parent fallback consistent with direct keys.
|
||||
const normalizedParentKeys = parentKeys.map((key) => normalizeKey(key)).filter(Boolean);
|
||||
if (normalizedParentKeys.length > 0) {
|
||||
for (const [entryKey, entry] of Object.entries(params.entries ?? {})) {
|
||||
@@ -151,6 +174,7 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
|
||||
return direct;
|
||||
}
|
||||
|
||||
/** Resolves nested allowlists where an unconfigured outer/inner list means "no restriction". */
|
||||
export function resolveNestedAllowlistDecision(params: {
|
||||
outerConfigured: boolean;
|
||||
outerMatched: boolean;
|
||||
@@ -158,6 +182,8 @@ export function resolveNestedAllowlistDecision(params: {
|
||||
innerMatched: boolean;
|
||||
}): boolean {
|
||||
if (!params.outerConfigured) {
|
||||
// Unconfigured outer lists mean the whole nested policy is inactive; do not
|
||||
// require an inner match until the outer scope has opted into restriction.
|
||||
return true;
|
||||
}
|
||||
if (!params.outerMatched) {
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
export type CommandAuthorizer = {
|
||||
/** True when this authorizer has policy data for the current sender/context. */
|
||||
configured: boolean;
|
||||
/** True when the configured policy allows the control command. */
|
||||
allowed: boolean;
|
||||
};
|
||||
|
||||
/** Fallback policy used when access groups are disabled for a channel/account. */
|
||||
export type CommandGatingModeWhenAccessGroupsOff = "allow" | "deny" | "configured";
|
||||
|
||||
/** Resolves command authorization from one or more configured policy sources. */
|
||||
export function resolveCommandAuthorizedFromAuthorizers(params: {
|
||||
/** True when configured access groups should be enforced. */
|
||||
useAccessGroups: boolean;
|
||||
/** Candidate authorizers; any configured allow grants access. */
|
||||
authorizers: CommandAuthorizer[];
|
||||
/** Fallback behavior when access groups are disabled. Defaults to allow. */
|
||||
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
|
||||
}): boolean {
|
||||
const { useAccessGroups, authorizers } = params;
|
||||
@@ -23,16 +30,23 @@ export function resolveCommandAuthorizedFromAuthorizers(params: {
|
||||
if (!anyConfigured) {
|
||||
return true;
|
||||
}
|
||||
// "configured" preserves legacy permissive behavior until a concrete authorizer exists.
|
||||
return authorizers.some((entry) => entry.configured && entry.allowed);
|
||||
}
|
||||
return authorizers.some((entry) => entry.configured && entry.allowed);
|
||||
}
|
||||
|
||||
/** Returns both command authorization and whether a text control command must be blocked. */
|
||||
export function resolveControlCommandGate(params: {
|
||||
/** True when configured access groups should be enforced. */
|
||||
useAccessGroups: boolean;
|
||||
/** Candidate authorizers checked before allowing text control commands. */
|
||||
authorizers: CommandAuthorizer[];
|
||||
/** True when text commands are enabled for this inbound surface. */
|
||||
allowTextCommands: boolean;
|
||||
/** True when the inbound text contains a recognized control command. */
|
||||
hasControlCommand: boolean;
|
||||
/** Fallback behavior when access groups are disabled. Defaults to allow. */
|
||||
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
|
||||
}): { commandAuthorized: boolean; shouldBlock: boolean } {
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
@@ -44,13 +58,21 @@ export function resolveControlCommandGate(params: {
|
||||
return { commandAuthorized, shouldBlock };
|
||||
}
|
||||
|
||||
/** Convenience wrapper for text command gates with primary and secondary authorizers. */
|
||||
export function resolveDualTextControlCommandGate(params: {
|
||||
/** True when configured access groups should be enforced. */
|
||||
useAccessGroups: boolean;
|
||||
/** True when the primary authorizer has policy data for this sender/context. */
|
||||
primaryConfigured: boolean;
|
||||
/** True when the primary authorizer allows the command. */
|
||||
primaryAllowed: boolean;
|
||||
/** True when the secondary authorizer has policy data for this sender/context. */
|
||||
secondaryConfigured: boolean;
|
||||
/** True when the secondary authorizer allows the command. */
|
||||
secondaryAllowed: boolean;
|
||||
/** True when the inbound text contains a recognized control command. */
|
||||
hasControlCommand: boolean;
|
||||
/** Fallback behavior when access groups are disabled. Defaults to allow. */
|
||||
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
|
||||
}): { commandAuthorized: boolean; shouldBlock: boolean } {
|
||||
return resolveControlCommandGate({
|
||||
|
||||
@@ -36,6 +36,7 @@ type ChannelPresenceSignal = {
|
||||
source: ChannelPresenceSignalSource;
|
||||
};
|
||||
|
||||
/** Returns true when a channel config section has operator data beyond an enabled toggle. */
|
||||
export function hasMeaningfulChannelConfig(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
@@ -43,6 +44,7 @@ export function hasMeaningfulChannelConfig(value: unknown): boolean {
|
||||
return Object.keys(value).some((key) => key !== "enabled");
|
||||
}
|
||||
|
||||
/** Lists channel ids explicitly disabled in config, normalized for status/activation checks. */
|
||||
export function listExplicitlyDisabledChannelIdsForConfig(cfg: OpenClawConfig): string[] {
|
||||
const channels = isRecord(cfg.channels) ? cfg.channels : null;
|
||||
if (!channels) {
|
||||
@@ -77,6 +79,7 @@ function listPersistedAuthStateChannelIds(options: ChannelPresenceOptions): read
|
||||
if (options.discovery) {
|
||||
return listBundledChannelIdsWithPersistedAuthState(options.discovery);
|
||||
}
|
||||
// Bundled persisted-auth metadata is process-stable; cache it outside hot status/plugin lookups.
|
||||
if (persistedAuthStateChannelIds) {
|
||||
return persistedAuthStateChannelIds;
|
||||
}
|
||||
@@ -102,6 +105,7 @@ function hasPersistedAuthState(params: {
|
||||
});
|
||||
}
|
||||
|
||||
/** Lists channel ids that appear configured through config, env vars, or persisted auth state. */
|
||||
export function listPotentialConfiguredChannelIds(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
@@ -114,6 +118,7 @@ export function listPotentialConfiguredChannelIds(
|
||||
);
|
||||
}
|
||||
|
||||
/** Lists deduped configured-channel signals while preserving their source type. */
|
||||
export function listPotentialConfiguredChannelPresenceSignals(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
@@ -192,6 +197,7 @@ function hasEnvConfiguredChannel(
|
||||
);
|
||||
}
|
||||
|
||||
/** Fast boolean check for any configured channel signal without materializing full plugin state. */
|
||||
export function hasPotentialConfiguredChannels(
|
||||
cfg: OpenClawConfig | null | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
|
||||
@@ -24,6 +24,7 @@ function shouldAppendId(id: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Resolves a concise conversation label for session lists, logs, and route summaries. */
|
||||
export function resolveConversationLabel(ctx: MsgContext): string | undefined {
|
||||
const explicit = normalizeOptionalString(ctx.ConversationLabel);
|
||||
if (explicit) {
|
||||
@@ -69,5 +70,7 @@ export function resolveConversationLabel(ctx: MsgContext): string | undefined {
|
||||
if (base.startsWith("#") || base.startsWith("@")) {
|
||||
return base;
|
||||
}
|
||||
// Numeric and address-like ids disambiguate generic group labels, but avoid appending them to
|
||||
// explicit handles/channels or labels that already carry an id.
|
||||
return `${base} id:${id}`;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type ConversationResolutionSource =
|
||||
| "inbound-bundled-plugin"
|
||||
| "inbound-fallback";
|
||||
|
||||
/** Canonical conversation identity chosen for binding/spawn decisions. */
|
||||
type ConversationResolution = {
|
||||
canonical: {
|
||||
channel: string;
|
||||
@@ -45,6 +46,7 @@ type ConversationResolution = {
|
||||
source: ConversationResolutionSource;
|
||||
};
|
||||
|
||||
/** Raw command context used to resolve the conversation a command should bind to. */
|
||||
export type ResolveCommandConversationResolutionInput = {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string | null;
|
||||
@@ -63,6 +65,7 @@ export type ResolveCommandConversationResolutionInput = {
|
||||
includePlacementHint?: boolean;
|
||||
};
|
||||
|
||||
/** Raw inbound context used to resolve the conversation a message belongs to. */
|
||||
type ResolveInboundConversationResolutionInput = {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string | null;
|
||||
@@ -263,6 +266,7 @@ function resolveChannelTargetId(params: {
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Convert command route facts into the provider hook context without inventing defaults. */
|
||||
function buildThreadingContext(params: {
|
||||
fallbackTo?: string;
|
||||
originatingTo?: string;
|
||||
@@ -282,6 +286,7 @@ function buildThreadingContext(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve where top-level thread bindings should attach for a channel. */
|
||||
export function resolveChannelDefaultBindingPlacement(
|
||||
rawChannel?: string | null,
|
||||
): "current" | "child" | undefined {
|
||||
@@ -294,6 +299,7 @@ export function resolveChannelDefaultBindingPlacement(
|
||||
return pluginPlacement ?? resolveBundledChannelThreadBindingDefaultPlacement(channel);
|
||||
}
|
||||
|
||||
/** Resolve command-originated conversation binding identity, preferring provider hooks first. */
|
||||
export function resolveCommandConversationResolution(
|
||||
params: ResolveCommandConversationResolutionInput,
|
||||
): ConversationResolution | null {
|
||||
@@ -362,6 +368,7 @@ export function resolveCommandConversationResolution(
|
||||
return focusedResolution;
|
||||
}
|
||||
|
||||
// Fallback order keeps explicit command/origin targets ahead of ambient context.
|
||||
const baseConversationId =
|
||||
resolveChannelTargetId({
|
||||
channel,
|
||||
@@ -401,6 +408,7 @@ export function resolveCommandConversationResolution(
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve inbound message conversation identity, respecting provider-owned rejection. */
|
||||
export function resolveInboundConversationResolution(
|
||||
params: ResolveInboundConversationResolutionInput,
|
||||
): ConversationResolution | null {
|
||||
@@ -437,6 +445,7 @@ export function resolveInboundConversationResolution(
|
||||
plugin,
|
||||
});
|
||||
if (providerResolution || providerConversation === null) {
|
||||
// A null provider result is an explicit rejection; do not reinterpret it generically.
|
||||
return providerResolution;
|
||||
}
|
||||
|
||||
@@ -453,6 +462,7 @@ export function resolveInboundConversationResolution(
|
||||
plugin,
|
||||
});
|
||||
if (artifactResolution || artifactConversation === null) {
|
||||
// Bundled artifact resolvers keep the same stop-on-null contract as provider hooks.
|
||||
return artifactResolution;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
import type { ChannelId } from "./plugins/types.public.js";
|
||||
export type { AccessGroupMembershipResolver } from "../plugin-sdk/access-groups.js";
|
||||
|
||||
/** Runtime callbacks needed by the legacy direct-DM authorizer bridge. */
|
||||
export type DirectDmCommandAuthorizationRuntime = {
|
||||
/** Returns whether a raw body should run command authorization. */
|
||||
shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean;
|
||||
/** @deprecated Command authorization is resolved by channel ingress. Kept for runtime injection compatibility. */
|
||||
resolveCommandAuthorizedFromAuthorizers?: (params: {
|
||||
@@ -26,14 +28,18 @@ export type DirectDmCommandAuthorizationRuntime = {
|
||||
|
||||
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
|
||||
export type ResolvedInboundDirectDmAccess = {
|
||||
/** DM access decision after configured and pairing-store allowlists are merged. */
|
||||
access: {
|
||||
decision: "allow" | "block" | "pairing";
|
||||
reasonCode: DmGroupAccessReasonCode;
|
||||
reason: string;
|
||||
effectiveAllowFrom: string[];
|
||||
};
|
||||
/** Whether command authorization was applicable to this inbound body. */
|
||||
shouldComputeAuth: boolean;
|
||||
/** Whether the sender matched the effective DM allowlist used for command checks. */
|
||||
senderAllowedForCommands: boolean;
|
||||
/** Command authorization result when applicable. */
|
||||
commandAuthorized: boolean | undefined;
|
||||
};
|
||||
|
||||
@@ -46,11 +52,17 @@ function toLegacyDmReasonCode(reasonCode: string): DmGroupAccessReasonCode {
|
||||
case DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED:
|
||||
return reasonCode;
|
||||
default:
|
||||
// Legacy direct-DM consumers only understand the compact DM reason enum.
|
||||
// Unknown ingress reasons fail closed as not-allowlisted.
|
||||
return DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED;
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
|
||||
/**
|
||||
* Resolves legacy direct-DM access and command authorization for channel adapters.
|
||||
*
|
||||
* @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`.
|
||||
*/
|
||||
export async function resolveInboundDirectDmAccessWithRuntime(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: ChannelId;
|
||||
@@ -79,6 +91,8 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
|
||||
readStore: params.readStoreAllowFrom,
|
||||
})
|
||||
: [];
|
||||
// Expand configured and pairing-store allowlists independently so diagnostics and command
|
||||
// authorization use the same effective entries as the legacy DM access decision.
|
||||
const [allowFrom, effectiveStoreAllowFrom] = await Promise.all([
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
@@ -112,6 +126,9 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
|
||||
params.senderId,
|
||||
access.effectiveAllowFrom,
|
||||
);
|
||||
// Older channel runtimes may not inject the shared command authorizer. Keep
|
||||
// the local allowlist decision as the fallback so legacy adapters retain their
|
||||
// pre-access-groups behavior.
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? (params.runtime.resolveCommandAuthorizedFromAuthorizers?.({
|
||||
useAccessGroups: params.cfg.commands?.useAccessGroups !== false,
|
||||
@@ -138,7 +155,12 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
|
||||
/**
|
||||
* Builds the pre-crypto direct-DM authorizer used before encrypted payload
|
||||
* parsing can hand off to normal channel ingress.
|
||||
*
|
||||
* @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`.
|
||||
*/
|
||||
export function createPreCryptoDirectDmAuthorizer(params: {
|
||||
resolveAccess: (
|
||||
senderId: string,
|
||||
@@ -163,6 +185,8 @@ export function createPreCryptoDirectDmAuthorizer(params: {
|
||||
return "allow";
|
||||
}
|
||||
if (access.decision === "pairing") {
|
||||
// Pairing challenges are optional because some adapters only need to signal pairing state
|
||||
// while another layer sends the challenge text.
|
||||
if (params.issuePairingChallenge) {
|
||||
await params.issuePairingChallenge({
|
||||
senderId: input.senderId,
|
||||
@@ -171,6 +195,8 @@ export function createPreCryptoDirectDmAuthorizer(params: {
|
||||
}
|
||||
return "pairing";
|
||||
}
|
||||
// Block notifications stay callback-only so pre-crypto adapters can log or
|
||||
// metric the drop without forcing a reply on hostile or unauthenticated DMs.
|
||||
params.onBlocked?.({
|
||||
senderId: input.senderId,
|
||||
reason: access.reason,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion";
|
||||
|
||||
export type DirectDmPreCryptoGuardPolicy = {
|
||||
/** Provider message kinds accepted before decrypted content is available. */
|
||||
allowedKinds: readonly number[];
|
||||
/** Maximum future timestamp skew accepted before rejecting a message. */
|
||||
maxFutureSkewSec: number;
|
||||
/** Maximum encrypted payload bytes accepted before crypto work starts. */
|
||||
maxCiphertextBytes: number;
|
||||
/** Maximum decrypted plaintext bytes accepted after crypto succeeds. */
|
||||
maxPlaintextBytes: number;
|
||||
/** Per-sender and global limits applied before expensive crypto/decode work. */
|
||||
rateLimit: {
|
||||
windowMs: number;
|
||||
maxPerSenderPerWindow: number;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user