mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-18 12:02:02 +08:00
Compare commits
871 Commits
fix/comman
...
memory-wik
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
162dfdd593 | ||
|
|
5b9e4d93f7 | ||
|
|
debfc3e267 | ||
|
|
bb9b9de146 | ||
|
|
3c37501544 | ||
|
|
f0dfaad99e | ||
|
|
c21971f51c | ||
|
|
615a1f0e1f | ||
|
|
bc3c3e40e8 | ||
|
|
69bc4c697a | ||
|
|
1f52db4230 | ||
|
|
7522ae057c | ||
|
|
0e063984fd | ||
|
|
2d0ddac97c | ||
|
|
2b1df6a73e | ||
|
|
c21cf08013 | ||
|
|
7f6f6d8023 | ||
|
|
f2494aa33f | ||
|
|
5b5018bac5 | ||
|
|
ba484d263b | ||
|
|
ae12aa49c3 | ||
|
|
43b62c8753 | ||
|
|
a14aef191b | ||
|
|
5a1cf20aee | ||
|
|
124cd5e307 | ||
|
|
45663f2879 | ||
|
|
dc7b21bf36 | ||
|
|
e331694df6 | ||
|
|
54a884865e | ||
|
|
36aeef30c2 | ||
|
|
0312085408 | ||
|
|
85c75f6573 | ||
|
|
649de6d156 | ||
|
|
b697cec223 | ||
|
|
6236db5192 | ||
|
|
4c97f0f0ce | ||
|
|
365d5a410b | ||
|
|
8119915664 | ||
|
|
9d8d1dd4c5 | ||
|
|
f336d8c948 | ||
|
|
7087845f58 | ||
|
|
9c9b0effda | ||
|
|
eac6e2d42d | ||
|
|
fb64ba7bf7 | ||
|
|
81f48384cb | ||
|
|
ad49549c92 | ||
|
|
a799fd7ca7 | ||
|
|
1baf5533aa | ||
|
|
282188a326 | ||
|
|
58e822e712 | ||
|
|
eafe0a6d67 | ||
|
|
389075abc8 | ||
|
|
65f9fc397e | ||
|
|
d56831f81b | ||
|
|
0af808b457 | ||
|
|
a227d1cc65 | ||
|
|
1697bb7d23 | ||
|
|
0e51f2f2ab | ||
|
|
17085ec1a4 | ||
|
|
25fae3d722 | ||
|
|
fd6d3f270d | ||
|
|
01e443755c | ||
|
|
7b53b00009 | ||
|
|
0b159d7250 | ||
|
|
9e9730a55e | ||
|
|
294ee477ac | ||
|
|
2988203a5e | ||
|
|
6a559f0293 | ||
|
|
0d3cd4ac42 | ||
|
|
44fd8b0d6e | ||
|
|
947a43dae3 | ||
|
|
d5ed6d26e9 | ||
|
|
a543c240c9 | ||
|
|
86361f4fca | ||
|
|
ce7ef626b8 | ||
|
|
5eb6921a18 | ||
|
|
02c08b3929 | ||
|
|
2197ce62bd | ||
|
|
b3e6822ef8 | ||
|
|
a5ff85f01c | ||
|
|
763dc614c0 | ||
|
|
dbc67a5626 | ||
|
|
424b65b697 | ||
|
|
90a45a4907 | ||
|
|
fca8ff5748 | ||
|
|
ce19b6bf6a | ||
|
|
c19f322ff9 | ||
|
|
49fbecbf16 | ||
|
|
2ceafbafcc | ||
|
|
e811d04db4 | ||
|
|
91a3af4e24 | ||
|
|
eb4bc200d7 | ||
|
|
494c25b0c4 | ||
|
|
8a6bb1b80e | ||
|
|
880def088e | ||
|
|
b9179ee4b6 | ||
|
|
68bfc6fcf5 | ||
|
|
de2182877a | ||
|
|
5a3293d400 | ||
|
|
28458fa4a8 | ||
|
|
60cd350220 | ||
|
|
e8ea1fe99d | ||
|
|
9a9dc1dbec | ||
|
|
c5a181bf8b | ||
|
|
adededf2f9 | ||
|
|
b3afb2f950 | ||
|
|
38935d18c7 | ||
|
|
242b2e66f2 | ||
|
|
3243c9b5b0 | ||
|
|
2ddeed40c8 | ||
|
|
f13815d9bd | ||
|
|
e1d026f575 | ||
|
|
a305f2f6b0 | ||
|
|
565a228591 | ||
|
|
647aade27a | ||
|
|
1ac4e46cbb | ||
|
|
6c69998291 | ||
|
|
bbfc46fe02 | ||
|
|
b7824ec414 | ||
|
|
1335ce37a8 | ||
|
|
c4dcaf91cd | ||
|
|
858b194095 | ||
|
|
5a89ffe0d8 | ||
|
|
e69b4e4606 | ||
|
|
bc6e5128d2 | ||
|
|
d34d88e0a3 | ||
|
|
55eb9841d9 | ||
|
|
f285087c85 | ||
|
|
f7dc5f930a | ||
|
|
944199d77b | ||
|
|
674b658ff0 | ||
|
|
fb10773a38 | ||
|
|
58744f3d87 | ||
|
|
087eb621ff | ||
|
|
b28cc98c9b | ||
|
|
04681e9770 | ||
|
|
fbb56f0ed2 | ||
|
|
6c7426ed54 | ||
|
|
cf2fc4fdbb | ||
|
|
38a673b688 | ||
|
|
37dccb52ed | ||
|
|
cdbef11809 | ||
|
|
b176bf13af | ||
|
|
6239ab3667 | ||
|
|
59318d9ff8 | ||
|
|
fab7b2a4de | ||
|
|
b081f88952 | ||
|
|
1c3f82dcef | ||
|
|
13a60aa93b | ||
|
|
625fd5b3e3 | ||
|
|
c8b7058058 | ||
|
|
db76f18712 | ||
|
|
7b79579d20 | ||
|
|
e608b7e6f6 | ||
|
|
e318f48ff2 | ||
|
|
371c4147f3 | ||
|
|
768e606f96 | ||
|
|
28d478dc52 | ||
|
|
679a393f6d | ||
|
|
0a6fd459f9 | ||
|
|
dfec7d7f80 | ||
|
|
972fe9286d | ||
|
|
a5991e8017 | ||
|
|
1b2f640c5a | ||
|
|
997a16fa50 | ||
|
|
ad0c4309e6 | ||
|
|
c00cd4b414 | ||
|
|
a3b2fdf7d6 | ||
|
|
0fab2b9b4e | ||
|
|
bcb14cdc40 | ||
|
|
43cc92dc07 | ||
|
|
7155aa9c15 | ||
|
|
9a66b9cd54 | ||
|
|
68e421c487 | ||
|
|
b8451e26a3 | ||
|
|
13df67ebc8 | ||
|
|
d01ec5cee9 | ||
|
|
e8817dde8e | ||
|
|
e16a64ba1a | ||
|
|
7b36fa7672 | ||
|
|
f5c0356b37 | ||
|
|
db0b91417e | ||
|
|
c25ed721f8 | ||
|
|
9fcef82f2d | ||
|
|
41b1d3647c | ||
|
|
820201a343 | ||
|
|
0d5f386f5c | ||
|
|
aad3bbebdd | ||
|
|
e8fb140642 | ||
|
|
50f5831382 | ||
|
|
39ca8d1c53 | ||
|
|
576fb46e28 | ||
|
|
8822f779d9 | ||
|
|
775fa78b1e | ||
|
|
1dea64ab99 | ||
|
|
829fe14188 | ||
|
|
cd313c7f67 | ||
|
|
4504efb7ec | ||
|
|
870cc22cb0 | ||
|
|
575c486ef4 | ||
|
|
b514d61000 | ||
|
|
e42f11ed62 | ||
|
|
ea7297b344 | ||
|
|
059197e496 | ||
|
|
8e8c7344bd | ||
|
|
34c1e53792 | ||
|
|
c12cbaca66 | ||
|
|
02261e931c | ||
|
|
f1b7dd6c0a | ||
|
|
7240830ca4 | ||
|
|
e2f0ea4625 | ||
|
|
2aabe0e8fd | ||
|
|
80826bc000 | ||
|
|
d0562a873f | ||
|
|
42ae213ba6 | ||
|
|
88a63a1816 | ||
|
|
1aca95ae15 | ||
|
|
86679ba84e | ||
|
|
0cb162f05c | ||
|
|
c15919846c | ||
|
|
df58f73a2d | ||
|
|
2091334399 | ||
|
|
687bb21b28 | ||
|
|
bd99671756 | ||
|
|
9c04bdf6de | ||
|
|
d9f1c61361 | ||
|
|
808c34b374 | ||
|
|
8c8c5fa635 | ||
|
|
8d05bdda43 | ||
|
|
9869941c06 | ||
|
|
21802f750f | ||
|
|
1275b9b873 | ||
|
|
7e1f04f36a | ||
|
|
326b36794f | ||
|
|
7a2abb1c50 | ||
|
|
ce1d2c1004 | ||
|
|
c2cd1aed5d | ||
|
|
d0e53a3529 | ||
|
|
ac9464441c | ||
|
|
8cde0167c5 | ||
|
|
60ec27bce0 | ||
|
|
998cc02af4 | ||
|
|
a1e0090fe4 | ||
|
|
dc39e84fdd | ||
|
|
7cf72f7bc8 | ||
|
|
c569e5faba | ||
|
|
fdacaf0853 | ||
|
|
f60c1bb9ad | ||
|
|
67c4733267 | ||
|
|
5b1b7f0f80 | ||
|
|
27d4992eef | ||
|
|
c9c656f2cb | ||
|
|
3107faf571 | ||
|
|
123cc880f3 | ||
|
|
4ff82e9c4a | ||
|
|
e0a0d1f0b3 | ||
|
|
6f900b55fa | ||
|
|
f2602a5d7b | ||
|
|
4dbe8f9f66 | ||
|
|
05e89ff117 | ||
|
|
8b501986aa | ||
|
|
b059328f60 | ||
|
|
8c7dd66a7b | ||
|
|
2f115bc645 | ||
|
|
d9fbfa268f | ||
|
|
d03985415d | ||
|
|
b7be963501 | ||
|
|
59eb291c6e | ||
|
|
80a37ef32a | ||
|
|
7dc085890e | ||
|
|
bbe9b7ba15 | ||
|
|
a03e430248 | ||
|
|
e169fcd263 | ||
|
|
54cd8ed25b | ||
|
|
69f4022950 | ||
|
|
dde1aa8fed | ||
|
|
86f15687b5 | ||
|
|
47e6c57a7a | ||
|
|
b59560c49a | ||
|
|
8c1b954c1b | ||
|
|
44f3539c4f | ||
|
|
ac783d75f0 | ||
|
|
35fd766131 | ||
|
|
f289ba6a05 | ||
|
|
9fd47a5aed | ||
|
|
8d2ccd851c | ||
|
|
d34b3ec701 | ||
|
|
53f6d962c2 | ||
|
|
fc4203d9d9 | ||
|
|
78ff2519e0 | ||
|
|
44cf74717b | ||
|
|
98e27b8a09 | ||
|
|
ca72c2677b | ||
|
|
ba1ffaca57 | ||
|
|
8e62c12ff3 | ||
|
|
4436ca23ca | ||
|
|
416a3148e9 | ||
|
|
1092691d14 | ||
|
|
9cd225ebbe | ||
|
|
ddd0fcdc83 | ||
|
|
4094bf9985 | ||
|
|
96d575de1d | ||
|
|
6f7d0a016c | ||
|
|
338c7b8d66 | ||
|
|
8cea63c61b | ||
|
|
5291a2cfd1 | ||
|
|
6ab359f5a9 | ||
|
|
695176542f | ||
|
|
f0ba7b95da | ||
|
|
76592217ff | ||
|
|
c8ff474c28 | ||
|
|
ed156ee2de | ||
|
|
ccd3fec23e | ||
|
|
add2b8e9f0 | ||
|
|
7a289940cd | ||
|
|
2499accca0 | ||
|
|
3da203556c | ||
|
|
1b243ad562 | ||
|
|
d8dbacb900 | ||
|
|
64c18bc77b | ||
|
|
3342096336 | ||
|
|
d27370702b | ||
|
|
d4360f8068 | ||
|
|
9b7c0bf8e9 | ||
|
|
3d23103081 | ||
|
|
474db91bed | ||
|
|
09b31f9123 | ||
|
|
7f6277b6e5 | ||
|
|
11eed107f4 | ||
|
|
9e2a1e12fd | ||
|
|
77a161c811 | ||
|
|
8a40cd7ed4 | ||
|
|
325ff24bae | ||
|
|
533bd00001 | ||
|
|
999d88d13d | ||
|
|
782247b423 | ||
|
|
fef4e4621a | ||
|
|
a4253deb67 | ||
|
|
3417dbabf4 | ||
|
|
ab6aa28049 | ||
|
|
61f7d53731 | ||
|
|
899f490c9c | ||
|
|
f178a9dc41 | ||
|
|
a88f240311 | ||
|
|
560a7aecd0 | ||
|
|
59ccea334d | ||
|
|
a685a7afc9 | ||
|
|
f96c753ed3 | ||
|
|
6cb11360fa | ||
|
|
d6b2be95b6 | ||
|
|
1111639a11 | ||
|
|
8daf60e2d9 | ||
|
|
eab50fe488 | ||
|
|
061a9b5c58 | ||
|
|
ac3f55504c | ||
|
|
f4fcaa09a3 | ||
|
|
b44c10e91c | ||
|
|
017c25b075 | ||
|
|
b8c8139138 | ||
|
|
98b76d83ea | ||
|
|
f832388e0e | ||
|
|
8c38c662c1 | ||
|
|
fbebf6147c | ||
|
|
191f867ef6 | ||
|
|
1ce9ab36df | ||
|
|
1f6e303e41 | ||
|
|
063ed12bc6 | ||
|
|
fcd9a04e47 | ||
|
|
d6b1cce55c | ||
|
|
0003a3cf3e | ||
|
|
4a7edbf471 | ||
|
|
d5801c03ed | ||
|
|
1566a5b3bc | ||
|
|
d014472ab8 | ||
|
|
a1281d45b2 | ||
|
|
539a8b1619 | ||
|
|
10bc10b853 | ||
|
|
f16e9364d2 | ||
|
|
978513aa6b | ||
|
|
e5be7c2cd4 | ||
|
|
f11005de45 | ||
|
|
13d1fc077b | ||
|
|
ad8341676e | ||
|
|
bd2ac38c1d | ||
|
|
01dc9792fc | ||
|
|
3a1ca98e53 | ||
|
|
d2a03eca1a | ||
|
|
a690eafdf7 | ||
|
|
6e482fc7cf | ||
|
|
d14497301f | ||
|
|
9d37f1e5df | ||
|
|
e87300e2f4 | ||
|
|
7388600b06 | ||
|
|
a6e1fe0296 | ||
|
|
917373b69c | ||
|
|
fe9c4fcf51 | ||
|
|
e336311126 | ||
|
|
d94938ff54 | ||
|
|
3a42641208 | ||
|
|
6164e83b44 | ||
|
|
425592cf9c | ||
|
|
1e7f39abdb | ||
|
|
7071f6e442 | ||
|
|
9697925d4a | ||
|
|
955f38086b | ||
|
|
8f592657ed | ||
|
|
cba1ac3c05 | ||
|
|
453f874d64 | ||
|
|
a27a632e9d | ||
|
|
0db491294b | ||
|
|
d08abd8ce4 | ||
|
|
07020c5627 | ||
|
|
38715ef1b5 | ||
|
|
7928da0f48 | ||
|
|
e2c41df0b9 | ||
|
|
49aef60447 | ||
|
|
0fdb176465 | ||
|
|
4aa31ee6e1 | ||
|
|
e0018999b3 | ||
|
|
bd2798ec5f | ||
|
|
5c9ec970b8 | ||
|
|
1990d2e761 | ||
|
|
ab0c102ed7 | ||
|
|
096e6462c7 | ||
|
|
79f02b6e54 | ||
|
|
ecc13c65f5 | ||
|
|
9005521d63 | ||
|
|
1722bfab93 | ||
|
|
4603f231c3 | ||
|
|
2a6e8dca47 | ||
|
|
3eecbc3c7d | ||
|
|
8f64e1e061 | ||
|
|
a0cf1cc4ad | ||
|
|
637a5b137e | ||
|
|
ca8570be02 | ||
|
|
a463a33eee | ||
|
|
ee04ba0386 | ||
|
|
ee03ad7d9c | ||
|
|
b27a6f8196 | ||
|
|
31d05bb3a4 | ||
|
|
d8e7017326 | ||
|
|
2b5d8ac951 | ||
|
|
15c218c43f | ||
|
|
201697c200 | ||
|
|
421db1a5ec | ||
|
|
7901296153 | ||
|
|
cd09f41fe0 | ||
|
|
7dd23a59db | ||
|
|
c4e9189dd3 | ||
|
|
32eff914c6 | ||
|
|
d88eb0e031 | ||
|
|
e9befcff9e | ||
|
|
08c0018536 | ||
|
|
dc08aea25f | ||
|
|
ea78fb2d32 | ||
|
|
8010f00816 | ||
|
|
f97b1fa0c3 | ||
|
|
7853d42ee9 | ||
|
|
c595ff8e62 | ||
|
|
e894d98cb2 | ||
|
|
41f20e9143 | ||
|
|
2f4b322911 | ||
|
|
e420468ebd | ||
|
|
8e3c597e80 | ||
|
|
29163a8caa | ||
|
|
0b7f6fa9d0 | ||
|
|
a8ac0b7976 | ||
|
|
92e3299793 | ||
|
|
b7e249fc08 | ||
|
|
58c670acc2 | ||
|
|
ca73e598e0 | ||
|
|
08296e9645 | ||
|
|
f6c3474342 | ||
|
|
e44a995e83 | ||
|
|
528868ef76 | ||
|
|
80c8567f9d | ||
|
|
9d7459f182 | ||
|
|
f7109c15f5 | ||
|
|
16ec0b5a8c | ||
|
|
5a4ca2f608 | ||
|
|
223a6a1d9f | ||
|
|
b1905c1423 | ||
|
|
9bee2a4ede | ||
|
|
0cc4f50576 | ||
|
|
e88c39b0a1 | ||
|
|
1ad4926839 | ||
|
|
5ab1b16098 | ||
|
|
d56e343d30 | ||
|
|
daa0a755df | ||
|
|
d780eb1301 | ||
|
|
7ebd78cf1b | ||
|
|
bd71ddabbd | ||
|
|
9ba1b91936 | ||
|
|
d0a1ecb768 | ||
|
|
61fbc9ad2e | ||
|
|
5417d88569 | ||
|
|
377637ca67 | ||
|
|
1a63f5b972 | ||
|
|
0a34c40e10 | ||
|
|
58696ef3a2 | ||
|
|
238d9a6510 | ||
|
|
c390e7c6ce | ||
|
|
961f527842 | ||
|
|
1a08d23e09 | ||
|
|
cfebdee073 | ||
|
|
5f7fa588db | ||
|
|
3700f3a22c | ||
|
|
a41e50efbc | ||
|
|
106b2794c5 | ||
|
|
1a893132f6 | ||
|
|
efd9aaea3f | ||
|
|
79a84f070d | ||
|
|
c03071d36c | ||
|
|
7a3497c8bd | ||
|
|
bda7131367 | ||
|
|
c3f806c9e4 | ||
|
|
e92c2b63f9 | ||
|
|
48a3511233 | ||
|
|
079494aee5 | ||
|
|
a29b501ec9 | ||
|
|
4ae1599ea5 | ||
|
|
d806682f78 | ||
|
|
0e05a304b6 | ||
|
|
b96589b1fc | ||
|
|
c7e0150af2 | ||
|
|
c6b54e1cef | ||
|
|
ef252976bc | ||
|
|
c9f288ceaf | ||
|
|
6b6c95b443 | ||
|
|
ca27d932b4 | ||
|
|
5b6e552b51 | ||
|
|
ca26489fe8 | ||
|
|
0b55c0ec81 | ||
|
|
61d9143b63 | ||
|
|
ae79210ddd | ||
|
|
4e266253ce | ||
|
|
87bcfe796f | ||
|
|
aa4cb43627 | ||
|
|
51c6b1c2bc | ||
|
|
48f2c2097d | ||
|
|
ed64ce3983 | ||
|
|
7c256bfdf4 | ||
|
|
6e9382b5c8 | ||
|
|
37d7c716f4 | ||
|
|
7e3f345ee9 | ||
|
|
e3d6209599 | ||
|
|
901fb18217 | ||
|
|
55ae9addc1 | ||
|
|
1919332fd3 | ||
|
|
1b9a1328a1 | ||
|
|
23d4aec907 | ||
|
|
f2bbf2b8e7 | ||
|
|
95df6d9332 | ||
|
|
7d54f2a3c2 | ||
|
|
78639eff76 | ||
|
|
c0d3743cdb | ||
|
|
a89f171865 | ||
|
|
78a948ee32 | ||
|
|
f5bb8cbb98 | ||
|
|
ccfdfec43f | ||
|
|
1366b943e5 | ||
|
|
ce61cb48ec | ||
|
|
e7ab634830 | ||
|
|
b83ddf3cef | ||
|
|
6d52014ef8 | ||
|
|
38e4fb3642 | ||
|
|
4bcc58fc6d | ||
|
|
1dc1635851 | ||
|
|
bfc37b42a5 | ||
|
|
673a08ccf5 | ||
|
|
f4d8393bf4 | ||
|
|
8d2daf7ef2 | ||
|
|
4ad1d96e5d | ||
|
|
681931345b | ||
|
|
153d06f890 | ||
|
|
7306cf3707 | ||
|
|
43f84890ce | ||
|
|
66aeb5ce23 | ||
|
|
06f2b90a0f | ||
|
|
8065586d13 | ||
|
|
1a7c3eb4fc | ||
|
|
5ac49b01c6 | ||
|
|
a5b5632809 | ||
|
|
637bc8e458 | ||
|
|
24f4322141 | ||
|
|
b523d6559c | ||
|
|
fd05e7ca1a | ||
|
|
60fb7a318e | ||
|
|
413a5ef75a | ||
|
|
1013cb3a5d | ||
|
|
a336c31962 | ||
|
|
d6d999eda6 | ||
|
|
9d36e7a73a | ||
|
|
501977106c | ||
|
|
1b7e16668e | ||
|
|
8ff570ee42 | ||
|
|
bc18e69fbf | ||
|
|
b7d3a26356 | ||
|
|
177be0f237 | ||
|
|
95106be59b | ||
|
|
1dc3ee6165 | ||
|
|
5ac2f58c57 | ||
|
|
8f421f0e78 | ||
|
|
134ff61754 | ||
|
|
aaa5dea358 | ||
|
|
b6e0a24d50 | ||
|
|
f9c721d5bf | ||
|
|
66405d5e8a | ||
|
|
c50e3c676a | ||
|
|
d4130e83c6 | ||
|
|
50628ef62c | ||
|
|
a2be2abc28 | ||
|
|
2edc3c8a3e | ||
|
|
5656f6c7ff | ||
|
|
27dc1bd0fc | ||
|
|
37b7e22e13 | ||
|
|
7a736bff90 | ||
|
|
06d57e5107 | ||
|
|
a040de33f1 | ||
|
|
b4ec7d77ce | ||
|
|
c185413a8e | ||
|
|
ff414df15f | ||
|
|
9f4c2caf06 | ||
|
|
9663343183 | ||
|
|
26b401c8e5 | ||
|
|
a171de283f | ||
|
|
283b103e75 | ||
|
|
b589de7a4f | ||
|
|
5116ce2d5e | ||
|
|
3826af6c40 | ||
|
|
800ac580b1 | ||
|
|
bf24bd16f3 | ||
|
|
ab7777b169 | ||
|
|
cae4538a86 | ||
|
|
8fdaa5da49 | ||
|
|
6dfdc92bd4 | ||
|
|
dab4a4790d | ||
|
|
2d0618f8b5 | ||
|
|
d9f21433a8 | ||
|
|
33cdb342cb | ||
|
|
ec55902989 | ||
|
|
0cebe9d593 | ||
|
|
e43a1f235e | ||
|
|
41ea5316aa | ||
|
|
dd978bf975 | ||
|
|
58d7df7985 | ||
|
|
0419bf6324 | ||
|
|
88dd641c6a | ||
|
|
3d5668c305 | ||
|
|
739ce82015 | ||
|
|
e77d72a91d | ||
|
|
10802e20d6 | ||
|
|
e6b95624d9 | ||
|
|
f8fc7f3e41 | ||
|
|
7ae8a10087 | ||
|
|
226e1afa4d | ||
|
|
95fe63e63f | ||
|
|
a211f09259 | ||
|
|
dfa14001a4 | ||
|
|
37e89b930f | ||
|
|
80789809a4 | ||
|
|
41da6faa9e | ||
|
|
dd0cd5dcda | ||
|
|
00e46301a4 | ||
|
|
90f33ed5da | ||
|
|
0153d102d7 | ||
|
|
d1a4cf28cc | ||
|
|
b66915a957 | ||
|
|
54f2de7e1c | ||
|
|
6243ca50e0 | ||
|
|
3921bb2df6 | ||
|
|
b5c9a46633 | ||
|
|
ff8f46884a | ||
|
|
e6c1e9c64b | ||
|
|
b98cccc06e | ||
|
|
81b0f280be | ||
|
|
4c8bb05c89 | ||
|
|
8f7792317d | ||
|
|
6a052ca4b8 | ||
|
|
a47c49bbf3 | ||
|
|
510fca655a | ||
|
|
348cd6b17a | ||
|
|
96b39e01b4 | ||
|
|
f8818a574c | ||
|
|
317e3f631a | ||
|
|
fe7059696b | ||
|
|
673878188d | ||
|
|
8ae6cf32bb | ||
|
|
71dd337628 | ||
|
|
ab96703b5c | ||
|
|
a484d08f5c | ||
|
|
09fc834c75 | ||
|
|
b4785525df | ||
|
|
4610ceb2a5 | ||
|
|
8301ddfa84 | ||
|
|
a22e44f259 | ||
|
|
1430de95a5 | ||
|
|
f3c00048cf | ||
|
|
f88b6ffb48 | ||
|
|
48fea1021a | ||
|
|
7d9a6b5572 | ||
|
|
f8920e96d0 | ||
|
|
c817bb87d4 | ||
|
|
24492b51c9 | ||
|
|
bb29c8696a | ||
|
|
8f2ff2497a | ||
|
|
8e2ecd053f | ||
|
|
725cbcc362 | ||
|
|
309154085b | ||
|
|
c1fa747f69 | ||
|
|
a5a7ea0e39 | ||
|
|
f1d7e9b569 | ||
|
|
23f3a2d59d | ||
|
|
6b543cafee | ||
|
|
0eb6cec32b | ||
|
|
a4223f836d | ||
|
|
345c71f264 | ||
|
|
87617c44ba | ||
|
|
40ea257792 | ||
|
|
7f6de686bb | ||
|
|
c5973755fd | ||
|
|
1acadc5bbf | ||
|
|
a20bc8640b | ||
|
|
594ea6e1b9 | ||
|
|
b4e1747391 | ||
|
|
d733786cf7 | ||
|
|
30c686423f | ||
|
|
d1414477a4 | ||
|
|
6acb43f294 | ||
|
|
1880b104ed | ||
|
|
f7f861082a | ||
|
|
51f77b5e04 | ||
|
|
0f224724dc | ||
|
|
ec359f5942 | ||
|
|
67520b6abf | ||
|
|
0335a8783c | ||
|
|
a47cb0a3b3 | ||
|
|
c7cc89904e | ||
|
|
e7e3f11b20 | ||
|
|
ce30557399 | ||
|
|
591347113e | ||
|
|
943d7de240 | ||
|
|
e7fe087677 | ||
|
|
6dc3e1f770 | ||
|
|
5f906c926d | ||
|
|
350238d402 | ||
|
|
e70168212d | ||
|
|
7af1def025 | ||
|
|
7422e90053 | ||
|
|
f2cd2c00b0 | ||
|
|
d25491aa6d | ||
|
|
1c5cbad0a6 | ||
|
|
1aee8c55ce | ||
|
|
a86fa3b211 | ||
|
|
ce87d5e242 | ||
|
|
5d7a73380f | ||
|
|
c01b4981af | ||
|
|
bedfa576a3 | ||
|
|
645c331200 | ||
|
|
79a0c71874 | ||
|
|
a797068206 | ||
|
|
5d0e8336ab | ||
|
|
8b79cbcd06 | ||
|
|
860721f28d | ||
|
|
220d10cad3 | ||
|
|
723c0ea2b7 | ||
|
|
6f841ff121 | ||
|
|
e1a047c43f | ||
|
|
a8436f0220 | ||
|
|
821a30981a | ||
|
|
2fef1ccbe7 | ||
|
|
0ffceca50a | ||
|
|
1b9ec88d9c | ||
|
|
0f5919a4ba | ||
|
|
a65f9971b7 | ||
|
|
0b36423f97 | ||
|
|
38c520acc3 | ||
|
|
84c182deb2 | ||
|
|
096d0cf412 | ||
|
|
e79d2ecd9e | ||
|
|
21f59a0ad5 | ||
|
|
672fcb187d | ||
|
|
9100923395 | ||
|
|
f2a710ce63 | ||
|
|
87b2a6a16a | ||
|
|
506b4decbd | ||
|
|
9c82974082 | ||
|
|
93338ffbcc | ||
|
|
c88870ac93 | ||
|
|
ad9481e2d1 | ||
|
|
e8c7481fd2 | ||
|
|
4a84412b3a | ||
|
|
8aeee0dc6d | ||
|
|
a830f4de4b | ||
|
|
8a33a8d607 | ||
|
|
8477f1841a | ||
|
|
d60149c655 | ||
|
|
c109a7623b | ||
|
|
eef80f31cf | ||
|
|
074e6d5047 | ||
|
|
6775611c5d | ||
|
|
9e41b2ffd6 | ||
|
|
6b12e3ebf6 | ||
|
|
c3b19d204a | ||
|
|
349a1c58f9 | ||
|
|
cdf321b320 | ||
|
|
9c24bda43b | ||
|
|
a6a379b37c | ||
|
|
00f256dd31 | ||
|
|
aa6f6135db | ||
|
|
2d481c9329 | ||
|
|
aaf5307638 | ||
|
|
22d8e47a50 | ||
|
|
0b9993df95 | ||
|
|
56136c83b7 | ||
|
|
c22372dec6 | ||
|
|
de20d3a024 | ||
|
|
7785dc21e6 | ||
|
|
6cc54e5059 | ||
|
|
7a5e65c71b | ||
|
|
44cd91b0a9 | ||
|
|
2d7d99f66e | ||
|
|
281ea15550 | ||
|
|
39c721d382 | ||
|
|
cfb7779584 | ||
|
|
d5bfc79112 | ||
|
|
90d246959b | ||
|
|
4ef8f4f53c | ||
|
|
41c700fe9e | ||
|
|
d425aa0912 | ||
|
|
514328a9ad | ||
|
|
9ca935720c | ||
|
|
ab564f8446 | ||
|
|
0c5e6037b0 | ||
|
|
2b6e08bbfa | ||
|
|
d82644cdc8 | ||
|
|
d7e3df5eaa | ||
|
|
c1c1c0f351 | ||
|
|
c52d896ef0 | ||
|
|
a55d45de3c | ||
|
|
16d0f0567e | ||
|
|
a200a746fc | ||
|
|
a58726e1ed | ||
|
|
f94a018191 | ||
|
|
1fb44f0aad | ||
|
|
0f8480ca0b | ||
|
|
77f9f6112e | ||
|
|
eef20a87d0 | ||
|
|
9c3d9c5c18 | ||
|
|
7f336aba56 | ||
|
|
c7a562683a | ||
|
|
cb770057b0 | ||
|
|
2537ae503d | ||
|
|
378b2c2f5c | ||
|
|
d12029a15a | ||
|
|
8fe7b3730f | ||
|
|
1234c873bc | ||
|
|
c921a6ecad | ||
|
|
a010ce462f | ||
|
|
5765c4cb2a | ||
|
|
4d405ac5ae | ||
|
|
b35b176837 | ||
|
|
6067f2d9ad | ||
|
|
baf4119ae3 | ||
|
|
b77964f704 | ||
|
|
3ded10f52a | ||
|
|
5a54005b4d | ||
|
|
6f566585d8 | ||
|
|
e777a2b230 | ||
|
|
a36bb119be | ||
|
|
2815e8ecc0 |
@@ -39,7 +39,6 @@ pnpm openclaw qa suite \
|
||||
--provider-mode live-openai \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--fast \
|
||||
--output-dir .artifacts/qa-e2e/run-all-live-openai-<tag>
|
||||
```
|
||||
|
||||
|
||||
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -257,6 +257,10 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/acpx/**"
|
||||
"extensions: arcee":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/arcee/**"
|
||||
"extensions: byteplus":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
BIN
.github/pr-assets/compaction-checkpoints/sessions-checkpoints-inline.png
vendored
Normal file
BIN
.github/pr-assets/compaction-checkpoints/sessions-checkpoints-inline.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
.github/pr-assets/compaction-checkpoints/sessions-overview-inline.png
vendored
Normal file
BIN
.github/pr-assets/compaction-checkpoints/sessions-overview-inline.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
97
CHANGELOG.md
97
CHANGELOG.md
@@ -7,34 +7,81 @@ Docs: https://docs.openclaw.ai
|
||||
### Changes
|
||||
|
||||
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
|
||||
- Tools/media: document per-provider music and video generation capabilities, and add shared live video-to-video sweep coverage for providers that support local reference clips.
|
||||
- Tools/media generation: preserve intent across auth-backed image, music, and video provider fallback, remap size, aspect ratio, resolution, and duration hints to the closest supported option, and surface explicit provider capabilities plus mode-aware video-to-video support.
|
||||
- Memory/wiki: restore the bundled `memory-wiki` stack with plugin, CLI, sync/query/apply tooling, and memory-host integration for wiki-backed memory workflows.
|
||||
- Providers/Arcee AI: add a bundled Arcee AI provider plugin with Trinity catalog entries, OpenRouter support, and updated onboarding/auth guidance. (#62068) Thanks @arthurbr11.
|
||||
- Providers/Google: add Gemma 4 model support and keep Google fallback resolution on the requested provider path so native Google Gemma routes work again. (#61507) Thanks @eyjohn.
|
||||
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, doctor flows, and Docker Claude CLI live lanes again.
|
||||
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.5.1` so plugin-local installs and strict version checks pick up the latest published runtime release. (#62148) Thanks @onutc.
|
||||
- Tools/media generation: auto-fallback across auth-backed image, music, and video providers by default, and remap fallback size, aspect ratio, resolution, and duration hints to the closest supported option instead of dropping intent on provider switches.
|
||||
- Tools/media generation: report applied fallback geometry and duration settings consistently in tool results, add a shared normalization contract for image/music/video runtimes, and simplify the bundled image-generation-core runtime test to only verify the plugin-sdk re-export seam.
|
||||
- Gateway/sessions: add persisted compaction checkpoints plus Sessions UI branch/restore actions so operators can inspect and recover pre-compaction session state. (#62146) Thanks @scoootscooob.
|
||||
- Providers/Ollama: detect vision capability from the `/api/show` response and set image input on models that support it so Ollama vision models accept image attachments. (#62193) Thanks @BruceMacD.
|
||||
- Memory/dreaming: ingest redacted session transcripts into the dreaming corpus with per-day session-corpus notes, cursor checkpointing, and promotion/doctor support. (#62227) Thanks @vignesh07.
|
||||
- Plugins/memory: add a public memory-artifact export seam to the unified memory capability so companion plugins like `memory-wiki` can bridge the active memory plugin without reaching into `memory-core` internals. Thanks @vincentkoc.
|
||||
- Memory/wiki: add structured claim/evidence fields plus compiled agent digest artifacts so `memory-wiki` behaves more like a persistent knowledge layer and less like markdown-only page storage. Thanks @vincentkoc.
|
||||
- Memory/wiki: add claim-health linting, contradiction clustering, staleness-aware dashboards, and freshness-weighted wiki search so `memory-wiki` can act more like a maintained belief layer than a passive markdown dump. Thanks @vincentkoc.
|
||||
- Memory/wiki: use compiled digest artifacts as the first-pass wiki index for search/get flows, and resolve claim ids back to owning pages so agents can retrieve knowledge by belief identity instead of only by file path. Thanks @vincentkoc.
|
||||
- Memory/wiki: add an opt-in `context.includeCompiledDigestPrompt` flag so memory prompt supplements can append a compact compiled wiki snapshot for legacy prompt assembly and context engines that explicitly consume memory prompt sections. Thanks @vincentkoc.
|
||||
- Memory/wiki: add task-backed `wiki import` with automatic local-file and markdown-vault detection so existing note stores can be backfilled into source pages with shared task progress instead of ad hoc one-off ingest flows. Thanks @vincentkoc.
|
||||
- Memory/wiki: keep imported Obsidian and Logseq notes readable by preserving markdown note bodies plus imported tags, aliases, and link hints instead of flattening every vault note into a fenced text blob. Thanks @vincentkoc.
|
||||
- Memory/wiki: use imported vault tags, aliases, and link hints in the compiled digest and wiki search ranking so imported Obsidian and Logseq notes are easier to recall by their original note metadata. Thanks @vincentkoc.
|
||||
- Memory/wiki: use imported vault aliases and link hints when building `## Related` backlinks so imported Obsidian and Logseq notes can reconnect their note graph after import. Thanks @vincentkoc.
|
||||
- Memory/wiki: let `wiki_get` and metadata updates resolve imported vault titles and aliases directly, so imported notes stay addressable by their original note names instead of only generated paths. Thanks @vincentkoc.
|
||||
- Memory/wiki: upgrade `reports/import-review.md` to flag duplicate imported titles and aliases plus obviously low-signal notes, so large vault imports are easier to triage before promotion or synthesis work. Thanks @vincentkoc.
|
||||
- Memory/wiki: add duplicate-body clustering to `reports/import-review.md` so large vault imports can surface copied or renamed notes even when titles and aliases differ. Thanks @vincentkoc.
|
||||
- Memory/wiki: preserve imported markdown vault relative paths in digest, lookup, and related-link reconstruction so imported note identity survives search and `wiki_get`. Thanks @vincentkoc.
|
||||
- Memory/wiki: auto-detect and import ChatGPT export JSON files as conversation source pages instead of misclassifying them as generic local files. Thanks @vincentkoc.
|
||||
- Plugin SDK/context engines: pass `availableTools` and `citationsMode` into `assemble()`, and expose `buildMemorySystemPromptAddition(...)` so non-legacy context engines can adopt the active memory prompt path without reimplementing it. Thanks @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Providers/Google: recognize Gemma model ids in native Google forward-compat resolution, keep the requested provider when cloning fallback templates, and force Gemma reasoning off so Gemma 4 routes stop failing through the Google catalog fallback. (#61507) Thanks @eyjohn.
|
||||
- Providers/Anthropic: skip `service_tier` injection for OAuth-authenticated stream wrapper requests so Claude OAuth requests stop failing with HTTP 401. (#60356) thanks @openperf.
|
||||
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, and fail loud on invalid elevated cross-host overrides. (#61739) Thanks @obviyus.
|
||||
- Agents/history: suppress commentary-only visible-text leaks in streaming and chat history views, and keep sanitized SSE history sequence numbers monotonic after transcript-only refreshes. (#61829) Thanks @100yenadmin.
|
||||
- Plugins/Windows: load plugin entrypoints through `file://` import specifiers on Windows without breaking plugin SDK alias resolution, fixing `ERR_UNSUPPORTED_ESM_URL_SCHEME` for absolute plugin paths. (#61832) Thanks @Zeesejo.
|
||||
- Discord/forwarding: recover forwarded referenced message text and attachments when Discord omits snapshot payloads, so forwarded-message relays keep the original content. (#61670) Thanks @artwalker.
|
||||
- Plugins/install: preserve plugin-schema defaults during fresh-install raw config validation so bundled plugin installs stop failing when required fields rely on schema defaults. (#61856) Thanks @SuperMarioYL.
|
||||
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
|
||||
- Agents/message tool: add a `read` plus `threadId` discoverability hint when the configured channel actions support threaded message reads.
|
||||
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
|
||||
- Agents/heartbeat: stop truncating live session transcripts after no-op heartbeat acks, move heartbeat cleanup to prompt assembly and compaction, and keep post-filter context-engine ingestion aligned with the real session baseline. (#60998) Thanks @nxmxbbd.
|
||||
- Plugins/media: when `plugins.allow` is set, capability fallback now merges bundled capability plugin ids into the allowlist (not only `plugins.entries`), so media understanding providers such as OpenAI-compatible STT load for voice transcription without requiring `openai` in `plugins.allow`. (#62205) Thanks @neeravmakwana.
|
||||
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
|
||||
- Agents/history and replies: buffer phaseless OpenAI WS text until a real assistant phase arrives, keep replay and SSE history sequence tracking aligned, hide commentary and leaked tool XML from user-visible history, and keep history-based follow-up replies on `final_answer` text only. (#61729, #61747, #61829, #61855, #61954) Thanks @100yenadmin, @afurm, and @openperf.
|
||||
- Plugins/channels: keep bundled channel artifact and secret-contract loading stable under lazy loading, preserve plugin-schema defaults during install, and fix Windows `file://` plus native-Jiti plugin loader paths so onboarding, doctor, `openclaw secret`, and bundled plugin installs work again. (#61832, #61836, #61853, #61856) Thanks @Zeesejo and @SuperMarioYL.
|
||||
- Auto-reply/media: allow managed generated-media `MEDIA:` paths from normal reply text again while still blocking arbitrary host-local media and document paths, so generated media keep delivering without reopening host-path injection holes.
|
||||
- Runtime event trust: mark background `notifyOnExit` summaries, ACP parent-stream relays, and wake-hook payloads as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text. (#62003)
|
||||
- Providers/Anthropic: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so prompt-cache prefixes keep matching, and skip `service_tier` injection on OAuth-authenticated stream wrapper requests so Claude OAuth streaming stops failing with HTTP 401. (#60356, #61793)
|
||||
- Control UI: show `/tts` audio replies in webchat, detect mistaken `?token=` auth links with the correct `#token=` hint, and keep Copy, Canvas, and mobile exec-approval UI from covering chat content on narrow screens. (#54842, #61514, #61598) Thanks @neeravmakwana.
|
||||
- TUI: route `/status` through the shared session-status command, keep commentary hidden in history, strip raw envelope metadata from async command notices, preserve fallback streaming before per-attempt failures finalize, and restore Kitty keyboard state on exit or fatal crashes. (#49130, #59985, #60043, #61463) Thanks @biefan, @MoerAI, @jwchmodx, and @100yenadmin.
|
||||
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
|
||||
- Memory/wiki: follow `current_node` when importing ChatGPT export mapping trees so imported conversation transcripts stop pulling in stale alternate branches. Thanks @vincentkoc.
|
||||
- Memory/wiki: extract readable text from object-shaped ChatGPT export message parts so imported conversation transcripts stop dropping rich content blocks. Thanks @vincentkoc.
|
||||
- Memory/wiki: preserve conversation turn order for ChatGPT imports when timestamps are missing or tied, so imported transcripts stop scrambling equal-time messages and current-branch lineage. Thanks @vincentkoc.
|
||||
- Memory/wiki: skip hidden and tool-role ChatGPT export messages during import so conversation source pages stop filling up with export-only scaffolding. Thanks @vincentkoc.
|
||||
- Memory/wiki: skip ChatGPT export conversations that end up with no readable visible turns, so imports stop generating empty placeholder source pages from hidden/tool-only records. Thanks @vincentkoc.
|
||||
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including reconnect recovery, pending approval persistence, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
|
||||
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one pass and restore a total-context overflow backstop so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
|
||||
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, fail loud on invalid elevated cross-host overrides, and keep `strictInlineEval` commands blocked after approval timeouts instead of falling through to automatic execution. (#61739) Thanks @obviyus.
|
||||
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
|
||||
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
|
||||
- Gateway/status and containers: auto-bind to `0.0.0.0` inside Docker and Podman environments, and probe local TLS gateways over `wss://` with self-signed fingerprint forwarding so container startup and loopback TLS status checks work again. (#61818, #61935) Thanks @openperf and @ThanhNguyxn07.
|
||||
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
|
||||
- Memory/dreaming: strip managed Light Sleep and REM blocks before daily-note ingestion so dreaming summaries stop re-ingesting their own staged output into new candidates. (#61720) Thanks @MonkeyLeeT.
|
||||
- Plugins/Windows: disable native Jiti loading for setup and doctor contract registries on Windows so onboarding and config-doctor plugin probes stop crashing with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. (#61836, #61853)
|
||||
- Gateway/history: seed SSE startup history and raw transcript sequence tracking from one initial transcript snapshot so first history events cannot diverge from subsequent message sequence numbering. (#61855) Thanks @100yenadmin.
|
||||
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one repair pass, and restore a total-context overflow backstop during tool loops so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
|
||||
- Gateway/containers: auto-bind to `0.0.0.0` during container startup for Docker and Podman compatibility, while keeping host-side status and doctor checks on the hardened loopback default when `gateway.bind` is unset. (#61818) Thanks @openperf.
|
||||
- TUI/status: route `/status` through the shared session-status command and move the old gateway-wide diagnostic summary to `/gateway-status` (`/gwstatus`). Thanks @vincentkoc.
|
||||
- Agents/history: use one shared assistant-visible sanitizer across embedded delivery and chat-history extraction so leaked `<tool_call>` and `<tool_result>` XML blocks stay hidden from user-facing replies. (#61729) Thanks @openperf.
|
||||
- Gateway/TUI: defer terminal chat finalization for per-attempt lifecycle errors so fallback retries keep streaming before the run is marked failed. (#60043) Thanks @jwchmodx.
|
||||
- TUI/command messages: strip inbound envelope metadata before rendering command/system messages so async completion notices stop leaking raw wrappers into the operator terminal. (#59985) Thanks @MoerAI.
|
||||
- TUI/terminal: restore Kitty keyboard protocol and `modifyOtherKeys` state on TUI exit and fatal CLI crashes so parent shells stop inheriting broken keyboard input after `openclaw tui` exits. (#49130) Thanks @biefan.
|
||||
- Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819.
|
||||
- Gateway/command queue: migrate legacy global queue state after in-process SIGUSR1 restarts so pre-4.5 hot-upgrade singletons missing `activeTaskWaiters` stop crashing restart recovery. (#61933) Thanks @openperf.
|
||||
- Discord: recover forwarded referenced message text and attachments when snapshots are missing, use `ws://` again for gateway monitor sockets, stop forcing a hardcoded temperature for Codex-backed auto-thread titles, and harden voice receive recovery so rapid speaker restarts keep their next utterance. (#41536, #61670) Thanks @artwalker and @wit-oc.
|
||||
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
|
||||
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again and keep legacy `x_search` auth resolution working so older xAI web-search configs continue to load. (#61377) Thanks @jjjojoj.
|
||||
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded, and strip managed Light Sleep and REM blocks before daily-note ingestion so memory indexing and dreaming stop reporting false-success or re-ingesting staged output. (#61720) Thanks @MonkeyLeeT.
|
||||
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps their content attached to the correct list item. (#60997) Thanks @gucasbrg.
|
||||
- MS Teams/security: validate file-consent upload URLs against HTTPS, Microsoft/SharePoint host allowlists, and private-IP DNS checks before uploading attachments, blocking SSRF-style consent-upload abuse. (#23596)
|
||||
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
|
||||
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
|
||||
- Tools/web search/Exa: show Exa Search in onboarding and configure provider pickers again by marking the bundled Exa provider as setup-visible. Thanks @vincentkoc.
|
||||
- Docs/i18n: relocalize final localized-page links after translation and remove the zh-CN homepage redirect override so localized Mintlify pages resolve to the correct language roots again. (#61796) Thanks @hxy91819.
|
||||
- Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951)
|
||||
- Exec/runtime events: mark background `notifyOnExit` summaries and ACP parent-stream relays as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text.
|
||||
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
|
||||
- Slack/thread mentions: add `channels.slack.thread.requireExplicitMention` so Slack channels that already require mentions can also require explicit `@bot` mentions inside bot-participated threads. (#58276) Thanks @praktika-engineer.
|
||||
- UI/light mode: target both root and nested WebKit scrollbar thumbs in the light theme so page-level and container scrollbars stay visible on light backgrounds. (#61753) Thanks @chziyue.
|
||||
- Matrix/onboarding: add an invite auto-join setup step with explicit off warnings and strict stable-target validation so new Matrix accounts stop silently ignoring invited rooms and fresh DM-style invites unless operators opt in. (#62168) Thanks @gumadeiras.
|
||||
- Telegram/doctor: keep top-level access-control fallback in place during multi-account normalization while still promoting legacy default auth into `accounts.default`, so existing named bots keep inherited allowlists without dropping the legacy default bot. (#62263) Thanks @obviyus.
|
||||
- Agents/subagents: honor `sessions_spawn(lightContext: true)` for spawned subagent runs by preserving lightweight bootstrap context through the gateway and embedded runner instead of silently falling back to full workspace bootstrap injection. (#62264) Thanks @theSamPadilla.
|
||||
- Slack/media: keep attachment downloads on the SSRF-guarded dispatcher path so Slack media fetching works on Node 22 without dropping pinned transport enforcement. (#62239) Thanks @openperf.
|
||||
- Docker/plugins: stop forcing bundled plugin discovery to `/app/extensions` in runtime images so packaged installs use compiled `dist/extensions` artifacts again and Node 24 containers do not boot through source-only plugin entry paths. Fixes #62044. (#62316) Thanks @gumadeiras.
|
||||
- Cron: load `jobId` into `id` when the on-disk store omits `id`, matching doctor migration and fixing `unknown cron job id` for hand-edited `jobs.json`. (#62246) Thanks @neeravmakwana.
|
||||
- Agents/model fallback: classify minimal HTTP 404 API errors (for example `404 status code (no body)`) as `model_not_found` so assistant failures throw into the fallback chain instead of stopping at the first fallback candidate. (#62119) Thanks @neeravmakwana.
|
||||
- Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistral’s Chat Completions API. (#62162) Thanks @neeravmakwana.
|
||||
- OpenAI TTS/Groq: send `wav` to Groq-compatible speech endpoints, honor explicit `responseFormat` overrides on OpenAI-compatible paths, and only mark voice-note output as voice-compatible when the actual format is `opus`. (#62233) Thanks @neeravmakwana.
|
||||
|
||||
## 2026.4.5
|
||||
|
||||
@@ -46,6 +93,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Agents/video generation: add the built-in `video_generate` tool so agents can create videos through configured providers and return the generated media directly in the reply.
|
||||
- Agents/music generation: ignore unsupported optional hints such as `durationSeconds` with a warning instead of hard-failing requests on providers like Google Lyria.
|
||||
- Providers/Arcee AI: add a bundled Arcee AI provider plugin with `ARCEEAI_API_KEY` onboarding, Trinity model catalog (mini, large-preview, large-thinking), OpenAI-compatible API support, and OpenRouter as an alternative auth path. (#62068) Thanks @arthurbr11.
|
||||
- Providers/ComfyUI: add a bundled `comfy` workflow media plugin for local ComfyUI and Comfy Cloud workflows, including shared `image_generate`, `video_generate`, and workflow-backed `music_generate` support, with prompt injection, optional reference-image upload, live tests, and output download.
|
||||
- Tools/music generation: add the built-in `music_generate` tool with bundled Google (Lyria) and MiniMax providers plus workflow-backed Comfy support, including async task tracking and follow-up delivery of finished audio.
|
||||
- Providers: add bundled Qwen, Fireworks AI, and StepFun providers, plus MiniMax TTS, Ollama Web Search, and MiniMax Search integrations for chat, speech, and search workflows. (#60032, #55921, #59318, #54648)
|
||||
@@ -927,6 +975,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/path resolution: prefer non-user-writable absolute helper binaries for OpenClaw CLI, ffmpeg, and OpenSSL resolution so PATH hijacks cannot replace trusted helpers with attacker-controlled executables.
|
||||
- Security/gateway command scopes: require `operator.admin` before Telegram target writeback and Talk Voice `/voice set` config writes persist through gateway message flows.
|
||||
- Security/OpenShell mirror: exclude workspace `hooks/` from mirror sync so untrusted sandbox files cannot become trusted host hooks on gateway startup.
|
||||
- Exec env policy: block Mercurial config redirects, Rust compiler wrappers, and GNU make flag env vars in host exec sanitization so inherited env and request-scoped overrides cannot redirect build-tool execution.
|
||||
|
||||
## 2026.3.24-beta.2
|
||||
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@@ -62,9 +62,10 @@ RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY openclaw.mjs ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs ./scripts/
|
||||
COPY scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
|
||||
|
||||
COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/
|
||||
|
||||
@@ -102,7 +103,19 @@ RUN pnpm qa:lab:build
|
||||
# Prune dev dependencies and strip build-only metadata before copying
|
||||
# runtime assets into the final image.
|
||||
FROM build AS runtime-assets
|
||||
RUN CI=true pnpm prune --prod && \
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
# Keep the install layer frozen, but allow prune to run against the full copied
|
||||
# workspace tree subset used during `pnpm install`. The build stage only copied
|
||||
# the root, `ui`, and opted-in plugin manifests into the install layer, so
|
||||
# prune must not rediscover unrelated workspaces from the later full source
|
||||
# copy.
|
||||
RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \
|
||||
for ext in $OPENCLAW_EXTENSIONS; do \
|
||||
printf ' - %s/%s\n' "$OPENCLAW_BUNDLED_PLUGIN_DIR" "$ext" >> /tmp/pnpm-workspace.runtime.yaml; \
|
||||
done && \
|
||||
cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \
|
||||
CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod && \
|
||||
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
|
||||
|
||||
# ── Runtime base images ─────────────────────────────────────────
|
||||
@@ -159,10 +172,6 @@ COPY --from=runtime-assets --chown=node:node /app/skills ./skills
|
||||
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
|
||||
COPY --from=runtime-assets --chown=node:node /app/qa ./qa
|
||||
|
||||
# In npm-installed Docker images, prefer the copied source extension tree for
|
||||
# bundled discovery so package metadata that points at source entries stays valid.
|
||||
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/${OPENCLAW_BUNDLED_PLUGIN_DIR}
|
||||
|
||||
# Keep pnpm available in the runtime image for container-local workflows.
|
||||
# Use a shared Corepack home so the non-root `node` user does not need a
|
||||
# first-run network fetch when invoking pnpm.
|
||||
|
||||
@@ -89,7 +89,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while many providers/models are supported, for the best experience and lower prompt-injection risk use the strongest latest-generation model available to you. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
Model note: while many providers and models are supported, prefer a current flagship model from the provider you trust and already use. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
@@ -371,7 +371,7 @@ Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
model: "<provider>/<model-id>",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>2026.4.5</title>
|
||||
<pubDate>Mon, 06 Apr 2026 04:55:17 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026040501</sparkle:version>
|
||||
<sparkle:version>2026040590</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.4.5</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.4.5</h2>
|
||||
@@ -436,4 +436,4 @@
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.1/OpenClaw-2026.4.1.zip" length="25841903" type="application/octet-stream" sparkle:edSignature="0TPiyshScmwDbgs626JU08NOUUFJmIsVFa5g0xmizfl64Fr+IoT4l/dkXarFqbZAJidtj5WN7Bff7fG8ye/7AA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
|
||||
@@ -3,19 +3,23 @@
|
||||
|
||||
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
|
||||
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
|
||||
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
// Local contributors can override this by running scripts/ios-configure-signing.sh.
|
||||
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
|
||||
#include? "../.local-signing.xcconfig"
|
||||
#include? "../LocalSigning.xcconfig"
|
||||
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
CODE_SIGN_STYLE = $(OPENCLAW_CODE_SIGN_STYLE)
|
||||
CODE_SIGN_IDENTITY = Apple Development
|
||||
DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
|
||||
DEVELOPMENT_TEAM = $(OPENCLAW_DEVELOPMENT_TEAM)
|
||||
|
||||
// Let Xcode manage provisioning for the selected local team.
|
||||
// Let Xcode manage provisioning for the selected local team unless a local override pins one.
|
||||
PROVISIONING_PROFILE_SPECIFIER =
|
||||
|
||||
@@ -13,3 +13,5 @@ OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
// Leave empty with automatic signing.
|
||||
OPENCLAW_APP_PROFILE =
|
||||
OPENCLAW_SHARE_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
@@ -148,6 +148,9 @@ pnpm ios:beta
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
|
||||
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
||||
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
||||
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
|
||||
- Recommended gateway-host storage for the APNs `.p8` file is `~/.openclaw/credentials/apns/AuthKey_<KEYID>.p8` with restrictive permissions, then point `OPENCLAW_APNS_PRIVATE_KEY_PATH` at that file.
|
||||
- `apps/ios/fastlane/.env` only covers App Store Connect / Fastlane auth; it does not provide gateway APNs credentials for local direct-push testing.
|
||||
- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
|
||||
|
||||
## APNs Expectations For Official Builds
|
||||
|
||||
@@ -61,9 +61,10 @@ final class NodeAppModel {
|
||||
let request: AgentDeepLink
|
||||
}
|
||||
|
||||
struct ExecApprovalPrompt: Identifiable, Equatable {
|
||||
struct ExecApprovalPrompt: Identifiable, Equatable, Codable, Sendable {
|
||||
let id: String
|
||||
let commandText: String
|
||||
let commandPreview: String?
|
||||
let allowedDecisions: [String]
|
||||
let host: String?
|
||||
let nodeId: String?
|
||||
@@ -82,11 +83,17 @@ final class NodeAppModel {
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private struct PersistedWatchExecApprovalBridgeState: Codable {
|
||||
var approvals: [ExecApprovalPrompt]
|
||||
var pendingApprovalIDs: [String]?
|
||||
}
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
||||
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchExecApproval")
|
||||
private let execApprovalNotificationLogger = Logger(
|
||||
subsystem: "ai.openclaw.ios",
|
||||
category: "ExecApprovalNotification")
|
||||
@@ -166,6 +173,8 @@ final class NodeAppModel {
|
||||
private var backgroundReconnectLeaseUntil: Date?
|
||||
private var lastSignificantLocationWakeAt: Date?
|
||||
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
|
||||
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
|
||||
private var pendingWatchExecApprovalRecoveryIDs: [String] = []
|
||||
private var pendingForegroundActionDrainInFlight = false
|
||||
|
||||
private var gatewayConnected = false
|
||||
@@ -179,6 +188,8 @@ final class NodeAppModel {
|
||||
var operatorSession: GatewayNodeSession { self.operatorGateway }
|
||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||
|
||||
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
|
||||
|
||||
var cameraHUDText: String?
|
||||
var cameraHUDKind: CameraHUDKind?
|
||||
var cameraFlashNonce: Int = 0
|
||||
@@ -213,12 +224,40 @@ final class NodeAppModel {
|
||||
self.watchMessagingService = watchMessagingService
|
||||
self.talkMode = talkMode
|
||||
self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey)
|
||||
self.restorePersistedWatchExecApprovalBridgeState()
|
||||
GatewayDiagnostics.bootstrap()
|
||||
GatewayDiagnostics.log("node app model: init start")
|
||||
self.watchMessagingService.setStatusHandler { [weak self] status in
|
||||
Task { @MainActor in
|
||||
GatewayDiagnostics.log(
|
||||
"node app model: watch status callback reachable=\(status.reachable) activation=\(status.activationState) backgrounded=\(self?.isBackgrounded ?? false)")
|
||||
await self?.handleWatchMessagingStatusChanged(status)
|
||||
}
|
||||
}
|
||||
self.watchMessagingService.setReplyHandler { [weak self] event in
|
||||
Task { @MainActor in
|
||||
await self?.handleWatchQuickReply(event)
|
||||
}
|
||||
}
|
||||
self.watchMessagingService.setExecApprovalResolveHandler { [weak self] event in
|
||||
Task { @MainActor in
|
||||
await self?.handleWatchExecApprovalResolve(event)
|
||||
}
|
||||
}
|
||||
self.watchMessagingService.setExecApprovalSnapshotRequestHandler { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
GatewayDiagnostics.log(
|
||||
"node app model: watch snapshot request id=\(event.requestId) backgrounded=\(self.isBackgrounded)")
|
||||
guard self.isBackgrounded else {
|
||||
self.watchExecApprovalLogger.debug(
|
||||
"watch exec approval snapshot skipped reason=watch_request_foreground")
|
||||
GatewayDiagnostics.log("node app model: watch snapshot request skipped in foreground")
|
||||
return
|
||||
}
|
||||
await self.refreshWatchExecApprovalSnapshotOnDemand(reason: "watch_request")
|
||||
}
|
||||
}
|
||||
|
||||
self.voiceWake.configure { [weak self] cmd in
|
||||
guard let self else { return }
|
||||
@@ -335,6 +374,7 @@ final class NodeAppModel {
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
|
||||
GatewayDiagnostics.log("node app model: scene phase=\(String(describing: phase))")
|
||||
switch phase {
|
||||
case .background:
|
||||
self.isBackgrounded = true
|
||||
@@ -2476,6 +2516,7 @@ extension NodeAppModel {
|
||||
func onNodeGatewayConnected() async {
|
||||
await self.registerAPNsTokenIfNeeded()
|
||||
await self.flushQueuedWatchRepliesIfConnected()
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "node_connected")
|
||||
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
|
||||
}
|
||||
|
||||
@@ -2622,6 +2663,378 @@ extension NodeAppModel {
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func restorePersistedWatchExecApprovalBridgeState() {
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.watchExecApprovalBridgeStateKey),
|
||||
let state = try? JSONDecoder().decode(PersistedWatchExecApprovalBridgeState.self, from: data)
|
||||
else {
|
||||
return
|
||||
}
|
||||
self.watchExecApprovalPromptsByID = Dictionary(
|
||||
uniqueKeysWithValues: state.approvals.map { ($0.id, $0) })
|
||||
self.pendingWatchExecApprovalRecoveryIDs = (state.pendingApprovalIDs ?? [])
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.sorted()
|
||||
self.pruneExpiredWatchExecApprovalPrompts()
|
||||
}
|
||||
|
||||
private func persistWatchExecApprovalBridgeState() {
|
||||
self.pruneExpiredWatchExecApprovalPrompts()
|
||||
let approvals = self.watchExecApprovalPromptsByID.values.sorted { lhs, rhs in
|
||||
let lhsExpires = lhs.expiresAtMs ?? Int.max
|
||||
let rhsExpires = rhs.expiresAtMs ?? Int.max
|
||||
if lhsExpires != rhsExpires {
|
||||
return lhsExpires < rhsExpires
|
||||
}
|
||||
return lhs.id < rhs.id
|
||||
}
|
||||
let pendingApprovalIDs = self.pendingWatchExecApprovalRecoveryIDs
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.sorted()
|
||||
guard let data = try? JSONEncoder().encode(
|
||||
PersistedWatchExecApprovalBridgeState(
|
||||
approvals: approvals,
|
||||
pendingApprovalIDs: pendingApprovalIDs))
|
||||
else {
|
||||
return
|
||||
}
|
||||
UserDefaults.standard.set(data, forKey: Self.watchExecApprovalBridgeStateKey)
|
||||
}
|
||||
|
||||
private func pruneExpiredWatchExecApprovalPrompts(nowMs: Int? = nil) {
|
||||
let currentNowMs = nowMs ?? Int(Date().timeIntervalSince1970 * 1000)
|
||||
self.watchExecApprovalPromptsByID = self.watchExecApprovalPromptsByID.filter { _, prompt in
|
||||
guard let expiresAtMs = prompt.expiresAtMs else { return true }
|
||||
return expiresAtMs > currentNowMs
|
||||
}
|
||||
}
|
||||
|
||||
private func handleWatchMessagingStatusChanged(_ status: WatchMessagingStatus) async {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: status changed reachable=\(status.reachable) activation=\(status.activationState) backgrounded=\(self.isBackgrounded)")
|
||||
guard self.isBackgrounded else { return }
|
||||
guard status.supported, status.paired, status.appInstalled else { return }
|
||||
guard status.reachable || status.activationState == "activated" else { return }
|
||||
let reason = status.reachable ? "watch_reachable" : "watch_activated"
|
||||
await self.syncWatchExecApprovalSnapshot(reason: reason)
|
||||
}
|
||||
|
||||
private func appendPendingWatchExecApprovalRecoveryID(_ approvalId: String) {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
guard !self.pendingWatchExecApprovalRecoveryIDs.contains(normalizedApprovalID) else { return }
|
||||
self.pendingWatchExecApprovalRecoveryIDs.append(normalizedApprovalID)
|
||||
self.pendingWatchExecApprovalRecoveryIDs.sort()
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: queued recovery id=\(normalizedApprovalID) pendingCount=\(self.pendingWatchExecApprovalRecoveryIDs.count)")
|
||||
self.persistWatchExecApprovalBridgeState()
|
||||
}
|
||||
|
||||
private func removePendingWatchExecApprovalRecoveryID(_ approvalId: String) {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
let originalCount = self.pendingWatchExecApprovalRecoveryIDs.count
|
||||
self.pendingWatchExecApprovalRecoveryIDs.removeAll { $0 == normalizedApprovalID }
|
||||
guard self.pendingWatchExecApprovalRecoveryIDs.count != originalCount else { return }
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: cleared recovery id=\(normalizedApprovalID) pendingCount=\(self.pendingWatchExecApprovalRecoveryIDs.count)")
|
||||
self.persistWatchExecApprovalBridgeState()
|
||||
}
|
||||
|
||||
private func upsertWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
|
||||
self.watchExecApprovalPromptsByID[prompt.id] = prompt
|
||||
self.removePendingWatchExecApprovalRecoveryID(prompt.id)
|
||||
self.persistWatchExecApprovalBridgeState()
|
||||
}
|
||||
|
||||
private func removeWatchExecApprovalPrompt(_ approvalId: String) {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
self.watchExecApprovalPromptsByID.removeValue(forKey: normalizedApprovalID)
|
||||
self.removePendingWatchExecApprovalRecoveryID(normalizedApprovalID)
|
||||
self.persistWatchExecApprovalBridgeState()
|
||||
}
|
||||
|
||||
private static func makeWatchExecApprovalItem(from prompt: ExecApprovalPrompt) -> OpenClawWatchExecApprovalItem {
|
||||
let decisions = prompt.allowedDecisions.compactMap { decision in
|
||||
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return OpenClawWatchExecApprovalDecision(rawValue: normalizedDecision)
|
||||
}
|
||||
let preview = Self.trimmedOrNil(prompt.commandPreview) ?? Self.trimmedOrNil(prompt.commandText)
|
||||
return OpenClawWatchExecApprovalItem(
|
||||
id: prompt.id,
|
||||
commandText: prompt.commandText,
|
||||
commandPreview: preview,
|
||||
host: Self.trimmedOrNil(prompt.host),
|
||||
nodeId: Self.trimmedOrNil(prompt.nodeId),
|
||||
agentId: Self.trimmedOrNil(prompt.agentId),
|
||||
expiresAtMs: prompt.expiresAtMs,
|
||||
allowedDecisions: decisions,
|
||||
// Prefer the watch's neutral/default presentation until exec.approval.get
|
||||
// carries an explicit risk signal for exec approvals.
|
||||
risk: nil)
|
||||
}
|
||||
|
||||
nonisolated private static func shouldResetWatchExecApprovalResolvingStateOnPrompt(
|
||||
reason: String) -> Bool
|
||||
{
|
||||
reason == "resolve_retry"
|
||||
}
|
||||
|
||||
private func publishWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt, reason: String) async {
|
||||
let message = OpenClawWatchExecApprovalPromptMessage(
|
||||
approval: Self.makeWatchExecApprovalItem(from: prompt),
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
deliveryId: UUID().uuidString,
|
||||
resetResolvingState: Self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason))
|
||||
do {
|
||||
_ = try await self.watchMessagingService.sendExecApprovalPrompt(message)
|
||||
self.watchExecApprovalLogger.debug(
|
||||
"watch exec approval prompt sent id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
|
||||
} catch {
|
||||
self.watchExecApprovalLogger.error(
|
||||
"watch exec approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot")
|
||||
}
|
||||
|
||||
private func publishWatchExecApprovalResolved(
|
||||
approvalId: String,
|
||||
decision: OpenClawWatchExecApprovalDecision?,
|
||||
source: String) async
|
||||
{
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
self.removeWatchExecApprovalPrompt(normalizedApprovalID)
|
||||
let message = OpenClawWatchExecApprovalResolvedMessage(
|
||||
approvalId: normalizedApprovalID,
|
||||
decision: decision,
|
||||
resolvedAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
source: source)
|
||||
do {
|
||||
_ = try await self.watchMessagingService.sendExecApprovalResolved(message)
|
||||
} catch {
|
||||
self.watchExecApprovalLogger.error(
|
||||
"watch exec approval resolved update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot")
|
||||
}
|
||||
|
||||
private func publishWatchExecApprovalExpired(
|
||||
approvalId: String,
|
||||
reason: OpenClawWatchExecApprovalCloseReason) async
|
||||
{
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
self.removeWatchExecApprovalPrompt(normalizedApprovalID)
|
||||
let message = OpenClawWatchExecApprovalExpiredMessage(
|
||||
approvalId: normalizedApprovalID,
|
||||
reason: reason,
|
||||
expiredAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
||||
do {
|
||||
_ = try await self.watchMessagingService.sendExecApprovalExpired(message)
|
||||
} catch {
|
||||
self.watchExecApprovalLogger.error(
|
||||
"watch exec approval expiry update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)")
|
||||
}
|
||||
|
||||
private func syncWatchExecApprovalSnapshot(reason: String) async {
|
||||
self.pruneExpiredWatchExecApprovalPrompts()
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: sync snapshot start reason=\(reason) cacheCount=\(self.watchExecApprovalPromptsByID.count) backgrounded=\(self.isBackgrounded)")
|
||||
let approvals = self.watchExecApprovalPromptsByID.values
|
||||
.sorted { lhs, rhs in
|
||||
let lhsExpires = lhs.expiresAtMs ?? Int.max
|
||||
let rhsExpires = rhs.expiresAtMs ?? Int.max
|
||||
if lhsExpires != rhsExpires {
|
||||
return lhsExpires < rhsExpires
|
||||
}
|
||||
return lhs.id < rhs.id
|
||||
}
|
||||
.map(Self.makeWatchExecApprovalItem)
|
||||
let message = OpenClawWatchExecApprovalSnapshotMessage(
|
||||
approvals: approvals,
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
snapshotId: UUID().uuidString)
|
||||
do {
|
||||
_ = try await self.watchMessagingService.syncExecApprovalSnapshot(message)
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)")
|
||||
self.watchExecApprovalLogger.debug(
|
||||
"watch exec approval snapshot sent reason=\(reason, privacy: .public) count=\(approvals.count, privacy: .public)")
|
||||
} catch {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)")
|
||||
self.watchExecApprovalLogger.error(
|
||||
"watch exec approval snapshot failed reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshWatchExecApprovalSnapshotOnDemand(reason: String) async {
|
||||
GatewayDiagnostics.log("watch exec approval: refresh on demand start reason=\(reason)")
|
||||
await self.hydrateWatchExecApprovalCacheIfNeeded(reason: reason)
|
||||
await self.syncWatchExecApprovalSnapshot(reason: reason)
|
||||
GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)")
|
||||
}
|
||||
|
||||
nonisolated private static func watchExecApprovalIDsNeedingFetch(
|
||||
candidateIDs: [String],
|
||||
cachedApprovalIDs: [String]) -> [String]
|
||||
{
|
||||
let cachedIDs = Set(cachedApprovalIDs.compactMap { id -> String? in
|
||||
let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return normalizedID.isEmpty ? nil : normalizedID
|
||||
})
|
||||
var idsToFetch: [String] = []
|
||||
var seen = Set<String>()
|
||||
for rawID in candidateIDs {
|
||||
let normalizedID = rawID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedID.isEmpty else { continue }
|
||||
guard seen.insert(normalizedID).inserted else { continue }
|
||||
guard !cachedIDs.contains(normalizedID) else { continue }
|
||||
idsToFetch.append(normalizedID)
|
||||
}
|
||||
return idsToFetch
|
||||
}
|
||||
|
||||
private func hydrateWatchExecApprovalCacheIfNeeded(reason: String) async {
|
||||
self.pruneExpiredWatchExecApprovalPrompts()
|
||||
|
||||
let approvalIDs = await self.pendingExecApprovalIDsForWatchRecovery()
|
||||
let missingApprovalIDs = Self.watchExecApprovalIDsNeedingFetch(
|
||||
candidateIDs: approvalIDs,
|
||||
cachedApprovalIDs: Array(self.watchExecApprovalPromptsByID.keys))
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: hydrate candidates reason=\(reason) ids=\(approvalIDs.joined(separator: ",")) missing=\(missingApprovalIDs.joined(separator: ",")) cached=\(self.watchExecApprovalPromptsByID.count)")
|
||||
guard !missingApprovalIDs.isEmpty else {
|
||||
self.watchExecApprovalLogger.debug(
|
||||
"watch exec approval hydrate skipped reason=\(reason, privacy: .public): no missing approval ids")
|
||||
return
|
||||
}
|
||||
|
||||
for approvalId in missingApprovalIDs {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: hydrate fetch start id=\(approvalId) reason=\(reason)")
|
||||
let outcome = await self.fetchExecApprovalPrompt(
|
||||
approvalId: approvalId,
|
||||
sourceReason: reason)
|
||||
switch outcome {
|
||||
case let .loaded(prompt):
|
||||
GatewayDiagnostics.log("watch exec approval: hydrate fetch loaded id=\(approvalId)")
|
||||
self.upsertWatchExecApprovalPrompt(prompt)
|
||||
case .stale:
|
||||
GatewayDiagnostics.log("watch exec approval: hydrate fetch stale id=\(approvalId)")
|
||||
self.removePendingWatchExecApprovalRecoveryID(approvalId)
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
forApprovalID: approvalId,
|
||||
notificationCenter: self.notificationCenter)
|
||||
case let .failed(message):
|
||||
self.watchExecApprovalLogger.error(
|
||||
"watch exec approval hydrate failed id=\(approvalId, privacy: .public) reason=\(reason, privacy: .public) error=\(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pendingExecApprovalIDsForWatchRecovery() async -> [String] {
|
||||
var ids: [String] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
func append(_ rawID: String?) {
|
||||
let approvalId = rawID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !approvalId.isEmpty, seen.insert(approvalId).inserted else { return }
|
||||
ids.append(approvalId)
|
||||
}
|
||||
|
||||
append(self.pendingExecApprovalPrompt?.id)
|
||||
for approvalId in self.pendingWatchExecApprovalRecoveryIDs {
|
||||
append(approvalId)
|
||||
}
|
||||
for approvalId in self.watchExecApprovalPromptsByID.keys.sorted() {
|
||||
append(approvalId)
|
||||
}
|
||||
|
||||
let delivered = await self.notificationCenter.deliveredNotifications()
|
||||
GatewayDiagnostics.log("watch exec approval: delivered notifications count=\(delivered.count)")
|
||||
for snapshot in delivered {
|
||||
guard ExecApprovalNotificationBridge.payloadKind(userInfo: snapshot.userInfo)
|
||||
== ExecApprovalNotificationBridge.requestedKind
|
||||
else {
|
||||
continue
|
||||
}
|
||||
append(ExecApprovalNotificationBridge.approvalID(from: snapshot.userInfo))
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
private func handleWatchExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) async {
|
||||
let normalizedApprovalID = event.approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
|
||||
self.pendingExecApprovalPromptResolving = true
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
}
|
||||
let outcome = await self.resolveExecApprovalNotificationDecision(
|
||||
approvalId: normalizedApprovalID,
|
||||
decision: event.decision.rawValue,
|
||||
sourceReason: "watch_resolve")
|
||||
if case let .failed(message) = outcome {
|
||||
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = message
|
||||
}
|
||||
if let prompt = self.watchExecApprovalPromptsByID[normalizedApprovalID] {
|
||||
await self.publishWatchExecApprovalPrompt(prompt, reason: "resolve_retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleExecApprovalRequestedRemotePush(approvalId: String) async -> Bool {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return false }
|
||||
self.appendPendingWatchExecApprovalRecoveryID(normalizedApprovalID)
|
||||
let fetchedPrompt = await self.fetchExecApprovalPrompt(
|
||||
approvalId: normalizedApprovalID,
|
||||
sourceReason: "push_request")
|
||||
switch fetchedPrompt {
|
||||
case let .loaded(prompt):
|
||||
self.upsertWatchExecApprovalPrompt(prompt)
|
||||
await self.publishWatchExecApprovalPrompt(prompt, reason: "push_request")
|
||||
return true
|
||||
case .stale:
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
forApprovalID: normalizedApprovalID,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
await self.publishWatchExecApprovalExpired(
|
||||
approvalId: normalizedApprovalID,
|
||||
reason: .notFound)
|
||||
return true
|
||||
case let .failed(message):
|
||||
self.watchExecApprovalLogger.error(
|
||||
"watch exec approval push fetch failed id=\(normalizedApprovalID, privacy: .public) error=\(message, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func handleExecApprovalResolvedRemotePush(approvalId: String) async {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
|
||||
let hadWatchPrompt = self.watchExecApprovalPromptsByID[normalizedApprovalID] != nil
|
||||
let hadPendingPrompt = self.pendingExecApprovalPrompt?.id == normalizedApprovalID
|
||||
let hadPendingRecoveryID = self.pendingWatchExecApprovalRecoveryIDs.contains(normalizedApprovalID)
|
||||
guard hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID else {
|
||||
return
|
||||
}
|
||||
|
||||
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .resolved)
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
}
|
||||
|
||||
func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool {
|
||||
let wakeId = Self.makePushWakeAttemptID()
|
||||
guard Self.isSilentPushPayload(userInfo) else {
|
||||
@@ -2641,13 +3054,24 @@ extension NodeAppModel {
|
||||
notificationCenter: self.notificationCenter)
|
||||
{
|
||||
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
|
||||
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
||||
await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
|
||||
}
|
||||
self.execApprovalNotificationLogger.info(
|
||||
"Handled exec approval cleanup push wakeId=\(wakeId, privacy: .public)")
|
||||
return true
|
||||
}
|
||||
|
||||
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo) == ExecApprovalNotificationBridge.requestedKind,
|
||||
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
|
||||
{
|
||||
let handled = await self.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
|
||||
if handled {
|
||||
self.execApprovalNotificationLogger.info(
|
||||
"Handled exec approval request push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)")
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let outcomeMessage =
|
||||
"Silent push outcome wakeId=\(wakeId) "
|
||||
@@ -2832,6 +3256,7 @@ extension NodeAppModel {
|
||||
private struct ExecApprovalGetResponse: Decodable {
|
||||
var id: String
|
||||
var commandText: String
|
||||
var commandPreview: String?
|
||||
var allowedDecisions: [String]
|
||||
var host: String?
|
||||
var nodeId: String?
|
||||
@@ -2861,6 +3286,7 @@ extension NodeAppModel {
|
||||
forApprovalID: approvalId,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
||||
await self.publishWatchExecApprovalExpired(approvalId: approvalId, reason: .notFound)
|
||||
case let .failed(message):
|
||||
self.execApprovalNotificationLogger.error(
|
||||
"Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
|
||||
@@ -2877,6 +3303,10 @@ extension NodeAppModel {
|
||||
self.pendingExecApprovalPrompt = prompt
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
self.upsertWatchExecApprovalPrompt(prompt)
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.publishWatchExecApprovalPrompt(prompt, reason: "present_prompt")
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeExecApprovalPrompt(from details: ExecApprovalGetResponse) -> ExecApprovalPrompt? {
|
||||
@@ -2886,6 +3316,7 @@ extension NodeAppModel {
|
||||
return ExecApprovalPrompt(
|
||||
id: approvalId,
|
||||
commandText: commandText,
|
||||
commandPreview: details.commandPreview?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
allowedDecisions: details.allowedDecisions.compactMap { decision in
|
||||
let trimmed = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
@@ -2896,9 +3327,46 @@ extension NodeAppModel {
|
||||
expiresAtMs: details.expiresAtMs)
|
||||
}
|
||||
|
||||
private func fetchExecApprovalPrompt(approvalId: String) async -> ExecApprovalPromptFetchOutcome {
|
||||
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
nonisolated private static func shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: String,
|
||||
isBackgrounded: Bool) -> Bool
|
||||
{
|
||||
guard isBackgrounded else { return false }
|
||||
switch sourceReason {
|
||||
case "watch_request", "push_request", "watch_resolve", "notification_action":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchExecApprovalPrompt(
|
||||
approvalId: String,
|
||||
sourceReason: String? = nil) async -> ExecApprovalPromptFetchOutcome
|
||||
{
|
||||
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fetchReason: String
|
||||
if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
|
||||
fetchReason = normalizedSourceReason
|
||||
} else {
|
||||
fetchReason = "direct"
|
||||
}
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)")
|
||||
let connected: Bool
|
||||
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: fetchReason,
|
||||
isBackgrounded: self.isBackgrounded)
|
||||
{
|
||||
connected = await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||
timeoutMs: 12_000,
|
||||
reason: fetchReason)
|
||||
} else {
|
||||
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
}
|
||||
guard connected else {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: fetch prompt operator not connected id=\(approvalId) reason=\(fetchReason)")
|
||||
return .failed(message: "operator_not_connected")
|
||||
}
|
||||
|
||||
@@ -2910,13 +3378,21 @@ extension NodeAppModel {
|
||||
timeoutSeconds: 12)
|
||||
let details = try JSONDecoder().decode(ExecApprovalGetResponse.self, from: response)
|
||||
guard let prompt = Self.makeExecApprovalPrompt(from: details) else {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: fetch prompt invalid payload id=\(approvalId) reason=\(fetchReason)")
|
||||
return .failed(message: "invalid_prompt_payload")
|
||||
}
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: fetch prompt loaded id=\(approvalId) reason=\(fetchReason)")
|
||||
return .loaded(prompt)
|
||||
} catch {
|
||||
if Self.isApprovalNotificationStaleError(error) {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: fetch prompt stale id=\(approvalId) reason=\(fetchReason)")
|
||||
return .stale
|
||||
}
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: fetch prompt failed id=\(approvalId) reason=\(fetchReason) error=\(error.localizedDescription)")
|
||||
return .failed(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
@@ -2950,17 +3426,56 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveExecApprovalNotificationDecision(
|
||||
func handleExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String
|
||||
) async {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
|
||||
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
|
||||
self.pendingExecApprovalPromptResolving = true
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
}
|
||||
|
||||
let outcome = await self.resolveExecApprovalNotificationDecision(
|
||||
approvalId: normalizedApprovalID,
|
||||
decision: decision)
|
||||
switch outcome {
|
||||
case .resolved, .stale, .unavailable:
|
||||
break
|
||||
case let .failed(message):
|
||||
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String,
|
||||
sourceReason: String? = nil
|
||||
) async -> ExecApprovalResolutionOutcome {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolutionReason = (normalizedSourceReason?.isEmpty == false) ? normalizedSourceReason! : "direct"
|
||||
guard !normalizedApprovalID.isEmpty, !normalizedDecision.isEmpty else {
|
||||
return .failed(message: "Invalid approval request.")
|
||||
}
|
||||
|
||||
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
let connected: Bool
|
||||
if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: resolutionReason,
|
||||
isBackgrounded: self.isBackgrounded)
|
||||
{
|
||||
connected = await self.ensureOperatorApprovalConnectionForWatchReview(
|
||||
timeoutMs: 12_000,
|
||||
reason: resolutionReason)
|
||||
} else {
|
||||
connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
}
|
||||
guard connected else {
|
||||
self.execApprovalNotificationLogger.error(
|
||||
"Exec approval action failed id=\(normalizedApprovalID, privacy: .public): operator not connected")
|
||||
@@ -2978,6 +3493,10 @@ extension NodeAppModel {
|
||||
forApprovalID: normalizedApprovalID,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
await self.publishWatchExecApprovalResolved(
|
||||
approvalId: normalizedApprovalID,
|
||||
decision: OpenClawWatchExecApprovalDecision(rawValue: normalizedDecision),
|
||||
source: "iphone")
|
||||
return .resolved
|
||||
} catch {
|
||||
if Self.isApprovalNotificationStaleError(error) {
|
||||
@@ -2985,6 +3504,7 @@ extension NodeAppModel {
|
||||
forApprovalID: normalizedApprovalID,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .notFound)
|
||||
return .stale
|
||||
}
|
||||
if Self.isApprovalNotificationUnavailableError(error) {
|
||||
@@ -2992,6 +3512,7 @@ extension NodeAppModel {
|
||||
forApprovalID: normalizedApprovalID,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .unavailable)
|
||||
return .unavailable
|
||||
}
|
||||
let logMessage =
|
||||
@@ -3096,6 +3617,96 @@ extension NodeAppModel {
|
||||
sessionBox: sessionBox)
|
||||
}
|
||||
|
||||
private func ensureOperatorApprovalConnectionForWatchReview(timeoutMs: Int, reason: String) async -> Bool {
|
||||
let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let reconnectReason = normalizedReason.isEmpty ? "watch_request" : normalizedReason
|
||||
if await self.isOperatorConnected() {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_connected reason=\(reconnectReason) phase=already_connected")
|
||||
return true
|
||||
}
|
||||
|
||||
guard self.isBackgrounded else {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=false strategy=default")
|
||||
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: timeoutMs)
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") reason=\(reconnectReason) phase=foreground_delegate")
|
||||
return connected
|
||||
}
|
||||
|
||||
guard self.gatewayAutoReconnectEnabled else {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_timeout reason=\(reconnectReason) phase=auto_reconnect_disabled")
|
||||
return false
|
||||
}
|
||||
|
||||
guard let cfg = self.activeGatewayConnectConfig else {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_timeout reason=\(reconnectReason) phase=no_active_gateway_config")
|
||||
return false
|
||||
}
|
||||
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=true")
|
||||
let leaseSeconds = min(45.0, max(15.0, Double(max(timeoutMs, 1_000)) / 1000.0 + 8.0))
|
||||
self.grantBackgroundReconnectLease(seconds: leaseSeconds, reason: "watch_review_\(reconnectReason)")
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_lease_granted reason=\(reconnectReason) seconds=\(leaseSeconds)")
|
||||
|
||||
let hadReconnectLoop = self.operatorGatewayTask != nil
|
||||
let canStartReconnectLoop = hadReconnectLoop || self.shouldStartOperatorGatewayLoop(
|
||||
token: cfg.token,
|
||||
bootstrapToken: cfg.bootstrapToken,
|
||||
password: cfg.password,
|
||||
stableID: cfg.effectiveStableID)
|
||||
guard canStartReconnectLoop else {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_timeout reason=\(reconnectReason) phase=no_operator_reconnect_auth")
|
||||
return false
|
||||
}
|
||||
|
||||
self.ensureOperatorReconnectLoopIfNeeded()
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_loop_\(hadReconnectLoop ? "reused" : "started") reason=\(reconnectReason)")
|
||||
|
||||
let initialWaitMs = min(2_500, max(750, timeoutMs / 4))
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_wait reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)")
|
||||
if await self.waitForOperatorConnection(timeoutMs: initialWaitMs, pollMs: 200) {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_connected reason=\(reconnectReason) phase=initial")
|
||||
return true
|
||||
}
|
||||
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_restart reason=\(reconnectReason)")
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
await self.operatorGateway.disconnect()
|
||||
self.operatorConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.stopGatewayHealthMonitor()
|
||||
|
||||
let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
||||
self.startOperatorGatewayLoop(
|
||||
url: cfg.url,
|
||||
stableID: cfg.effectiveStableID,
|
||||
token: cfg.token,
|
||||
bootstrapToken: cfg.bootstrapToken,
|
||||
password: cfg.password,
|
||||
nodeOptions: cfg.nodeOptions,
|
||||
sessionBox: sessionBox)
|
||||
|
||||
let remainingWaitMs = max(250, timeoutMs - initialWaitMs)
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_wait reason=\(reconnectReason) phase=restart timeoutMs=\(remainingWaitMs)")
|
||||
let connected = await self.waitForOperatorConnection(timeoutMs: remainingWaitMs, pollMs: 200)
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") reason=\(reconnectReason) phase=restart")
|
||||
return connected
|
||||
}
|
||||
|
||||
private func ensureOperatorApprovalConnection(timeoutMs: Int) async -> Bool {
|
||||
if await self.isOperatorConnected() {
|
||||
return true
|
||||
@@ -3526,6 +4137,18 @@ extension NodeAppModel {
|
||||
self.pendingExecApprovalPrompt
|
||||
}
|
||||
|
||||
func _test_recordPendingWatchExecApprovalRecoveryID(_ approvalId: String) {
|
||||
self.appendPendingWatchExecApprovalRecoveryID(approvalId)
|
||||
}
|
||||
|
||||
func _test_pendingWatchExecApprovalRecoveryIDs() -> [String] {
|
||||
self.pendingWatchExecApprovalRecoveryIDs
|
||||
}
|
||||
|
||||
func _test_pendingExecApprovalIDsForWatchRecovery() async -> [String] {
|
||||
await self.pendingExecApprovalIDsForWatchRecovery()
|
||||
}
|
||||
|
||||
nonisolated static func _test_isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
||||
self.isApprovalNotificationStaleError(error)
|
||||
}
|
||||
@@ -3534,6 +4157,30 @@ extension NodeAppModel {
|
||||
self.isApprovalNotificationUnavailableError(error)
|
||||
}
|
||||
|
||||
nonisolated static func _test_shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: String,
|
||||
isBackgrounded: Bool) -> Bool
|
||||
{
|
||||
self.shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: sourceReason,
|
||||
isBackgrounded: isBackgrounded)
|
||||
}
|
||||
|
||||
nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
|
||||
candidateIDs: [String],
|
||||
cachedApprovalIDs: [String]) -> [String]
|
||||
{
|
||||
self.watchExecApprovalIDsNeedingFetch(
|
||||
candidateIDs: candidateIDs,
|
||||
cachedApprovalIDs: cachedApprovalIDs)
|
||||
}
|
||||
|
||||
nonisolated static func _test_shouldResetWatchExecApprovalResolvingStateOnPrompt(
|
||||
reason: String) -> Bool
|
||||
{
|
||||
self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason)
|
||||
}
|
||||
|
||||
static func _test_makeExecApprovalPrompt(
|
||||
id: String,
|
||||
commandText: String,
|
||||
@@ -3547,6 +4194,7 @@ extension NodeAppModel {
|
||||
from: ExecApprovalGetResponse(
|
||||
id: id,
|
||||
commandText: commandText,
|
||||
commandPreview: nil,
|
||||
allowedDecisions: allowedDecisions,
|
||||
host: host,
|
||||
nodeId: nodeId,
|
||||
@@ -3558,6 +4206,10 @@ extension NodeAppModel {
|
||||
self.expectedDeepLinkKey()
|
||||
}
|
||||
|
||||
static func _test_resetPersistedWatchExecApprovalBridgeState() {
|
||||
UserDefaults.standard.removeObject(forKey: self.watchExecApprovalBridgeStateKey)
|
||||
}
|
||||
|
||||
nonisolated static func _test_shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
|
||||
@@ -15,6 +15,11 @@ private struct PendingWatchPromptAction {
|
||||
|
||||
private typealias PendingExecApprovalPrompt = ExecApprovalNotificationPrompt
|
||||
|
||||
@MainActor
|
||||
enum OpenClawAppModelRegistry {
|
||||
static var appModel: NodeAppModel?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
|
||||
@@ -24,10 +29,12 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
private var pendingAPNsDeviceToken: Data?
|
||||
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
|
||||
private var pendingExecApprovalPrompts: [PendingExecApprovalPrompt] = []
|
||||
private var pendingExecApprovalRequestedPushIDs: [String] = []
|
||||
private var pendingExecApprovalResolvedPushIDs: [String] = []
|
||||
|
||||
weak var appModel: NodeAppModel? {
|
||||
didSet {
|
||||
guard let model = self.appModel else { return }
|
||||
guard let model = self.resolvedAppModel() else { return }
|
||||
if let token = self.pendingAPNsDeviceToken {
|
||||
self.pendingAPNsDeviceToken = nil
|
||||
Task { @MainActor in
|
||||
@@ -56,22 +63,56 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.pendingExecApprovalRequestedPushIDs.isEmpty {
|
||||
let pending = self.pendingExecApprovalRequestedPushIDs
|
||||
self.pendingExecApprovalRequestedPushIDs.removeAll()
|
||||
Task { @MainActor in
|
||||
for approvalId in pending {
|
||||
_ = await model.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.pendingExecApprovalResolvedPushIDs.isEmpty {
|
||||
let pending = self.pendingExecApprovalResolvedPushIDs
|
||||
self.pendingExecApprovalResolvedPushIDs.removeAll()
|
||||
Task { @MainActor in
|
||||
for approvalId in pending {
|
||||
await model.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedAppModel() -> NodeAppModel? {
|
||||
self.appModel ?? OpenClawAppModelRegistry.appModel
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func _test_resolvedAppModel() -> NodeAppModel? {
|
||||
self.resolvedAppModel()
|
||||
}
|
||||
#endif
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool
|
||||
{
|
||||
GatewayDiagnostics.log("app delegate: didFinishLaunching")
|
||||
if self.appModel == nil {
|
||||
self.appModel = OpenClawAppModelRegistry.appModel
|
||||
}
|
||||
self.registerBackgroundWakeRefreshTask()
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
notificationCenter.delegate = self
|
||||
ExecApprovalNotificationBridge.registerCategory(center: notificationCenter)
|
||||
application.registerForRemoteNotifications()
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
if let appModel = self.appModel {
|
||||
if let appModel = self.resolvedAppModel() {
|
||||
Task { @MainActor in
|
||||
appModel.updateAPNsDeviceToken(deviceToken)
|
||||
}
|
||||
@@ -98,12 +139,22 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
notificationCenter: notificationCenter)
|
||||
{
|
||||
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
|
||||
self.appModel?.dismissPendingExecApprovalPrompt(approvalId: approvalId)
|
||||
if let appModel = self.resolvedAppModel() {
|
||||
await appModel.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
|
||||
} else {
|
||||
self.pendingExecApprovalResolvedPushIDs.append(approvalId)
|
||||
}
|
||||
}
|
||||
completionHandler(.newData)
|
||||
return
|
||||
}
|
||||
guard let appModel = self.appModel else {
|
||||
guard let appModel = self.resolvedAppModel() else {
|
||||
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo)
|
||||
== ExecApprovalNotificationBridge.requestedKind,
|
||||
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
|
||||
{
|
||||
self.pendingExecApprovalRequestedPushIDs.append(approvalId)
|
||||
}
|
||||
self.logger.info("APNs wake skipped: appModel unavailable")
|
||||
self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model")
|
||||
completionHandler(.noData)
|
||||
@@ -119,6 +170,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
|
||||
func scenePhaseChanged(_ phase: ScenePhase) {
|
||||
GatewayDiagnostics.log("app delegate: scene phase changed=\(String(describing: phase))")
|
||||
if phase == .background {
|
||||
self.scheduleBackgroundWakeRefresh(afterSeconds: 120, reason: "scene_background")
|
||||
}
|
||||
@@ -163,7 +215,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
self.backgroundWakeTask?.cancel()
|
||||
|
||||
let wakeTask = Task { @MainActor [weak self] in
|
||||
guard let self, let appModel = self.appModel else { return false }
|
||||
guard let self, let appModel = self.resolvedAppModel() else { return false }
|
||||
return await appModel.handleBackgroundRefreshWake(trigger: "bg_app_refresh")
|
||||
}
|
||||
self.backgroundWakeTask = wakeTask
|
||||
@@ -248,7 +300,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
|
||||
private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async {
|
||||
guard let appModel = self.appModel else {
|
||||
guard let appModel = self.resolvedAppModel() else {
|
||||
self.pendingWatchPromptActions.append(action)
|
||||
return
|
||||
}
|
||||
@@ -261,7 +313,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
|
||||
private func routeExecApprovalPrompt(_ prompt: PendingExecApprovalPrompt) {
|
||||
guard let appModel = self.appModel else {
|
||||
guard let appModel = self.resolvedAppModel() else {
|
||||
self.pendingExecApprovalPrompts.append(prompt)
|
||||
return
|
||||
}
|
||||
@@ -561,6 +613,7 @@ struct OpenClawApp: App {
|
||||
Self.installUncaughtExceptionLogger()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let appModel = NodeAppModel()
|
||||
OpenClawAppModelRegistry.appModel = appModel
|
||||
_appModel = State(initialValue: appModel)
|
||||
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
|
||||
}
|
||||
|
||||
@@ -8,9 +8,30 @@ struct ExecApprovalNotificationPrompt: Sendable, Equatable {
|
||||
enum ExecApprovalNotificationBridge {
|
||||
static let requestedKind = "exec.approval.requested"
|
||||
static let resolvedKind = "exec.approval.resolved"
|
||||
static let categoryIdentifier = "openclaw.exec-approval"
|
||||
static let reviewActionIdentifier = "openclaw.exec-approval.review"
|
||||
|
||||
private static let localRequestPrefix = "exec.approval."
|
||||
|
||||
static func registerCategory(center: UNUserNotificationCenter = .current()) {
|
||||
let category = UNNotificationCategory(
|
||||
identifier: self.categoryIdentifier,
|
||||
actions: [
|
||||
UNNotificationAction(
|
||||
identifier: self.reviewActionIdentifier,
|
||||
title: "Review",
|
||||
options: [.foreground]),
|
||||
],
|
||||
intentIdentifiers: [],
|
||||
options: [])
|
||||
|
||||
center.getNotificationCategories { categories in
|
||||
var updated = categories
|
||||
updated.update(with: category)
|
||||
center.setNotificationCategories(updated)
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
|
||||
self.payloadKind(userInfo: userInfo) == self.requestedKind
|
||||
}
|
||||
@@ -20,7 +41,11 @@ enum ExecApprovalNotificationBridge {
|
||||
userInfo: [AnyHashable: Any]
|
||||
) -> ExecApprovalNotificationPrompt?
|
||||
{
|
||||
guard actionIdentifier == UNNotificationDefaultActionIdentifier else { return nil }
|
||||
guard actionIdentifier == UNNotificationDefaultActionIdentifier
|
||||
|| actionIdentifier == self.reviewActionIdentifier
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard self.payloadKind(userInfo: userInfo) == self.requestedKind else { return nil }
|
||||
guard let approvalId = self.approvalID(from: userInfo) else { return nil }
|
||||
return ExecApprovalNotificationPrompt(approvalId: approvalId)
|
||||
@@ -71,7 +96,7 @@ enum ExecApprovalNotificationBridge {
|
||||
"\(self.localRequestPrefix)\(approvalId)"
|
||||
}
|
||||
|
||||
private static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
|
||||
static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
|
||||
let raw = self.openClawPayload(userInfo: userInfo)?["kind"] as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? "unknown" : trimmed
|
||||
|
||||
@@ -88,6 +88,20 @@ struct WatchQuickReplyEvent: Sendable, Equatable {
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchExecApprovalResolveEvent: Sendable, Equatable {
|
||||
var replyId: String
|
||||
var approvalId: String
|
||||
var decision: OpenClawWatchExecApprovalDecision
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchExecApprovalSnapshotRequestEvent: Sendable, Equatable {
|
||||
var requestId: String
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchNotificationSendResult: Sendable, Equatable {
|
||||
var deliveredImmediately: Bool
|
||||
var queuedForDelivery: Bool
|
||||
@@ -96,10 +110,22 @@ struct WatchNotificationSendResult: Sendable, Equatable {
|
||||
|
||||
protocol WatchMessagingServicing: AnyObject, Sendable {
|
||||
func status() async -> WatchMessagingStatus
|
||||
func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?)
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?)
|
||||
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?)
|
||||
func setExecApprovalSnapshotRequestHandler(
|
||||
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
|
||||
func sendNotification(
|
||||
id: String,
|
||||
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
|
||||
func sendExecApprovalPrompt(
|
||||
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
|
||||
func sendExecApprovalResolved(
|
||||
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
|
||||
func sendExecApprovalExpired(
|
||||
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
|
||||
func syncExecApprovalSnapshot(
|
||||
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
|
||||
}
|
||||
|
||||
extension CameraController: CameraServicing {}
|
||||
|
||||
363
apps/ios/Sources/Services/WatchConnectivityTransport.swift
Normal file
363
apps/ios/Sources/Services/WatchConnectivityTransport.swift
Normal file
@@ -0,0 +1,363 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
@preconcurrency import WatchConnectivity
|
||||
|
||||
private struct WatchConnectivityTransportCallbacks {
|
||||
var statusUpdateHandler: (@Sendable (WatchMessagingStatus) -> Void)?
|
||||
var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
|
||||
var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
|
||||
}
|
||||
|
||||
private func sendReachableWatchMessage(_ payload: [String: Any], with session: WCSession) async throws {
|
||||
// WatchConnectivity replies arrive on its own queue. Keep this continuation explicitly
|
||||
// nonisolated so Swift 6 does not inherit a caller actor (for example MainActor) into the
|
||||
// Objective-C callback boundary and trap on the reply callback executor check.
|
||||
try await withCheckedThrowingContinuation(isolation: nil) {
|
||||
(continuation: CheckedContinuation<Void, Error>) in
|
||||
session.sendMessage(
|
||||
payload,
|
||||
replyHandler: { _ in
|
||||
continuation.resume(returning: ())
|
||||
},
|
||||
errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
|
||||
private let session: WCSession?
|
||||
private let callbacksLock = NSLock()
|
||||
private var callbacks = WatchConnectivityTransportCallbacks()
|
||||
|
||||
override init() {
|
||||
if WCSession.isSupported() {
|
||||
self.session = WCSession.default
|
||||
} else {
|
||||
self.session = nil
|
||||
}
|
||||
super.init()
|
||||
if let session = self.session {
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func isSupportedOnDevice() -> Bool {
|
||||
WCSession.isSupported()
|
||||
}
|
||||
|
||||
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||
guard WCSession.isSupported() else {
|
||||
return WatchMessagingStatus(
|
||||
supported: false,
|
||||
paired: false,
|
||||
appInstalled: false,
|
||||
reachable: false,
|
||||
activationState: "unsupported")
|
||||
}
|
||||
return self.status(for: WCSession.default)
|
||||
}
|
||||
|
||||
func status() async -> WatchMessagingStatus {
|
||||
await self.ensureActivated()
|
||||
return self.currentStatusSnapshot()
|
||||
}
|
||||
|
||||
func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||
guard let session = self.session else {
|
||||
return WatchMessagingStatus(
|
||||
supported: false,
|
||||
paired: false,
|
||||
appInstalled: false,
|
||||
reachable: false,
|
||||
activationState: "unsupported")
|
||||
}
|
||||
return Self.status(for: session)
|
||||
}
|
||||
|
||||
func setStatusUpdateHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
|
||||
self.updateCallbacks { $0.statusUpdateHandler = handler }
|
||||
}
|
||||
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
||||
self.updateCallbacks { $0.replyHandler = handler }
|
||||
}
|
||||
|
||||
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
|
||||
self.updateCallbacks { $0.execApprovalResolveHandler = handler }
|
||||
}
|
||||
|
||||
func setExecApprovalSnapshotRequestHandler(
|
||||
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
|
||||
{
|
||||
self.updateCallbacks { $0.execApprovalSnapshotRequestHandler = handler }
|
||||
}
|
||||
|
||||
func sendPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult {
|
||||
await self.ensureActivated()
|
||||
let session = try self.requireReadySession()
|
||||
if session.isReachable {
|
||||
do {
|
||||
try await sendReachableWatchMessage(payload, with: session)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: true,
|
||||
queuedForDelivery: false,
|
||||
transport: "sendMessage")
|
||||
} catch {
|
||||
Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
_ = session.transferUserInfo(payload)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: false,
|
||||
queuedForDelivery: true,
|
||||
transport: "transferUserInfo")
|
||||
}
|
||||
|
||||
func sendSnapshotPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult {
|
||||
await self.ensureActivated()
|
||||
let session = try self.requireReadySession()
|
||||
if session.isReachable {
|
||||
do {
|
||||
try await sendReachableWatchMessage(payload, with: session)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: true,
|
||||
queuedForDelivery: false,
|
||||
transport: "sendMessage")
|
||||
} catch {
|
||||
Self.logger.error(
|
||||
"watch snapshot sendMessage failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try session.updateApplicationContext(payload)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: false,
|
||||
queuedForDelivery: true,
|
||||
transport: "applicationContext")
|
||||
} catch {
|
||||
Self.logger.error(
|
||||
"watch updateApplicationContext failed: \(error.localizedDescription, privacy: .public)")
|
||||
_ = session.transferUserInfo(payload)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: false,
|
||||
queuedForDelivery: true,
|
||||
transport: "transferUserInfo")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCallbacks(_ update: (inout WatchConnectivityTransportCallbacks) -> Void) {
|
||||
self.callbacksLock.lock()
|
||||
defer { self.callbacksLock.unlock() }
|
||||
update(&self.callbacks)
|
||||
}
|
||||
|
||||
private func callbacksSnapshot() -> WatchConnectivityTransportCallbacks {
|
||||
self.callbacksLock.lock()
|
||||
defer { self.callbacksLock.unlock() }
|
||||
return self.callbacks
|
||||
}
|
||||
|
||||
private func requireReadySession() throws -> WCSession {
|
||||
guard let session = self.session else {
|
||||
throw WatchMessagingError.unsupported
|
||||
}
|
||||
let snapshot = Self.status(for: session)
|
||||
guard snapshot.paired else {
|
||||
throw WatchMessagingError.notPaired
|
||||
}
|
||||
guard snapshot.appInstalled else {
|
||||
throw WatchMessagingError.watchAppNotInstalled
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private func ensureActivated() async {
|
||||
guard let session = self.session else { return }
|
||||
if session.activationState == .activated {
|
||||
return
|
||||
}
|
||||
session.activate()
|
||||
for _ in 0..<8 {
|
||||
if session.activationState == .activated {
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitStatusUpdate(_ snapshot: WatchMessagingStatus) {
|
||||
guard let handler = self.callbacksSnapshot().statusUpdateHandler else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
handler(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitReply(_ event: WatchQuickReplyEvent) {
|
||||
guard let handler = self.callbacksSnapshot().replyHandler else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
|
||||
guard let handler = self.callbacksSnapshot().execApprovalResolveHandler else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
|
||||
guard let handler = self.callbacksSnapshot().execApprovalSnapshotRequestHandler else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||
WatchMessagingStatus(
|
||||
supported: true,
|
||||
paired: session.isPaired,
|
||||
appInstalled: session.isWatchAppInstalled,
|
||||
reachable: session.isReachable,
|
||||
activationState: self.activationStateLabel(session.activationState))
|
||||
}
|
||||
|
||||
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||
switch state {
|
||||
case .notActivated:
|
||||
"notActivated"
|
||||
case .inactive:
|
||||
"inactive"
|
||||
case .activated:
|
||||
"activated"
|
||||
@unknown default:
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchConnectivityTransport: WCSessionDelegate {
|
||||
func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: (any Error)?)
|
||||
{
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: activation complete state=\(Self.activationStateLabel(activationState)) error=\(error?.localizedDescription ?? "none")")
|
||||
if let error {
|
||||
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
|
||||
} else {
|
||||
Self.logger.debug(
|
||||
"watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
||||
}
|
||||
self.emitStatusUpdate(Self.status(for: session))
|
||||
}
|
||||
|
||||
func sessionDidBecomeInactive(_: WCSession) {}
|
||||
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
GatewayDiagnostics.log("watch messaging: session did deactivate; reactivating")
|
||||
session.activate()
|
||||
self.emitStatusUpdate(Self.status(for: session))
|
||||
}
|
||||
|
||||
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
let type = (message["type"] as? String) ?? "unknown"
|
||||
GatewayDiagnostics.log("watch messaging: didReceiveMessage type=\(type)")
|
||||
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") {
|
||||
self.emitReply(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
self.emitExecApprovalResolve(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
self.emitExecApprovalSnapshotRequest(event)
|
||||
}
|
||||
}
|
||||
|
||||
func session(
|
||||
_: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void)
|
||||
{
|
||||
let type = (message["type"] as? String) ?? "unknown"
|
||||
GatewayDiagnostics.log("watch messaging: didReceiveMessageWithReply type=\(type)")
|
||||
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") {
|
||||
replyHandler(["ok": true])
|
||||
self.emitReply(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
replyHandler(["ok": true])
|
||||
self.emitExecApprovalResolve(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
|
||||
message,
|
||||
transport: "sendMessage")
|
||||
{
|
||||
replyHandler(["ok": true])
|
||||
self.emitExecApprovalSnapshotRequest(event)
|
||||
return
|
||||
}
|
||||
replyHandler(["ok": false, "error": "unsupported_payload"])
|
||||
}
|
||||
|
||||
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
let type = (userInfo["type"] as? String) ?? "unknown"
|
||||
GatewayDiagnostics.log("watch messaging: didReceiveUserInfo type=\(type)")
|
||||
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(
|
||||
userInfo,
|
||||
transport: "transferUserInfo")
|
||||
{
|
||||
self.emitReply(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
|
||||
userInfo,
|
||||
transport: "transferUserInfo")
|
||||
{
|
||||
self.emitExecApprovalResolve(event)
|
||||
return
|
||||
}
|
||||
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
|
||||
userInfo,
|
||||
transport: "transferUserInfo")
|
||||
{
|
||||
self.emitExecApprovalSnapshotRequest(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: reachability changed reachable=\(session.isReachable) paired=\(session.isPaired) installed=\(session.isWatchAppInstalled)")
|
||||
self.emitStatusUpdate(Self.status(for: session))
|
||||
}
|
||||
}
|
||||
219
apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift
Normal file
219
apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift
Normal file
@@ -0,0 +1,219 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum WatchMessagingPayloadCodec {
|
||||
static func nowMs() -> Int {
|
||||
Int(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
static func nonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func encodeNotificationPayload(
|
||||
id: String,
|
||||
params: OpenClawWatchNotifyParams) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.notify.rawValue,
|
||||
"id": id,
|
||||
"title": params.title,
|
||||
"body": params.body,
|
||||
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
||||
"sentAtMs": nowMs(),
|
||||
]
|
||||
if let promptId = nonEmpty(params.promptId) {
|
||||
payload["promptId"] = promptId
|
||||
}
|
||||
if let sessionKey = nonEmpty(params.sessionKey) {
|
||||
payload["sessionKey"] = sessionKey
|
||||
}
|
||||
if let kind = nonEmpty(params.kind) {
|
||||
payload["kind"] = kind
|
||||
}
|
||||
if let details = nonEmpty(params.details) {
|
||||
payload["details"] = details
|
||||
}
|
||||
if let expiresAtMs = params.expiresAtMs {
|
||||
payload["expiresAtMs"] = expiresAtMs
|
||||
}
|
||||
if let risk = params.risk {
|
||||
payload["risk"] = risk.rawValue
|
||||
}
|
||||
if let actions = params.actions, !actions.isEmpty {
|
||||
payload["actions"] = actions.map { action in
|
||||
var encoded: [String: Any] = [
|
||||
"id": action.id,
|
||||
"label": action.label,
|
||||
]
|
||||
if let style = nonEmpty(action.style) {
|
||||
encoded["style"] = style
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeExecApprovalItem(_ item: OpenClawWatchExecApprovalItem) -> [String: Any] {
|
||||
var payload: [String: Any] = [
|
||||
"id": item.id,
|
||||
"commandText": item.commandText,
|
||||
"allowedDecisions": item.allowedDecisions.map(\.rawValue),
|
||||
]
|
||||
if let commandPreview = nonEmpty(item.commandPreview) {
|
||||
payload["commandPreview"] = commandPreview
|
||||
}
|
||||
if let host = nonEmpty(item.host) {
|
||||
payload["host"] = host
|
||||
}
|
||||
if let nodeId = nonEmpty(item.nodeId) {
|
||||
payload["nodeId"] = nodeId
|
||||
}
|
||||
if let agentId = nonEmpty(item.agentId) {
|
||||
payload["agentId"] = agentId
|
||||
}
|
||||
if let expiresAtMs = item.expiresAtMs {
|
||||
payload["expiresAtMs"] = expiresAtMs
|
||||
}
|
||||
if let risk = item.risk {
|
||||
payload["risk"] = risk.rawValue
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeExecApprovalPromptPayload(
|
||||
_ message: OpenClawWatchExecApprovalPromptMessage) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue,
|
||||
"approval": encodeExecApprovalItem(message.approval),
|
||||
]
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
}
|
||||
if let deliveryId = nonEmpty(message.deliveryId) {
|
||||
payload["deliveryId"] = deliveryId
|
||||
}
|
||||
if message.resetResolvingState == true {
|
||||
payload["resetResolvingState"] = true
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeExecApprovalResolvedPayload(
|
||||
_ message: OpenClawWatchExecApprovalResolvedMessage) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalResolved.rawValue,
|
||||
"approvalId": message.approvalId,
|
||||
]
|
||||
if let decision = message.decision {
|
||||
payload["decision"] = decision.rawValue
|
||||
}
|
||||
if let resolvedAtMs = message.resolvedAtMs {
|
||||
payload["resolvedAtMs"] = resolvedAtMs
|
||||
}
|
||||
if let source = nonEmpty(message.source) {
|
||||
payload["source"] = source
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeExecApprovalExpiredPayload(
|
||||
_ message: OpenClawWatchExecApprovalExpiredMessage) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalExpired.rawValue,
|
||||
"approvalId": message.approvalId,
|
||||
"reason": message.reason.rawValue,
|
||||
]
|
||||
if let expiredAtMs = message.expiredAtMs {
|
||||
payload["expiredAtMs"] = expiredAtMs
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func encodeExecApprovalSnapshotPayload(
|
||||
_ message: OpenClawWatchExecApprovalSnapshotMessage) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
|
||||
"approvals": message.approvals.map(encodeExecApprovalItem),
|
||||
]
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
}
|
||||
if let snapshotId = nonEmpty(message.snapshotId) {
|
||||
payload["snapshotId"] = snapshotId
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func parseQuickReplyPayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchQuickReplyEvent?
|
||||
{
|
||||
guard (payload["type"] as? String) == OpenClawWatchPayloadType.reply.rawValue else {
|
||||
return nil
|
||||
}
|
||||
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
|
||||
return nil
|
||||
}
|
||||
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
||||
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
|
||||
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
|
||||
let note = nonEmpty(payload["note"] as? String)
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
|
||||
return WatchQuickReplyEvent(
|
||||
replyId: replyId,
|
||||
promptId: promptId,
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
sessionKey: sessionKey,
|
||||
note: note,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
}
|
||||
|
||||
static func parseExecApprovalResolvePayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchExecApprovalResolveEvent?
|
||||
{
|
||||
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalResolve.rawValue else {
|
||||
return nil
|
||||
}
|
||||
guard let approvalId = nonEmpty(payload["approvalId"] as? String),
|
||||
let rawDecision = nonEmpty(payload["decision"] as? String),
|
||||
let decision = OpenClawWatchExecApprovalDecision(rawValue: rawDecision)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
return WatchExecApprovalResolveEvent(
|
||||
replyId: replyId,
|
||||
approvalId: approvalId,
|
||||
decision: decision,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
}
|
||||
|
||||
static func parseExecApprovalSnapshotRequestPayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchExecApprovalSnapshotRequestEvent?
|
||||
{
|
||||
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else {
|
||||
return nil
|
||||
}
|
||||
let requestId = nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
return WatchExecApprovalSnapshotRequestEvent(
|
||||
requestId: requestId,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
@preconcurrency import WatchConnectivity
|
||||
|
||||
enum WatchMessagingError: LocalizedError {
|
||||
case unsupported
|
||||
@@ -21,272 +19,136 @@ enum WatchMessagingError: LocalizedError {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServicing {
|
||||
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
private let session: WCSession?
|
||||
private var pendingActivationContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
|
||||
private let transport: WatchConnectivityTransport
|
||||
private var statusHandler: (@Sendable (WatchMessagingStatus) -> Void)?
|
||||
private var lastEmittedStatus: WatchMessagingStatus?
|
||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
private var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
|
||||
private var execApprovalSnapshotRequestHandler: (
|
||||
@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
|
||||
|
||||
override init() {
|
||||
if WCSession.isSupported() {
|
||||
self.session = WCSession.default
|
||||
} else {
|
||||
self.session = nil
|
||||
init(transport: WatchConnectivityTransport = WatchConnectivityTransport()) {
|
||||
self.transport = transport
|
||||
self.transport.setStatusUpdateHandler { [weak self] snapshot in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.emitStatusIfChanged(snapshot)
|
||||
}
|
||||
}
|
||||
super.init()
|
||||
if let session = self.session {
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
self.transport.setReplyHandler { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.emitReply(event)
|
||||
}
|
||||
}
|
||||
self.transport.setExecApprovalResolveHandler { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.emitExecApprovalResolve(event)
|
||||
}
|
||||
}
|
||||
self.transport.setExecApprovalSnapshotRequestHandler { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.emitExecApprovalSnapshotRequest(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func isSupportedOnDevice() -> Bool {
|
||||
WCSession.isSupported()
|
||||
WatchConnectivityTransport.isSupportedOnDevice()
|
||||
}
|
||||
|
||||
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||
guard WCSession.isSupported() else {
|
||||
return WatchMessagingStatus(
|
||||
supported: false,
|
||||
paired: false,
|
||||
appInstalled: false,
|
||||
reachable: false,
|
||||
activationState: "unsupported")
|
||||
}
|
||||
let session = WCSession.default
|
||||
return status(for: session)
|
||||
WatchConnectivityTransport.currentStatusSnapshot()
|
||||
}
|
||||
|
||||
func status() async -> WatchMessagingStatus {
|
||||
await self.ensureActivated()
|
||||
guard let session = self.session else {
|
||||
return WatchMessagingStatus(
|
||||
supported: false,
|
||||
paired: false,
|
||||
appInstalled: false,
|
||||
reachable: false,
|
||||
activationState: "unsupported")
|
||||
await self.transport.status()
|
||||
}
|
||||
|
||||
func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
|
||||
self.statusHandler = handler
|
||||
guard let handler else {
|
||||
self.lastEmittedStatus = nil
|
||||
GatewayDiagnostics.log("watch messaging: cleared status handler")
|
||||
return
|
||||
}
|
||||
return Self.status(for: session)
|
||||
let snapshot = self.transport.currentStatusSnapshot()
|
||||
self.lastEmittedStatus = snapshot
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: set status handler supported=\(snapshot.supported) paired=\(snapshot.paired) appInstalled=\(snapshot.appInstalled) reachable=\(snapshot.reachable) activation=\(snapshot.activationState)")
|
||||
handler(snapshot)
|
||||
}
|
||||
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
||||
self.replyHandler = handler
|
||||
}
|
||||
|
||||
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
|
||||
self.execApprovalResolveHandler = handler
|
||||
}
|
||||
|
||||
func setExecApprovalSnapshotRequestHandler(
|
||||
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
|
||||
{
|
||||
self.execApprovalSnapshotRequestHandler = handler
|
||||
}
|
||||
|
||||
func sendNotification(
|
||||
id: String,
|
||||
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
await self.ensureActivated()
|
||||
guard let session = self.session else {
|
||||
throw WatchMessagingError.unsupported
|
||||
}
|
||||
|
||||
let snapshot = Self.status(for: session)
|
||||
guard snapshot.paired else { throw WatchMessagingError.notPaired }
|
||||
guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled }
|
||||
|
||||
var payload: [String: Any] = [
|
||||
"type": "watch.notify",
|
||||
"id": id,
|
||||
"title": params.title,
|
||||
"body": params.body,
|
||||
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
||||
"sentAtMs": Int(Date().timeIntervalSince1970 * 1000),
|
||||
]
|
||||
if let promptId = Self.nonEmpty(params.promptId) {
|
||||
payload["promptId"] = promptId
|
||||
}
|
||||
if let sessionKey = Self.nonEmpty(params.sessionKey) {
|
||||
payload["sessionKey"] = sessionKey
|
||||
}
|
||||
if let kind = Self.nonEmpty(params.kind) {
|
||||
payload["kind"] = kind
|
||||
}
|
||||
if let details = Self.nonEmpty(params.details) {
|
||||
payload["details"] = details
|
||||
}
|
||||
if let expiresAtMs = params.expiresAtMs {
|
||||
payload["expiresAtMs"] = expiresAtMs
|
||||
}
|
||||
if let risk = params.risk {
|
||||
payload["risk"] = risk.rawValue
|
||||
}
|
||||
if let actions = params.actions, !actions.isEmpty {
|
||||
payload["actions"] = actions.map { action in
|
||||
var encoded: [String: Any] = [
|
||||
"id": action.id,
|
||||
"label": action.label,
|
||||
]
|
||||
if let style = Self.nonEmpty(action.style) {
|
||||
encoded["style"] = style
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.reachable {
|
||||
do {
|
||||
try await self.sendReachableMessage(payload, with: session)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: true,
|
||||
queuedForDelivery: false,
|
||||
transport: "sendMessage")
|
||||
} catch {
|
||||
Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
_ = session.transferUserInfo(payload)
|
||||
return WatchNotificationSendResult(
|
||||
deliveredImmediately: false,
|
||||
queuedForDelivery: true,
|
||||
transport: "transferUserInfo")
|
||||
let payload = WatchMessagingPayloadCodec.encodeNotificationPayload(id: id, params: params)
|
||||
return try await self.transport.sendPayload(payload)
|
||||
}
|
||||
|
||||
private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
session.sendMessage(
|
||||
payload,
|
||||
replyHandler: { _ in
|
||||
continuation.resume()
|
||||
},
|
||||
errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
)
|
||||
func sendExecApprovalPrompt(
|
||||
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
try await self.transport.sendPayload(
|
||||
WatchMessagingPayloadCodec.encodeExecApprovalPromptPayload(message))
|
||||
}
|
||||
|
||||
func sendExecApprovalResolved(
|
||||
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
try await self.transport.sendPayload(
|
||||
WatchMessagingPayloadCodec.encodeExecApprovalResolvedPayload(message))
|
||||
}
|
||||
|
||||
func sendExecApprovalExpired(
|
||||
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
try await self.transport.sendPayload(
|
||||
WatchMessagingPayloadCodec.encodeExecApprovalExpiredPayload(message))
|
||||
}
|
||||
|
||||
func syncExecApprovalSnapshot(
|
||||
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
try await self.transport.sendSnapshotPayload(
|
||||
WatchMessagingPayloadCodec.encodeExecApprovalSnapshotPayload(message))
|
||||
}
|
||||
|
||||
private func emitStatusIfChanged(_ snapshot: WatchMessagingStatus) {
|
||||
guard snapshot != self.lastEmittedStatus else {
|
||||
return
|
||||
}
|
||||
self.lastEmittedStatus = snapshot
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: status supported=\(snapshot.supported) paired=\(snapshot.paired) appInstalled=\(snapshot.appInstalled) reachable=\(snapshot.reachable) activation=\(snapshot.activationState)")
|
||||
self.statusHandler?(snapshot)
|
||||
}
|
||||
|
||||
private func emitReply(_ event: WatchQuickReplyEvent) {
|
||||
self.replyHandler?(event)
|
||||
}
|
||||
|
||||
nonisolated private static func nonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
private func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
|
||||
self.execApprovalResolveHandler?(event)
|
||||
}
|
||||
|
||||
nonisolated private static func parseQuickReplyPayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchQuickReplyEvent?
|
||||
{
|
||||
guard (payload["type"] as? String) == "watch.reply" else {
|
||||
return nil
|
||||
}
|
||||
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
|
||||
return nil
|
||||
}
|
||||
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
||||
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
|
||||
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
|
||||
let note = nonEmpty(payload["note"] as? String)
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
|
||||
return WatchQuickReplyEvent(
|
||||
replyId: replyId,
|
||||
promptId: promptId,
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
sessionKey: sessionKey,
|
||||
note: note,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
}
|
||||
|
||||
private func ensureActivated() async {
|
||||
guard let session = self.session else { return }
|
||||
if session.activationState == .activated { return }
|
||||
session.activate()
|
||||
await withCheckedContinuation { continuation in
|
||||
self.pendingActivationContinuations.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||
WatchMessagingStatus(
|
||||
supported: true,
|
||||
paired: session.isPaired,
|
||||
appInstalled: session.isWatchAppInstalled,
|
||||
reachable: session.isReachable,
|
||||
activationState: activationStateLabel(session.activationState))
|
||||
}
|
||||
|
||||
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||
switch state {
|
||||
case .notActivated:
|
||||
"notActivated"
|
||||
case .inactive:
|
||||
"inactive"
|
||||
case .activated:
|
||||
"activated"
|
||||
@unknown default:
|
||||
"unknown"
|
||||
}
|
||||
private func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
|
||||
GatewayDiagnostics.log(
|
||||
"watch messaging: snapshot request id=\(event.requestId) transport=\(event.transport) sentAtMs=\(event.sentAtMs ?? -1)")
|
||||
self.execApprovalSnapshotRequestHandler?(event)
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchMessagingService: WCSessionDelegate {
|
||||
nonisolated func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: (any Error)?)
|
||||
{
|
||||
if let error {
|
||||
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
|
||||
} else {
|
||||
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
||||
}
|
||||
// Always resume all waiters so callers never hang, even on error.
|
||||
Task { @MainActor in
|
||||
let waiters = self.pendingActivationContinuations
|
||||
self.pendingActivationContinuations.removeAll()
|
||||
for continuation in waiters {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
|
||||
nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.emitReply(event)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(
|
||||
_: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void)
|
||||
{
|
||||
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
|
||||
replyHandler(["ok": false, "error": "unsupported_payload"])
|
||||
return
|
||||
}
|
||||
replyHandler(["ok": true])
|
||||
Task { @MainActor in
|
||||
self.emitReply(event)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.emitReply(event)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,32 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
|
||||
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-123"))
|
||||
}
|
||||
|
||||
@Test func parsePromptMapsReviewAction() {
|
||||
let prompt = ExecApprovalNotificationBridge.parsePrompt(
|
||||
actionIdentifier: ExecApprovalNotificationBridge.reviewActionIdentifier,
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-456",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-456"))
|
||||
}
|
||||
|
||||
@Test func parsePromptIgnoresUnexpectedActionIdentifiers() {
|
||||
let prompt = ExecApprovalNotificationBridge.parsePrompt(
|
||||
actionIdentifier: "openclaw.exec-approval.allow-once",
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-789",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(prompt == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleResolvedPushRemovesMatchingNotifications() async {
|
||||
let center = MockNotificationCenter()
|
||||
center.delivered = [
|
||||
|
||||
@@ -46,16 +46,37 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
transport: "sendMessage")
|
||||
var sendError: Error?
|
||||
var lastSent: (id: String, params: OpenClawWatchNotifyParams)?
|
||||
var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage?
|
||||
var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage?
|
||||
var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage?
|
||||
var lastSentExecApprovalSnapshot: OpenClawWatchExecApprovalSnapshotMessage?
|
||||
private var statusHandler: (@Sendable (WatchMessagingStatus) -> Void)?
|
||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
private var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
|
||||
private var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
|
||||
|
||||
func status() async -> WatchMessagingStatus {
|
||||
self.currentStatus
|
||||
}
|
||||
|
||||
func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
|
||||
self.statusHandler = handler
|
||||
}
|
||||
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
||||
self.replyHandler = handler
|
||||
}
|
||||
|
||||
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
|
||||
self.execApprovalResolveHandler = handler
|
||||
}
|
||||
|
||||
func setExecApprovalSnapshotRequestHandler(
|
||||
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
|
||||
{
|
||||
self.execApprovalSnapshotRequestHandler = handler
|
||||
}
|
||||
|
||||
func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult {
|
||||
self.lastSent = (id: id, params: params)
|
||||
if let sendError = self.sendError {
|
||||
@@ -64,9 +85,57 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
return self.nextSendResult
|
||||
}
|
||||
|
||||
func sendExecApprovalPrompt(
|
||||
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
self.lastSentExecApprovalPrompt = message
|
||||
if let sendError = self.sendError {
|
||||
throw sendError
|
||||
}
|
||||
return self.nextSendResult
|
||||
}
|
||||
|
||||
func sendExecApprovalResolved(
|
||||
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
self.lastSentExecApprovalResolved = message
|
||||
if let sendError = self.sendError {
|
||||
throw sendError
|
||||
}
|
||||
return self.nextSendResult
|
||||
}
|
||||
|
||||
func sendExecApprovalExpired(
|
||||
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
self.lastSentExecApprovalExpired = message
|
||||
if let sendError = self.sendError {
|
||||
throw sendError
|
||||
}
|
||||
return self.nextSendResult
|
||||
}
|
||||
|
||||
func syncExecApprovalSnapshot(
|
||||
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
self.lastSentExecApprovalSnapshot = message
|
||||
if let sendError = self.sendError {
|
||||
throw sendError
|
||||
}
|
||||
return self.nextSendResult
|
||||
}
|
||||
|
||||
func emitReply(_ event: WatchQuickReplyEvent) {
|
||||
self.replyHandler?(event)
|
||||
}
|
||||
|
||||
func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
|
||||
self.execApprovalResolveHandler?(event)
|
||||
}
|
||||
|
||||
func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
|
||||
self.execApprovalSnapshotRequestHandler?(event)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
@@ -184,6 +253,118 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(prompt.id == "approval-active")
|
||||
}
|
||||
|
||||
@Test @MainActor func presentingExecApprovalPromptSyncsWatchPrompt() async throws {
|
||||
let watchService = MockWatchMessagingService()
|
||||
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||
let prompt = try #require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-watch-sync",
|
||||
commandText: "npm publish",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: "node-1",
|
||||
agentId: "main",
|
||||
expiresAtMs: 1234))
|
||||
|
||||
appModel._test_presentExecApprovalPrompt(prompt)
|
||||
await Task.yield()
|
||||
|
||||
let sent = try #require(watchService.lastSentExecApprovalPrompt)
|
||||
#expect(sent.approval.id == "approval-watch-sync")
|
||||
#expect(sent.approval.allowedDecisions == [.allowOnce, .deny])
|
||||
#expect(sent.approval.host == "gateway")
|
||||
#expect(sent.approval.risk == nil)
|
||||
#expect(sent.resetResolvingState != true)
|
||||
}
|
||||
|
||||
@Test @MainActor func watchExecApprovalSnapshotRequestPublishesCachedApprovalsInBackground() async throws {
|
||||
let watchService = MockWatchMessagingService()
|
||||
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60_000
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-watch-snapshot",
|
||||
commandText: "echo from watch",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: nil,
|
||||
agentId: nil,
|
||||
expiresAtMs: futureExpiryMs)))
|
||||
await Task.yield()
|
||||
|
||||
appModel.setScenePhase(.background)
|
||||
watchService.emitExecApprovalSnapshotRequest(
|
||||
WatchExecApprovalSnapshotRequestEvent(
|
||||
requestId: "snapshot-1",
|
||||
sentAtMs: 111,
|
||||
transport: "sendMessage"))
|
||||
await Task.yield()
|
||||
|
||||
let snapshot = try #require(watchService.lastSentExecApprovalSnapshot)
|
||||
#expect(snapshot.approvals.map(\.id) == ["approval-watch-snapshot"])
|
||||
}
|
||||
|
||||
@Test @MainActor func watchExecApprovalSnapshotRequestSkipsForegroundRecovery() async throws {
|
||||
let watchService = MockWatchMessagingService()
|
||||
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60_000
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-watch-foreground-skip",
|
||||
commandText: "echo foreground",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: nil,
|
||||
agentId: nil,
|
||||
expiresAtMs: futureExpiryMs)))
|
||||
await Task.yield()
|
||||
watchService.lastSentExecApprovalSnapshot = nil
|
||||
|
||||
watchService.emitExecApprovalSnapshotRequest(
|
||||
WatchExecApprovalSnapshotRequestEvent(
|
||||
requestId: "snapshot-foreground",
|
||||
sentAtMs: 222,
|
||||
transport: "sendMessage"))
|
||||
await Task.yield()
|
||||
|
||||
#expect(watchService.lastSentExecApprovalSnapshot == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func pendingWatchRecoveryIDsAreIncludedWithoutDeliveredNotifications() async {
|
||||
NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState()
|
||||
defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() }
|
||||
|
||||
let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter())
|
||||
appModel._test_recordPendingWatchExecApprovalRecoveryID("approval-watch-recovery")
|
||||
|
||||
let ids = await appModel._test_pendingExecApprovalIDsForWatchRecovery()
|
||||
#expect(ids == ["approval-watch-recovery"])
|
||||
}
|
||||
|
||||
@Test @MainActor func presentingExecApprovalPromptClearsPendingWatchRecoveryID() throws {
|
||||
NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState()
|
||||
defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() }
|
||||
|
||||
let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter())
|
||||
appModel._test_recordPendingWatchExecApprovalRecoveryID("approval-watch-clear")
|
||||
#expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"])
|
||||
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-watch-clear",
|
||||
commandText: "echo clear",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: nil,
|
||||
agentId: nil,
|
||||
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60_000)))
|
||||
|
||||
#expect(appModel._test_pendingWatchExecApprovalRecoveryIDs().isEmpty)
|
||||
}
|
||||
|
||||
@Test func approvalNotificationErrorClassificationPrefersStructuredDetails() {
|
||||
let staleError = GatewayResponseError(
|
||||
method: "exec.approval.get",
|
||||
@@ -200,6 +381,48 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(NodeAppModel._test_isApprovalNotificationUnavailableError(unavailableError))
|
||||
}
|
||||
|
||||
@Test func backgroundAwareExecApprovalReconnectCoversWatchAndPushPaths() {
|
||||
#expect(
|
||||
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: "watch_request",
|
||||
isBackgrounded: true)
|
||||
)
|
||||
#expect(
|
||||
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: "push_request",
|
||||
isBackgrounded: true)
|
||||
)
|
||||
#expect(
|
||||
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: "watch_resolve",
|
||||
isBackgrounded: true)
|
||||
)
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: "direct",
|
||||
isBackgrounded: true)
|
||||
)
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: "watch_request",
|
||||
isBackgrounded: false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() {
|
||||
let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch(
|
||||
candidateIDs: ["cached", "pending", "cached", "other", "", " pending "],
|
||||
cachedApprovalIDs: ["cached", "also-cached"])
|
||||
|
||||
#expect(idsToFetch == ["pending", "other"])
|
||||
}
|
||||
|
||||
@Test func watchExecApprovalRetryPromptResetsResolvingStateOnlyForRetryReason() {
|
||||
#expect(NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "resolve_retry"))
|
||||
#expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "push_request"))
|
||||
#expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "present_prompt"))
|
||||
}
|
||||
|
||||
@Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() {
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
|
||||
@@ -590,6 +813,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
note: nil,
|
||||
sentAtMs: 1234,
|
||||
transport: "transferUserInfo"))
|
||||
await Task.yield()
|
||||
#expect(appModel._test_queuedWatchReplyCount() == 1)
|
||||
}
|
||||
|
||||
|
||||
26
apps/ios/Tests/OpenClawAppDelegateTests.swift
Normal file
26
apps/ios/Tests/OpenClawAppDelegateTests.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct OpenClawAppDelegateTests {
|
||||
@Test @MainActor func resolvesRegistryModelBeforeViewTaskAssignsDelegateModel() {
|
||||
let registryModel = NodeAppModel()
|
||||
OpenClawAppModelRegistry.appModel = registryModel
|
||||
defer { OpenClawAppModelRegistry.appModel = nil }
|
||||
|
||||
let delegate = OpenClawAppDelegate()
|
||||
|
||||
#expect(delegate._test_resolvedAppModel() === registryModel)
|
||||
}
|
||||
|
||||
@Test @MainActor func prefersExplicitDelegateModelOverRegistryFallback() {
|
||||
let registryModel = NodeAppModel()
|
||||
let explicitModel = NodeAppModel()
|
||||
OpenClawAppModelRegistry.appModel = registryModel
|
||||
defer { OpenClawAppModelRegistry.appModel = nil }
|
||||
|
||||
let delegate = OpenClawAppDelegate()
|
||||
delegate.appModel = explicitModel
|
||||
|
||||
#expect(delegate._test_resolvedAppModel() === explicitModel)
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,79 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct OpenClawWatchApp: App {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var inboxStore = WatchInboxStore()
|
||||
@State private var receiver: WatchConnectivityReceiver?
|
||||
@State private var execApprovalRefreshTask: Task<Void, Never>?
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchInboxView(store: self.inboxStore) { action in
|
||||
guard let receiver = self.receiver else { return }
|
||||
let draft = self.inboxStore.makeReplyDraft(action: action)
|
||||
self.inboxStore.markReplySending(actionLabel: action.label)
|
||||
Task { @MainActor in
|
||||
let result = await receiver.sendReply(draft)
|
||||
self.inboxStore.markReplyResult(result, actionLabel: action.label)
|
||||
}
|
||||
}
|
||||
WatchInboxView(
|
||||
store: self.inboxStore,
|
||||
onAction: { action in
|
||||
guard let receiver = self.receiver else { return }
|
||||
let draft = self.inboxStore.makeReplyDraft(action: action)
|
||||
self.inboxStore.markReplySending(actionLabel: action.label)
|
||||
Task { @MainActor in
|
||||
let result = await receiver.sendReply(draft)
|
||||
self.inboxStore.markReplyResult(result, actionLabel: action.label)
|
||||
}
|
||||
},
|
||||
onExecApprovalDecision: { approvalId, decision in
|
||||
guard let receiver = self.receiver else { return }
|
||||
self.inboxStore.markExecApprovalSending(approvalId: approvalId, decision: decision)
|
||||
Task { @MainActor in
|
||||
let result = await receiver.sendExecApprovalResolve(
|
||||
approvalId: approvalId,
|
||||
decision: decision)
|
||||
self.inboxStore.markExecApprovalSendResult(
|
||||
approvalId: approvalId,
|
||||
decision: decision,
|
||||
result: result)
|
||||
}
|
||||
},
|
||||
onRefreshExecApprovalReview: {
|
||||
self.refreshExecApprovalReview(force: true)
|
||||
})
|
||||
.task {
|
||||
if self.receiver == nil {
|
||||
let receiver = WatchConnectivityReceiver(store: self.inboxStore)
|
||||
receiver.activate()
|
||||
self.receiver = receiver
|
||||
}
|
||||
self.refreshExecApprovalReview()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newPhase in
|
||||
guard newPhase == .active else { return }
|
||||
self.refreshExecApprovalReview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshExecApprovalReview(force: Bool = false) {
|
||||
guard let receiver = self.receiver else { return }
|
||||
guard force || self.inboxStore.shouldAutoRequestExecApprovalSnapshot else { return }
|
||||
|
||||
self.execApprovalRefreshTask?.cancel()
|
||||
self.execApprovalRefreshTask = Task { @MainActor in
|
||||
self.inboxStore.beginExecApprovalReviewLoading()
|
||||
for attempt in 0..<5 {
|
||||
if Task.isCancelled { return }
|
||||
await receiver.requestExecApprovalSnapshot()
|
||||
if !self.inboxStore.execApprovals.isEmpty
|
||||
|| self.inboxStore.hasCompletedExecApprovalSnapshotRefresh
|
||||
{
|
||||
self.inboxStore.markExecApprovalReviewLoaded()
|
||||
return
|
||||
}
|
||||
if attempt < 4 {
|
||||
try? await Task.sleep(nanoseconds: 700_000_000)
|
||||
}
|
||||
}
|
||||
if self.inboxStore.execApprovals.isEmpty {
|
||||
self.inboxStore.markExecApprovalReviewUnavailable(
|
||||
"Couldn't load approval from your iPhone yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,31 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func requestExecApprovalSnapshot() async {
|
||||
await self.ensureActivated()
|
||||
guard let session = self.session else { return }
|
||||
let request = WatchExecApprovalSnapshotRequestMessage(
|
||||
requestId: UUID().uuidString,
|
||||
sentAtMs: Self.nowMs())
|
||||
let payload = Self.encodeSnapshotRequestPayload(request)
|
||||
if session.isReachable {
|
||||
do {
|
||||
try await withCheckedThrowingContinuation(isolation: nil) {
|
||||
(continuation: CheckedContinuation<Void, Error>) in
|
||||
session.sendMessage(payload, replyHandler: { _ in
|
||||
continuation.resume(returning: ())
|
||||
}, errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
})
|
||||
}
|
||||
return
|
||||
} catch {
|
||||
// Fall through to queued delivery.
|
||||
}
|
||||
}
|
||||
_ = session.transferUserInfo(payload)
|
||||
}
|
||||
|
||||
func sendReply(_ draft: WatchReplyDraft) async -> WatchReplySendResult {
|
||||
await self.ensureActivated()
|
||||
guard let session = self.session else {
|
||||
@@ -63,7 +88,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
}
|
||||
|
||||
var payload: [String: Any] = [
|
||||
"type": "watch.reply",
|
||||
"type": WatchPayloadType.reply.rawValue,
|
||||
"replyId": draft.replyId,
|
||||
"promptId": draft.promptId,
|
||||
"actionId": draft.actionId,
|
||||
@@ -83,11 +108,38 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
payload["note"] = note
|
||||
}
|
||||
|
||||
return await self.sendPayload(payload, session: session)
|
||||
}
|
||||
|
||||
func sendExecApprovalResolve(
|
||||
approvalId: String,
|
||||
decision: WatchExecApprovalDecision) async -> WatchReplySendResult
|
||||
{
|
||||
await self.ensureActivated()
|
||||
guard let session = self.session else {
|
||||
return WatchReplySendResult(
|
||||
deliveredImmediately: false,
|
||||
queuedForDelivery: false,
|
||||
transport: "none",
|
||||
errorMessage: "watch session unavailable")
|
||||
}
|
||||
|
||||
let payload = Self.encodeExecApprovalResolvePayload(
|
||||
WatchExecApprovalResolveMessage(
|
||||
approvalId: approvalId,
|
||||
decision: decision,
|
||||
replyId: UUID().uuidString,
|
||||
sentAtMs: Self.nowMs()))
|
||||
return await self.sendPayload(payload, session: session)
|
||||
}
|
||||
|
||||
private func sendPayload(_ payload: [String: Any], session: WCSession) async -> WatchReplySendResult {
|
||||
if session.isReachable {
|
||||
do {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
try await withCheckedThrowingContinuation(isolation: nil) {
|
||||
(continuation: CheckedContinuation<Void, Error>) in
|
||||
session.sendMessage(payload, replyHandler: { _ in
|
||||
continuation.resume()
|
||||
continuation.resume(returning: ())
|
||||
}, errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
})
|
||||
@@ -110,6 +162,10 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
errorMessage: nil)
|
||||
}
|
||||
|
||||
private static func nowMs() -> Int {
|
||||
Int(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
private static func normalizeObject(_ value: Any) -> [String: Any]? {
|
||||
if let object = value as? [String: Any] {
|
||||
return object
|
||||
@@ -147,7 +203,9 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
}
|
||||
|
||||
private static func parseNotificationPayload(_ payload: [String: Any]) -> WatchNotifyMessage? {
|
||||
guard let type = payload["type"] as? String, type == "watch.notify" else {
|
||||
guard let type = payload["type"] as? String,
|
||||
type == WatchPayloadType.notify.rawValue
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -189,6 +247,153 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
risk: risk,
|
||||
actions: actions)
|
||||
}
|
||||
|
||||
private static func parseExecApprovalDecision(_ value: Any?) -> WatchExecApprovalDecision? {
|
||||
let raw = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return WatchExecApprovalDecision(rawValue: raw)
|
||||
}
|
||||
|
||||
private static func parseExecApprovalItem(_ value: Any?) -> WatchExecApprovalItem? {
|
||||
guard let payload = value.flatMap(Self.normalizeObject) else {
|
||||
return nil
|
||||
}
|
||||
let id = (payload["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let commandText = (payload["commandText"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !id.isEmpty, !commandText.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let commandPreview = (payload["commandPreview"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let host = (payload["host"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nodeId = (payload["nodeId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let agentId = (payload["agentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue
|
||||
let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let risk = WatchRiskLevel(rawValue: riskRaw)
|
||||
let allowedDecisions = (payload["allowedDecisions"] as? [Any] ?? []).compactMap {
|
||||
Self.parseExecApprovalDecision($0)
|
||||
}
|
||||
return WatchExecApprovalItem(
|
||||
id: id,
|
||||
commandText: commandText,
|
||||
commandPreview: commandPreview,
|
||||
host: host,
|
||||
nodeId: nodeId,
|
||||
agentId: agentId,
|
||||
expiresAtMs: expiresAtMs,
|
||||
allowedDecisions: allowedDecisions,
|
||||
risk: risk)
|
||||
}
|
||||
|
||||
private static func parseExecApprovalPromptPayload(
|
||||
_ payload: [String: Any]) -> WatchExecApprovalPromptMessage?
|
||||
{
|
||||
guard let type = payload["type"] as? String,
|
||||
type == WatchPayloadType.execApprovalPrompt.rawValue,
|
||||
let approval = Self.parseExecApprovalItem(payload["approval"])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
let deliveryId = (payload["deliveryId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resetResolvingState = payload["resetResolvingState"] as? Bool
|
||||
return WatchExecApprovalPromptMessage(
|
||||
approval: approval,
|
||||
sentAtMs: sentAtMs,
|
||||
deliveryId: deliveryId,
|
||||
resetResolvingState: resetResolvingState)
|
||||
}
|
||||
|
||||
private static func parseExecApprovalResolvedPayload(
|
||||
_ payload: [String: Any]) -> WatchExecApprovalResolvedMessage?
|
||||
{
|
||||
guard let type = payload["type"] as? String,
|
||||
type == WatchPayloadType.execApprovalResolved.rawValue
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let approvalId = (payload["approvalId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !approvalId.isEmpty else { return nil }
|
||||
let decision = Self.parseExecApprovalDecision(payload["decision"])
|
||||
let resolvedAtMs = (payload["resolvedAtMs"] as? Int)
|
||||
?? (payload["resolvedAtMs"] as? NSNumber)?.intValue
|
||||
let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return WatchExecApprovalResolvedMessage(
|
||||
approvalId: approvalId,
|
||||
decision: decision,
|
||||
resolvedAtMs: resolvedAtMs,
|
||||
source: source)
|
||||
}
|
||||
|
||||
private static func parseExecApprovalExpiredPayload(
|
||||
_ payload: [String: Any]) -> WatchExecApprovalExpiredMessage?
|
||||
{
|
||||
guard let type = payload["type"] as? String,
|
||||
type == WatchPayloadType.execApprovalExpired.rawValue
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let approvalId = (payload["approvalId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let rawReason = (payload["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !approvalId.isEmpty,
|
||||
let reason = WatchExecApprovalCloseReason(rawValue: rawReason)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let expiredAtMs = (payload["expiredAtMs"] as? Int) ?? (payload["expiredAtMs"] as? NSNumber)?.intValue
|
||||
return WatchExecApprovalExpiredMessage(
|
||||
approvalId: approvalId,
|
||||
reason: reason,
|
||||
expiredAtMs: expiredAtMs)
|
||||
}
|
||||
|
||||
private static func parseExecApprovalSnapshotPayload(
|
||||
_ payload: [String: Any]) -> WatchExecApprovalSnapshotMessage?
|
||||
{
|
||||
guard let type = payload["type"] as? String,
|
||||
type == WatchPayloadType.execApprovalSnapshot.rawValue
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let approvals = (payload["approvals"] as? [Any] ?? []).compactMap { item in
|
||||
Self.parseExecApprovalItem(item)
|
||||
}
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return WatchExecApprovalSnapshotMessage(
|
||||
approvals: approvals,
|
||||
sentAtMs: sentAtMs,
|
||||
snapshotId: snapshotId)
|
||||
}
|
||||
|
||||
private static func encodeSnapshotRequestPayload(
|
||||
_ request: WatchExecApprovalSnapshotRequestMessage) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": WatchPayloadType.execApprovalSnapshotRequest.rawValue,
|
||||
"requestId": request.requestId,
|
||||
]
|
||||
if let sentAtMs = request.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private static func encodeExecApprovalResolvePayload(
|
||||
_ message: WatchExecApprovalResolveMessage) -> [String: Any]
|
||||
{
|
||||
var payload: [String: Any] = [
|
||||
"type": WatchPayloadType.execApprovalResolve.rawValue,
|
||||
"approvalId": message.approvalId,
|
||||
"decision": message.decision.rawValue,
|
||||
"replyId": message.replyId,
|
||||
]
|
||||
if let sentAtMs = message.sentAtMs {
|
||||
payload["sentAtMs"] = sentAtMs
|
||||
}
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchConnectivityReceiver: WCSessionDelegate {
|
||||
@@ -196,13 +401,14 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
|
||||
_: WCSession,
|
||||
activationDidCompleteWith _: WCSessionActivationState,
|
||||
error _: (any Error)?)
|
||||
{}
|
||||
{
|
||||
Task {
|
||||
await self.requestExecApprovalSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let incoming = Self.parseNotificationPayload(message) else { return }
|
||||
Task { @MainActor in
|
||||
self.store.consume(message: incoming, transport: "sendMessage")
|
||||
}
|
||||
self.consumeIncomingPayload(message, transport: "sendMessage")
|
||||
}
|
||||
|
||||
func session(
|
||||
@@ -210,27 +416,47 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void)
|
||||
{
|
||||
guard let incoming = Self.parseNotificationPayload(message) else {
|
||||
replyHandler(["ok": false])
|
||||
return
|
||||
}
|
||||
replyHandler(["ok": true])
|
||||
Task { @MainActor in
|
||||
self.store.consume(message: incoming, transport: "sendMessage")
|
||||
}
|
||||
self.consumeIncomingPayload(message, transport: "sendMessage")
|
||||
}
|
||||
|
||||
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
guard let incoming = Self.parseNotificationPayload(userInfo) else { return }
|
||||
Task { @MainActor in
|
||||
self.store.consume(message: incoming, transport: "transferUserInfo")
|
||||
}
|
||||
self.consumeIncomingPayload(userInfo, transport: "transferUserInfo")
|
||||
}
|
||||
|
||||
func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
guard let incoming = Self.parseNotificationPayload(applicationContext) else { return }
|
||||
Task { @MainActor in
|
||||
self.store.consume(message: incoming, transport: "applicationContext")
|
||||
self.consumeIncomingPayload(applicationContext, transport: "applicationContext")
|
||||
}
|
||||
|
||||
private func consumeIncomingPayload(_ payload: [String: Any], transport: String) {
|
||||
if let incoming = Self.parseNotificationPayload(payload) {
|
||||
Task { @MainActor in
|
||||
self.store.consume(message: incoming, transport: transport)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let prompt = Self.parseExecApprovalPromptPayload(payload) {
|
||||
Task { @MainActor in
|
||||
self.store.consume(execApprovalPrompt: prompt, transport: transport)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let resolved = Self.parseExecApprovalResolvedPayload(payload) {
|
||||
Task { @MainActor in
|
||||
self.store.consume(execApprovalResolved: resolved)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let expired = Self.parseExecApprovalExpiredPayload(payload) {
|
||||
Task { @MainActor in
|
||||
self.store.consume(execApprovalExpired: expired)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let snapshot = Self.parseExecApprovalSnapshotPayload(payload) {
|
||||
Task { @MainActor in
|
||||
self.store.consume(execApprovalSnapshot: snapshot, transport: transport)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,86 @@ import Observation
|
||||
import UserNotifications
|
||||
import WatchKit
|
||||
|
||||
enum WatchPayloadType: String, Codable, Sendable, Equatable {
|
||||
case notify = "watch.notify"
|
||||
case reply = "watch.reply"
|
||||
case execApprovalPrompt = "watch.execApproval.prompt"
|
||||
case execApprovalResolve = "watch.execApproval.resolve"
|
||||
case execApprovalResolved = "watch.execApproval.resolved"
|
||||
case execApprovalExpired = "watch.execApproval.expired"
|
||||
case execApprovalSnapshot = "watch.execApproval.snapshot"
|
||||
case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest"
|
||||
}
|
||||
|
||||
enum WatchRiskLevel: String, Codable, Sendable, Equatable {
|
||||
case low
|
||||
case medium
|
||||
case high
|
||||
}
|
||||
|
||||
enum WatchExecApprovalDecision: String, Codable, Sendable, Equatable {
|
||||
case allowOnce = "allow-once"
|
||||
case deny
|
||||
}
|
||||
|
||||
enum WatchExecApprovalCloseReason: String, Codable, Sendable, Equatable {
|
||||
case expired
|
||||
case notFound = "not-found"
|
||||
case unavailable
|
||||
case replaced
|
||||
case resolved
|
||||
}
|
||||
|
||||
struct WatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
|
||||
var id: String
|
||||
var commandText: String
|
||||
var commandPreview: String?
|
||||
var host: String?
|
||||
var nodeId: String?
|
||||
var agentId: String?
|
||||
var expiresAtMs: Int?
|
||||
var allowedDecisions: [WatchExecApprovalDecision]
|
||||
var risk: WatchRiskLevel?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalPromptMessage: Codable, Sendable, Equatable {
|
||||
var approval: WatchExecApprovalItem
|
||||
var sentAtMs: Int?
|
||||
var deliveryId: String?
|
||||
var resetResolvingState: Bool?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalResolvedMessage: Codable, Sendable, Equatable {
|
||||
var approvalId: String
|
||||
var decision: WatchExecApprovalDecision?
|
||||
var resolvedAtMs: Int?
|
||||
var source: String?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalExpiredMessage: Codable, Sendable, Equatable {
|
||||
var approvalId: String
|
||||
var reason: WatchExecApprovalCloseReason
|
||||
var expiredAtMs: Int?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable {
|
||||
var approvals: [WatchExecApprovalItem]
|
||||
var sentAtMs: Int?
|
||||
var snapshotId: String?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable {
|
||||
var requestId: String
|
||||
var sentAtMs: Int?
|
||||
}
|
||||
|
||||
struct WatchExecApprovalResolveMessage: Codable, Sendable, Equatable {
|
||||
var approvalId: String
|
||||
var decision: WatchExecApprovalDecision
|
||||
var replyId: String
|
||||
var sentAtMs: Int?
|
||||
}
|
||||
|
||||
struct WatchPromptAction: Codable, Sendable, Equatable, Identifiable {
|
||||
var id: String
|
||||
var label: String
|
||||
@@ -23,6 +103,18 @@ struct WatchNotifyMessage: Sendable {
|
||||
var actions: [WatchPromptAction]
|
||||
}
|
||||
|
||||
struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
var approval: WatchExecApprovalItem
|
||||
var transport: String
|
||||
var updatedAt: Date
|
||||
var isResolving: Bool
|
||||
var pendingDecision: WatchExecApprovalDecision?
|
||||
var statusText: String?
|
||||
var statusAt: Date?
|
||||
|
||||
var id: String { self.approval.id }
|
||||
}
|
||||
|
||||
@MainActor @Observable final class WatchInboxStore {
|
||||
private struct PersistedState: Codable {
|
||||
var title: String
|
||||
@@ -39,13 +131,20 @@ struct WatchNotifyMessage: Sendable {
|
||||
var actions: [WatchPromptAction]?
|
||||
var replyStatusText: String?
|
||||
var replyStatusAt: Date?
|
||||
var execApprovals: [WatchExecApprovalRecord]
|
||||
var selectedExecApprovalID: String?
|
||||
var lastExecApprovalSnapshotID: String?
|
||||
var lastExecApprovalOutcomeText: String?
|
||||
var lastExecApprovalOutcomeAt: Date?
|
||||
}
|
||||
|
||||
private static let persistedStateKey = "watch.inbox.state.v1"
|
||||
private static let persistedStateKey = "watch.inbox.state.v2"
|
||||
private static let defaultTitle = "OpenClaw"
|
||||
private static let defaultBody = "Waiting for messages from your iPhone."
|
||||
private let defaults: UserDefaults
|
||||
|
||||
var title = "OpenClaw"
|
||||
var body = "Waiting for messages from your iPhone."
|
||||
var title = WatchInboxStore.defaultTitle
|
||||
var body = WatchInboxStore.defaultBody
|
||||
var transport = "none"
|
||||
var updatedAt: Date?
|
||||
var promptId: String?
|
||||
@@ -58,16 +157,88 @@ struct WatchNotifyMessage: Sendable {
|
||||
var replyStatusText: String?
|
||||
var replyStatusAt: Date?
|
||||
var isReplySending = false
|
||||
var execApprovals: [WatchExecApprovalRecord] = []
|
||||
var selectedExecApprovalID: String?
|
||||
var lastExecApprovalOutcomeText: String?
|
||||
var lastExecApprovalOutcomeAt: Date?
|
||||
var isExecApprovalReviewLoading = false
|
||||
var execApprovalReviewStatusText: String?
|
||||
var execApprovalReviewStatusAt: Date?
|
||||
private var lastExecApprovalSnapshotID: String?
|
||||
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
|
||||
private var lastDeliveryKey: String?
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
self.restorePersistedState()
|
||||
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
|
||||
Task {
|
||||
await self.ensureNotificationAuthorization()
|
||||
}
|
||||
}
|
||||
|
||||
var sortedExecApprovals: [WatchExecApprovalRecord] {
|
||||
self.execApprovals.sorted { lhs, rhs in
|
||||
let lhsExpires = lhs.approval.expiresAtMs ?? Int.max
|
||||
let rhsExpires = rhs.approval.expiresAtMs ?? Int.max
|
||||
if lhsExpires != rhsExpires {
|
||||
return lhsExpires < rhsExpires
|
||||
}
|
||||
return lhs.updatedAt > rhs.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
var activeExecApproval: WatchExecApprovalRecord? {
|
||||
if let selectedExecApprovalID,
|
||||
let selected = self.execApprovals.first(where: { $0.id == selectedExecApprovalID })
|
||||
{
|
||||
return selected
|
||||
}
|
||||
return self.sortedExecApprovals.first
|
||||
}
|
||||
|
||||
var shouldAutoRequestExecApprovalSnapshot: Bool {
|
||||
self.execApprovals.isEmpty
|
||||
&& self.actions.isEmpty
|
||||
&& self.title == Self.defaultTitle
|
||||
&& self.body == Self.defaultBody
|
||||
&& !self.hasCompletedExecApprovalSnapshotRefreshInSession
|
||||
}
|
||||
|
||||
var hasCompletedExecApprovalSnapshotRefresh: Bool {
|
||||
self.hasCompletedExecApprovalSnapshotRefreshInSession
|
||||
}
|
||||
|
||||
var shouldShowExecApprovalReviewStatus: Bool {
|
||||
self.execApprovals.isEmpty && !(self.execApprovalReviewStatusText?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func beginExecApprovalReviewLoading() {
|
||||
guard self.execApprovals.isEmpty else {
|
||||
self.markExecApprovalReviewLoaded()
|
||||
return
|
||||
}
|
||||
self.isExecApprovalReviewLoading = true
|
||||
self.execApprovalReviewStatusText = "Loading approval from iPhone…"
|
||||
self.execApprovalReviewStatusAt = Date()
|
||||
}
|
||||
|
||||
func markExecApprovalReviewLoaded() {
|
||||
self.isExecApprovalReviewLoading = false
|
||||
self.execApprovalReviewStatusText = nil
|
||||
self.execApprovalReviewStatusAt = nil
|
||||
}
|
||||
|
||||
func markExecApprovalReviewUnavailable(_ message: String) {
|
||||
guard self.execApprovals.isEmpty else {
|
||||
self.markExecApprovalReviewLoaded()
|
||||
return
|
||||
}
|
||||
self.isExecApprovalReviewLoading = false
|
||||
self.execApprovalReviewStatusText = message
|
||||
self.execApprovalReviewStatusAt = Date()
|
||||
}
|
||||
|
||||
func consume(message: WatchNotifyMessage, transport: String) {
|
||||
let messageID = message.id?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -82,6 +253,7 @@ struct WatchNotifyMessage: Sendable {
|
||||
self.title = normalizedTitle
|
||||
self.body = message.body
|
||||
self.transport = transport
|
||||
self.markExecApprovalReviewLoaded()
|
||||
self.updatedAt = Date()
|
||||
self.promptId = message.promptId
|
||||
self.sessionKey = message.sessionKey
|
||||
@@ -105,6 +277,209 @@ struct WatchNotifyMessage: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func consume(
|
||||
execApprovalPrompt message: WatchExecApprovalPromptMessage,
|
||||
transport: String)
|
||||
{
|
||||
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
|
||||
self.upsertExecApproval(
|
||||
message.approval,
|
||||
transport: transport,
|
||||
keepSelectionIfPossible: true,
|
||||
resetResolvingState: message.resetResolvingState == true)
|
||||
self.markExecApprovalReviewLoaded()
|
||||
self.lastExecApprovalOutcomeText = nil
|
||||
self.lastExecApprovalOutcomeAt = nil
|
||||
|
||||
Task {
|
||||
await self.postLocalNotification(
|
||||
identifier: "watch.execApproval.\(message.approval.id)",
|
||||
title: "Exec approval required",
|
||||
body: message.approval.commandPreview ?? message.approval.commandText,
|
||||
risk: message.approval.risk?.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
func consume(
|
||||
execApprovalSnapshot message: WatchExecApprovalSnapshotMessage,
|
||||
transport: String)
|
||||
{
|
||||
let snapshotID = message.snapshotId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let snapshotID, !snapshotID.isEmpty, snapshotID == self.lastExecApprovalSnapshotID {
|
||||
return
|
||||
}
|
||||
|
||||
let existingRecordsByID = Dictionary(
|
||||
uniqueKeysWithValues: self.execApprovals.map { ($0.id, $0) })
|
||||
self.execApprovals = message.approvals.map { approval in
|
||||
self.mergedExecApprovalRecord(
|
||||
approval: approval,
|
||||
transport: transport,
|
||||
existingRecord: existingRecordsByID[approval.id])
|
||||
}
|
||||
self.lastExecApprovalSnapshotID = snapshotID
|
||||
self.hasCompletedExecApprovalSnapshotRefreshInSession = true
|
||||
if let selectedExecApprovalID,
|
||||
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
|
||||
{
|
||||
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
|
||||
} else if self.selectedExecApprovalID == nil {
|
||||
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
|
||||
}
|
||||
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
|
||||
self.markExecApprovalReviewLoaded()
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) {
|
||||
self.removeExecApproval(id: message.approvalId)
|
||||
let statusText: String
|
||||
switch message.decision {
|
||||
case .allowOnce:
|
||||
statusText = "Allowed once"
|
||||
case .deny:
|
||||
statusText = "Denied"
|
||||
case nil:
|
||||
statusText = "Approval resolved"
|
||||
}
|
||||
self.lastExecApprovalOutcomeText = statusText
|
||||
self.lastExecApprovalOutcomeAt = Date()
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) {
|
||||
self.removeExecApproval(id: message.approvalId)
|
||||
let statusText: String
|
||||
switch message.reason {
|
||||
case .expired:
|
||||
statusText = "Approval expired"
|
||||
case .notFound:
|
||||
statusText = "Approval no longer available"
|
||||
case .resolved:
|
||||
statusText = "Approval resolved elsewhere"
|
||||
case .replaced:
|
||||
statusText = "Approval replaced"
|
||||
case .unavailable:
|
||||
statusText = "Approval unavailable"
|
||||
}
|
||||
self.lastExecApprovalOutcomeText = statusText
|
||||
self.lastExecApprovalOutcomeAt = Date()
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
func selectExecApproval(id: String) {
|
||||
let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedID.isEmpty else { return }
|
||||
guard self.execApprovals.contains(where: { $0.id == normalizedID }) else { return }
|
||||
self.selectedExecApprovalID = normalizedID
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
func markExecApprovalSending(approvalId: String, decision: WatchExecApprovalDecision) {
|
||||
guard let index = self.execApprovals.firstIndex(where: { $0.id == approvalId }) else { return }
|
||||
self.execApprovals[index].isResolving = true
|
||||
self.execApprovals[index].pendingDecision = decision
|
||||
self.execApprovals[index].statusText = "Sending \(Self.decisionLabel(decision))…"
|
||||
self.execApprovals[index].statusAt = Date()
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
func markExecApprovalSendResult(
|
||||
approvalId: String,
|
||||
decision: WatchExecApprovalDecision,
|
||||
result: WatchReplySendResult)
|
||||
{
|
||||
guard let index = self.execApprovals.firstIndex(where: { $0.id == approvalId }) else { return }
|
||||
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
|
||||
self.execApprovals[index].isResolving = false
|
||||
self.execApprovals[index].statusText = "Failed: \(errorMessage)"
|
||||
} else if result.deliveredImmediately {
|
||||
self.execApprovals[index].isResolving = true
|
||||
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): sent"
|
||||
} else if result.queuedForDelivery {
|
||||
self.execApprovals[index].isResolving = true
|
||||
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): queued"
|
||||
} else {
|
||||
self.execApprovals[index].isResolving = true
|
||||
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): sent"
|
||||
}
|
||||
self.execApprovals[index].pendingDecision = result.errorMessage == nil ? decision : nil
|
||||
self.execApprovals[index].statusAt = Date()
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
private func upsertExecApproval(
|
||||
_ approval: WatchExecApprovalItem,
|
||||
transport: String,
|
||||
keepSelectionIfPossible: Bool,
|
||||
resetResolvingState: Bool = false)
|
||||
{
|
||||
if let index = self.execApprovals.firstIndex(where: { $0.id == approval.id }) {
|
||||
self.execApprovals[index] = self.mergedExecApprovalRecord(
|
||||
approval: approval,
|
||||
transport: transport,
|
||||
existingRecord: self.execApprovals[index],
|
||||
resetResolvingState: resetResolvingState)
|
||||
} else {
|
||||
self.execApprovals.append(
|
||||
self.mergedExecApprovalRecord(
|
||||
approval: approval,
|
||||
transport: transport,
|
||||
existingRecord: nil,
|
||||
resetResolvingState: resetResolvingState))
|
||||
}
|
||||
if !keepSelectionIfPossible || self.selectedExecApprovalID == nil {
|
||||
self.selectedExecApprovalID = approval.id
|
||||
}
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
private func mergedExecApprovalRecord(
|
||||
approval: WatchExecApprovalItem,
|
||||
transport: String,
|
||||
existingRecord: WatchExecApprovalRecord?,
|
||||
resetResolvingState: Bool = false) -> WatchExecApprovalRecord
|
||||
{
|
||||
// Preserve in-flight state across ordinary snapshot/prompt refreshes so duplicate
|
||||
// submissions stay disabled, but clear it when the iPhone explicitly republishes a
|
||||
// prompt after a failed resolve so the watch can retry.
|
||||
let isResolving = resetResolvingState ? false : (existingRecord?.isResolving ?? false)
|
||||
let pendingDecision = resetResolvingState ? nil : existingRecord?.pendingDecision
|
||||
let statusText = resetResolvingState ? nil : existingRecord?.statusText
|
||||
let statusAt = resetResolvingState ? nil : existingRecord?.statusAt
|
||||
return WatchExecApprovalRecord(
|
||||
approval: approval,
|
||||
transport: transport,
|
||||
updatedAt: Date(),
|
||||
isResolving: isResolving,
|
||||
pendingDecision: pendingDecision,
|
||||
statusText: statusText,
|
||||
statusAt: statusAt)
|
||||
}
|
||||
|
||||
private func removeExecApproval(id: String) {
|
||||
let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedID.isEmpty else { return }
|
||||
self.execApprovals.removeAll { $0.id == normalizedID }
|
||||
if self.selectedExecApprovalID == normalizedID {
|
||||
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
|
||||
}
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
private func pruneExpiredExecApprovals(nowMs: Int) {
|
||||
self.execApprovals.removeAll { record in
|
||||
guard let expiresAtMs = record.approval.expiresAtMs else { return false }
|
||||
return expiresAtMs <= nowMs
|
||||
}
|
||||
if let selectedExecApprovalID,
|
||||
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
|
||||
{
|
||||
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
|
||||
}
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
private func restorePersistedState() {
|
||||
guard let data = self.defaults.data(forKey: Self.persistedStateKey),
|
||||
let state = try? JSONDecoder().decode(PersistedState.self, from: data)
|
||||
@@ -126,10 +501,15 @@ struct WatchNotifyMessage: Sendable {
|
||||
self.actions = state.actions ?? []
|
||||
self.replyStatusText = state.replyStatusText
|
||||
self.replyStatusAt = state.replyStatusAt
|
||||
self.execApprovals = state.execApprovals
|
||||
self.selectedExecApprovalID = state.selectedExecApprovalID
|
||||
self.lastExecApprovalSnapshotID = state.lastExecApprovalSnapshotID
|
||||
self.lastExecApprovalOutcomeText = state.lastExecApprovalOutcomeText
|
||||
self.lastExecApprovalOutcomeAt = state.lastExecApprovalOutcomeAt
|
||||
}
|
||||
|
||||
private func persistState() {
|
||||
guard let updatedAt = self.updatedAt else { return }
|
||||
let updatedAt = self.updatedAt ?? self.lastExecApprovalOutcomeAt ?? Date()
|
||||
let state = PersistedState(
|
||||
title: self.title,
|
||||
body: self.body,
|
||||
@@ -144,7 +524,12 @@ struct WatchNotifyMessage: Sendable {
|
||||
risk: self.risk,
|
||||
actions: self.actions,
|
||||
replyStatusText: self.replyStatusText,
|
||||
replyStatusAt: self.replyStatusAt)
|
||||
replyStatusAt: self.replyStatusAt,
|
||||
execApprovals: self.execApprovals,
|
||||
selectedExecApprovalID: self.selectedExecApprovalID,
|
||||
lastExecApprovalSnapshotID: self.lastExecApprovalSnapshotID,
|
||||
lastExecApprovalOutcomeText: self.lastExecApprovalOutcomeText,
|
||||
lastExecApprovalOutcomeAt: self.lastExecApprovalOutcomeAt)
|
||||
guard let data = try? JSONEncoder().encode(state) else { return }
|
||||
self.defaults.set(data, forKey: Self.persistedStateKey)
|
||||
}
|
||||
@@ -187,7 +572,7 @@ struct WatchNotifyMessage: Sendable {
|
||||
actionLabel: action.label,
|
||||
sessionKey: self.sessionKey,
|
||||
note: nil,
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
||||
sentAtMs: Self.nowMs())
|
||||
}
|
||||
|
||||
func markReplySending(actionLabel: String) {
|
||||
@@ -227,4 +612,17 @@ struct WatchNotifyMessage: Sendable {
|
||||
_ = try? await UNUserNotificationCenter.current().add(request)
|
||||
WKInterfaceDevice.current().play(self.mapHapticRisk(risk))
|
||||
}
|
||||
|
||||
private static func decisionLabel(_ decision: WatchExecApprovalDecision) -> String {
|
||||
switch decision {
|
||||
case .allowOnce:
|
||||
"Allow Once"
|
||||
case .deny:
|
||||
"Deny"
|
||||
}
|
||||
}
|
||||
|
||||
private static func nowMs() -> Int {
|
||||
Int(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,246 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WatchInboxView: View {
|
||||
@Bindable var store: WatchInboxStore
|
||||
var store: WatchInboxStore
|
||||
var onAction: ((WatchPromptAction) -> Void)?
|
||||
var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
||||
var onRefreshExecApprovalReview: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
if self.store.sortedExecApprovals.count == 1,
|
||||
let record = self.store.activeExecApproval
|
||||
{
|
||||
WatchExecApprovalDetailView(
|
||||
store: self.store,
|
||||
record: record,
|
||||
onDecision: self.onExecApprovalDecision)
|
||||
} else if !self.store.sortedExecApprovals.isEmpty {
|
||||
WatchExecApprovalListView(
|
||||
store: self.store,
|
||||
onDecision: self.onExecApprovalDecision)
|
||||
} else if self.store.shouldShowExecApprovalReviewStatus {
|
||||
WatchExecApprovalLoadingView(
|
||||
store: self.store,
|
||||
onRetry: self.onRefreshExecApprovalReview)
|
||||
} else {
|
||||
WatchGenericInboxView(store: self.store, onAction: self.onAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchExecApprovalLoadingView: View {
|
||||
var store: WatchInboxStore
|
||||
var onRetry: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Exec approval")
|
||||
.font(.headline)
|
||||
|
||||
if self.store.isExecApprovalReviewLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if let statusText = self.store.execApprovalReviewStatusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.body)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if !self.store.isExecApprovalReviewLoading {
|
||||
Button("Retry") {
|
||||
self.onRetry?()
|
||||
}
|
||||
}
|
||||
|
||||
Text("Keep your iPhone nearby and unlocked if review details take a moment to appear.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Exec approval")
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchExecApprovalListView: View {
|
||||
var store: WatchInboxStore
|
||||
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Exec approvals") {
|
||||
ForEach(self.store.sortedExecApprovals) { record in
|
||||
NavigationLink {
|
||||
WatchExecApprovalDetailView(
|
||||
store: self.store,
|
||||
record: record,
|
||||
onDecision: self.onDecision)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(record.approval.commandPreview ?? record.approval.commandText)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
Text(self.metadataLine(for: record))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let statusText = record.statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(record.isResolving ? Color.secondary : Color.red)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let outcome = self.store.lastExecApprovalOutcomeText, !outcome.isEmpty {
|
||||
Section("Last result") {
|
||||
Text(outcome)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Approvals")
|
||||
}
|
||||
|
||||
private func metadataLine(for record: WatchExecApprovalRecord) -> String {
|
||||
var parts: [String] = []
|
||||
if let host = record.approval.host, !host.isEmpty {
|
||||
parts.append(host)
|
||||
}
|
||||
if let nodeId = record.approval.nodeId, !nodeId.isEmpty {
|
||||
parts.append(nodeId)
|
||||
}
|
||||
if let expiresText = Self.expiresText(record.approval.expiresAtMs) {
|
||||
parts.append(expiresText)
|
||||
}
|
||||
return parts.isEmpty ? "Pending review" : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private static func expiresText(_ expiresAtMs: Int?) -> String? {
|
||||
guard let expiresAtMs else { return nil }
|
||||
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
|
||||
if deltaSeconds < 60 {
|
||||
return "Expires in <1m"
|
||||
}
|
||||
return "Expires in \(deltaSeconds / 60)m"
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchExecApprovalDetailView: View {
|
||||
var store: WatchInboxStore
|
||||
let record: WatchExecApprovalRecord
|
||||
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(self.record.approval.commandText)
|
||||
.font(.headline)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let host = self.record.approval.host, !host.isEmpty {
|
||||
self.metadataRow(label: "Host", value: host)
|
||||
}
|
||||
if let nodeId = self.record.approval.nodeId, !nodeId.isEmpty {
|
||||
self.metadataRow(label: "Node", value: nodeId)
|
||||
}
|
||||
if let agentId = self.record.approval.agentId, !agentId.isEmpty {
|
||||
self.metadataRow(label: "Agent", value: agentId)
|
||||
}
|
||||
if let expiresText = Self.expiresText(self.record.approval.expiresAtMs) {
|
||||
self.metadataRow(label: "Expires", value: expiresText)
|
||||
}
|
||||
if let riskText = self.riskText(self.record.approval.risk) {
|
||||
self.metadataRow(label: "Risk", value: riskText)
|
||||
}
|
||||
|
||||
if let statusText = self.currentRecord?.statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle((self.currentRecord?.isResolving ?? false) ? Color.secondary : Color.red)
|
||||
}
|
||||
|
||||
if let currentRecord,
|
||||
currentRecord.approval.allowedDecisions.contains(.allowOnce)
|
||||
{
|
||||
Button("Allow Once") {
|
||||
self.onDecision?(currentRecord.id, .allowOnce)
|
||||
}
|
||||
.disabled(currentRecord.isResolving)
|
||||
}
|
||||
|
||||
if let currentRecord,
|
||||
currentRecord.approval.allowedDecisions.contains(.deny)
|
||||
{
|
||||
Button(role: .destructive) {
|
||||
self.onDecision?(currentRecord.id, .deny)
|
||||
} label: {
|
||||
Text("Deny")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.disabled(currentRecord.isResolving)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Exec approval")
|
||||
.onAppear {
|
||||
self.store.selectExecApproval(id: self.record.id)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentRecord: WatchExecApprovalRecord? {
|
||||
self.store.execApprovals.first(where: { $0.id == self.record.id })
|
||||
}
|
||||
|
||||
private func metadataRow(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.footnote)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func riskText(_ risk: WatchRiskLevel?) -> String? {
|
||||
switch risk {
|
||||
case .high:
|
||||
return "High"
|
||||
case .medium:
|
||||
return "Medium"
|
||||
case .low:
|
||||
return "Low"
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func expiresText(_ expiresAtMs: Int?) -> String? {
|
||||
guard let expiresAtMs else { return nil }
|
||||
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
|
||||
if deltaSeconds < 60 {
|
||||
return "<1 minute"
|
||||
}
|
||||
return "\(deltaSeconds / 60) minutes"
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchGenericInboxView: View {
|
||||
var store: WatchInboxStore
|
||||
var onAction: ((WatchPromptAction) -> Void)?
|
||||
|
||||
private func role(for action: WatchPromptAction) -> ButtonRole? {
|
||||
@@ -18,40 +257,46 @@ struct WatchInboxView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(store.title)
|
||||
Text(self.store.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(store.body)
|
||||
Text(self.store.body)
|
||||
.font(.body)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let details = store.details, !details.isEmpty {
|
||||
if let details = self.store.details, !details.isEmpty {
|
||||
Text(details)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if !store.actions.isEmpty {
|
||||
ForEach(store.actions) { action in
|
||||
if let outcome = self.store.lastExecApprovalOutcomeText, !outcome.isEmpty {
|
||||
Text(outcome)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !self.store.actions.isEmpty {
|
||||
ForEach(self.store.actions) { action in
|
||||
Button(role: self.role(for: action)) {
|
||||
self.onAction?(action)
|
||||
} label: {
|
||||
Text(action.label)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.disabled(store.isReplySending)
|
||||
.disabled(self.store.isReplySending)
|
||||
}
|
||||
}
|
||||
|
||||
if let replyStatusText = store.replyStatusText, !replyStatusText.isEmpty {
|
||||
if let replyStatusText = self.store.replyStatusText, !replyStatusText.isEmpty {
|
||||
Text(replyStatusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let updatedAt = store.updatedAt {
|
||||
if let updatedAt = self.store.updatedAt {
|
||||
Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -60,5 +305,6 @@ struct WatchInboxView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("OpenClaw")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ ASC_KEYCHAIN_SERVICE=openclaw-asc-key
|
||||
ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
|
||||
```
|
||||
|
||||
Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth and optional beta-archive settings. It does **not** configure gateway-side direct APNs push delivery for local iOS builds.
|
||||
|
||||
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
|
||||
|
||||
```bash
|
||||
@@ -53,6 +55,8 @@ IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
|
||||
|
||||
Tip: run `scripts/ios-team-id.sh` from repo root to print a Team ID for `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing.
|
||||
|
||||
For local/manual iOS builds that stay on direct APNs, configure the gateway host separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`. Those gateway runtime env vars are separate from Fastlane's `.env`.
|
||||
|
||||
Validate auth:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -237,12 +237,19 @@ targets:
|
||||
configFiles:
|
||||
Debug: Config/Signing.xcconfig
|
||||
Release: Config/Signing.xcconfig
|
||||
attributes:
|
||||
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
settings:
|
||||
base:
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_APP_PROFILE)"
|
||||
info:
|
||||
path: WatchApp/Info.plist
|
||||
properties:
|
||||
@@ -265,9 +272,16 @@ targets:
|
||||
configFiles:
|
||||
Debug: Config/Signing.xcconfig
|
||||
Release: Config/Signing.xcconfig
|
||||
attributes:
|
||||
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_EXTENSION_PROFILE)"
|
||||
info:
|
||||
path: WatchExtension/Info.plist
|
||||
properties:
|
||||
|
||||
@@ -28,6 +28,8 @@ enum HostEnvSecurityPolicy {
|
||||
"CC",
|
||||
"CXX",
|
||||
"CARGO_BUILD_RUSTC",
|
||||
"CARGO_BUILD_RUSTC_WRAPPER",
|
||||
"RUSTC_WRAPPER",
|
||||
"CMAKE_C_COMPILER",
|
||||
"CMAKE_CXX_COMPILER",
|
||||
"SHELL",
|
||||
@@ -44,9 +46,12 @@ enum HostEnvSecurityPolicy {
|
||||
"DOTNET_ADDITIONAL_DEPS",
|
||||
"GLIBC_TUNABLES",
|
||||
"MAVEN_OPTS",
|
||||
"MAKEFLAGS",
|
||||
"MFLAGS",
|
||||
"SBT_OPTS",
|
||||
"GRADLE_OPTS",
|
||||
"ANT_OPTS"
|
||||
"ANT_OPTS",
|
||||
"HGRCPATH"
|
||||
]
|
||||
|
||||
static let blockedOverrideKeys: Set<String> = [
|
||||
@@ -83,6 +88,8 @@ enum HostEnvSecurityPolicy {
|
||||
"CGO_CFLAGS",
|
||||
"CGO_LDFLAGS",
|
||||
"GOFLAGS",
|
||||
"MAKEFLAGS",
|
||||
"MFLAGS",
|
||||
"CORECLR_PROFILER_PATH",
|
||||
"PHPRC",
|
||||
"PHP_INI_SCAN_DIR",
|
||||
@@ -134,7 +141,9 @@ enum HostEnvSecurityPolicy {
|
||||
"GOPRIVATE",
|
||||
"GOENV",
|
||||
"GOPATH",
|
||||
"HGRCPATH",
|
||||
"PYTHONUSERBASE",
|
||||
"RUSTC_WRAPPER",
|
||||
"VIRTUAL_ENV",
|
||||
"LUA_PATH",
|
||||
"LUA_CPATH",
|
||||
@@ -142,6 +151,7 @@ enum HostEnvSecurityPolicy {
|
||||
"GEM_PATH",
|
||||
"BUNDLE_GEMFILE",
|
||||
"COMPOSER_HOME",
|
||||
"CARGO_BUILD_RUSTC_WRAPPER",
|
||||
"XDG_CONFIG_HOME",
|
||||
"AWS_CONFIG_FILE"
|
||||
]
|
||||
|
||||
@@ -1327,6 +1327,236 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionCompactionCheckpoint: Codable, Sendable {
|
||||
public let checkpointid: String
|
||||
public let sessionkey: String
|
||||
public let sessionid: String
|
||||
public let createdat: Int
|
||||
public let reason: AnyCodable
|
||||
public let tokensbefore: Int?
|
||||
public let tokensafter: Int?
|
||||
public let summary: String?
|
||||
public let firstkeptentryid: String?
|
||||
public let precompaction: [String: AnyCodable]
|
||||
public let postcompaction: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
checkpointid: String,
|
||||
sessionkey: String,
|
||||
sessionid: String,
|
||||
createdat: Int,
|
||||
reason: AnyCodable,
|
||||
tokensbefore: Int?,
|
||||
tokensafter: Int?,
|
||||
summary: String?,
|
||||
firstkeptentryid: String?,
|
||||
precompaction: [String: AnyCodable],
|
||||
postcompaction: [String: AnyCodable])
|
||||
{
|
||||
self.checkpointid = checkpointid
|
||||
self.sessionkey = sessionkey
|
||||
self.sessionid = sessionid
|
||||
self.createdat = createdat
|
||||
self.reason = reason
|
||||
self.tokensbefore = tokensbefore
|
||||
self.tokensafter = tokensafter
|
||||
self.summary = summary
|
||||
self.firstkeptentryid = firstkeptentryid
|
||||
self.precompaction = precompaction
|
||||
self.postcompaction = postcompaction
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case checkpointid = "checkpointId"
|
||||
case sessionkey = "sessionKey"
|
||||
case sessionid = "sessionId"
|
||||
case createdat = "createdAt"
|
||||
case reason
|
||||
case tokensbefore = "tokensBefore"
|
||||
case tokensafter = "tokensAfter"
|
||||
case summary
|
||||
case firstkeptentryid = "firstKeptEntryId"
|
||||
case precompaction = "preCompaction"
|
||||
case postcompaction = "postCompaction"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionListParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionGetParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionBranchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionRestoreParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionListResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let checkpoints: [SessionCompactionCheckpoint]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
checkpoints: [SessionCompactionCheckpoint])
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.checkpoints = checkpoints
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case checkpoints
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionGetResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
checkpoint: SessionCompactionCheckpoint)
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.checkpoint = checkpoint
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionBranchResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let sourcekey: String
|
||||
public let key: String
|
||||
public let sessionid: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
public let entry: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
sourcekey: String,
|
||||
key: String,
|
||||
sessionid: String,
|
||||
checkpoint: SessionCompactionCheckpoint,
|
||||
entry: [String: AnyCodable])
|
||||
{
|
||||
self.ok = ok
|
||||
self.sourcekey = sourcekey
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.checkpoint = checkpoint
|
||||
self.entry = entry
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case sourcekey = "sourceKey"
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case checkpoint
|
||||
case entry
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionRestoreResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let sessionid: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
public let entry: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
sessionid: String,
|
||||
checkpoint: SessionCompactionCheckpoint,
|
||||
entry: [String: AnyCodable])
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.checkpoint = checkpoint
|
||||
self.entry = entry
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case checkpoint
|
||||
case entry
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let agentid: String?
|
||||
|
||||
@@ -5,12 +5,36 @@ public enum OpenClawWatchCommand: String, Codable, Sendable {
|
||||
case notify = "watch.notify"
|
||||
}
|
||||
|
||||
public enum OpenClawWatchPayloadType: String, Codable, Sendable, Equatable {
|
||||
case notify = "watch.notify"
|
||||
case reply = "watch.reply"
|
||||
case execApprovalPrompt = "watch.execApproval.prompt"
|
||||
case execApprovalResolve = "watch.execApproval.resolve"
|
||||
case execApprovalResolved = "watch.execApproval.resolved"
|
||||
case execApprovalExpired = "watch.execApproval.expired"
|
||||
case execApprovalSnapshot = "watch.execApproval.snapshot"
|
||||
case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest"
|
||||
}
|
||||
|
||||
public enum OpenClawWatchRisk: String, Codable, Sendable, Equatable {
|
||||
case low
|
||||
case medium
|
||||
case high
|
||||
}
|
||||
|
||||
public enum OpenClawWatchExecApprovalDecision: String, Codable, Sendable, Equatable {
|
||||
case allowOnce = "allow-once"
|
||||
case deny
|
||||
}
|
||||
|
||||
public enum OpenClawWatchExecApprovalCloseReason: String, Codable, Sendable, Equatable {
|
||||
case expired
|
||||
case notFound = "not-found"
|
||||
case unavailable
|
||||
case replaced
|
||||
case resolved
|
||||
}
|
||||
|
||||
public struct OpenClawWatchAction: Codable, Sendable, Equatable {
|
||||
public var id: String
|
||||
public var label: String
|
||||
@@ -23,6 +47,151 @@ public struct OpenClawWatchAction: Codable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
|
||||
public var id: String
|
||||
public var commandText: String
|
||||
public var commandPreview: String?
|
||||
public var host: String?
|
||||
public var nodeId: String?
|
||||
public var agentId: String?
|
||||
public var expiresAtMs: Int?
|
||||
public var allowedDecisions: [OpenClawWatchExecApprovalDecision]
|
||||
public var risk: OpenClawWatchRisk?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
commandText: String,
|
||||
commandPreview: String? = nil,
|
||||
host: String? = nil,
|
||||
nodeId: String? = nil,
|
||||
agentId: String? = nil,
|
||||
expiresAtMs: Int? = nil,
|
||||
allowedDecisions: [OpenClawWatchExecApprovalDecision] = [],
|
||||
risk: OpenClawWatchRisk? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.commandText = commandText
|
||||
self.commandPreview = commandPreview
|
||||
self.host = host
|
||||
self.nodeId = nodeId
|
||||
self.agentId = agentId
|
||||
self.expiresAtMs = expiresAtMs
|
||||
self.allowedDecisions = allowedDecisions
|
||||
self.risk = risk
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawWatchExecApprovalPromptMessage: Codable, Sendable, Equatable {
|
||||
public var type: OpenClawWatchPayloadType
|
||||
public var approval: OpenClawWatchExecApprovalItem
|
||||
public var sentAtMs: Int?
|
||||
public var deliveryId: String?
|
||||
public var resetResolvingState: Bool?
|
||||
|
||||
public init(
|
||||
approval: OpenClawWatchExecApprovalItem,
|
||||
sentAtMs: Int? = nil,
|
||||
deliveryId: String? = nil,
|
||||
resetResolvingState: Bool? = nil)
|
||||
{
|
||||
self.type = .execApprovalPrompt
|
||||
self.approval = approval
|
||||
self.sentAtMs = sentAtMs
|
||||
self.deliveryId = deliveryId
|
||||
self.resetResolvingState = resetResolvingState
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equatable {
|
||||
public var type: OpenClawWatchPayloadType
|
||||
public var approvalId: String
|
||||
public var decision: OpenClawWatchExecApprovalDecision
|
||||
public var replyId: String
|
||||
public var sentAtMs: Int?
|
||||
|
||||
public init(
|
||||
approvalId: String,
|
||||
decision: OpenClawWatchExecApprovalDecision,
|
||||
replyId: String,
|
||||
sentAtMs: Int? = nil)
|
||||
{
|
||||
self.type = .execApprovalResolve
|
||||
self.approvalId = approvalId
|
||||
self.decision = decision
|
||||
self.replyId = replyId
|
||||
self.sentAtMs = sentAtMs
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equatable {
|
||||
public var type: OpenClawWatchPayloadType
|
||||
public var approvalId: String
|
||||
public var decision: OpenClawWatchExecApprovalDecision?
|
||||
public var resolvedAtMs: Int?
|
||||
public var source: String?
|
||||
|
||||
public init(
|
||||
approvalId: String,
|
||||
decision: OpenClawWatchExecApprovalDecision? = nil,
|
||||
resolvedAtMs: Int? = nil,
|
||||
source: String? = nil)
|
||||
{
|
||||
self.type = .execApprovalResolved
|
||||
self.approvalId = approvalId
|
||||
self.decision = decision
|
||||
self.resolvedAtMs = resolvedAtMs
|
||||
self.source = source
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equatable {
|
||||
public var type: OpenClawWatchPayloadType
|
||||
public var approvalId: String
|
||||
public var reason: OpenClawWatchExecApprovalCloseReason
|
||||
public var expiredAtMs: Int?
|
||||
|
||||
public init(
|
||||
approvalId: String,
|
||||
reason: OpenClawWatchExecApprovalCloseReason,
|
||||
expiredAtMs: Int? = nil)
|
||||
{
|
||||
self.type = .execApprovalExpired
|
||||
self.approvalId = approvalId
|
||||
self.reason = reason
|
||||
self.expiredAtMs = expiredAtMs
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable {
|
||||
public var type: OpenClawWatchPayloadType
|
||||
public var approvals: [OpenClawWatchExecApprovalItem]
|
||||
public var sentAtMs: Int?
|
||||
public var snapshotId: String?
|
||||
|
||||
public init(
|
||||
approvals: [OpenClawWatchExecApprovalItem],
|
||||
sentAtMs: Int? = nil,
|
||||
snapshotId: String? = nil)
|
||||
{
|
||||
self.type = .execApprovalSnapshot
|
||||
self.approvals = approvals
|
||||
self.sentAtMs = sentAtMs
|
||||
self.snapshotId = snapshotId
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawWatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable {
|
||||
public var type: OpenClawWatchPayloadType
|
||||
public var requestId: String
|
||||
public var sentAtMs: Int?
|
||||
|
||||
public init(requestId: String, sentAtMs: Int? = nil) {
|
||||
self.type = .execApprovalSnapshotRequest
|
||||
self.requestId = requestId
|
||||
self.sentAtMs = sentAtMs
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawWatchStatusPayload: Codable, Sendable, Equatable {
|
||||
public var supported: Bool
|
||||
public var paired: Bool
|
||||
|
||||
@@ -1327,6 +1327,236 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionCompactionCheckpoint: Codable, Sendable {
|
||||
public let checkpointid: String
|
||||
public let sessionkey: String
|
||||
public let sessionid: String
|
||||
public let createdat: Int
|
||||
public let reason: AnyCodable
|
||||
public let tokensbefore: Int?
|
||||
public let tokensafter: Int?
|
||||
public let summary: String?
|
||||
public let firstkeptentryid: String?
|
||||
public let precompaction: [String: AnyCodable]
|
||||
public let postcompaction: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
checkpointid: String,
|
||||
sessionkey: String,
|
||||
sessionid: String,
|
||||
createdat: Int,
|
||||
reason: AnyCodable,
|
||||
tokensbefore: Int?,
|
||||
tokensafter: Int?,
|
||||
summary: String?,
|
||||
firstkeptentryid: String?,
|
||||
precompaction: [String: AnyCodable],
|
||||
postcompaction: [String: AnyCodable])
|
||||
{
|
||||
self.checkpointid = checkpointid
|
||||
self.sessionkey = sessionkey
|
||||
self.sessionid = sessionid
|
||||
self.createdat = createdat
|
||||
self.reason = reason
|
||||
self.tokensbefore = tokensbefore
|
||||
self.tokensafter = tokensafter
|
||||
self.summary = summary
|
||||
self.firstkeptentryid = firstkeptentryid
|
||||
self.precompaction = precompaction
|
||||
self.postcompaction = postcompaction
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case checkpointid = "checkpointId"
|
||||
case sessionkey = "sessionKey"
|
||||
case sessionid = "sessionId"
|
||||
case createdat = "createdAt"
|
||||
case reason
|
||||
case tokensbefore = "tokensBefore"
|
||||
case tokensafter = "tokensAfter"
|
||||
case summary
|
||||
case firstkeptentryid = "firstKeptEntryId"
|
||||
case precompaction = "preCompaction"
|
||||
case postcompaction = "postCompaction"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionListParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionGetParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionBranchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionRestoreParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionListResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let checkpoints: [SessionCompactionCheckpoint]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
checkpoints: [SessionCompactionCheckpoint])
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.checkpoints = checkpoints
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case checkpoints
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionGetResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
checkpoint: SessionCompactionCheckpoint)
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.checkpoint = checkpoint
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionBranchResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let sourcekey: String
|
||||
public let key: String
|
||||
public let sessionid: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
public let entry: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
sourcekey: String,
|
||||
key: String,
|
||||
sessionid: String,
|
||||
checkpoint: SessionCompactionCheckpoint,
|
||||
entry: [String: AnyCodable])
|
||||
{
|
||||
self.ok = ok
|
||||
self.sourcekey = sourcekey
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.checkpoint = checkpoint
|
||||
self.entry = entry
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case sourcekey = "sourceKey"
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case checkpoint
|
||||
case entry
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionRestoreResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let sessionid: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
public let entry: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
sessionid: String,
|
||||
checkpoint: SessionCompactionCheckpoint,
|
||||
entry: [String: AnyCodable])
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.checkpoint = checkpoint
|
||||
self.entry = entry
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case checkpoint
|
||||
case entry
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let agentid: String?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
1c74540dd152c55dbda3e5dee1e37008ee3e6aabb0608e571292832c7a1c012c config-baseline.json
|
||||
7e30316f2326b7d07b71d7b8a96049a74b81428921299b5c4b5aa3d080e03305 config-baseline.core.json
|
||||
66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json
|
||||
d6ebc4948499b997c4a3727cf31849d4a598de9f1a4c197417dcc0b0ec1b734f config-baseline.plugin.json
|
||||
64ff922efc6146d867f3858141772094a8a72cba99a8fd61878551175dd8c822 config-baseline.json
|
||||
5d0ce975352ff2b03077f6d71e9fe99ab0f0b118da0f72d47dc989c83f13d668 config-baseline.core.json
|
||||
d22f4414b79ee03d896e58d875c80523bcc12303cbacb1700261e6ec73945187 config-baseline.channel.json
|
||||
1891bcb68d80ab8b7546a2946b5a9d82b18c3e92ffd2c834d15928e73fa11564 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
23bfae10a189a7d0548bc7213a9180841bbb1125e97ce1d2d0b7a765773a92fd plugin-sdk-api-baseline.json
|
||||
6c64b352b19368015c867b4c16225d676110544943497238c2f78602ad2fb519 plugin-sdk-api-baseline.jsonl
|
||||
3d483bffbe5abb831df3b1efdf40e1ae0d22d644853a7629ecdaa6d535386ee6 plugin-sdk-api-baseline.json
|
||||
eebeff7cc3ca490d3cae268ea97c5968f37f50fe1a9c7eabeeab85a4ae66a9d9 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -227,7 +227,10 @@ Quick mental model (evaluation order for group messages):
|
||||
|
||||
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
|
||||
|
||||
Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams.
|
||||
Replying to a bot message counts as an implicit mention when the channel
|
||||
supports reply metadata. Quoting a bot message can also count as an implicit
|
||||
mention on channels that expose quote metadata. Current built-in cases include
|
||||
Telegram, WhatsApp, Slack, Discord, Microsoft Teams, and ZaloUser.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -61,13 +61,17 @@ What the Matrix wizard actually asks for:
|
||||
- optional device name
|
||||
- whether to enable E2EE
|
||||
- whether to configure Matrix room access now
|
||||
- whether to configure Matrix invite auto-join now
|
||||
- when invite auto-join is enabled, whether it should be `allowlist`, `always`, or `off`
|
||||
|
||||
Wizard behavior that matters:
|
||||
|
||||
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut and only writes `enabled: true` for that account.
|
||||
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut so setup can keep auth in env vars instead of copying secrets into config.
|
||||
- When you add another Matrix account interactively, the entered account name is normalized into the account ID used in config and env vars. For example, `Ops Bot` becomes `ops-bot`.
|
||||
- DM allowlist prompts accept full `@user:server` values immediately. Display names only work when live directory lookup finds one exact match; otherwise the wizard asks you to retry with a full Matrix ID.
|
||||
- Room allowlist prompts accept room IDs and aliases directly. They can also resolve joined-room names live, but unresolved names are only kept as typed during setup and are ignored later by runtime allowlist resolution. Prefer `!room:server` or `#alias:server`.
|
||||
- The wizard now shows an explicit warning before the invite auto-join step because `channels.matrix.autoJoin` defaults to `off`; agents will not join invited rooms or fresh DM-style invites unless you set it.
|
||||
- In invite auto-join allowlist mode, use only stable invite targets: `!roomId:server`, `#alias:server`, or `*`. Plain room names are rejected.
|
||||
- Runtime room/session identity uses the stable Matrix room ID. Room-declared aliases are only used as lookup inputs, not as the long-term session key or stable group identity.
|
||||
- To resolve room names before saving them, use `openclaw channels resolve --channel matrix "Project Room"`.
|
||||
|
||||
@@ -77,6 +81,8 @@ Wizard behavior that matters:
|
||||
If you leave it unset, the bot will not join invited rooms or fresh DM-style invites, so it will not appear in new groups or invited DMs unless you join manually first.
|
||||
|
||||
Set `autoJoin: "allowlist"` together with `autoJoinAllowlist` to restrict which invites it accepts, or set `autoJoin: "always"` if you want it to join every invite.
|
||||
|
||||
In `allowlist` mode, `autoJoinAllowlist` only accepts `!roomId:server`, `#alias:server`, or `*`.
|
||||
</Warning>
|
||||
|
||||
Allowlist example:
|
||||
|
||||
@@ -75,10 +75,13 @@ self-check, and writes a Markdown report under `.artifacts/qa-e2e/`.
|
||||
Private debugger UI:
|
||||
|
||||
```bash
|
||||
pnpm qa:lab:build
|
||||
pnpm openclaw qa ui
|
||||
pnpm qa:lab:up
|
||||
```
|
||||
|
||||
That one command builds the QA site, starts the Docker-backed gateway + QA Lab
|
||||
stack, and prints the QA Lab URL. From that site you can pick scenarios, choose
|
||||
the model lane, launch individual runs, and watch results live.
|
||||
|
||||
Full repo-backed QA suite:
|
||||
|
||||
```bash
|
||||
@@ -96,10 +99,10 @@ Current scope is intentionally narrow:
|
||||
- threaded routing grammar
|
||||
- channel-owned message actions
|
||||
- Markdown reporting
|
||||
- Docker-backed QA site with run controls
|
||||
|
||||
Follow-up work will add:
|
||||
|
||||
- Dockerized OpenClaw orchestration
|
||||
- provider/model matrix execution
|
||||
- richer scenario discovery
|
||||
- OpenClaw-native orchestration later
|
||||
|
||||
@@ -399,7 +399,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
|
||||
|
||||
- explicit app mention (`<@botId>`)
|
||||
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
|
||||
- implicit reply-to-bot thread behavior
|
||||
- implicit reply-to-bot thread behavior (disabled when `thread.requireExplicitMention` is `true`)
|
||||
|
||||
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
|
||||
|
||||
@@ -423,6 +423,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
|
||||
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
|
||||
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
|
||||
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
|
||||
- `channels.slack.thread.requireExplicitMention` (default `false`): when `true`, suppress implicit thread mentions so the bot only responds to explicit `@bot` mentions inside threads, even when the bot already participated in the thread. Without this, replies in a bot-participated thread bypass `requireMention` gating.
|
||||
|
||||
Reply threading controls:
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ Example:
|
||||
- `channels.zalouser.groups.<group>.requireMention` controls whether group replies require a mention.
|
||||
- Resolution order: exact group id/name -> normalized group slug -> `*` -> default (`true`).
|
||||
- This applies both to allowlisted groups and open group mode.
|
||||
- Quoting a bot message counts as an implicit mention for group activation.
|
||||
- Authorized control commands (for example `/new`) can bypass mention gating.
|
||||
- When a group message is skipped because mention is required, OpenClaw stores it as pending group history and includes it on the next processed group message.
|
||||
- Group history limit defaults to `messages.groupChat.historyLimit` (fallback `50`). You can override per account with `channels.zalouser.historyLimit`.
|
||||
|
||||
@@ -115,6 +115,8 @@ engine is used automatically.
|
||||
A plugin can register a context engine using the plugin API:
|
||||
|
||||
```ts
|
||||
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
|
||||
|
||||
export default function register(api) {
|
||||
api.registerContextEngine("my-engine", () => ({
|
||||
info: {
|
||||
@@ -128,12 +130,15 @@ export default function register(api) {
|
||||
return { ingested: true };
|
||||
},
|
||||
|
||||
async assemble({ sessionId, messages, tokenBudget }) {
|
||||
async assemble({ sessionId, messages, tokenBudget, availableTools, citationsMode }) {
|
||||
// Return messages that fit the budget
|
||||
return {
|
||||
messages: buildContext(messages, tokenBudget),
|
||||
estimatedTokens: countTokens(messages),
|
||||
systemPromptAddition: "Use lcm_grep to search history...",
|
||||
systemPromptAddition: buildMemorySystemPromptAddition({
|
||||
availableTools: availableTools ?? new Set(),
|
||||
citationsMode,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -248,7 +253,13 @@ OpenClaw resolves when it needs a context engine.
|
||||
- **Memory plugins** (`plugins.slots.memory`) are separate from context engines.
|
||||
Memory plugins provide search/retrieval; context engines control what the
|
||||
model sees. They can work together — a context engine might use memory
|
||||
plugin data during assembly.
|
||||
plugin data during assembly. Plugin engines that want the active memory
|
||||
prompt path should prefer `buildMemorySystemPromptAddition(...)` from
|
||||
`openclaw/plugin-sdk/core`, which converts the active memory prompt sections
|
||||
into a ready-to-prepend `systemPromptAddition`. If an engine needs lower-level
|
||||
control, it can still pull raw lines from
|
||||
`openclaw/plugin-sdk/memory-host-core` via
|
||||
`buildActiveMemoryPromptSection(...)`.
|
||||
- **Session pruning** (trimming old tool results in-memory) still runs
|
||||
regardless of which context engine is active.
|
||||
|
||||
|
||||
@@ -21,13 +21,36 @@ Current pieces:
|
||||
- `qa/`: repo-backed seed assets for the kickoff task and baseline QA
|
||||
scenarios.
|
||||
|
||||
The long-term goal is a two-pane QA site:
|
||||
The current QA operator flow is a two-pane QA site:
|
||||
|
||||
- Left: Gateway dashboard (Control UI) with the agent.
|
||||
- Right: QA Lab, showing the Slack-ish transcript and scenario plan.
|
||||
|
||||
That lets an operator or automation loop give the agent a QA mission, observe
|
||||
real channel behavior, and record what worked, failed, or stayed blocked.
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
pnpm qa:lab:up
|
||||
```
|
||||
|
||||
That builds the QA site, starts the Docker-backed gateway lane, and exposes the
|
||||
QA Lab page where an operator or automation loop can give the agent a QA
|
||||
mission, observe real channel behavior, and record what worked, failed, or
|
||||
stayed blocked.
|
||||
|
||||
For faster QA Lab UI iteration without rebuilding the Docker image each time,
|
||||
start the stack with a bind-mounted QA Lab bundle:
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa docker-build-image
|
||||
pnpm qa:lab:build
|
||||
pnpm qa:lab:up:fast
|
||||
pnpm qa:lab:watch
|
||||
```
|
||||
|
||||
`qa:lab:up:fast` keeps the Docker services on a prebuilt image and bind-mounts
|
||||
`extensions/qa-lab/web/dist` into the `qa-lab` container. `qa:lab:watch`
|
||||
rebuilds that bundle on change, and the browser auto-reloads when the QA Lab
|
||||
asset hash changes.
|
||||
|
||||
## Repo-backed seeds
|
||||
|
||||
|
||||
@@ -1158,6 +1158,7 @@
|
||||
{
|
||||
"group": "Tools",
|
||||
"pages": [
|
||||
"tools/media-overview",
|
||||
"tools/apply-patch",
|
||||
{
|
||||
"group": "Web Browser",
|
||||
@@ -1230,6 +1231,7 @@
|
||||
"pages": [
|
||||
"providers/alibaba",
|
||||
"providers/anthropic",
|
||||
"providers/arcee",
|
||||
"providers/bedrock",
|
||||
"providers/bedrock-mantle",
|
||||
"providers/chutes",
|
||||
|
||||
@@ -657,6 +657,31 @@ for usage/billing and raise limits as needed.
|
||||
OpenClaw supports **OpenAI Code (Codex)** via OAuth (ChatGPT sign-in). Onboarding can run the OAuth flow and will set the default model to `openai-codex/gpt-5.4` when appropriate. See [Model providers](/concepts/model-providers) and [Onboarding (CLI)](/start/wizard).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Why does ChatGPT GPT-5.4 not unlock openai/gpt-5.4 in OpenClaw?">
|
||||
OpenClaw treats the two routes separately:
|
||||
|
||||
- `openai-codex/gpt-5.4` = ChatGPT/Codex OAuth
|
||||
- `openai/gpt-5.4` = direct OpenAI Platform API
|
||||
|
||||
In OpenClaw, ChatGPT/Codex sign-in is wired to the `openai-codex/*` route,
|
||||
not the direct `openai/*` route. If you want the direct API path in
|
||||
OpenClaw, set `OPENAI_API_KEY` (or the equivalent OpenAI provider config).
|
||||
If you want ChatGPT/Codex sign-in in OpenClaw, use `openai-codex/*`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Why can Codex OAuth limits differ from ChatGPT web?">
|
||||
`openai-codex/*` uses the Codex OAuth route, and its usable quota windows are
|
||||
OpenAI-managed and plan-dependent. In practice, those limits can differ from
|
||||
the ChatGPT website/app experience, even when both are tied to the same account.
|
||||
|
||||
OpenClaw can show the currently visible provider usage/quota windows in
|
||||
`openclaw models status`, but it does not invent or normalize ChatGPT-web
|
||||
entitlements into direct API access. If you want the direct OpenAI Platform
|
||||
billing/limit path, use `openai/*` with an API key.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Do you support OpenAI subscription auth (Codex OAuth)?">
|
||||
Yes. OpenClaw fully supports **OpenAI Code (Codex) subscription OAuth**.
|
||||
OpenAI explicitly allows subscription OAuth usage in external tools/workflows
|
||||
|
||||
@@ -26,6 +26,7 @@ Most days:
|
||||
- Faster local full-suite run on a roomy machine: `pnpm test:max`
|
||||
- Direct Vitest watch loop: `pnpm test:watch`
|
||||
- Direct file targeting now routes extension/channel paths too: `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts`
|
||||
- Docker-backed QA site: `pnpm qa:lab:up`
|
||||
|
||||
When you touch tests or want extra confidence:
|
||||
|
||||
@@ -46,7 +47,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
### Unit / integration (default)
|
||||
|
||||
- Command: `pnpm test`
|
||||
- Config: native Vitest `projects` via `vitest.config.ts`
|
||||
- Config: ten sequential shard runs (`vitest.full-*.config.ts`) over the existing scoped Vitest projects
|
||||
- Files: core/unit inventories under `src/**/*.test.ts`, `packages/**/*.test.ts`, `test/**/*.test.ts`, and the whitelisted `ui` node tests covered by `vitest.unit.config.ts`
|
||||
- Scope:
|
||||
- Pure unit tests
|
||||
@@ -57,9 +58,13 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
- No real keys required
|
||||
- Should be fast and stable
|
||||
- Projects note:
|
||||
- Untargeted `pnpm test` still uses the native Vitest root `projects` config.
|
||||
- Untargeted `pnpm test` now runs eleven smaller shard configs (`core-unit-src`, `core-unit-security`, `core-unit-ui`, `core-unit-support`, `core-support-boundary`, `core-contracts`, `core-bundled`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
|
||||
- `pnpm test --watch` still uses the native root `vitest.config.ts` project graph, because a multi-shard watch loop is not practical.
|
||||
- `pnpm test`, `pnpm test:watch`, and `pnpm test:perf:imports` route explicit file/directory targets through scoped lanes first, so `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts` avoids paying the full root project startup tax.
|
||||
- `pnpm test:changed` expands changed git paths into the same scoped lanes when the diff only touches routable source/test files; config/setup edits still fall back to the broad root-project rerun.
|
||||
- Selected `plugin-sdk` and `commands` tests also route through dedicated light lanes that skip `test/setup-openclaw-runtime.ts`; stateful/runtime-heavy files stay on the existing lanes.
|
||||
- Selected `plugin-sdk` and `commands` helper source files also map changed-mode runs to explicit sibling tests in those light lanes, so helper edits avoid rerunning the full heavy suite for that directory.
|
||||
- `auto-reply` now has three dedicated buckets: top-level core helpers, top-level `reply.*` integration tests, and the `src/auto-reply/reply/**` subtree. This keeps the heaviest reply harness work off the cheap status/chunk/token tests.
|
||||
- Embedded runner note:
|
||||
- When you change message-tool discovery inputs or compaction runtime context,
|
||||
keep both levels of coverage.
|
||||
@@ -75,7 +80,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
- Base Vitest config now defaults to `threads`.
|
||||
- The shared Vitest config also fixes `isolate: false` and uses the non-isolated runner across the root projects, e2e, and live configs.
|
||||
- The root UI lane keeps its `jsdom` setup and optimizer, but now runs on the shared non-isolated runner too.
|
||||
- `pnpm test` inherits the same `threads` + `isolate: false` defaults from the root `vitest.config.ts` projects config.
|
||||
- Each `pnpm test` shard inherits the same `threads` + `isolate: false` defaults from the shared Vitest config.
|
||||
- The shared `scripts/run-vitest.mjs` launcher now also adds `--no-maglev` for Vitest child Node processes by default to reduce V8 compile churn during big local runs. Set `OPENCLAW_VITEST_ENABLE_MAGLEV=1` if you need to compare against stock V8 behavior.
|
||||
- Fast-local iteration note:
|
||||
- `pnpm test:changed` routes through scoped lanes when the changed paths map cleanly to a smaller suite.
|
||||
@@ -86,6 +91,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
- Perf-debug note:
|
||||
- `pnpm test:perf:imports` enables Vitest import-duration reporting plus import-breakdown output.
|
||||
- `pnpm test:perf:imports:changed` scopes the same profiling view to files changed since `origin/main`.
|
||||
- `pnpm test:perf:changed:bench -- --ref <git-ref>` compares routed `test:changed` against the native root-project path for that committed diff and prints wall time plus macOS max RSS.
|
||||
- `pnpm test:perf:changed:bench -- --worktree` benchmarks the current dirty tree by routing the changed file list through `scripts/test-projects.mjs` and the root Vitest config.
|
||||
- `pnpm test:perf:profile:main` writes a main-thread CPU profile for Vitest/Vite startup and transform overhead.
|
||||
- `pnpm test:perf:profile:runner` writes runner CPU+heap profiles for the unit suite with file parallelism disabled.
|
||||
|
||||
@@ -246,17 +253,17 @@ openclaw models list
|
||||
openclaw models list --json
|
||||
```
|
||||
|
||||
## Live: CLI backend smoke (Codex CLI or other local CLIs)
|
||||
## Live: CLI backend smoke (Claude, Codex, Gemini, or other local CLIs)
|
||||
|
||||
- Test: `src/gateway/gateway-cli-backend.live.test.ts`
|
||||
- Goal: validate the Gateway + agent pipeline using a local CLI backend, without touching your default config.
|
||||
- Backend-specific smoke defaults live with the owning extension's `cli-backend.ts` definition.
|
||||
- Enable:
|
||||
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND=1`
|
||||
- Defaults:
|
||||
- Model: `codex-cli/gpt-5.4`
|
||||
- Command: `codex`
|
||||
- Args: `["exec","--json","--color","never","--sandbox","read-only","--skip-git-repo-check"]`
|
||||
- Default provider/model: `claude-cli/claude-sonnet-4-6`
|
||||
- Command/args/image behavior come from the owning CLI backend plugin metadata.
|
||||
- Overrides (optional):
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.4"`
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_COMMAND="/full/path/to/codex"`
|
||||
@@ -280,11 +287,19 @@ Docker recipe:
|
||||
pnpm test:docker:live-cli-backend
|
||||
```
|
||||
|
||||
Single-provider Docker recipes:
|
||||
|
||||
```bash
|
||||
pnpm test:docker:live-cli-backend:claude
|
||||
pnpm test:docker:live-cli-backend:codex
|
||||
pnpm test:docker:live-cli-backend:gemini
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The Docker runner lives at `scripts/test-live-cli-backend-docker.sh`.
|
||||
- It runs the live CLI-backend smoke inside the repo Docker image as the non-root `node` user.
|
||||
- For `codex-cli`, it installs the Linux `@openai/codex` package into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
|
||||
- It resolves CLI smoke metadata from the owning extension, then installs the matching Linux CLI package (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
|
||||
|
||||
## Live: ACP bind smoke (`/acp spawn ... --bind here`)
|
||||
|
||||
@@ -298,12 +313,15 @@ Notes:
|
||||
- `pnpm test:live src/gateway/gateway-acp-bind.live.test.ts`
|
||||
- `OPENCLAW_LIVE_ACP_BIND=1`
|
||||
- Defaults:
|
||||
- ACP agent: `claude`
|
||||
- ACP agents in Docker: `claude,codex,gemini`
|
||||
- ACP agent for direct `pnpm test:live ...`: `claude`
|
||||
- Synthetic channel: Slack DM-style conversation context
|
||||
- ACP backend: `acpx`
|
||||
- Overrides:
|
||||
- `OPENCLAW_LIVE_ACP_BIND_AGENT=claude`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_AGENT=codex`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_AGENT=gemini`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND='npx -y @agentclientprotocol/claude-agent-acp@<version>'`
|
||||
- Notes:
|
||||
- This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally.
|
||||
@@ -323,10 +341,20 @@ Docker recipe:
|
||||
pnpm test:docker:live-acp-bind
|
||||
```
|
||||
|
||||
Single-agent Docker recipes:
|
||||
|
||||
```bash
|
||||
pnpm test:docker:live-acp-bind:claude
|
||||
pnpm test:docker:live-acp-bind:codex
|
||||
pnpm test:docker:live-acp-bind:gemini
|
||||
```
|
||||
|
||||
Docker notes:
|
||||
|
||||
- The Docker runner lives at `scripts/test-live-acp-bind-docker.sh`.
|
||||
- It sources `~/.profile`, stages the matching CLI auth material into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code` or `@openai/codex`) if missing.
|
||||
- By default, it runs the ACP bind smoke against all supported live CLI agents in sequence: `claude`, `codex`, then `gemini`.
|
||||
- Use `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude`, `OPENCLAW_LIVE_ACP_BIND_AGENTS=codex`, or `OPENCLAW_LIVE_ACP_BIND_AGENTS=gemini` to narrow the matrix.
|
||||
- It sources `~/.profile`, stages the matching CLI auth material into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) if missing.
|
||||
- Inside Docker, the runner sets `OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND=$HOME/.npm-global/bin/acpx` so acpx keeps provider env vars from the sourced profile available to the child harness CLI.
|
||||
|
||||
### Recommended live recipes
|
||||
@@ -447,6 +475,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
|
||||
|
||||
- Test: `src/image-generation/runtime.live.test.ts`
|
||||
- Command: `pnpm test:live src/image-generation/runtime.live.test.ts`
|
||||
- Harness: `pnpm test:live:media image`
|
||||
- Scope:
|
||||
- Enumerates every registered image-generation provider plugin
|
||||
- Loads missing provider env vars from your login shell (`~/.profile`) before probing
|
||||
@@ -471,6 +500,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
|
||||
|
||||
- Test: `extensions/music-generation-providers.live.test.ts`
|
||||
- Enable: `OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/music-generation-providers.live.test.ts`
|
||||
- Harness: `pnpm test:live:media music`
|
||||
- Scope:
|
||||
- Exercises the shared bundled music-generation provider path
|
||||
- Currently covers Google and MiniMax
|
||||
@@ -494,6 +524,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
|
||||
|
||||
- Test: `extensions/video-generation-providers.live.test.ts`
|
||||
- Enable: `OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/video-generation-providers.live.test.ts`
|
||||
- Harness: `pnpm test:live:media video`
|
||||
- Scope:
|
||||
- Exercises the shared bundled video-generation provider path
|
||||
- Loads provider env vars from your login shell (`~/.profile`) before probing
|
||||
@@ -501,20 +532,39 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
|
||||
- Skips providers with no usable auth/profile/model
|
||||
- Runs both declared runtime modes when available:
|
||||
- `generate` with prompt-only input
|
||||
- `imageToVideo` when the provider declares `capabilities.imageToVideo.enabled`
|
||||
- `imageToVideo` when the provider declares `capabilities.imageToVideo.enabled` and the selected provider/model accepts buffer-backed local image input in the shared sweep
|
||||
- `videoToVideo` when the provider declares `capabilities.videoToVideo.enabled` and the selected provider/model accepts buffer-backed local video input in the shared sweep
|
||||
- Current declared-but-skipped `imageToVideo` providers in the shared sweep:
|
||||
- `vydra` because bundled `veo3` is text-only and bundled `kling` requires a remote image URL
|
||||
- Provider-specific Vydra coverage:
|
||||
- `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_VYDRA_VIDEO=1 pnpm test:live -- extensions/vydra/vydra.live.test.ts`
|
||||
- that file runs `veo3` text-to-video plus a `kling` lane that uses a remote image URL fixture by default
|
||||
- Current `videoToVideo` live coverage:
|
||||
- `google`
|
||||
- `openai`
|
||||
- `runway` only when the selected model is `runway/gen4_aleph`
|
||||
- Current declared-but-skipped `videoToVideo` providers in the shared sweep:
|
||||
- `alibaba`, `qwen`, `xai` because those paths currently require remote `http(s)` / MP4 reference URLs
|
||||
- `google` because the current shared Gemini/Veo lane uses local buffer-backed input and that path is not accepted in the shared sweep
|
||||
- `openai` because the current shared lane lacks org-specific video inpaint/remix access guarantees
|
||||
- Optional narrowing:
|
||||
- `OPENCLAW_LIVE_VIDEO_GENERATION_PROVIDERS="google,openai,runway"`
|
||||
- `OPENCLAW_LIVE_VIDEO_GENERATION_MODELS="google/veo-3.1-fast-generate-preview,openai/sora-2,runway/gen4_aleph"`
|
||||
- Optional auth behavior:
|
||||
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides
|
||||
|
||||
## Media live harness
|
||||
|
||||
- Command: `pnpm test:live:media`
|
||||
- Purpose:
|
||||
- Runs the shared image, music, and video live suites through one repo-native entrypoint
|
||||
- Auto-loads missing provider env vars from `~/.profile`
|
||||
- Auto-narrows each suite to providers that currently have usable auth by default
|
||||
- Reuses `scripts/test-live.mjs`, so heartbeat and quiet-mode behavior stay consistent
|
||||
- Examples:
|
||||
- `pnpm test:live:media`
|
||||
- `pnpm test:live:media image video --providers openai,google,minimax`
|
||||
- `pnpm test:live:media video --video-providers openai,runway --all-providers`
|
||||
- `pnpm test:live:media music --quiet`
|
||||
|
||||
## Docker runners (optional "works in Linux" checks)
|
||||
|
||||
These Docker runners split into two buckets:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: refactor: Make plugin-sdk a real workspace package incrementally
|
||||
title: "refactor: Make plugin-sdk a real workspace package incrementally"
|
||||
type: refactor
|
||||
status: active
|
||||
date: 2026-04-05
|
||||
|
||||
@@ -161,6 +161,22 @@ export OPENCLAW_APNS_KEY_ID="KEYID"
|
||||
export OPENCLAW_APNS_PRIVATE_KEY_P8="$(cat /path/to/AuthKey_KEYID.p8)"
|
||||
```
|
||||
|
||||
These are gateway-host runtime env vars, not Fastlane settings. `apps/ios/fastlane/.env` only stores
|
||||
App Store Connect / TestFlight auth such as `ASC_KEY_ID` and `ASC_ISSUER_ID`; it does not configure
|
||||
direct APNs delivery for local iOS builds.
|
||||
|
||||
Recommended gateway-host storage:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.openclaw/credentials/apns
|
||||
chmod 700 ~/.openclaw/credentials/apns
|
||||
mv /path/to/AuthKey_KEYID.p8 ~/.openclaw/credentials/apns/AuthKey_KEYID.p8
|
||||
chmod 600 ~/.openclaw/credentials/apns/AuthKey_KEYID.p8
|
||||
export OPENCLAW_APNS_PRIVATE_KEY_PATH="$HOME/.openclaw/credentials/apns/AuthKey_KEYID.p8"
|
||||
```
|
||||
|
||||
Do not commit the `.p8` file or place it under the repo checkout.
|
||||
|
||||
## Discovery paths
|
||||
|
||||
### Bonjour (LAN)
|
||||
|
||||
@@ -609,8 +609,9 @@ conversation, and it runs after core approval handling finishes.
|
||||
|
||||
Provider plugins now have two layers:
|
||||
|
||||
- manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before
|
||||
runtime load, plus `providerAuthChoices` for cheap onboarding/auth-choice
|
||||
- manifest metadata: `providerAuthEnvVars` for cheap provider env-auth lookup
|
||||
before runtime load, `channelEnvVars` for cheap channel env/setup lookup
|
||||
before runtime load, plus `providerAuthChoices` for cheap onboarding/auth-choice
|
||||
labels and CLI flag metadata before runtime load
|
||||
- config-time hooks: `catalog` / legacy `discovery` plus `applyConfigDefaults`
|
||||
- runtime hooks: `normalizeModelId`, `normalizeTransport`,
|
||||
@@ -645,6 +646,10 @@ one-flag auth wiring without loading provider runtime. Keep provider runtime
|
||||
`envVars` for operator-facing hints such as onboarding labels or OAuth
|
||||
client-id/client-secret setup vars.
|
||||
|
||||
Use manifest `channelEnvVars` when a channel has env-driven auth or setup that
|
||||
generic shell-env fallback, config/status checks, or setup prompts should see
|
||||
without loading channel runtime.
|
||||
|
||||
### Hook order and usage
|
||||
|
||||
For model/provider plugins, OpenClaw calls hooks in this rough order.
|
||||
@@ -1115,7 +1120,8 @@ authoring plugins:
|
||||
`openclaw/plugin-sdk/secret-input`, and
|
||||
`openclaw/plugin-sdk/webhook-ingress` for shared setup/auth/reply/webhook
|
||||
wiring. `channel-inbound` is the shared home for debounce, mention matching,
|
||||
envelope formatting, and inbound envelope context helpers.
|
||||
inbound mention-policy helpers, envelope formatting, and inbound envelope
|
||||
context helpers.
|
||||
`channel-setup` is the narrow optional-install setup seam.
|
||||
`setup-runtime` is the runtime-safe setup surface used by `setupEntry` /
|
||||
deferred startup, including the import-safe setup patch adapters.
|
||||
@@ -1488,14 +1494,23 @@ Use this when your plugin needs to replace or extend the default context
|
||||
pipeline rather than just add memory search or hooks.
|
||||
|
||||
```ts
|
||||
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
|
||||
|
||||
export default function (api) {
|
||||
api.registerContextEngine("lossless-claw", () => ({
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
async ingest() {
|
||||
return { ingested: true };
|
||||
},
|
||||
async assemble({ messages }) {
|
||||
return { messages, estimatedTokens: 0 };
|
||||
async assemble({ messages, availableTools, citationsMode }) {
|
||||
return {
|
||||
messages,
|
||||
estimatedTokens: 0,
|
||||
systemPromptAddition: buildMemorySystemPromptAddition({
|
||||
availableTools: availableTools ?? new Set(),
|
||||
citationsMode,
|
||||
}),
|
||||
};
|
||||
},
|
||||
async compact() {
|
||||
return { ok: true, compacted: false };
|
||||
@@ -1508,7 +1523,10 @@ If your engine does **not** own the compaction algorithm, keep `compact()`
|
||||
implemented and delegate it explicitly:
|
||||
|
||||
```ts
|
||||
import { delegateCompactionToRuntime } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
buildMemorySystemPromptAddition,
|
||||
delegateCompactionToRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
|
||||
export default function (api) {
|
||||
api.registerContextEngine("my-memory-engine", () => ({
|
||||
@@ -1520,8 +1538,15 @@ export default function (api) {
|
||||
async ingest() {
|
||||
return { ingested: true };
|
||||
},
|
||||
async assemble({ messages }) {
|
||||
return { messages, estimatedTokens: 0 };
|
||||
async assemble({ messages, availableTools, citationsMode }) {
|
||||
return {
|
||||
messages,
|
||||
estimatedTokens: 0,
|
||||
systemPromptAddition: buildMemorySystemPromptAddition({
|
||||
availableTools: availableTools ?? new Set(),
|
||||
citationsMode,
|
||||
}),
|
||||
};
|
||||
},
|
||||
async compact(params) {
|
||||
return await delegateCompactionToRuntime(params);
|
||||
|
||||
@@ -93,6 +93,9 @@ Those belong in your plugin code and `package.json`.
|
||||
"providerAuthEnvVars": {
|
||||
"openrouter": ["OPENROUTER_API_KEY"]
|
||||
},
|
||||
"channelEnvVars": {
|
||||
"openrouter-chatops": ["OPENROUTER_CHATOPS_TOKEN"]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "openrouter",
|
||||
@@ -142,6 +145,7 @@ Those belong in your plugin code and `package.json`.
|
||||
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
|
||||
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
|
||||
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
|
||||
| `channelEnvVars` | No | `Record<string, string[]>` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. |
|
||||
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
|
||||
| `contracts` | No | `object` | Static bundled capability snapshot for speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. |
|
||||
| `channelConfigs` | No | `Record<string, object>` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. |
|
||||
@@ -436,6 +440,9 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s
|
||||
- `providerAuthEnvVars` is the cheap metadata path for auth probes, env-marker
|
||||
validation, and similar provider-auth surfaces that should not boot plugin
|
||||
runtime just to inspect env names.
|
||||
- `channelEnvVars` is the cheap metadata path for shell-env fallback, setup
|
||||
prompts, and similar channel surfaces that should not boot plugin runtime
|
||||
just to inspect env names.
|
||||
- `providerAuthChoices` is the cheap metadata path for auth-choice pickers,
|
||||
`--auth-choice` resolution, preferred-provider mapping, and simple onboarding
|
||||
CLI flag registration before provider runtime loads. For runtime wizard
|
||||
|
||||
@@ -108,9 +108,15 @@ For setup specifically:
|
||||
- `openclaw/plugin-sdk/channel-setup` covers the optional-install setup
|
||||
builders plus a few setup-safe primitives:
|
||||
`createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`,
|
||||
`createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`,
|
||||
`createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and
|
||||
`splitSetupEntries`
|
||||
|
||||
If your channel supports env-driven setup or auth and generic startup/config
|
||||
flows should know those env names before runtime loads, declare them in the
|
||||
plugin manifest with `channelEnvVars`. Keep channel runtime `envVars` or local
|
||||
constants for operator-facing copy only.
|
||||
`createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`,
|
||||
`createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and
|
||||
`splitSetupEntries`
|
||||
|
||||
- use the broader `openclaw/plugin-sdk/setup` seam only when you also need the
|
||||
heavier shared setup/config helpers such as
|
||||
`moveSingleAccountChannelSectionToDefaultAccount(...)`
|
||||
@@ -146,6 +152,87 @@ surfaces:
|
||||
|
||||
Auth-only channels can usually stop at the default path: core handles approvals and the plugin just exposes outbound/auth capabilities. Native approval channels such as Matrix, Slack, Telegram, and custom chat transports should use the shared native helpers instead of rolling their own approval lifecycle.
|
||||
|
||||
## Inbound mention policy
|
||||
|
||||
Keep inbound mention handling split in two layers:
|
||||
|
||||
- plugin-owned evidence gathering
|
||||
- shared policy evaluation
|
||||
|
||||
Use `openclaw/plugin-sdk/channel-inbound` for the shared layer.
|
||||
|
||||
Good fit for plugin-local logic:
|
||||
|
||||
- reply-to-bot detection
|
||||
- quoted-bot detection
|
||||
- thread-participation checks
|
||||
- service/system-message exclusions
|
||||
- platform-native caches needed to prove bot participation
|
||||
|
||||
Good fit for the shared helper:
|
||||
|
||||
- `requireMention`
|
||||
- explicit mention result
|
||||
- implicit mention allowlist
|
||||
- command bypass
|
||||
- final skip decision
|
||||
|
||||
Preferred flow:
|
||||
|
||||
1. Compute local mention facts.
|
||||
2. Pass those facts into `resolveInboundMentionDecision({ facts, policy })`.
|
||||
3. Use `decision.effectiveWasMentioned`, `decision.shouldBypassMention`, and `decision.shouldSkip` in your inbound gate.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
implicitMentionKindWhen,
|
||||
matchesMentionWithExplicit,
|
||||
resolveInboundMentionDecision,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
|
||||
const mentionMatch = matchesMentionWithExplicit(text, {
|
||||
mentionRegexes,
|
||||
mentionPatterns,
|
||||
});
|
||||
|
||||
const facts = {
|
||||
canDetectMention: true,
|
||||
wasMentioned: mentionMatch.matched,
|
||||
hasAnyMention: mentionMatch.hasExplicitMention,
|
||||
implicitMentionKinds: [
|
||||
...implicitMentionKindWhen("reply_to_bot", isReplyToBot),
|
||||
...implicitMentionKindWhen("quoted_bot", isQuoteOfBot),
|
||||
],
|
||||
};
|
||||
|
||||
const decision = resolveInboundMentionDecision({
|
||||
facts,
|
||||
policy: {
|
||||
isGroup,
|
||||
requireMention,
|
||||
allowedImplicitMentionKinds: requireExplicitMention ? [] : ["reply_to_bot", "quoted_bot"],
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
commandAuthorized,
|
||||
},
|
||||
});
|
||||
|
||||
if (decision.shouldSkip) return;
|
||||
```
|
||||
|
||||
`api.runtime.channel.mentions` exposes the same shared mention helpers for
|
||||
bundled channel plugins that already depend on runtime injection:
|
||||
|
||||
- `buildMentionRegexes`
|
||||
- `matchesMentionPatterns`
|
||||
- `matchesMentionWithExplicit`
|
||||
- `implicitMentionKindWhen`
|
||||
- `resolveInboundMentionDecision`
|
||||
|
||||
The older `resolveMentionGating*` helpers remain on
|
||||
`openclaw/plugin-sdk/channel-inbound` as compatibility exports only. New code
|
||||
should use `resolveInboundMentionDecision({ facts, policy })`.
|
||||
|
||||
## Walkthrough
|
||||
|
||||
<Steps>
|
||||
|
||||
@@ -249,7 +249,8 @@ Current bundled provider examples:
|
||||
| `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers |
|
||||
| `plugin-sdk/provider-http` | Provider HTTP helpers | Generic provider HTTP/endpoint capability helpers |
|
||||
| `plugin-sdk/provider-web-fetch` | Provider web-fetch helpers | Web-fetch provider registration/cache helpers |
|
||||
| `plugin-sdk/provider-web-search` | Provider web-search helpers | Web-search provider registration/cache/config helpers |
|
||||
| `plugin-sdk/provider-web-search-contract` | Provider web-search contract helpers | Narrow web-search config/credential contract helpers such as `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
|
||||
| `plugin-sdk/provider-web-search` | Provider web-search helpers | Web-search provider registration/cache/runtime helpers |
|
||||
| `plugin-sdk/provider-tools` | Provider tool/schema compat helpers | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, Gemini schema cleanup + diagnostics, and xAI compat helpers such as `resolveXaiModelCompatPatch` / `applyXaiModelCompat` |
|
||||
| `plugin-sdk/provider-usage` | Provider usage helpers | `fetchClaudeUsage`, `fetchGeminiUsage`, `fetchGithubCopilotUsage`, and other provider usage helpers |
|
||||
| `plugin-sdk/provider-stream` | Provider stream wrapper helpers | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
|
||||
|
||||
@@ -108,12 +108,13 @@ explicitly promotes one as public.
|
||||
| `plugin-sdk/group-access` | Shared group-access decision helpers |
|
||||
| `plugin-sdk/direct-dm` | Shared direct-DM auth/guard helpers |
|
||||
| `plugin-sdk/interactive-runtime` | Interactive reply payload normalization/reduction helpers |
|
||||
| `plugin-sdk/channel-inbound` | Debounce, mention matching, envelope helpers |
|
||||
| `plugin-sdk/channel-inbound` | Inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
|
||||
| `plugin-sdk/channel-send-result` | Reply result types |
|
||||
| `plugin-sdk/channel-actions` | `createMessageToolButtonsSchema`, `createMessageToolCardSchema` |
|
||||
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
|
||||
| `plugin-sdk/channel-contract` | Channel contract types |
|
||||
| `plugin-sdk/channel-feedback` | Feedback/reaction wiring |
|
||||
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract helpers such as `collectSimpleChannelFieldAssignments`, `getChannelSurface`, `pushAssignment`, and secret target types |
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Provider subpaths">
|
||||
@@ -132,8 +133,10 @@ explicitly promotes one as public.
|
||||
| `plugin-sdk/provider-model-shared` | `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and model-id normalization helpers such as `normalizeNativeXaiModelId` |
|
||||
| `plugin-sdk/provider-catalog-shared` | `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` |
|
||||
| `plugin-sdk/provider-http` | Generic provider HTTP/endpoint capability helpers |
|
||||
| `plugin-sdk/provider-web-fetch-contract` | Narrow web-fetch config/selection contract helpers such as `enablePluginInConfig` and `WebFetchProviderPlugin` |
|
||||
| `plugin-sdk/provider-web-fetch` | Web-fetch provider registration/cache helpers |
|
||||
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/config helpers |
|
||||
| `plugin-sdk/provider-web-search-contract` | Narrow web-search config/credential contract helpers such as `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
|
||||
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/runtime helpers |
|
||||
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, Gemini schema cleanup + diagnostics, and xAI compat helpers such as `resolveXaiModelCompatPatch` / `applyXaiModelCompat` |
|
||||
| `plugin-sdk/provider-usage` | `fetchClaudeUsage` and similar |
|
||||
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
|
||||
@@ -154,6 +157,8 @@ explicitly promotes one as public.
|
||||
| `plugin-sdk/command-detection` | Shared command detection helpers |
|
||||
| `plugin-sdk/command-surface` | Command-body normalization and command-surface helpers |
|
||||
| `plugin-sdk/allow-from` | `formatAllowFromLowercase` |
|
||||
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract collection helpers for channel/plugin secret surfaces |
|
||||
| `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing |
|
||||
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, and secret-collection helpers |
|
||||
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
|
||||
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, and SSRF policy helpers |
|
||||
@@ -381,6 +386,7 @@ AI CLI backend such as `codex-cli`.
|
||||
| Method | What it registers |
|
||||
| ------------------------------------------ | ------------------------------------- |
|
||||
| `api.registerContextEngine(id, factory)` | Context engine (one active at a time) |
|
||||
| `api.registerMemoryCapability(capability)` | Unified memory capability |
|
||||
| `api.registerMemoryPromptSection(builder)` | Memory prompt section builder |
|
||||
| `api.registerMemoryFlushPlan(resolver)` | Memory flush plan resolver |
|
||||
| `api.registerMemoryRuntime(runtime)` | Memory runtime adapter |
|
||||
@@ -391,8 +397,13 @@ AI CLI backend such as `codex-cli`.
|
||||
| ---------------------------------------------- | ---------------------------------------------- |
|
||||
| `api.registerMemoryEmbeddingProvider(adapter)` | Memory embedding adapter for the active plugin |
|
||||
|
||||
- `registerMemoryCapability` is the preferred exclusive memory-plugin API.
|
||||
- `registerMemoryCapability` may also expose `publicArtifacts.listArtifacts(...)`
|
||||
so companion plugins can consume exported memory artifacts through
|
||||
`openclaw/plugin-sdk/memory-host-core` instead of reaching into a specific
|
||||
memory plugin's private layout.
|
||||
- `registerMemoryPromptSection`, `registerMemoryFlushPlan`, and
|
||||
`registerMemoryRuntime` are exclusive to memory plugins.
|
||||
`registerMemoryRuntime` are legacy-compatible exclusive memory-plugin APIs.
|
||||
- `registerMemoryEmbeddingProvider` lets the active memory plugin register one
|
||||
or more embedding adapter ids (for example `openai`, `gemini`, or a custom
|
||||
plugin-defined id).
|
||||
|
||||
@@ -330,6 +330,46 @@ api.runtime.tools.registerMemoryCli(/* ... */);
|
||||
|
||||
Channel-specific runtime helpers (available when a channel plugin is loaded).
|
||||
|
||||
`api.runtime.channel.mentions` is the shared inbound mention-policy surface for
|
||||
bundled channel plugins that use runtime injection:
|
||||
|
||||
```typescript
|
||||
const mentionMatch = api.runtime.channel.mentions.matchesMentionWithExplicit(text, {
|
||||
mentionRegexes,
|
||||
mentionPatterns,
|
||||
});
|
||||
|
||||
const decision = api.runtime.channel.mentions.resolveInboundMentionDecision({
|
||||
facts: {
|
||||
canDetectMention: true,
|
||||
wasMentioned: mentionMatch.matched,
|
||||
implicitMentionKinds: api.runtime.channel.mentions.implicitMentionKindWhen(
|
||||
"reply_to_bot",
|
||||
isReplyToBot,
|
||||
),
|
||||
},
|
||||
policy: {
|
||||
isGroup,
|
||||
requireMention,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
commandAuthorized,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Available mention helpers:
|
||||
|
||||
- `buildMentionRegexes`
|
||||
- `matchesMentionPatterns`
|
||||
- `matchesMentionWithExplicit`
|
||||
- `implicitMentionKindWhen`
|
||||
- `resolveInboundMentionDecision`
|
||||
|
||||
`api.runtime.channel.mentions` intentionally does not expose the older
|
||||
`resolveMentionGating*` compatibility helpers. Prefer the normalized
|
||||
`{ facts, policy }` path.
|
||||
|
||||
## Storing runtime references
|
||||
|
||||
Use `createPluginRuntimeStore` to store the runtime reference for use outside
|
||||
|
||||
87
docs/providers/arcee.md
Normal file
87
docs/providers/arcee.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: "Arcee AI"
|
||||
summary: "Arcee AI setup (auth + model selection)"
|
||||
read_when:
|
||||
- You want to use Arcee AI with OpenClaw
|
||||
- You need the API key env var or CLI auth choice
|
||||
---
|
||||
|
||||
# Arcee AI
|
||||
|
||||
[Arcee AI](https://arcee.ai) provides access to the Trinity family of mixture-of-experts models through an OpenAI-compatible API. All Trinity models are Apache 2.0 licensed.
|
||||
|
||||
Arcee AI models can be accessed directly via the Arcee platform or through [OpenRouter](/providers/openrouter).
|
||||
|
||||
- Provider: `arcee`
|
||||
- Auth: `ARCEEAI_API_KEY` (direct) or `OPENROUTER_API_KEY` (via OpenRouter)
|
||||
- API: OpenAI-compatible
|
||||
- Base URL: `https://api.arcee.ai/api/v1` (direct) or `https://openrouter.ai/api/v1` (OpenRouter)
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Get an API key from [Arcee AI](https://chat.arcee.ai/) or [OpenRouter](https://openrouter.ai/keys).
|
||||
|
||||
2. Set the API key (recommended: store it for the Gateway):
|
||||
|
||||
```bash
|
||||
# Direct (Arcee platform)
|
||||
openclaw onboard --auth-choice arceeai-api-key
|
||||
|
||||
# Via OpenRouter
|
||||
openclaw onboard --auth-choice arceeai-openrouter
|
||||
```
|
||||
|
||||
3. Set a default model:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "arcee/trinity-large-thinking" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Non-interactive example
|
||||
|
||||
```bash
|
||||
# Direct (Arcee platform)
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice arceeai-api-key \
|
||||
--arceeai-api-key "$ARCEEAI_API_KEY"
|
||||
|
||||
# Via OpenRouter
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice arceeai-openrouter \
|
||||
--openrouter-api-key "$OPENROUTER_API_KEY"
|
||||
```
|
||||
|
||||
## Environment note
|
||||
|
||||
If the Gateway runs as a daemon (launchd/systemd), make sure `ARCEEAI_API_KEY`
|
||||
(or `OPENROUTER_API_KEY`) is available to that process (for example, in
|
||||
`~/.openclaw/.env` or via `env.shellEnv`).
|
||||
|
||||
## Built-in catalog
|
||||
|
||||
OpenClaw currently ships this bundled Arcee catalog:
|
||||
|
||||
| Model ref | Name | Input | Context | Cost (in/out per 1M) | Notes |
|
||||
| ------------------------------ | ---------------------- | ----- | ------- | -------------------- | ----------------------------------------- |
|
||||
| `arcee/trinity-large-thinking` | Trinity Large Thinking | text | 256K | $0.25 / $0.90 | Default model; reasoning enabled |
|
||||
| `arcee/trinity-large-preview` | Trinity Large Preview | text | 128K | $0.25 / $1.00 | General-purpose; 400B params, 13B active |
|
||||
| `arcee/trinity-mini` | Trinity Mini 26B | text | 128K | $0.045 / $0.15 | Fast and cost-efficient; function calling |
|
||||
|
||||
The same model refs work for both direct and OpenRouter setups (for example `arcee/trinity-large-thinking`).
|
||||
|
||||
The onboarding preset sets `arcee/trinity-large-thinking` as the default model.
|
||||
|
||||
## Supported features
|
||||
|
||||
- Streaming
|
||||
- Tool use / function calling
|
||||
- Structured output (JSON mode and JSON schema)
|
||||
- Extended thinking (Trinity Large Thinking)
|
||||
@@ -29,6 +29,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
||||
- [Alibaba Model Studio](/providers/alibaba)
|
||||
- [Amazon Bedrock](/providers/bedrock)
|
||||
- [Anthropic (API + Claude CLI)](/providers/anthropic)
|
||||
- [Arcee AI (Trinity models)](/providers/arcee)
|
||||
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
|
||||
- [Chutes](/providers/chutes)
|
||||
- [ComfyUI](/providers/comfy)
|
||||
|
||||
@@ -33,15 +33,15 @@ openclaw onboard --mistral-api-key "$MISTRAL_API_KEY"
|
||||
|
||||
OpenClaw currently ships this bundled Mistral catalog:
|
||||
|
||||
| Model ref | Input | Context | Max output | Notes |
|
||||
| -------------------------------- | ----------- | ------- | ---------- | ------------------------ |
|
||||
| `mistral/mistral-large-latest` | text, image | 262,144 | 16,384 | Default model |
|
||||
| `mistral/mistral-medium-2508` | text, image | 262,144 | 8,192 | Mistral Medium 3.1 |
|
||||
| `mistral/mistral-small-latest` | text, image | 128,000 | 16,384 | Smaller multimodal model |
|
||||
| `mistral/pixtral-large-latest` | text, image | 128,000 | 32,768 | Pixtral |
|
||||
| `mistral/codestral-latest` | text | 256,000 | 4,096 | Coding |
|
||||
| `mistral/devstral-medium-latest` | text | 262,144 | 32,768 | Devstral 2 |
|
||||
| `mistral/magistral-small` | text | 128,000 | 40,000 | Reasoning-enabled |
|
||||
| Model ref | Input | Context | Max output | Notes |
|
||||
| -------------------------------- | ----------- | ------- | ---------- | ---------------------------------------------------------------- |
|
||||
| `mistral/mistral-large-latest` | text, image | 262,144 | 16,384 | Default model |
|
||||
| `mistral/mistral-medium-2508` | text, image | 262,144 | 8,192 | Mistral Medium 3.1 |
|
||||
| `mistral/mistral-small-latest` | text, image | 128,000 | 16,384 | Mistral Small 4; adjustable reasoning via API `reasoning_effort` |
|
||||
| `mistral/pixtral-large-latest` | text, image | 128,000 | 32,768 | Pixtral |
|
||||
| `mistral/codestral-latest` | text | 256,000 | 4,096 | Coding |
|
||||
| `mistral/devstral-medium-latest` | text | 262,144 | 32,768 | Devstral 2 |
|
||||
| `mistral/magistral-small` | text | 128,000 | 40,000 | Reasoning-enabled |
|
||||
|
||||
## Config snippet (audio transcription with Voxtral)
|
||||
|
||||
@@ -58,6 +58,17 @@ OpenClaw currently ships this bundled Mistral catalog:
|
||||
}
|
||||
```
|
||||
|
||||
## Adjustable reasoning (`mistral-small-latest`)
|
||||
|
||||
`mistral/mistral-small-latest` maps to Mistral Small 4 and supports [adjustable reasoning](https://docs.mistral.ai/capabilities/reasoning/adjustable) on the Chat Completions API via `reasoning_effort` (`none` minimizes extra thinking in the output; `high` surfaces full thinking traces before the final answer).
|
||||
|
||||
OpenClaw maps the session **thinking** level to Mistral’s API:
|
||||
|
||||
- **off** / **minimal** → `none`
|
||||
- **low** / **medium** / **high** / **xhigh** / **adaptive** → `high`
|
||||
|
||||
Other bundled Mistral catalog models do not use this parameter; keep using `magistral-*` models when you want Mistral’s native reasoning-first behavior.
|
||||
|
||||
## Notes
|
||||
|
||||
- Mistral auth uses `MISTRAL_API_KEY`.
|
||||
|
||||
@@ -28,6 +28,7 @@ Config key:
|
||||
Allowed values:
|
||||
|
||||
- `"friendly"`: default; enable the OpenAI-specific overlay.
|
||||
- `"on"`: alias for `"friendly"`.
|
||||
- `"off"`: disable the overlay and use the base OpenClaw prompt only.
|
||||
|
||||
Scope:
|
||||
@@ -77,11 +78,20 @@ You can also set it directly with the config CLI:
|
||||
openclaw config set plugins.entries.openai.config.personality off
|
||||
```
|
||||
|
||||
OpenClaw normalizes this setting case-insensitively at runtime, so values like
|
||||
`"Off"` still disable the friendly overlay.
|
||||
|
||||
## Option A: OpenAI API key (OpenAI Platform)
|
||||
|
||||
**Best for:** direct API access and usage-based billing.
|
||||
Get your API key from the OpenAI dashboard.
|
||||
|
||||
Route summary:
|
||||
|
||||
- `openai/gpt-5.4` = direct OpenAI Platform API route
|
||||
- Requires `OPENAI_API_KEY` (or equivalent OpenAI provider config)
|
||||
- In OpenClaw, ChatGPT/Codex sign-in is routed through `openai-codex/*`, not `openai/*`
|
||||
|
||||
### CLI setup
|
||||
|
||||
```bash
|
||||
@@ -172,6 +182,12 @@ parameters, provider selection, and failover behavior.
|
||||
**Best for:** using ChatGPT/Codex subscription access instead of an API key.
|
||||
Codex cloud requires ChatGPT sign-in, while the Codex CLI supports ChatGPT or API key sign-in.
|
||||
|
||||
Route summary:
|
||||
|
||||
- `openai-codex/gpt-5.4` = ChatGPT/Codex OAuth route
|
||||
- Uses ChatGPT/Codex sign-in, not a direct OpenAI Platform API key
|
||||
- Provider-side limits for `openai-codex/*` can differ from the ChatGPT web/app experience
|
||||
|
||||
### CLI setup (Codex OAuth)
|
||||
|
||||
```bash
|
||||
@@ -193,6 +209,10 @@ openclaw models auth login --provider openai-codex
|
||||
OpenAI's current Codex docs list `gpt-5.4` as the current Codex model. OpenClaw
|
||||
maps that to `openai-codex/gpt-5.4` for ChatGPT/Codex OAuth usage.
|
||||
|
||||
This route is intentionally separate from `openai/gpt-5.4`. If you want the
|
||||
direct OpenAI Platform API path, use `openai/*` with an API key. If you want
|
||||
ChatGPT/Codex sign-in, use `openai-codex/*`.
|
||||
|
||||
If onboarding reuses an existing Codex CLI login, those credentials stay
|
||||
managed by Codex CLI. On expiry, OpenClaw re-reads the external Codex source
|
||||
first and, when the provider can refresh it, writes the refreshed credential
|
||||
|
||||
@@ -85,8 +85,28 @@ Notes:
|
||||
|
||||
- `vydra/veo3` is bundled as text-to-video only.
|
||||
- `vydra/kling` currently requires a remote image URL reference. Local file uploads are rejected up front.
|
||||
- Vydra's current `kling` HTTP route has been inconsistent about whether it requires `image_url` or `video_url`; the bundled provider maps the same remote image URL into both fields.
|
||||
- The bundled plugin stays conservative and does not forward undocumented style knobs such as aspect ratio, resolution, watermark, or generated audio.
|
||||
|
||||
Provider-specific live coverage:
|
||||
|
||||
```bash
|
||||
OPENCLAW_LIVE_TEST=1 \
|
||||
OPENCLAW_LIVE_VYDRA_VIDEO=1 \
|
||||
pnpm test:live -- extensions/vydra/vydra.live.test.ts
|
||||
```
|
||||
|
||||
The bundled Vydra live file now covers:
|
||||
|
||||
- `vydra/veo3` text-to-video
|
||||
- `vydra/kling` image-to-video using a remote image URL
|
||||
|
||||
Override the remote image fixture when needed:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_LIVE_VYDRA_KLING_IMAGE_URL="https://example.com/reference.png"
|
||||
```
|
||||
|
||||
See [Video Generation](/tools/video-generation) for shared tool behavior.
|
||||
|
||||
## Speech synthesis
|
||||
|
||||
@@ -41,7 +41,6 @@ Scope intent:
|
||||
- `talk.providers.*.apiKey`
|
||||
- `messages.tts.providers.*.apiKey`
|
||||
- `tools.web.fetch.firecrawl.apiKey`
|
||||
- `plugins.entries.firecrawl.config.webFetch.apiKey`
|
||||
- `plugins.entries.brave.config.webSearch.apiKey`
|
||||
- `plugins.entries.google.config.webSearch.apiKey`
|
||||
- `plugins.entries.xai.config.webSearch.apiKey`
|
||||
|
||||
@@ -13,13 +13,18 @@ title: "Tests"
|
||||
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
|
||||
- `pnpm test:coverage:changed`: Runs unit coverage only for files changed since `origin/main`.
|
||||
- `pnpm test:changed`: expands changed git paths into scoped Vitest lanes when the diff only touches routable source/test files. Config/setup changes still fall back to the native root projects run so wiring edits rerun broadly when needed.
|
||||
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes, but still falls back to the native root projects run when you do a full untargeted sweep.
|
||||
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute eleven sequential shard configs (`vitest.full-core-unit-src.config.ts`, `vitest.full-core-unit-security.config.ts`, `vitest.full-core-unit-ui.config.ts`, `vitest.full-core-unit-support.config.ts`, `vitest.full-core-support-boundary.config.ts`, `vitest.full-core-contracts.config.ts`, `vitest.full-core-bundled.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
|
||||
- Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes.
|
||||
- Selected `plugin-sdk` and `commands` helper source files also map `pnpm test:changed` to explicit sibling tests in those light lanes, so small helper edits avoid rerunning the heavy runtime-backed suites.
|
||||
- `auto-reply` now also splits into three dedicated configs (`core`, `top-level`, `reply`) so the reply harness does not dominate the lighter top-level status/token/helper tests.
|
||||
- Base Vitest config now defaults to `pool: "threads"` and `isolate: false`, with the shared non-isolated runner enabled across the repo configs.
|
||||
- `pnpm test:channels` runs `vitest.channels.config.ts`.
|
||||
- `pnpm test:extensions` runs `vitest.extensions.config.ts`.
|
||||
- `pnpm test:extensions`: runs extension/plugin suites.
|
||||
- `pnpm test:perf:imports`: enables Vitest import-duration + import-breakdown reporting, while still using scoped lane routing for explicit file/directory targets.
|
||||
- `pnpm test:perf:imports:changed`: same import profiling, but only for files changed since `origin/main`.
|
||||
- `pnpm test:perf:changed:bench -- --ref <git-ref>` benchmarks the routed changed-mode path against the native root-project run for the same committed git diff.
|
||||
- `pnpm test:perf:changed:bench -- --worktree` benchmarks the current worktree change set without committing first.
|
||||
- `pnpm test:perf:profile:main`: writes a CPU profile for the Vitest main thread (`.artifacts/vitest-main-profile`).
|
||||
- `pnpm test:perf:profile:runner`: writes CPU + heap profiles for the unit runner (`.artifacts/vitest-runner-profile`).
|
||||
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
|
||||
|
||||
@@ -68,7 +68,9 @@ Use `action: "list"` to inspect available providers and models at runtime:
|
||||
| `count` | number | Number of images to generate (1–4) |
|
||||
| `filename` | string | Output filename hint |
|
||||
|
||||
Not all providers support all parameters. The tool passes what each provider supports, ignores the rest, and reports dropped overrides in the tool result.
|
||||
Not all providers support all parameters. When a fallback provider supports a nearby geometry option instead of the exact requested one, OpenClaw remaps to the closest supported size, aspect ratio, or resolution before submission. Truly unsupported overrides are still reported in the tool result.
|
||||
|
||||
Tool results report the applied settings. When OpenClaw remaps geometry during provider fallback, the returned `size`, `aspectRatio`, and `resolution` values reflect what was actually sent, and `details.normalization` captures the requested-to-applied translation.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -104,6 +106,10 @@ Notes:
|
||||
|
||||
- Auto-detection is auth-aware. A provider default only enters the candidate list
|
||||
when OpenClaw can actually authenticate that provider.
|
||||
- Auto-detection is enabled by default. Set
|
||||
`agents.defaults.mediaGenerationAutoProviderFallback: false` if you want image
|
||||
generation to use only the explicit `model`, `primary`, and `fallbacks`
|
||||
entries.
|
||||
- Use `action: "list"` to inspect the currently registered providers, their
|
||||
default models, and auth env-var hints.
|
||||
|
||||
|
||||
60
docs/tools/media-overview.md
Normal file
60
docs/tools/media-overview.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
summary: "Unified landing page for media generation, understanding, and speech capabilities"
|
||||
read_when:
|
||||
- Looking for an overview of media capabilities
|
||||
- Deciding which media provider to configure
|
||||
- Understanding how async media generation works
|
||||
title: "Media Overview"
|
||||
---
|
||||
|
||||
# Media Generation and Understanding
|
||||
|
||||
OpenClaw generates images, videos, and music, understands inbound media (images, audio, video), and speaks replies aloud with text-to-speech. All media capabilities are tool-driven: the agent decides when to use them based on the conversation, and each tool only appears when at least one backing provider is configured.
|
||||
|
||||
## Capabilities at a glance
|
||||
|
||||
| Capability | Tool | Providers | What it does |
|
||||
| -------------------- | ---------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Image generation | `image_generate` | ComfyUI, fal, Google, MiniMax, OpenAI, Vydra | Creates or edits images from text prompts or references |
|
||||
| Video generation | `video_generate` | Alibaba, BytePlus, ComfyUI, fal, Google, MiniMax, OpenAI, Qwen, Runway, Together, Vydra, xAI | Creates videos from text, images, or existing videos |
|
||||
| Music generation | `music_generate` | ComfyUI, Google, MiniMax | Creates music or audio tracks from text prompts |
|
||||
| Text-to-speech (TTS) | `tts` | ElevenLabs, Microsoft, MiniMax, OpenAI | Converts outbound replies to spoken audio |
|
||||
| Media understanding | (automatic) | Any vision/audio-capable model provider, plus CLI fallbacks | Summarizes inbound images, audio, and video |
|
||||
|
||||
## Provider capability matrix
|
||||
|
||||
This table shows which providers support which media capabilities across the platform.
|
||||
|
||||
| Provider | Image | Video | Music | TTS | STT / Transcription | Media Understanding |
|
||||
| ---------- | ----- | ----- | ----- | --- | ------------------- | ------------------- |
|
||||
| Alibaba | | Yes | | | | |
|
||||
| BytePlus | | Yes | | | | |
|
||||
| ComfyUI | Yes | Yes | Yes | | | |
|
||||
| Deepgram | | | | | Yes | |
|
||||
| ElevenLabs | | | | Yes | | |
|
||||
| fal | Yes | Yes | | | | |
|
||||
| Google | Yes | Yes | Yes | | | Yes |
|
||||
| Microsoft | | | | Yes | | |
|
||||
| MiniMax | Yes | Yes | Yes | Yes | | |
|
||||
| OpenAI | Yes | Yes | | Yes | Yes | Yes |
|
||||
| Qwen | | Yes | | | | |
|
||||
| Runway | | Yes | | | | |
|
||||
| Together | | Yes | | | | |
|
||||
| Vydra | Yes | Yes | | | | |
|
||||
| xAI | | Yes | | | | |
|
||||
|
||||
<Note>
|
||||
Media understanding uses any vision-capable or audio-capable model registered in your provider config. The table above highlights providers with dedicated media-understanding support; most LLM providers with multimodal models (Anthropic, Google, OpenAI, etc.) can also understand inbound media when configured as the active reply model.
|
||||
</Note>
|
||||
|
||||
## How async generation works
|
||||
|
||||
Video and music generation run as background tasks because provider processing typically takes 30 seconds to several minutes. When the agent calls `video_generate` or `music_generate`, OpenClaw submits the request to the provider, returns a task ID immediately, and tracks the job in the task ledger. The agent continues responding to other messages while the job runs. When the provider finishes, OpenClaw wakes the agent so it can post the finished media back into the original channel. Image generation and TTS are synchronous and complete inline with the reply.
|
||||
|
||||
## Quick links
|
||||
|
||||
- [Image Generation](/tools/image-generation) -- generating and editing images
|
||||
- [Video Generation](/tools/video-generation) -- text-to-video, image-to-video, and video-to-video
|
||||
- [Music Generation](/tools/music-generation) -- creating music and audio tracks
|
||||
- [Text-to-Speech](/tools/tts) -- converting replies to spoken audio
|
||||
- [Media Understanding](/nodes/media-understanding) -- understanding inbound images, audio, and video
|
||||
@@ -131,8 +131,12 @@ Direct generation example:
|
||||
| `filename` | string | Output filename hint |
|
||||
|
||||
Not all providers support all parameters. OpenClaw still validates hard limits
|
||||
such as input counts before submission, but unsupported optional hints are
|
||||
ignored with a warning when the selected provider or model cannot honor them.
|
||||
such as input counts before submission. When a provider supports duration but
|
||||
uses a shorter maximum than the requested value, OpenClaw automatically clamps
|
||||
to the closest supported duration. Truly unsupported optional hints are ignored
|
||||
with a warning when the selected provider or model cannot honor them.
|
||||
|
||||
Tool results report the applied settings. When OpenClaw clamps duration during provider fallback, the returned `durationSeconds` reflects the submitted value and `details.normalization.durationSeconds` shows the requested-to-applied mapping.
|
||||
|
||||
## Async behavior for the shared provider-backed path
|
||||
|
||||
@@ -144,6 +148,25 @@ ignored with a warning when the selected provider or model cannot honor them.
|
||||
- Prompt hint: later user/manual turns in the same session get a small runtime hint when a music task is already in flight so the model does not blindly call `music_generate` again.
|
||||
- No-session fallback: direct/local contexts without a real agent session still run inline and return the final audio result in the same turn.
|
||||
|
||||
### Task lifecycle
|
||||
|
||||
Each `music_generate` request moves through four states:
|
||||
|
||||
1. **queued** -- task created, waiting for the provider to accept it.
|
||||
2. **running** -- provider is processing (typically 30 seconds to 3 minutes depending on provider and duration).
|
||||
3. **succeeded** -- track ready; the agent wakes and posts it to the conversation.
|
||||
4. **failed** -- provider error or timeout; the agent wakes with error details.
|
||||
|
||||
Check status from the CLI:
|
||||
|
||||
```bash
|
||||
openclaw tasks list
|
||||
openclaw tasks show <taskId>
|
||||
openclaw tasks cancel <taskId>
|
||||
```
|
||||
|
||||
Duplicate prevention: if a music task is already `queued` or `running` for the current session, `music_generate` returns the existing task status instead of starting a new one. Use `action: "status"` to check explicitly without triggering a new generation.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Model selection
|
||||
@@ -175,6 +198,10 @@ When generating music, OpenClaw tries providers in this order:
|
||||
If a provider fails, the next candidate is tried automatically. If all fail, the
|
||||
error includes details from each attempt.
|
||||
|
||||
Set `agents.defaults.mediaGenerationAutoProviderFallback: false` if you want
|
||||
music generation to use only the explicit `model`, `primary`, and `fallbacks`
|
||||
entries.
|
||||
|
||||
## Provider notes
|
||||
|
||||
- Google uses Lyria 3 batch generation. The current bundled flow supports
|
||||
@@ -229,6 +256,12 @@ Opt-in live coverage for the shared bundled providers:
|
||||
OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/music-generation-providers.live.test.ts
|
||||
```
|
||||
|
||||
Repo wrapper:
|
||||
|
||||
```bash
|
||||
pnpm test:live:media music
|
||||
```
|
||||
|
||||
This live file loads missing provider env vars from `~/.profile`, prefers
|
||||
live/env API keys ahead of stored auth profiles by default, and runs both
|
||||
`generate` and declared `edit` coverage when the provider enables edit mode.
|
||||
|
||||
@@ -57,6 +57,25 @@ While a job is in flight, duplicate `video_generate` calls in the same session r
|
||||
|
||||
Outside of session-backed agent runs (for example, direct tool invocations), the tool falls back to inline generation and returns the final media path in the same turn.
|
||||
|
||||
### Task lifecycle
|
||||
|
||||
Each `video_generate` request moves through four states:
|
||||
|
||||
1. **queued** -- task created, waiting for the provider to accept it.
|
||||
2. **running** -- provider is processing (typically 30 seconds to 5 minutes depending on provider and resolution).
|
||||
3. **succeeded** -- video ready; the agent wakes and posts it to the conversation.
|
||||
4. **failed** -- provider error or timeout; the agent wakes with error details.
|
||||
|
||||
Check status from the CLI:
|
||||
|
||||
```bash
|
||||
openclaw tasks list
|
||||
openclaw tasks show <taskId>
|
||||
openclaw tasks cancel <taskId>
|
||||
```
|
||||
|
||||
Duplicate prevention: if a video task is already `queued` or `running` for the current session, `video_generate` returns the existing task status instead of starting a new one. Use `action: "status"` to check explicitly without triggering a new generation.
|
||||
|
||||
## Supported providers
|
||||
|
||||
| Provider | Default model | Text | Image ref | Video ref | API key |
|
||||
@@ -84,20 +103,20 @@ runtime modes at runtime.
|
||||
This is the explicit mode contract used by `video_generate`, contract tests,
|
||||
and the shared live sweep.
|
||||
|
||||
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
|
||||
| -------- | ---------- | -------------- | -------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| Alibaba | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| BytePlus | Yes | Yes | No | `generate`, `imageToVideo` |
|
||||
| ComfyUI | Yes | Yes | No | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
|
||||
| fal | Yes | Yes | No | `generate`, `imageToVideo` |
|
||||
| Google | Yes | Yes | Yes | `generate`, `imageToVideo`, `videoToVideo` |
|
||||
| MiniMax | Yes | Yes | No | `generate`, `imageToVideo` |
|
||||
| OpenAI | Yes | Yes | Yes | `generate`, `imageToVideo`, `videoToVideo` |
|
||||
| Qwen | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| Runway | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
|
||||
| Together | Yes | Yes | No | `generate`, `imageToVideo` |
|
||||
| Vydra | Yes | Yes | No | `generate`, `imageToVideo` |
|
||||
| xAI | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
|
||||
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
|
||||
| -------- | ---------- | -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Alibaba | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| BytePlus | Yes | Yes | No | `generate`, `imageToVideo` |
|
||||
| ComfyUI | Yes | Yes | No | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
|
||||
| fal | Yes | Yes | No | `generate`, `imageToVideo` |
|
||||
| Google | Yes | Yes | Yes | `generate`, `imageToVideo`; shared `videoToVideo` skipped because the current buffer-backed Gemini/Veo sweep does not accept that input |
|
||||
| MiniMax | Yes | Yes | No | `generate`, `imageToVideo` |
|
||||
| OpenAI | Yes | Yes | Yes | `generate`, `imageToVideo`; shared `videoToVideo` skipped because this org/input path currently needs provider-side inpaint/remix access |
|
||||
| Qwen | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| Runway | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
|
||||
| Together | Yes | Yes | No | `generate`, `imageToVideo` |
|
||||
| Vydra | Yes | Yes | No | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL |
|
||||
| xAI | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
|
||||
|
||||
## Tool parameters
|
||||
|
||||
@@ -121,7 +140,7 @@ and the shared live sweep.
|
||||
| Parameter | Type | Description |
|
||||
| ----------------- | ------- | ------------------------------------------------------------------------ |
|
||||
| `aspectRatio` | string | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` |
|
||||
| `resolution` | string | `480P`, `720P`, or `1080P` |
|
||||
| `resolution` | string | `480P`, `720P`, `768P`, or `1080P` |
|
||||
| `durationSeconds` | number | Target duration in seconds (rounded to nearest provider-supported value) |
|
||||
| `size` | string | Size hint when the provider supports it |
|
||||
| `audio` | boolean | Enable generated audio when supported |
|
||||
@@ -135,7 +154,9 @@ and the shared live sweep.
|
||||
| `model` | string | Provider/model override (e.g. `runway/gen4.5`) |
|
||||
| `filename` | string | Output filename hint |
|
||||
|
||||
Not all providers support all parameters. Unsupported overrides are ignored on a best-effort basis and reported as warnings in the tool result. Hard capability limits (such as too many reference inputs) fail before submission.
|
||||
Not all providers support all parameters. OpenClaw already normalizes duration to the closest provider-supported value, and it also remaps translated geometry hints such as size-to-aspect-ratio when a fallback provider exposes a different control surface. Truly unsupported overrides are ignored on a best-effort basis and reported as warnings in the tool result. Hard capability limits (such as too many reference inputs) fail before submission.
|
||||
|
||||
Tool results report the applied settings. When OpenClaw remaps duration or geometry during provider fallback, the returned `durationSeconds`, `size`, `aspectRatio`, and `resolution` values reflect what was submitted, and `details.normalization` captures the requested-to-applied translation.
|
||||
|
||||
Reference inputs also select the runtime mode:
|
||||
|
||||
@@ -163,6 +184,10 @@ When generating a video, OpenClaw resolves the model in this order:
|
||||
|
||||
If a provider fails, the next candidate is tried automatically. If all candidates fail, the error includes details from each attempt.
|
||||
|
||||
Set `agents.defaults.mediaGenerationAutoProviderFallback: false` if you want
|
||||
video generation to use only the explicit `model`, `primary`, and `fallbacks`
|
||||
entries.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
@@ -235,6 +260,12 @@ Opt-in live coverage for the shared bundled providers:
|
||||
OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/video-generation-providers.live.test.ts
|
||||
```
|
||||
|
||||
Repo wrapper:
|
||||
|
||||
```bash
|
||||
pnpm test:live:media video
|
||||
```
|
||||
|
||||
This live file loads missing provider env vars from `~/.profile`, prefers
|
||||
live/env API keys ahead of stored auth profiles by default, and runs the
|
||||
declared modes it can exercise safely with local media:
|
||||
@@ -246,8 +277,6 @@ declared modes it can exercise safely with local media:
|
||||
|
||||
Today the shared `videoToVideo` live lane covers:
|
||||
|
||||
- `google`
|
||||
- `openai`
|
||||
- `runway` only when you select `runway/gen4_aleph`
|
||||
|
||||
## Configuration
|
||||
|
||||
452
docs/tts.md
452
docs/tts.md
@@ -1,452 +1,6 @@
|
||||
---
|
||||
summary: "Text-to-speech (TTS) for outbound replies"
|
||||
read_when:
|
||||
- Enabling text-to-speech for replies
|
||||
- Configuring TTS providers or limits
|
||||
- Using /tts commands
|
||||
title: "Text-to-Speech (legacy path)"
|
||||
title: "Text-to-Speech"
|
||||
redirect: /tools/tts
|
||||
---
|
||||
|
||||
# Text-to-speech (TTS)
|
||||
|
||||
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, MiniMax, or OpenAI.
|
||||
It works anywhere OpenClaw can send audio.
|
||||
|
||||
## Supported services
|
||||
|
||||
- **ElevenLabs** (primary or fallback provider)
|
||||
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
|
||||
- **MiniMax** (primary or fallback provider; uses the T2A v2 API)
|
||||
- **OpenAI** (primary or fallback provider; also used for summaries)
|
||||
|
||||
### Microsoft speech notes
|
||||
|
||||
The bundled Microsoft speech provider currently uses Microsoft Edge's online
|
||||
neural TTS service via the `node-edge-tts` library. It's a hosted service (not
|
||||
local), uses Microsoft endpoints, and does not require an API key.
|
||||
`node-edge-tts` exposes speech configuration options and output formats, but
|
||||
not all options are supported by the service. Legacy config and directive input
|
||||
using `edge` still works and is normalized to `microsoft`.
|
||||
|
||||
Because this path is a public web service without a published SLA or quota,
|
||||
treat it as best-effort. If you need guaranteed limits and support, use OpenAI
|
||||
or ElevenLabs.
|
||||
|
||||
## Optional keys
|
||||
|
||||
If you want OpenAI, ElevenLabs, or MiniMax:
|
||||
|
||||
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
|
||||
- `MINIMAX_API_KEY`
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
Microsoft speech does **not** require an API key.
|
||||
|
||||
If multiple providers are configured, the selected provider is used first and the others are fallback options.
|
||||
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
|
||||
so that provider must also be authenticated if you enable summaries.
|
||||
|
||||
## Service links
|
||||
|
||||
- [OpenAI Text-to-Speech guide](https://platform.openai.com/docs/guides/text-to-speech)
|
||||
- [OpenAI Audio API reference](https://platform.openai.com/docs/api-reference/audio)
|
||||
- [ElevenLabs Text to Speech](https://elevenlabs.io/docs/api-reference/text-to-speech)
|
||||
- [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication)
|
||||
- [MiniMax T2A v2 API](https://platform.minimaxi.com/document/T2A%20V2)
|
||||
- [node-edge-tts](https://github.com/SchneeHertz/node-edge-tts)
|
||||
- [Microsoft Speech output formats](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech#audio-outputs)
|
||||
|
||||
## Is it enabled by default?
|
||||
|
||||
No. Auto‑TTS is **off** by default. Enable it in config with
|
||||
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
|
||||
|
||||
When `messages.tts.provider` is unset, OpenClaw picks the first configured
|
||||
speech provider in registry auto-select order.
|
||||
|
||||
## Config
|
||||
|
||||
TTS config lives under `messages.tts` in `openclaw.json`.
|
||||
Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
|
||||
### Minimal config (enable + provider)
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "elevenlabs",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAI primary with ElevenLabs fallback
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "openai",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
modelOverrides: {
|
||||
enabled: true,
|
||||
},
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "openai_api_key",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy",
|
||||
},
|
||||
elevenlabs: {
|
||||
apiKey: "elevenlabs_api_key",
|
||||
baseUrl: "https://api.elevenlabs.io",
|
||||
voiceId: "voice_id",
|
||||
modelId: "eleven_multilingual_v2",
|
||||
seed: 42,
|
||||
applyTextNormalization: "auto",
|
||||
languageCode: "en",
|
||||
voiceSettings: {
|
||||
stability: 0.5,
|
||||
similarityBoost: 0.75,
|
||||
style: 0.0,
|
||||
useSpeakerBoost: true,
|
||||
speed: 1.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Microsoft primary (no API key)
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "microsoft",
|
||||
providers: {
|
||||
microsoft: {
|
||||
enabled: true,
|
||||
voice: "en-US-MichelleNeural",
|
||||
lang: "en-US",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
rate: "+10%",
|
||||
pitch: "-5%",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### MiniMax primary
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "minimax",
|
||||
providers: {
|
||||
minimax: {
|
||||
apiKey: "minimax_api_key",
|
||||
baseUrl: "https://api.minimax.io",
|
||||
model: "speech-2.8-hd",
|
||||
voiceId: "English_expressive_narrator",
|
||||
speed: 1.0,
|
||||
vol: 1.0,
|
||||
pitch: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Disable Microsoft speech
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
providers: {
|
||||
microsoft: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Custom limits + prefs path
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
maxTextLength: 4000,
|
||||
timeoutMs: 30000,
|
||||
prefsPath: "~/.openclaw/settings/tts.json",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Only reply with audio after an inbound voice message
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "inbound",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Disable auto-summary for long replies
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```
|
||||
/tts summary off
|
||||
```
|
||||
|
||||
### Notes on fields
|
||||
|
||||
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
|
||||
- `inbound` only sends audio after an inbound voice message.
|
||||
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
|
||||
- `enabled`: legacy toggle (doctor migrates this to `auto`).
|
||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||
- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, `"minimax"`, or `"openai"` (fallback is automatic).
|
||||
- If `provider` is **unset**, OpenClaw uses the first configured speech provider in registry auto-select order.
|
||||
- Legacy `provider: "edge"` still works and is normalized to `microsoft`.
|
||||
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
|
||||
- Accepts `provider/model` or a configured model alias.
|
||||
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
||||
- `allowProvider` defaults to `false` (provider switching is opt-in).
|
||||
- `providers.<id>`: provider-owned settings keyed by speech provider id.
|
||||
- Legacy direct provider blocks (`messages.tts.openai`, `messages.tts.elevenlabs`, `messages.tts.microsoft`, `messages.tts.edge`) are auto-migrated to `messages.tts.providers.<id>` on load.
|
||||
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
||||
- `timeoutMs`: request timeout (ms).
|
||||
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
|
||||
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `MINIMAX_API_KEY`, `OPENAI_API_KEY`).
|
||||
- `providers.elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
||||
- `providers.openai.baseUrl`: override the OpenAI TTS endpoint.
|
||||
- Resolution order: `messages.tts.providers.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
|
||||
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
|
||||
- `providers.elevenlabs.voiceSettings`:
|
||||
- `stability`, `similarityBoost`, `style`: `0..1`
|
||||
- `useSpeakerBoost`: `true|false`
|
||||
- `speed`: `0.5..2.0` (1.0 = normal)
|
||||
- `providers.elevenlabs.applyTextNormalization`: `auto|on|off`
|
||||
- `providers.elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
|
||||
- `providers.elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
|
||||
- `providers.minimax.baseUrl`: override MiniMax API base URL (default `https://api.minimax.io`, env: `MINIMAX_API_HOST`).
|
||||
- `providers.minimax.model`: TTS model (default `speech-2.8-hd`, env: `MINIMAX_TTS_MODEL`).
|
||||
- `providers.minimax.voiceId`: voice identifier (default `English_expressive_narrator`, env: `MINIMAX_TTS_VOICE_ID`).
|
||||
- `providers.minimax.speed`: playback speed `0.5..2.0` (default 1.0).
|
||||
- `providers.minimax.vol`: volume `(0, 10]` (default 1.0; must be greater than 0).
|
||||
- `providers.minimax.pitch`: pitch shift `-12..12` (default 0).
|
||||
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
|
||||
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
|
||||
- `providers.microsoft.lang`: language code (e.g. `en-US`).
|
||||
- `providers.microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
|
||||
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
|
||||
- `providers.microsoft.rate` / `providers.microsoft.pitch` / `providers.microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
|
||||
- `providers.microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
|
||||
- `providers.microsoft.proxy`: proxy URL for Microsoft speech requests.
|
||||
- `providers.microsoft.timeoutMs`: request timeout override (ms).
|
||||
- `edge.*`: legacy alias for the same Microsoft settings.
|
||||
|
||||
## Model-driven overrides (default on)
|
||||
|
||||
By default, the model **can** emit TTS directives for a single reply.
|
||||
When `messages.tts.auto` is `tagged`, these directives are required to trigger audio.
|
||||
|
||||
When enabled, the model can emit `[[tts:...]]` directives to override the voice
|
||||
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
|
||||
provide expressive tags (laughter, singing cues, etc) that should only appear in
|
||||
the audio.
|
||||
|
||||
`provider=...` directives are ignored unless `modelOverrides.allowProvider: true`.
|
||||
|
||||
Example reply payload:
|
||||
|
||||
```
|
||||
Here you go.
|
||||
|
||||
[[tts:voiceId=pMsXgVXv3BLzUgSXRplE model=eleven_v3 speed=1.1]]
|
||||
[[tts:text]](laughs) Read the song once more.[[/tts:text]]
|
||||
```
|
||||
|
||||
Available directive keys (when enabled):
|
||||
|
||||
- `provider` (registered speech provider id, for example `openai`, `elevenlabs`, `minimax`, or `microsoft`; requires `allowProvider: true`)
|
||||
- `voice` (OpenAI voice) or `voiceId` (ElevenLabs / MiniMax)
|
||||
- `model` (OpenAI TTS model, ElevenLabs model id, or MiniMax model)
|
||||
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
|
||||
- `vol` / `volume` (MiniMax volume, 0-10)
|
||||
- `pitch` (MiniMax pitch, -12 to 12)
|
||||
- `applyTextNormalization` (`auto|on|off`)
|
||||
- `languageCode` (ISO 639-1)
|
||||
- `seed`
|
||||
|
||||
Disable all model overrides:
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
modelOverrides: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Optional allowlist (enable provider switching while keeping other knobs configurable):
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
modelOverrides: {
|
||||
enabled: true,
|
||||
allowProvider: true,
|
||||
allowSeed: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Per-user preferences
|
||||
|
||||
Slash commands write local overrides to `prefsPath` (default:
|
||||
`~/.openclaw/settings/tts.json`, override with `OPENCLAW_TTS_PREFS` or
|
||||
`messages.tts.prefsPath`).
|
||||
|
||||
Stored fields:
|
||||
|
||||
- `enabled`
|
||||
- `provider`
|
||||
- `maxLength` (summary threshold; default 1500 chars)
|
||||
- `summarize` (default `true`)
|
||||
|
||||
These override `messages.tts.*` for that host.
|
||||
|
||||
## Output formats (fixed)
|
||||
|
||||
- **Feishu / Matrix / Telegram / WhatsApp**: Opus voice message (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
|
||||
- 48kHz / 64kbps is a good voice message tradeoff.
|
||||
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
|
||||
- 44.1kHz / 128kbps is the default balance for speech clarity.
|
||||
- **MiniMax**: MP3 (`speech-2.8-hd` model, 32kHz sample rate). Voice-note format not natively supported; use OpenAI or ElevenLabs for guaranteed Opus voice messages.
|
||||
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
|
||||
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
|
||||
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).
|
||||
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
|
||||
guaranteed Opus voice messages.
|
||||
- If the configured Microsoft output format fails, OpenClaw retries with MP3.
|
||||
|
||||
OpenAI/ElevenLabs output formats are fixed per channel (see above).
|
||||
|
||||
## Auto-TTS behavior
|
||||
|
||||
When enabled, OpenClaw:
|
||||
|
||||
- skips TTS if the reply already contains media or a `MEDIA:` directive.
|
||||
- skips very short replies (< 10 chars).
|
||||
- summarizes long replies when enabled using `agents.defaults.model.primary` (or `summaryModel`).
|
||||
- attaches the generated audio to the reply.
|
||||
|
||||
If the reply exceeds `maxLength` and summary is off (or no API key for the
|
||||
summary model), audio
|
||||
is skipped and the normal text reply is sent.
|
||||
|
||||
## Flow diagram
|
||||
|
||||
```
|
||||
Reply -> TTS enabled?
|
||||
no -> send text
|
||||
yes -> has media / MEDIA: / short?
|
||||
yes -> send text
|
||||
no -> length > limit?
|
||||
no -> TTS -> attach audio
|
||||
yes -> summary enabled?
|
||||
no -> send text
|
||||
yes -> summarize (summaryModel or agents.defaults.model.primary)
|
||||
-> TTS -> attach audio
|
||||
```
|
||||
|
||||
## Slash command usage
|
||||
|
||||
There is a single command: `/tts`.
|
||||
See [Slash commands](/tools/slash-commands) for enablement details.
|
||||
|
||||
Discord note: `/tts` is a built-in Discord command, so OpenClaw registers
|
||||
`/voice` as the native command there. Text `/tts ...` still works.
|
||||
|
||||
```
|
||||
/tts off
|
||||
/tts always
|
||||
/tts inbound
|
||||
/tts tagged
|
||||
/tts status
|
||||
/tts provider openai
|
||||
/tts limit 2000
|
||||
/tts summary off
|
||||
/tts audio Hello from OpenClaw
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Commands require an authorized sender (allowlist/owner rules still apply).
|
||||
- `commands.text` or native command registration must be enabled.
|
||||
- `off|always|inbound|tagged` are per‑session toggles (`/tts on` is an alias for `/tts always`).
|
||||
- `limit` and `summary` are stored in local prefs, not the main config.
|
||||
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
|
||||
- `/tts status` includes fallback visibility for the latest attempt:
|
||||
- success fallback: `Fallback: <primary> -> <used>` plus `Attempts: ...`
|
||||
- failure: `Error: ...` plus `Attempts: ...`
|
||||
- detailed diagnostics: `Attempt details: provider:outcome(reasonCode) latency`
|
||||
- OpenAI and ElevenLabs API failures now include parsed provider error detail and request id (when returned by the provider), which is surfaced in TTS errors/logs.
|
||||
|
||||
## Agent tool
|
||||
|
||||
The `tts` tool converts text to speech and returns an audio attachment for
|
||||
reply delivery. When the channel is Feishu, Matrix, Telegram, or WhatsApp,
|
||||
the audio is delivered as a voice message rather than a file attachment.
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
Gateway methods:
|
||||
|
||||
- `tts.status`
|
||||
- `tts.enable`
|
||||
- `tts.disable`
|
||||
- `tts.convert`
|
||||
- `tts.setProvider`
|
||||
- `tts.providers`
|
||||
This page has moved to [Text-to-Speech](/tools/tts).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.4.5",
|
||||
"version": "2026.4.6",
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"acpx": "0.5.0"
|
||||
"acpx": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
56
extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
Normal file
56
extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
declare module "acpx/runtime" {
|
||||
export const ACPX_BACKEND_ID: string;
|
||||
|
||||
export type AcpRuntimeDoctorReport = import("../runtime-api.js").AcpRuntimeDoctorReport;
|
||||
export type AcpRuntimeEnsureInput = import("../runtime-api.js").AcpRuntimeEnsureInput;
|
||||
export type AcpRuntimeEvent = import("../runtime-api.js").AcpRuntimeEvent;
|
||||
export type AcpRuntimeHandle = import("../runtime-api.js").AcpRuntimeHandle;
|
||||
export type AcpRuntimeCapabilities = import("../runtime-api.js").AcpRuntimeCapabilities;
|
||||
export type AcpRuntimeStatus = import("../runtime-api.js").AcpRuntimeStatus;
|
||||
export type AcpRuntimeTurnInput = import("../runtime-api.js").AcpRuntimeTurnInput;
|
||||
|
||||
export type AcpAgentRegistry = {
|
||||
resolve(agent: string): string | undefined;
|
||||
list(): string[];
|
||||
};
|
||||
|
||||
export type AcpSessionRecord = Record<string, unknown>;
|
||||
|
||||
export type AcpSessionStore = {
|
||||
load(sessionId: string): Promise<AcpSessionRecord | undefined>;
|
||||
save(record: AcpSessionRecord): Promise<void>;
|
||||
};
|
||||
|
||||
export type AcpRuntimeOptions = {
|
||||
cwd: string;
|
||||
sessionStore: AcpSessionStore;
|
||||
agentRegistry: AcpAgentRegistry;
|
||||
mcpServers?: unknown;
|
||||
permissionMode?: unknown;
|
||||
nonInteractivePermissions?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export class AcpxRuntime {
|
||||
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
|
||||
isHealthy(): boolean;
|
||||
probeAvailability(): Promise<void>;
|
||||
doctor(): Promise<AcpRuntimeDoctorReport>;
|
||||
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
|
||||
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
|
||||
getCapabilities(input?: {
|
||||
handle?: AcpRuntimeHandle;
|
||||
}): AcpRuntimeCapabilities | Promise<AcpRuntimeCapabilities>;
|
||||
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
|
||||
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
|
||||
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
|
||||
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
close(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;
|
||||
export function createAgentRegistry(params: { overrides?: unknown }): AcpAgentRegistry;
|
||||
export function createFileSessionStore(params: { stateDir: string }): AcpSessionStore;
|
||||
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseControlJsonError } from "./control-errors.js";
|
||||
|
||||
describe("parseControlJsonError", () => {
|
||||
it("reads structured control-command errors", () => {
|
||||
expect(
|
||||
parseControlJsonError({
|
||||
error: {
|
||||
code: "NO_SESSION",
|
||||
message: "No matching session",
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
code: "NO_SESSION",
|
||||
message: "No matching session",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when payload has no error object", () => {
|
||||
expect(parseControlJsonError({ action: "session_ensured" })).toBeNull();
|
||||
expect(parseControlJsonError("bad")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import {
|
||||
asOptionalBoolean,
|
||||
asOptionalString,
|
||||
asTrimmedString,
|
||||
type AcpxErrorEvent,
|
||||
isRecord,
|
||||
} from "./shared.js";
|
||||
|
||||
export function parseControlJsonError(value: unknown): AcpxErrorEvent | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const error = isRecord(value.error) ? value.error : null;
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
const message = asTrimmedString(error.message) || "acpx reported an error";
|
||||
const codeValue = error.code;
|
||||
return {
|
||||
message,
|
||||
code:
|
||||
typeof codeValue === "number" && Number.isFinite(codeValue)
|
||||
? String(codeValue)
|
||||
: asOptionalString(codeValue),
|
||||
retryable: asOptionalBoolean(error.retryable),
|
||||
};
|
||||
}
|
||||
6
extensions/acpx/src/runtime-internals/error-format.mjs
Normal file
6
extensions/acpx/src/runtime-internals/error-format.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
export function formatErrorMessage(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name || "Error";
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePromptEventLine } from "./events.js";
|
||||
|
||||
describe("parsePromptEventLine", () => {
|
||||
it("parses raw ACP session/update agent_message_chunk lines", () => {
|
||||
const line = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "s1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsePromptEventLine(line)).toEqual({
|
||||
type: "text_delta",
|
||||
text: "hello",
|
||||
stream: "output",
|
||||
tag: "agent_message_chunk",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses usage_update with stable metadata", () => {
|
||||
const line = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "s1",
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 12,
|
||||
size: 500,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsePromptEventLine(line)).toEqual({
|
||||
type: "status",
|
||||
text: "usage updated: 12/500",
|
||||
tag: "usage_update",
|
||||
used: 12,
|
||||
size: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses tool_call_update without using call ids as primary fallback label", () => {
|
||||
const line = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "s1",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call_ABC123",
|
||||
status: "in_progress",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsePromptEventLine(line)).toEqual({
|
||||
type: "tool_call",
|
||||
text: "tool call (in_progress)",
|
||||
tag: "tool_call_update",
|
||||
toolCallId: "call_ABC123",
|
||||
status: "in_progress",
|
||||
title: "tool call",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps compatibility with simplified text/done lines", () => {
|
||||
expect(parsePromptEventLine(JSON.stringify({ type: "text", content: "alpha" }))).toEqual({
|
||||
type: "text_delta",
|
||||
text: "alpha",
|
||||
stream: "output",
|
||||
});
|
||||
expect(parsePromptEventLine(JSON.stringify({ type: "done", stopReason: "end_turn" }))).toEqual({
|
||||
type: "done",
|
||||
stopReason: "end_turn",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { z } from "zod";
|
||||
import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "../../runtime-api.js";
|
||||
import {
|
||||
asOptionalBoolean,
|
||||
asOptionalString,
|
||||
asString,
|
||||
asTrimmedString,
|
||||
type AcpxErrorEvent,
|
||||
type AcpxJsonObject,
|
||||
isRecord,
|
||||
} from "./shared.js";
|
||||
|
||||
const AcpxJsonObjectSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const AcpxErrorEventSchema = z.object({
|
||||
type: z.literal("error"),
|
||||
message: z.string().trim().min(1).catch("acpx reported an error"),
|
||||
code: z.string().optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
|
||||
const parsed = AcpxErrorEventSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function parseJsonLines(value: string): AcpxJsonObject[] {
|
||||
const events: AcpxJsonObject[] = [];
|
||||
for (const line of value.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const parsed = safeParseJsonWithSchema(AcpxJsonObjectSchema, trimmed);
|
||||
if (parsed) {
|
||||
events.push(parsed);
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function asOptionalFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveStructuredPromptPayload(parsed: Record<string, unknown>): {
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
tag?: AcpSessionUpdateTag;
|
||||
} {
|
||||
const method = asTrimmedString(parsed.method);
|
||||
if (method === "session/update") {
|
||||
const params = parsed.params;
|
||||
if (isRecord(params) && isRecord(params.update)) {
|
||||
const update = params.update;
|
||||
const tag = asOptionalString(update.sessionUpdate) as AcpSessionUpdateTag | undefined;
|
||||
return {
|
||||
type: tag ?? "",
|
||||
payload: update,
|
||||
...(tag ? { tag } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const sessionUpdate = asOptionalString(parsed.sessionUpdate) as AcpSessionUpdateTag | undefined;
|
||||
if (sessionUpdate) {
|
||||
return {
|
||||
type: sessionUpdate,
|
||||
payload: parsed,
|
||||
tag: sessionUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
const type = asTrimmedString(parsed.type);
|
||||
const tag = asOptionalString(parsed.tag) as AcpSessionUpdateTag | undefined;
|
||||
return {
|
||||
type,
|
||||
payload: parsed,
|
||||
...(tag ? { tag } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStatusTextForTag(params: {
|
||||
tag: AcpSessionUpdateTag;
|
||||
payload: Record<string, unknown>;
|
||||
}): string | null {
|
||||
const { tag, payload } = params;
|
||||
if (tag === "available_commands_update") {
|
||||
const commands = Array.isArray(payload.availableCommands) ? payload.availableCommands : [];
|
||||
return commands.length > 0
|
||||
? `available commands updated (${commands.length})`
|
||||
: "available commands updated";
|
||||
}
|
||||
if (tag === "current_mode_update") {
|
||||
const mode =
|
||||
asTrimmedString(payload.currentModeId) ||
|
||||
asTrimmedString(payload.modeId) ||
|
||||
asTrimmedString(payload.mode);
|
||||
return mode ? `mode updated: ${mode}` : "mode updated";
|
||||
}
|
||||
if (tag === "config_option_update") {
|
||||
const id = asTrimmedString(payload.id) || asTrimmedString(payload.configOptionId);
|
||||
const value =
|
||||
asTrimmedString(payload.currentValue) ||
|
||||
asTrimmedString(payload.value) ||
|
||||
asTrimmedString(payload.optionValue);
|
||||
if (id && value) {
|
||||
return `config updated: ${id}=${value}`;
|
||||
}
|
||||
if (id) {
|
||||
return `config updated: ${id}`;
|
||||
}
|
||||
return "config updated";
|
||||
}
|
||||
if (tag === "session_info_update") {
|
||||
return (
|
||||
asTrimmedString(payload.summary) || asTrimmedString(payload.message) || "session updated"
|
||||
);
|
||||
}
|
||||
if (tag === "plan") {
|
||||
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
||||
const first = entries.find((entry) => isRecord(entry));
|
||||
const content = asTrimmedString(first?.content);
|
||||
return content ? `plan: ${content}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveTextChunk(params: {
|
||||
payload: Record<string, unknown>;
|
||||
stream: "output" | "thought";
|
||||
tag: AcpSessionUpdateTag;
|
||||
}): AcpRuntimeEvent | null {
|
||||
const contentRaw = params.payload.content;
|
||||
if (isRecord(contentRaw)) {
|
||||
const contentType = asTrimmedString(contentRaw.type);
|
||||
if (contentType && contentType !== "text") {
|
||||
return null;
|
||||
}
|
||||
const text = asString(contentRaw.text);
|
||||
if (text && text.length > 0) {
|
||||
return {
|
||||
type: "text_delta",
|
||||
text,
|
||||
stream: params.stream,
|
||||
tag: params.tag,
|
||||
};
|
||||
}
|
||||
}
|
||||
const text = asString(params.payload.text);
|
||||
if (!text || text.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "text_delta",
|
||||
text,
|
||||
stream: params.stream,
|
||||
tag: params.tag,
|
||||
};
|
||||
}
|
||||
|
||||
function createTextDeltaEvent(params: {
|
||||
content: string | null | undefined;
|
||||
stream: "output" | "thought";
|
||||
tag?: AcpSessionUpdateTag;
|
||||
}): AcpRuntimeEvent | null {
|
||||
if (params.content == null || params.content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "text_delta",
|
||||
text: params.content,
|
||||
stream: params.stream,
|
||||
...(params.tag ? { tag: params.tag } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createToolCallEvent(params: {
|
||||
payload: Record<string, unknown>;
|
||||
tag: AcpSessionUpdateTag;
|
||||
}): AcpRuntimeEvent {
|
||||
const title = asTrimmedString(params.payload.title) || "tool call";
|
||||
const status = asTrimmedString(params.payload.status);
|
||||
const toolCallId = asOptionalString(params.payload.toolCallId);
|
||||
return {
|
||||
type: "tool_call",
|
||||
text: status ? `${title} (${status})` : title,
|
||||
tag: params.tag,
|
||||
...(toolCallId ? { toolCallId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const parsed = safeParseJsonWithSchema(AcpxJsonObjectSchema, trimmed);
|
||||
if (!parsed) {
|
||||
return {
|
||||
type: "status",
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
const structured = resolveStructuredPromptPayload(parsed);
|
||||
const type = structured.type;
|
||||
const payload = structured.payload;
|
||||
const tag = structured.tag;
|
||||
|
||||
switch (type) {
|
||||
case "text":
|
||||
return createTextDeltaEvent({
|
||||
content: asString(payload.content),
|
||||
stream: "output",
|
||||
tag,
|
||||
});
|
||||
case "thought":
|
||||
return createTextDeltaEvent({
|
||||
content: asString(payload.content),
|
||||
stream: "thought",
|
||||
tag,
|
||||
});
|
||||
case "tool_call":
|
||||
return createToolCallEvent({
|
||||
payload,
|
||||
tag: tag ?? "tool_call",
|
||||
});
|
||||
case "tool_call_update":
|
||||
return createToolCallEvent({
|
||||
payload,
|
||||
tag: tag ?? "tool_call_update",
|
||||
});
|
||||
case "agent_message_chunk":
|
||||
return resolveTextChunk({
|
||||
payload,
|
||||
stream: "output",
|
||||
tag: "agent_message_chunk",
|
||||
});
|
||||
case "agent_thought_chunk":
|
||||
return resolveTextChunk({
|
||||
payload,
|
||||
stream: "thought",
|
||||
tag: "agent_thought_chunk",
|
||||
});
|
||||
case "usage_update": {
|
||||
const used = asOptionalFiniteNumber(payload.used);
|
||||
const size = asOptionalFiniteNumber(payload.size);
|
||||
const text =
|
||||
used != null && size != null ? `usage updated: ${used}/${size}` : "usage updated";
|
||||
return {
|
||||
type: "status",
|
||||
text,
|
||||
tag: "usage_update",
|
||||
...(used != null ? { used } : {}),
|
||||
...(size != null ? { size } : {}),
|
||||
};
|
||||
}
|
||||
case "available_commands_update":
|
||||
case "current_mode_update":
|
||||
case "config_option_update":
|
||||
case "session_info_update":
|
||||
case "plan": {
|
||||
const text = resolveStatusTextForTag({
|
||||
tag: type as AcpSessionUpdateTag,
|
||||
payload,
|
||||
});
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "status",
|
||||
text,
|
||||
tag: type as AcpSessionUpdateTag,
|
||||
};
|
||||
}
|
||||
case "client_operation": {
|
||||
const method = asTrimmedString(payload.method) || "operation";
|
||||
const status = asTrimmedString(payload.status);
|
||||
const summary = asTrimmedString(payload.summary);
|
||||
const text = [method, status, summary].filter(Boolean).join(" ");
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return { type: "status", text, ...(tag ? { tag } : {}) };
|
||||
}
|
||||
case "update": {
|
||||
const update = asTrimmedString(payload.update);
|
||||
if (!update) {
|
||||
return null;
|
||||
}
|
||||
return { type: "status", text: update, ...(tag ? { tag } : {}) };
|
||||
}
|
||||
case "done": {
|
||||
return {
|
||||
type: "done",
|
||||
stopReason: asOptionalString(payload.stopReason),
|
||||
};
|
||||
}
|
||||
case "error": {
|
||||
const message = asTrimmedString(payload.message) || "acpx runtime error";
|
||||
return {
|
||||
type: "error",
|
||||
message,
|
||||
code: asOptionalString(payload.code),
|
||||
retryable: asOptionalBoolean(payload.retryable),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAcpJsonRpcMessage, isJsonRpcId, normalizeJsonRpcId } from "./jsonrpc.js";
|
||||
|
||||
describe("jsonrpc helpers", () => {
|
||||
it("validates json-rpc ids", () => {
|
||||
expect(isJsonRpcId(null)).toBe(true);
|
||||
expect(isJsonRpcId("abc")).toBe(true);
|
||||
expect(isJsonRpcId(12)).toBe(true);
|
||||
expect(isJsonRpcId(Number.NaN)).toBe(false);
|
||||
expect(isJsonRpcId({})).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes json-rpc ids", () => {
|
||||
expect(normalizeJsonRpcId("abc")).toBe("abc");
|
||||
expect(normalizeJsonRpcId(12)).toBe("12");
|
||||
expect(normalizeJsonRpcId(null)).toBeNull();
|
||||
expect(normalizeJsonRpcId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts request, response, and notification shapes", () => {
|
||||
expect(
|
||||
isAcpJsonRpcMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/prompt",
|
||||
id: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isAcpJsonRpcMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
stopReason: "end_turn",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isAcpJsonRpcMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed result/error response shapes", () => {
|
||||
expect(
|
||||
isAcpJsonRpcMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isAcpJsonRpcMessage({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {},
|
||||
error: {
|
||||
code: -1,
|
||||
message: "bad",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { isRecord } from "./shared.js";
|
||||
|
||||
export type JsonRpcId = string | number | null;
|
||||
|
||||
function hasExclusiveResultOrError(value: Record<string, unknown>): boolean {
|
||||
const hasResult = Object.hasOwn(value, "result");
|
||||
const hasError = Object.hasOwn(value, "error");
|
||||
return hasResult !== hasError;
|
||||
}
|
||||
|
||||
export function isJsonRpcId(value: unknown): value is JsonRpcId {
|
||||
return (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
(typeof value === "number" && Number.isFinite(value))
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeJsonRpcId(value: unknown): string | null {
|
||||
if (!isJsonRpcId(value) || value == null) {
|
||||
return null;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function isAcpJsonRpcMessage(value: unknown): value is Record<string, unknown> {
|
||||
if (!isRecord(value) || value.jsonrpc !== "2.0") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasMethod = typeof value.method === "string" && value.method.length > 0;
|
||||
const hasId = Object.hasOwn(value, "id");
|
||||
|
||||
if (hasMethod && !hasId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasMethod && hasId) {
|
||||
return isJsonRpcId(value.id);
|
||||
}
|
||||
|
||||
if (!hasMethod && hasId) {
|
||||
return isJsonRpcId(value.id) && hasExclusiveResultOrError(value);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { spawnAndCollectMock } = vi.hoisted(() => ({
|
||||
spawnAndCollectMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./process.js", () => ({
|
||||
spawnAndCollect: spawnAndCollectMock,
|
||||
}));
|
||||
|
||||
import { __testing, resolveAcpxAgentCommand } from "./mcp-agent-command.js";
|
||||
|
||||
describe("resolveAcpxAgentCommand", () => {
|
||||
it.each([
|
||||
["cursor", "cursor-agent acp"],
|
||||
["gemini", "gemini --acp"],
|
||||
["openclaw", "openclaw acp"],
|
||||
["copilot", "copilot --acp --stdio"],
|
||||
["pi", "npx -y pi-acp@0.0.22"],
|
||||
["codex", "npx -y @zed-industries/codex-acp@0.9.5"],
|
||||
["claude", "npx -y @zed-industries/claude-agent-acp@0.21.0"],
|
||||
])("uses the current acpx built-in for %s by default", async (agent, expected) => {
|
||||
spawnAndCollectMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ agents: {} }),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const command = await resolveAcpxAgentCommand({
|
||||
acpxCommand: "/plugin/node_modules/.bin/acpx",
|
||||
cwd: "/plugin",
|
||||
agent,
|
||||
});
|
||||
|
||||
expect(command).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns null for unknown agent ids instead of falling back to raw commands", async () => {
|
||||
spawnAndCollectMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ agents: {} }),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const command = await resolveAcpxAgentCommand({
|
||||
acpxCommand: "/plugin/node_modules/.bin/acpx",
|
||||
cwd: "/plugin",
|
||||
agent: "sh -c whoami",
|
||||
});
|
||||
|
||||
expect(command).toBeNull();
|
||||
});
|
||||
|
||||
it("threads stripProviderAuthEnvVars through the config show probe", async () => {
|
||||
spawnAndCollectMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
agents: {
|
||||
codex: {
|
||||
command: "custom-codex",
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const command = await resolveAcpxAgentCommand({
|
||||
acpxCommand: "/plugin/node_modules/.bin/acpx",
|
||||
cwd: "/plugin",
|
||||
agent: "codex",
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
|
||||
expect(command).toBe("custom-codex");
|
||||
expect(spawnAndCollectMock).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
args: ["--cwd", "/plugin", "config", "show"],
|
||||
cwd: "/plugin",
|
||||
stripProviderAuthEnvVars: true,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMcpProxyAgentCommand", () => {
|
||||
it("escapes Windows-style proxy paths without double-escaping backslashes", () => {
|
||||
const quoted = __testing.quoteCommandPart(
|
||||
"C:\\repo\\extensions\\acpx\\src\\runtime-internals\\mcp-proxy.mjs",
|
||||
);
|
||||
|
||||
expect(quoted).toBe(
|
||||
'"C:\\\\repo\\\\extensions\\\\acpx\\\\src\\\\runtime-internals\\\\mcp-proxy.mjs"',
|
||||
);
|
||||
expect(quoted).not.toContain("\\\\\\");
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnAndCollect, type SpawnCommandOptions } from "./process.js";
|
||||
|
||||
// Keep this mirror aligned with openclaw/acpx src/agent-registry.ts built-ins.
|
||||
const ACPX_BUILTIN_AGENT_COMMANDS: Record<string, string> = {
|
||||
pi: "npx -y pi-acp@0.0.22",
|
||||
openclaw: "openclaw acp",
|
||||
codex: "npx -y @zed-industries/codex-acp@0.9.5",
|
||||
claude: "npx -y @zed-industries/claude-agent-acp@0.21.0",
|
||||
gemini: "gemini --acp",
|
||||
cursor: "cursor-agent acp",
|
||||
copilot: "copilot --acp --stdio",
|
||||
droid: "droid exec --output-format acp",
|
||||
iflow: "iflow --experimental-acp",
|
||||
kilocode: "npx -y @kilocode/cli acp",
|
||||
kimi: "kimi acp",
|
||||
kiro: "kiro-cli acp",
|
||||
opencode: "npx -y opencode-ai acp",
|
||||
qwen: "qwen --acp",
|
||||
};
|
||||
|
||||
const MCP_PROXY_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "mcp-proxy.mjs");
|
||||
|
||||
type AcpxConfigDisplay = {
|
||||
agents?: Record<string, { command?: unknown }>;
|
||||
};
|
||||
|
||||
type AcpMcpServer = {
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Array<{ name: string; value: string }>;
|
||||
};
|
||||
|
||||
function normalizeAgentName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function quoteCommandPart(value: string): string {
|
||||
if (value === "") {
|
||||
return '""';
|
||||
}
|
||||
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return `"${value.replace(/["\\]/g, "\\$&")}"`;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
quoteCommandPart,
|
||||
};
|
||||
|
||||
function toCommandLine(parts: string[]): string {
|
||||
return parts.map(quoteCommandPart).join(" ");
|
||||
}
|
||||
|
||||
function readConfiguredAgentOverrides(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [name, entry] of Object.entries(value)) {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
continue;
|
||||
}
|
||||
const command = (entry as { command?: unknown }).command;
|
||||
if (typeof command !== "string" || command.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
overrides[normalizeAgentName(name)] = command.trim();
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
async function loadAgentOverrides(params: {
|
||||
acpxCommand: string;
|
||||
cwd: string;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
spawnOptions?: SpawnCommandOptions;
|
||||
}): Promise<Record<string, string>> {
|
||||
const result = await spawnAndCollect(
|
||||
{
|
||||
command: params.acpxCommand,
|
||||
args: ["--cwd", params.cwd, "config", "show"],
|
||||
cwd: params.cwd,
|
||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||
},
|
||||
params.spawnOptions,
|
||||
);
|
||||
if (result.error || (result.code ?? 0) !== 0) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as AcpxConfigDisplay;
|
||||
return readConfiguredAgentOverrides(parsed.agents);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveAcpxAgentCommand(params: {
|
||||
acpxCommand: string;
|
||||
cwd: string;
|
||||
agent: string;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
spawnOptions?: SpawnCommandOptions;
|
||||
}): Promise<string | null> {
|
||||
const normalizedAgent = normalizeAgentName(params.agent);
|
||||
const overrides = await loadAgentOverrides({
|
||||
acpxCommand: params.acpxCommand,
|
||||
cwd: params.cwd,
|
||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||
spawnOptions: params.spawnOptions,
|
||||
});
|
||||
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? null;
|
||||
}
|
||||
|
||||
export function buildMcpProxyAgentCommand(params: {
|
||||
targetCommand: string;
|
||||
mcpServers: AcpMcpServer[];
|
||||
}): string {
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
targetCommand: params.targetCommand,
|
||||
mcpServers: params.mcpServers,
|
||||
}),
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
return toCommandLine([process.execPath, MCP_PROXY_PATH, "--payload", payload]);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { formatErrorMessage } from "./error-format.mjs";
|
||||
import { splitCommandLine } from "./mcp-command-line.mjs";
|
||||
|
||||
function decodePayload(argv) {
|
||||
@@ -94,7 +95,7 @@ function main() {
|
||||
child.stdout.pipe(process.stdout);
|
||||
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.stderr.write(`${formatErrorMessage(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,460 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createWindowsCmdShimFixture } from "../../../../src/test-helpers/windows-cmd-shim.js";
|
||||
import {
|
||||
resolveSpawnCommand,
|
||||
spawnAndCollect,
|
||||
type SpawnCommandCache,
|
||||
waitForExit,
|
||||
} from "./process.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function winRuntime(env: NodeJS.ProcessEnv) {
|
||||
return {
|
||||
platform: "win32" as const,
|
||||
env,
|
||||
execPath: "C:\\node\\node.exe",
|
||||
};
|
||||
}
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-acpx-process-test-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
await rm(dir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 8,
|
||||
retryDelay: 8,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveSpawnCommand", () => {
|
||||
it("keeps non-windows spawns unchanged", () => {
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
command: "acpx",
|
||||
args: ["--help"],
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
platform: "darwin",
|
||||
env: {},
|
||||
execPath: "/usr/bin/node",
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
command: "acpx",
|
||||
args: ["--help"],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes node shebang wrappers through the current node runtime on posix", async () => {
|
||||
const dir = await createTempDir();
|
||||
const scriptPath = path.join(dir, "acpx");
|
||||
await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
command: scriptPath,
|
||||
args: ["--help"],
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
platform: "linux",
|
||||
env: {},
|
||||
execPath: "/custom/node",
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
command: "/custom/node",
|
||||
args: [scriptPath, "--help"],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes PATH-resolved node shebang wrappers through the current node runtime on posix", async () => {
|
||||
const dir = await createTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
const scriptPath = path.join(binDir, "acpx");
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
command: "acpx",
|
||||
args: ["--help"],
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
platform: "linux",
|
||||
env: { PATH: binDir },
|
||||
execPath: "/custom/node",
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
command: "/custom/node",
|
||||
args: [scriptPath, "--help"],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to node on PATH when execPath is unavailable for a node shebang wrapper", async () => {
|
||||
const dir = await createTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
const scriptPath = path.join(binDir, "acpx");
|
||||
const nodePath = path.join(binDir, "node");
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8");
|
||||
await writeFile(nodePath, "#!/bin/sh\nexit 0\n", "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
await chmod(nodePath, 0o755);
|
||||
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
command: scriptPath,
|
||||
args: ["--help"],
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
platform: "darwin",
|
||||
env: { PATH: binDir },
|
||||
execPath: "/missing/node",
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
command: nodePath,
|
||||
args: [scriptPath, "--help"],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes .js command execution through node on windows", () => {
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
command: "C:/tools/acpx/cli.js",
|
||||
args: ["--help"],
|
||||
},
|
||||
undefined,
|
||||
winRuntime({}),
|
||||
);
|
||||
|
||||
expect(resolved.command).toBe("C:\\node\\node.exe");
|
||||
expect(resolved.args).toEqual(["C:/tools/acpx/cli.js", "--help"]);
|
||||
expect(resolved.shell).toBeUndefined();
|
||||
expect(resolved.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves a .cmd wrapper from PATH and unwraps shim entrypoint", async () => {
|
||||
const dir = await createTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
const scriptPath = path.join(dir, "acpx", "dist", "index.js");
|
||||
const shimPath = path.join(binDir, "acpx.cmd");
|
||||
await createWindowsCmdShimFixture({
|
||||
shimPath,
|
||||
scriptPath,
|
||||
shimLine: '"%~dp0\\..\\acpx\\dist\\index.js" %*',
|
||||
});
|
||||
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
command: "acpx",
|
||||
args: ["--format", "json", "agent", "status"],
|
||||
},
|
||||
undefined,
|
||||
winRuntime({
|
||||
PATH: binDir,
|
||||
PATHEXT: ".CMD;.EXE;.BAT",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolved.command).toBe("C:\\node\\node.exe");
|
||||
expect(resolved.args[0]).toBe(scriptPath);
|
||||
expect(resolved.args.slice(1)).toEqual(["--format", "json", "agent", "status"]);
|
||||
expect(resolved.shell).toBeUndefined();
|
||||
expect(resolved.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers executable shim targets without shell", async () => {
|
||||
const dir = await createTempDir();
|
||||
const wrapperPath = path.join(dir, "acpx.cmd");
|
||||
const exePath = path.join(dir, "acpx.exe");
|
||||
await writeFile(exePath, "", "utf8");
|
||||
await writeFile(wrapperPath, ["@ECHO off", '"%~dp0\\acpx.exe" %*', ""].join("\r\n"), "utf8");
|
||||
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
command: wrapperPath,
|
||||
args: ["--help"],
|
||||
},
|
||||
undefined,
|
||||
winRuntime({}),
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
command: exePath,
|
||||
args: ["--help"],
|
||||
windowsHide: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to shell mode when wrapper cannot be safely unwrapped", async () => {
|
||||
const dir = await createTempDir();
|
||||
const wrapperPath = path.join(dir, "custom-wrapper.cmd");
|
||||
await writeFile(wrapperPath, "@ECHO off\r\necho wrapper\r\n", "utf8");
|
||||
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
command: wrapperPath,
|
||||
args: ["--arg", "value"],
|
||||
},
|
||||
undefined,
|
||||
winRuntime({}),
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
command: wrapperPath,
|
||||
args: ["--arg", "value"],
|
||||
shell: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed in strict mode when wrapper cannot be safely unwrapped", async () => {
|
||||
const dir = await createTempDir();
|
||||
const wrapperPath = path.join(dir, "strict-wrapper.cmd");
|
||||
await writeFile(wrapperPath, "@ECHO off\r\necho wrapper\r\n", "utf8");
|
||||
|
||||
expect(() =>
|
||||
resolveSpawnCommand(
|
||||
{
|
||||
command: wrapperPath,
|
||||
args: ["--arg", "value"],
|
||||
},
|
||||
{ strictWindowsCmdWrapper: true },
|
||||
winRuntime({}),
|
||||
),
|
||||
).toThrow(/without shell execution/);
|
||||
});
|
||||
|
||||
it("fails closed for wrapper fallback when args include a malicious cwd payload", async () => {
|
||||
const dir = await createTempDir();
|
||||
const wrapperPath = path.join(dir, "strict-wrapper.cmd");
|
||||
await writeFile(wrapperPath, "@ECHO off\r\necho wrapper\r\n", "utf8");
|
||||
const payload = "C:\\safe & calc.exe";
|
||||
const events: Array<{ resolution: string }> = [];
|
||||
|
||||
expect(() =>
|
||||
resolveSpawnCommand(
|
||||
{
|
||||
command: wrapperPath,
|
||||
args: ["--cwd", payload, "agent", "status"],
|
||||
},
|
||||
{
|
||||
strictWindowsCmdWrapper: true,
|
||||
onResolved: (event) => {
|
||||
events.push({ resolution: event.resolution });
|
||||
},
|
||||
},
|
||||
winRuntime({}),
|
||||
),
|
||||
).toThrow(/without shell execution/);
|
||||
expect(events).toEqual([{ resolution: "unresolved-wrapper" }]);
|
||||
});
|
||||
|
||||
it("reuses resolved command when cache is provided", async () => {
|
||||
const dir = await createTempDir();
|
||||
const wrapperPath = path.join(dir, "acpx.cmd");
|
||||
const scriptPath = path.join(dir, "acpx", "dist", "index.js");
|
||||
await createWindowsCmdShimFixture({
|
||||
shimPath: wrapperPath,
|
||||
scriptPath,
|
||||
shimLine: '"%~dp0\\acpx\\dist\\index.js" %*',
|
||||
});
|
||||
|
||||
const cache: SpawnCommandCache = {};
|
||||
const first = resolveSpawnCommand(
|
||||
{
|
||||
command: wrapperPath,
|
||||
args: ["--help"],
|
||||
},
|
||||
{ cache },
|
||||
winRuntime({}),
|
||||
);
|
||||
await rm(scriptPath, { force: true });
|
||||
|
||||
const second = resolveSpawnCommand(
|
||||
{
|
||||
command: wrapperPath,
|
||||
args: ["--version"],
|
||||
},
|
||||
{ cache },
|
||||
winRuntime({}),
|
||||
);
|
||||
|
||||
expect(first.command).toBe("C:\\node\\node.exe");
|
||||
expect(second.command).toBe("C:\\node\\node.exe");
|
||||
expect(first.args[0]).toBe(scriptPath);
|
||||
expect(second.args[0]).toBe(scriptPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForExit", () => {
|
||||
it("resolves when the child already exited before waiting starts", async () => {
|
||||
const child = spawn(process.execPath, ["-e", "process.exit(0)"], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once("close", () => {
|
||||
resolve();
|
||||
});
|
||||
child.once("error", reject);
|
||||
});
|
||||
|
||||
const exit = await waitForExit(child);
|
||||
expect(exit.code).toBe(0);
|
||||
expect(exit.signal).toBeNull();
|
||||
expect(exit.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnAndCollect", () => {
|
||||
type SpawnedEnvSnapshot = {
|
||||
openai?: string;
|
||||
github?: string;
|
||||
hf?: string;
|
||||
openclaw?: string;
|
||||
shell?: string;
|
||||
};
|
||||
|
||||
function stubProviderAuthEnv(env: Record<string, string>) {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
vi.stubEnv(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSpawnedEnvSnapshot(options?: {
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
openAiEnvKey?: string;
|
||||
githubEnvKey?: string;
|
||||
hfEnvKey?: string;
|
||||
}): Promise<SpawnedEnvSnapshot> {
|
||||
const openAiEnvKey = options?.openAiEnvKey ?? "OPENAI_API_KEY";
|
||||
const githubEnvKey = options?.githubEnvKey ?? "GITHUB_TOKEN";
|
||||
const hfEnvKey = options?.hfEnvKey ?? "HF_TOKEN";
|
||||
const result = await spawnAndCollect({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`process.stdout.write(JSON.stringify({openai:process.env.${openAiEnvKey},github:process.env.${githubEnvKey},hf:process.env.${hfEnvKey},openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}), () => process.exit(0))`,
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
stripProviderAuthEnvVars: options?.stripProviderAuthEnvVars,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.error).toBeNull();
|
||||
return JSON.parse(result.stdout.trim()) as SpawnedEnvSnapshot;
|
||||
}
|
||||
|
||||
it("returns abort error immediately when signal is already aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await spawnAndCollect(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(0)"],
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
undefined,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
expect(result.code).toBeNull();
|
||||
expect(result.error?.name).toBe("AbortError");
|
||||
});
|
||||
|
||||
it("terminates a running process when signal aborts", async () => {
|
||||
const controller = new AbortController();
|
||||
const resultPromise = spawnAndCollect(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => process.stdout.write('done'), 10_000)"],
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
undefined,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.error?.name).toBe("AbortError");
|
||||
});
|
||||
|
||||
it("strips shared provider auth env vars from spawned acpx children", async () => {
|
||||
stubProviderAuthEnv({
|
||||
OPENAI_API_KEY: "openai-secret",
|
||||
GITHUB_TOKEN: "gh-secret",
|
||||
HF_TOKEN: "hf-secret",
|
||||
OPENCLAW_API_KEY: "keep-me",
|
||||
});
|
||||
const parsed = await collectSpawnedEnvSnapshot({
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
expect(parsed.openai).toBeUndefined();
|
||||
expect(parsed.github).toBeUndefined();
|
||||
expect(parsed.hf).toBeUndefined();
|
||||
expect(parsed.openclaw).toBe("keep-me");
|
||||
expect(parsed.shell).toBe("acp");
|
||||
});
|
||||
|
||||
it("strips provider auth env vars case-insensitively", async () => {
|
||||
stubProviderAuthEnv({
|
||||
OpenAI_Api_Key: "openai-secret",
|
||||
Github_Token: "gh-secret",
|
||||
OPENCLAW_API_KEY: "keep-me",
|
||||
});
|
||||
const parsed = await collectSpawnedEnvSnapshot({
|
||||
stripProviderAuthEnvVars: true,
|
||||
openAiEnvKey: "OpenAI_Api_Key",
|
||||
githubEnvKey: "Github_Token",
|
||||
});
|
||||
expect(parsed.openai).toBeUndefined();
|
||||
expect(parsed.github).toBeUndefined();
|
||||
expect(parsed.openclaw).toBe("keep-me");
|
||||
expect(parsed.shell).toBe("acp");
|
||||
});
|
||||
|
||||
it("preserves provider auth env vars for explicit custom commands by default", async () => {
|
||||
stubProviderAuthEnv({
|
||||
OPENAI_API_KEY: "openai-secret",
|
||||
GITHUB_TOKEN: "gh-secret",
|
||||
HF_TOKEN: "hf-secret",
|
||||
OPENCLAW_API_KEY: "keep-me",
|
||||
});
|
||||
const parsed = await collectSpawnedEnvSnapshot();
|
||||
expect(parsed.openai).toBe("openai-secret");
|
||||
expect(parsed.github).toBe("gh-secret");
|
||||
expect(parsed.hf).toBe("hf-secret");
|
||||
expect(parsed.openclaw).toBe("keep-me");
|
||||
expect(parsed.shell).toBe("acp");
|
||||
});
|
||||
});
|
||||
@@ -1,369 +0,0 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
WindowsSpawnProgram,
|
||||
WindowsSpawnProgramCandidate,
|
||||
WindowsSpawnResolution,
|
||||
} from "../../runtime-api.js";
|
||||
import {
|
||||
applyWindowsSpawnProgramPolicy,
|
||||
listKnownProviderAuthEnvVarNames,
|
||||
materializeWindowsSpawnProgram,
|
||||
omitEnvKeysCaseInsensitive,
|
||||
resolveWindowsSpawnProgramCandidate,
|
||||
} from "../../runtime-api.js";
|
||||
|
||||
export type SpawnExit = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
type ResolvedSpawnCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
shell?: boolean;
|
||||
windowsHide?: boolean;
|
||||
};
|
||||
|
||||
type SpawnRuntime = {
|
||||
platform: NodeJS.Platform;
|
||||
env: NodeJS.ProcessEnv;
|
||||
execPath: string;
|
||||
};
|
||||
|
||||
export type SpawnCommandCache = {
|
||||
key?: string;
|
||||
candidate?: WindowsSpawnProgramCandidate;
|
||||
};
|
||||
|
||||
export type SpawnResolution = WindowsSpawnResolution | "unresolved-wrapper";
|
||||
export type SpawnResolutionEvent = {
|
||||
command: string;
|
||||
cacheHit: boolean;
|
||||
strictWindowsCmdWrapper: boolean;
|
||||
resolution: SpawnResolution;
|
||||
};
|
||||
|
||||
export type SpawnCommandOptions = {
|
||||
strictWindowsCmdWrapper?: boolean;
|
||||
cache?: SpawnCommandCache;
|
||||
onResolved?: (event: SpawnResolutionEvent) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_RUNTIME: SpawnRuntime = {
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
execPath: process.execPath,
|
||||
};
|
||||
|
||||
function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean {
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
if (!stat.isFile()) {
|
||||
return false;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return true;
|
||||
}
|
||||
accessSync(filePath, fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecutableFromPath(command: string, runtime: SpawnRuntime): string | undefined {
|
||||
const pathEnv = runtime.env.PATH ?? runtime.env.Path;
|
||||
if (!pathEnv) {
|
||||
return undefined;
|
||||
}
|
||||
for (const entry of pathEnv.split(path.delimiter).filter(Boolean)) {
|
||||
const candidate = path.join(entry, command);
|
||||
if (isExecutableFile(candidate, runtime.platform)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveNodeExecPath(runtime: SpawnRuntime): string {
|
||||
if (runtime.execPath && isExecutableFile(runtime.execPath, runtime.platform)) {
|
||||
return runtime.execPath;
|
||||
}
|
||||
return resolveExecutableFromPath("node", runtime) ?? runtime.execPath;
|
||||
}
|
||||
|
||||
function resolveNodeShebangScriptPath(command: string, runtime: SpawnRuntime): string | undefined {
|
||||
const commandPath =
|
||||
path.isAbsolute(command) || command.includes(path.sep)
|
||||
? command
|
||||
: resolveExecutableFromPath(command, runtime);
|
||||
if (!commandPath || !isExecutableFile(commandPath, runtime.platform)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const firstLine = readFileSync(commandPath, "utf8").split(/\r?\n/, 1)[0] ?? "";
|
||||
if (/^#!.*(?:\/usr\/bin\/env\s+node\b|\/node(?:js)?\b)/.test(firstLine)) {
|
||||
return commandPath;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveSpawnCommand(
|
||||
params: { command: string; args: string[] },
|
||||
options?: SpawnCommandOptions,
|
||||
runtime: SpawnRuntime = DEFAULT_RUNTIME,
|
||||
): ResolvedSpawnCommand {
|
||||
if (runtime.platform !== "win32") {
|
||||
const nodeShebangScript = resolveNodeShebangScriptPath(params.command, runtime);
|
||||
if (nodeShebangScript) {
|
||||
options?.onResolved?.({
|
||||
command: params.command,
|
||||
cacheHit: false,
|
||||
strictWindowsCmdWrapper: options?.strictWindowsCmdWrapper === true,
|
||||
resolution: "direct",
|
||||
});
|
||||
return {
|
||||
command: resolveNodeExecPath(runtime),
|
||||
args: [nodeShebangScript, ...params.args],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const strictWindowsCmdWrapper = options?.strictWindowsCmdWrapper === true;
|
||||
const cacheKey = params.command;
|
||||
const cachedProgram = options?.cache;
|
||||
|
||||
const cacheHit = cachedProgram?.key === cacheKey && cachedProgram.candidate != null;
|
||||
let candidate =
|
||||
cachedProgram?.key === cacheKey && cachedProgram.candidate
|
||||
? cachedProgram.candidate
|
||||
: undefined;
|
||||
if (!candidate) {
|
||||
candidate = resolveWindowsSpawnProgramCandidate({
|
||||
command: params.command,
|
||||
platform: runtime.platform,
|
||||
env: runtime.env,
|
||||
execPath: runtime.execPath,
|
||||
packageName: "acpx",
|
||||
});
|
||||
if (cachedProgram) {
|
||||
cachedProgram.key = cacheKey;
|
||||
cachedProgram.candidate = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
let program: WindowsSpawnProgram;
|
||||
try {
|
||||
program = applyWindowsSpawnProgramPolicy({
|
||||
candidate,
|
||||
allowShellFallback: !strictWindowsCmdWrapper,
|
||||
});
|
||||
} catch (error) {
|
||||
options?.onResolved?.({
|
||||
command: params.command,
|
||||
cacheHit,
|
||||
strictWindowsCmdWrapper,
|
||||
resolution: candidate.resolution,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const resolved = materializeWindowsSpawnProgram(program, params.args);
|
||||
options?.onResolved?.({
|
||||
command: params.command,
|
||||
cacheHit,
|
||||
strictWindowsCmdWrapper,
|
||||
resolution: resolved.resolution,
|
||||
});
|
||||
return {
|
||||
command: resolved.command,
|
||||
args: resolved.argv,
|
||||
shell: resolved.shell,
|
||||
windowsHide: resolved.windowsHide,
|
||||
};
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error("Operation aborted.");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
async function collectStreamOutput(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
let output = "";
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
output += String(chunk);
|
||||
}
|
||||
} catch {
|
||||
// Return whatever was captured before the stream failed.
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function spawnWithResolvedCommand(
|
||||
params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
},
|
||||
options?: SpawnCommandOptions,
|
||||
): ChildProcessWithoutNullStreams {
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
command: params.command,
|
||||
args: params.args,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const childEnv = omitEnvKeysCaseInsensitive(
|
||||
process.env,
|
||||
params.stripProviderAuthEnvVars ? listKnownProviderAuthEnvVarNames() : [],
|
||||
);
|
||||
childEnv.OPENCLAW_SHELL = "acp";
|
||||
|
||||
return spawn(resolved.command, resolved.args, {
|
||||
cwd: params.cwd,
|
||||
env: childEnv,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: resolved.shell,
|
||||
windowsHide: resolved.windowsHide,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<SpawnExit> {
|
||||
// Handle callers that start waiting after the child has already exited.
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return {
|
||||
code: child.exitCode,
|
||||
signal: child.signalCode,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return await new Promise<SpawnExit>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: SpawnExit) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
child.once("error", (err) => {
|
||||
finish({ code: null, signal: null, error: err });
|
||||
});
|
||||
|
||||
child.once("close", (code, signal) => {
|
||||
finish({ code, signal, error: null });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function spawnAndCollect(
|
||||
params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
},
|
||||
options?: SpawnCommandOptions,
|
||||
runtime?: {
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error: Error | null;
|
||||
}> {
|
||||
if (runtime?.signal?.aborted) {
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: null,
|
||||
signal: null,
|
||||
error: createAbortError(),
|
||||
};
|
||||
}
|
||||
const child = spawnWithResolvedCommand(params, options);
|
||||
child.stdin.end();
|
||||
|
||||
const stdoutPromise = collectStreamOutput(child.stdout);
|
||||
const stderrPromise = collectStreamOutput(child.stderr);
|
||||
|
||||
let abortKillTimer: NodeJS.Timeout | undefined;
|
||||
let aborted = false;
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
// Ignore kill races when child already exited.
|
||||
}
|
||||
abortKillTimer = setTimeout(() => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// Ignore kill races when child already exited.
|
||||
}
|
||||
}, 250);
|
||||
abortKillTimer.unref?.();
|
||||
};
|
||||
runtime?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const exit = await waitForExit(child);
|
||||
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
code: exit.code,
|
||||
signal: exit.signal,
|
||||
error: aborted ? createAbortError() : exit.error,
|
||||
};
|
||||
} finally {
|
||||
runtime?.signal?.removeEventListener("abort", onAbort);
|
||||
if (abortKillTimer) {
|
||||
clearTimeout(abortKillTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSpawnFailure(
|
||||
err: unknown,
|
||||
cwd: string,
|
||||
): "missing-command" | "missing-cwd" | null {
|
||||
if (!err || typeof err !== "object") {
|
||||
return null;
|
||||
}
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
return directoryExists(cwd) ? "missing-command" : "missing-cwd";
|
||||
}
|
||||
|
||||
function directoryExists(cwd: string): boolean {
|
||||
if (!cwd) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return existsSync(cwd);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { ResolvedAcpxPluginConfig } from "../config.js";
|
||||
|
||||
export type AcpxHandleState = {
|
||||
name: string;
|
||||
agent: string;
|
||||
cwd: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
acpxRecordId?: string;
|
||||
backendSessionId?: string;
|
||||
agentSessionId?: string;
|
||||
};
|
||||
|
||||
export type AcpxJsonObject = Record<string, unknown>;
|
||||
|
||||
export type AcpxErrorEvent = {
|
||||
message: string;
|
||||
code?: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asTrimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function asOptionalString(value: unknown): string | undefined {
|
||||
const text = asTrimmedString(value);
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
export function asOptionalBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
export function deriveAgentFromSessionKey(sessionKey: string, fallbackAgent: string): string {
|
||||
const match = sessionKey.match(/^agent:([^:]+):/i);
|
||||
const candidate = match?.[1] ? asTrimmedString(match[1]) : "";
|
||||
return candidate || fallbackAgent;
|
||||
}
|
||||
|
||||
export function buildPermissionArgs(mode: ResolvedAcpxPluginConfig["permissionMode"]): string[] {
|
||||
if (mode === "approve-all") {
|
||||
return ["--approve-all"];
|
||||
}
|
||||
if (mode === "deny-all") {
|
||||
return ["--deny-all"];
|
||||
}
|
||||
return ["--approve-reads"];
|
||||
}
|
||||
@@ -17,6 +17,13 @@ vi.mock("../runtime-api.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
ACPX_BACKEND_ID: "acpx",
|
||||
AcpxRuntime: class {},
|
||||
createAgentRegistry: vi.fn(() => ({})),
|
||||
createFileSessionStore: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { getAcpRuntimeBackend } from "../runtime-api.js";
|
||||
import { createAcpxRuntimeService } from "./service.js";
|
||||
|
||||
@@ -30,6 +37,8 @@ async function makeTempDir(): Promise<string> {
|
||||
|
||||
afterEach(async () => {
|
||||
runtimeRegistry.clear();
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -133,4 +142,50 @@ describe("createAcpxRuntimeService", () => {
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("can skip the embedded runtime probe via env", async () => {
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const probeAvailability = vi.fn(async () => {});
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () =>
|
||||
({
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability,
|
||||
isHealthy: () => false,
|
||||
doctor: async () => ({ ok: false, message: "nope" }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(probeAvailability).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeTruthy();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("can skip the embedded runtime backend via env", async () => {
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtimeFactory = vi.fn(() => {
|
||||
throw new Error("runtime factory should not run when ACPX is skipped");
|
||||
});
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: runtimeFactory as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(runtimeFactory).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
"skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
AcpRuntime,
|
||||
OpenClawPluginService,
|
||||
@@ -90,6 +91,11 @@ export function createAcpxRuntimeService(
|
||||
return {
|
||||
id: "acpx-runtime",
|
||||
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") {
|
||||
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: params.pluginConfig,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
@@ -113,6 +119,10 @@ export function createAcpxRuntimeService(
|
||||
});
|
||||
ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);
|
||||
|
||||
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
lifecycleRevision += 1;
|
||||
const currentRevision = lifecycleRevision;
|
||||
void (async () => {
|
||||
@@ -136,9 +146,7 @@ export function createAcpxRuntimeService(
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
ctx.logger.warn(
|
||||
`embedded acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
ctx.logger.warn(`embedded acpx runtime setup failed: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
16
extensions/acpx/tsconfig.json
Normal file
16
extensions/acpx/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.4.5",
|
||||
"version": "2026.4.6",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
16
extensions/alibaba/tsconfig.json
Normal file
16
extensions/alibaba/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
@@ -2,48 +2,28 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
fetchWithTimeout,
|
||||
postJsonRequest,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
|
||||
DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
|
||||
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
|
||||
buildDashscopeVideoGenerationInput,
|
||||
buildDashscopeVideoGenerationParameters,
|
||||
downloadDashscopeGeneratedVideos,
|
||||
extractDashscopeVideoUrls,
|
||||
pollDashscopeVideoTaskUntilComplete,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
import type {
|
||||
GeneratedVideoAsset,
|
||||
DashscopeVideoGenerationResponse,
|
||||
VideoGenerationProvider,
|
||||
VideoGenerationRequest,
|
||||
VideoGenerationResult,
|
||||
VideoGenerationSourceAsset,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
|
||||
const DEFAULT_ALIBABA_VIDEO_BASE_URL = "https://dashscope-intl.aliyuncs.com";
|
||||
const DEFAULT_ALIBABA_VIDEO_MODEL = "wan2.6-t2v";
|
||||
const DEFAULT_DURATION_SECONDS = 5;
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const POLL_INTERVAL_MS = 2_500;
|
||||
const MAX_POLL_ATTEMPTS = 120;
|
||||
const RESOLUTION_TO_SIZE: Record<string, string> = {
|
||||
"480P": "832*480",
|
||||
"720P": "1280*720",
|
||||
"1080P": "1920*1080",
|
||||
};
|
||||
|
||||
type AlibabaVideoGenerationResponse = {
|
||||
output?: {
|
||||
task_id?: string;
|
||||
task_status?: string;
|
||||
submit_time?: string;
|
||||
results?: Array<{
|
||||
video_url?: string;
|
||||
orig_prompt?: string;
|
||||
actual_prompt?: string;
|
||||
}>;
|
||||
video_url?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
request_id?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function resolveAlibabaVideoBaseUrl(req: VideoGenerationRequest): string {
|
||||
return req.cfg?.models?.providers?.alibaba?.baseUrl?.trim() || DEFAULT_ALIBABA_VIDEO_BASE_URL;
|
||||
@@ -53,139 +33,6 @@ function resolveDashscopeAigcApiBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
function resolveReferenceUrls(
|
||||
inputImages: VideoGenerationSourceAsset[] | undefined,
|
||||
inputVideos: VideoGenerationSourceAsset[] | undefined,
|
||||
): string[] {
|
||||
return [...(inputImages ?? []), ...(inputVideos ?? [])]
|
||||
.map((asset) => asset.url?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
function assertAlibabaReferenceInputsSupported(
|
||||
inputImages: VideoGenerationSourceAsset[] | undefined,
|
||||
inputVideos: VideoGenerationSourceAsset[] | undefined,
|
||||
): void {
|
||||
const unsupported = [...(inputImages ?? []), ...(inputVideos ?? [])].some(
|
||||
(asset) => !asset.url?.trim() && asset.buffer,
|
||||
);
|
||||
if (unsupported) {
|
||||
throw new Error(
|
||||
"Alibaba Wan video generation currently requires remote http(s) URLs for reference images/videos.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildAlibabaVideoGenerationInput(req: VideoGenerationRequest): Record<string, unknown> {
|
||||
assertAlibabaReferenceInputsSupported(req.inputImages, req.inputVideos);
|
||||
const input: Record<string, unknown> = {
|
||||
prompt: req.prompt,
|
||||
};
|
||||
const referenceUrls = resolveReferenceUrls(req.inputImages, req.inputVideos);
|
||||
if (
|
||||
referenceUrls.length === 1 &&
|
||||
(req.inputImages?.length ?? 0) === 1 &&
|
||||
!req.inputVideos?.length
|
||||
) {
|
||||
input.img_url = referenceUrls[0];
|
||||
} else if (referenceUrls.length > 0) {
|
||||
input.reference_urls = referenceUrls;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function buildAlibabaVideoGenerationParameters(
|
||||
req: VideoGenerationRequest,
|
||||
): Record<string, unknown> | undefined {
|
||||
const parameters: Record<string, unknown> = {};
|
||||
const size =
|
||||
req.size?.trim() || (req.resolution ? RESOLUTION_TO_SIZE[req.resolution] : undefined);
|
||||
if (size) {
|
||||
parameters.size = size;
|
||||
}
|
||||
if (req.aspectRatio?.trim()) {
|
||||
parameters.aspect_ratio = req.aspectRatio.trim();
|
||||
}
|
||||
if (typeof req.durationSeconds === "number" && Number.isFinite(req.durationSeconds)) {
|
||||
parameters.duration = Math.max(1, Math.round(req.durationSeconds));
|
||||
}
|
||||
if (typeof req.audio === "boolean") {
|
||||
parameters.enable_audio = req.audio;
|
||||
}
|
||||
if (typeof req.watermark === "boolean") {
|
||||
parameters.watermark = req.watermark;
|
||||
}
|
||||
return Object.keys(parameters).length > 0 ? parameters : undefined;
|
||||
}
|
||||
|
||||
function extractVideoUrls(payload: AlibabaVideoGenerationResponse): string[] {
|
||||
const urls = [
|
||||
...(payload.output?.results?.map((entry) => entry.video_url).filter(Boolean) ?? []),
|
||||
payload.output?.video_url,
|
||||
].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
async function pollTaskUntilComplete(params: {
|
||||
taskId: string;
|
||||
headers: Headers;
|
||||
timeoutMs?: number;
|
||||
fetchFn: typeof fetch;
|
||||
baseUrl: string;
|
||||
}): Promise<AlibabaVideoGenerationResponse> {
|
||||
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
|
||||
const response = await fetchWithTimeout(
|
||||
`${params.baseUrl}/api/v1/tasks/${params.taskId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: params.headers,
|
||||
},
|
||||
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
params.fetchFn,
|
||||
);
|
||||
await assertOkOrThrowHttpError(response, "Alibaba Wan video-generation task poll failed");
|
||||
const payload = (await response.json()) as AlibabaVideoGenerationResponse;
|
||||
const status = payload.output?.task_status?.trim().toUpperCase();
|
||||
if (status === "SUCCEEDED") {
|
||||
return payload;
|
||||
}
|
||||
if (status === "FAILED" || status === "CANCELED") {
|
||||
throw new Error(
|
||||
payload.output?.message?.trim() ||
|
||||
payload.message?.trim() ||
|
||||
`Alibaba Wan video generation task ${params.taskId} ${status?.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
throw new Error(`Alibaba Wan video generation task ${params.taskId} did not finish in time`);
|
||||
}
|
||||
|
||||
async function downloadGeneratedVideos(params: {
|
||||
urls: string[];
|
||||
timeoutMs?: number;
|
||||
fetchFn: typeof fetch;
|
||||
}): Promise<GeneratedVideoAsset[]> {
|
||||
const videos: GeneratedVideoAsset[] = [];
|
||||
for (const [index, url] of params.urls.entries()) {
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{ method: "GET" },
|
||||
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
params.fetchFn,
|
||||
);
|
||||
await assertOkOrThrowHttpError(response, "Alibaba Wan generated video download failed");
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
videos.push({
|
||||
buffer: Buffer.from(arrayBuffer),
|
||||
mimeType: response.headers.get("content-type")?.trim() || "video/mp4",
|
||||
fileName: `video-${index + 1}.mp4`,
|
||||
metadata: { sourceUrl: url },
|
||||
});
|
||||
}
|
||||
return videos;
|
||||
}
|
||||
|
||||
export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
|
||||
return {
|
||||
id: "alibaba",
|
||||
@@ -263,11 +110,17 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
|
||||
headers,
|
||||
body: {
|
||||
model,
|
||||
input: buildAlibabaVideoGenerationInput(req),
|
||||
parameters: buildAlibabaVideoGenerationParameters({
|
||||
...req,
|
||||
durationSeconds: req.durationSeconds ?? DEFAULT_DURATION_SECONDS,
|
||||
input: buildDashscopeVideoGenerationInput({
|
||||
providerLabel: "Alibaba Wan",
|
||||
req,
|
||||
}),
|
||||
parameters: buildDashscopeVideoGenerationParameters(
|
||||
{
|
||||
...req,
|
||||
durationSeconds: req.durationSeconds ?? DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
|
||||
},
|
||||
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
|
||||
),
|
||||
},
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn,
|
||||
@@ -277,26 +130,30 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
|
||||
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "Alibaba Wan video generation failed");
|
||||
const submitted = (await response.json()) as AlibabaVideoGenerationResponse;
|
||||
const submitted = (await response.json()) as DashscopeVideoGenerationResponse;
|
||||
const taskId = submitted.output?.task_id?.trim();
|
||||
if (!taskId) {
|
||||
throw new Error("Alibaba Wan video generation response missing task_id");
|
||||
}
|
||||
const completed = await pollTaskUntilComplete({
|
||||
const completed = await pollDashscopeVideoTaskUntilComplete({
|
||||
providerLabel: "Alibaba Wan",
|
||||
taskId,
|
||||
headers,
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn,
|
||||
baseUrl: resolveDashscopeAigcApiBaseUrl(baseUrl),
|
||||
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
|
||||
});
|
||||
const urls = extractVideoUrls(completed);
|
||||
const urls = extractDashscopeVideoUrls(completed);
|
||||
if (urls.length === 0) {
|
||||
throw new Error("Alibaba Wan video generation completed without output video URLs");
|
||||
}
|
||||
const videos = await downloadGeneratedVideos({
|
||||
const videos = await downloadDashscopeGeneratedVideos({
|
||||
providerLabel: "Alibaba Wan",
|
||||
urls,
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn,
|
||||
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
|
||||
});
|
||||
return {
|
||||
videos,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
@@ -102,7 +103,7 @@ export async function generateBearerTokenFromIam(params: {
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle IAM token generation unavailable", {
|
||||
region: params.region,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
@@ -233,7 +234,7 @@ export async function discoverMantleModels(params: {
|
||||
return models;
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle model discovery error", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return cached?.models ?? [];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.4.5",
|
||||
"version": "2026.4.6",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws/bedrock-token-generator": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
16
extensions/amazon-bedrock-mantle/tsconfig.json
Normal file
16
extensions/amazon-bedrock-mantle/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const LEGACY_PATH = "models.bedrockDiscovery";
|
||||
const TARGET_PATH = "plugins.entries.amazon-bedrock.config.discovery";
|
||||
const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isBlockedObjectKey(key: string): boolean {
|
||||
return BLOCKED_OBJECT_KEYS.has(key);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ListInferenceProfilesCommandOutput,
|
||||
} from "@aws-sdk/client-bedrock";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveAwsSdkEnvVarName } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import type {
|
||||
BedrockDiscoveryConfig,
|
||||
@@ -214,7 +215,7 @@ async function fetchInferenceProfileSummaries(
|
||||
return profiles;
|
||||
} catch (error) {
|
||||
log.debug?.("Skipping inference profile discovery", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
@@ -406,7 +407,7 @@ export async function discoverBedrockModels(params: {
|
||||
if (!hasLoggedBedrockError) {
|
||||
hasLoggedBedrockError = true;
|
||||
log.warn("Failed to discover Bedrock models", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -126,7 +126,6 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
repairToolUseResultPairing: true,
|
||||
validateAnthropicTurns: true,
|
||||
allowSyntheticToolResults: true,
|
||||
dropThinkingBlocks: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityMigrationPaths": ["models.bedrockDiscovery"]
|
||||
},
|
||||
"uiHints": {
|
||||
"discovery": {
|
||||
"label": "Model Discovery",
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.4.5",
|
||||
"version": "2026.4.6",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1024.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
16
extensions/amazon-bedrock/tsconfig.json
Normal file
16
extensions/amazon-bedrock/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
export {
|
||||
ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
|
||||
buildAnthropicVertexProvider,
|
||||
@@ -16,9 +15,9 @@ import { buildAnthropicVertexProvider } from "./provider-catalog.js";
|
||||
import { hasAnthropicVertexAvailableAuth } from "./region.js";
|
||||
|
||||
export function mergeImplicitAnthropicVertexProvider(params: {
|
||||
existing: ModelProviderConfig | undefined;
|
||||
implicit: ModelProviderConfig;
|
||||
}): ModelProviderConfig {
|
||||
existing?: ReturnType<typeof buildAnthropicVertexProvider>;
|
||||
implicit: ReturnType<typeof buildAnthropicVertexProvider>;
|
||||
}) {
|
||||
const { existing, implicit } = params;
|
||||
if (!existing) {
|
||||
return implicit;
|
||||
@@ -33,9 +32,7 @@ export function mergeImplicitAnthropicVertexProvider(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveImplicitAnthropicVertexProvider(params?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ModelProviderConfig | null {
|
||||
export function resolveImplicitAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }) {
|
||||
const env = params?.env ?? process.env;
|
||||
if (!hasAnthropicVertexAvailableAuth(env)) {
|
||||
return null;
|
||||
|
||||
@@ -75,7 +75,6 @@ describe("anthropic-vertex provider plugin", () => {
|
||||
repairToolUseResultPairing: true,
|
||||
validateAnthropicTurns: true,
|
||||
allowSyntheticToolResults: true,
|
||||
dropThinkingBlocks: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.4.5",
|
||||
"version": "2026.4.6",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
16
extensions/anthropic-vertex/tsconfig.json
Normal file
16
extensions/anthropic-vertex/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/cli-backend";
|
||||
import {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
CLAUDE_CLI_CLEAR_ENV,
|
||||
CLAUDE_CLI_HOST_MANAGED_ENV,
|
||||
CLAUDE_CLI_MODEL_ALIASES,
|
||||
@@ -15,6 +16,14 @@ import {
|
||||
export function buildAnthropicCliBackend(): CliBackendPlugin {
|
||||
return {
|
||||
id: CLAUDE_CLI_BACKEND_ID,
|
||||
liveTest: {
|
||||
defaultModelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
defaultImageProbe: true,
|
||||
docker: {
|
||||
npmPackage: "@anthropic-ai/claude-code",
|
||||
binaryName: "claude",
|
||||
},
|
||||
},
|
||||
bundleMcp: true,
|
||||
config: {
|
||||
command: "claude",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user