mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-12 01:01:37 +08:00
Compare commits
736 Commits
codex/pr-8
...
pe/exec-ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4c364cd27 | ||
|
|
5980c0d807 | ||
|
|
1c778f7afb | ||
|
|
84b34519a8 | ||
|
|
71ed6526b1 | ||
|
|
8483d03375 | ||
|
|
696b4863c3 | ||
|
|
a642ca9a89 | ||
|
|
1300b22630 | ||
|
|
29653e4106 | ||
|
|
1ba3368fa6 | ||
|
|
4dec9679e6 | ||
|
|
1ab84b4327 | ||
|
|
63b728de43 | ||
|
|
73ca3cf3c3 | ||
|
|
11d7499db1 | ||
|
|
ad55d486ce | ||
|
|
1b5bc33161 | ||
|
|
bcbe8b6299 | ||
|
|
bc4f27c89a | ||
|
|
6baa2b38b2 | ||
|
|
48f7db23f0 | ||
|
|
816fbe0cf0 | ||
|
|
69cea57f69 | ||
|
|
58e1351863 | ||
|
|
73f4657869 | ||
|
|
1768667374 | ||
|
|
8855a4aa58 | ||
|
|
4b4048fd22 | ||
|
|
5f1df99a9c | ||
|
|
b5046968f6 | ||
|
|
645ef817b6 | ||
|
|
9aa46843ec | ||
|
|
73049d291b | ||
|
|
7ff8323ed5 | ||
|
|
5434769e47 | ||
|
|
428fc16ac8 | ||
|
|
6ebe91d92b | ||
|
|
2d2c420ed2 | ||
|
|
3d85e84df3 | ||
|
|
bb691a0d25 | ||
|
|
9bdc183b7d | ||
|
|
b0b18d1e4a | ||
|
|
17ab3b11cb | ||
|
|
91266fa928 | ||
|
|
47a2efe483 | ||
|
|
9da0f80356 | ||
|
|
77bbffb998 | ||
|
|
bef3356375 | ||
|
|
086d3d012e | ||
|
|
72e164a3fe | ||
|
|
06f4c97130 | ||
|
|
9a936b3063 | ||
|
|
691d62630f | ||
|
|
7bcd5acc1a | ||
|
|
5f1d8a2ee4 | ||
|
|
dad3db40d3 | ||
|
|
d63c581dec | ||
|
|
7afac6015f | ||
|
|
57da466ecb | ||
|
|
127f3f86d7 | ||
|
|
c93d6d8daa | ||
|
|
aa71f7fe15 | ||
|
|
d85a7c6b67 | ||
|
|
d0736919aa | ||
|
|
bacc18a575 | ||
|
|
9a5f2f61e7 | ||
|
|
0dc04fb926 | ||
|
|
fb53c2d610 | ||
|
|
214f718be7 | ||
|
|
f36a1b0c81 | ||
|
|
3e765263dd | ||
|
|
fb028cadc8 | ||
|
|
800a0d3166 | ||
|
|
a5a5df67da | ||
|
|
0f1f9525f3 | ||
|
|
c3308b9195 | ||
|
|
7c416950c6 | ||
|
|
59b85d4eb9 | ||
|
|
44c3d8ea2e | ||
|
|
893f580072 | ||
|
|
af62fd45cd | ||
|
|
6ebc5e4719 | ||
|
|
f349fb82aa | ||
|
|
79212f9869 | ||
|
|
ea72414e1c | ||
|
|
ac848d318d | ||
|
|
4c60ab3666 | ||
|
|
9249e13891 | ||
|
|
94c0d9ac81 | ||
|
|
59efd95669 | ||
|
|
b764396dee | ||
|
|
45a434fb23 | ||
|
|
1760881574 | ||
|
|
a00e494992 | ||
|
|
428af92ac9 | ||
|
|
40d8e6eab7 | ||
|
|
1e0e2c0e2d | ||
|
|
303effce67 | ||
|
|
96f2e1ae43 | ||
|
|
0ce7cb1b7f | ||
|
|
364f8cd04f | ||
|
|
bcef46e63c | ||
|
|
747cfbbaad | ||
|
|
f4776138c7 | ||
|
|
e536ce86bf | ||
|
|
ed6c46ec7e | ||
|
|
4a6b50c789 | ||
|
|
b76fac10dc | ||
|
|
1e446845fd | ||
|
|
ce634337d2 | ||
|
|
c2adcd0a36 | ||
|
|
135005e3dd | ||
|
|
1926982c4c | ||
|
|
019dbcc749 | ||
|
|
72eef85942 | ||
|
|
164c35da85 | ||
|
|
543518bd43 | ||
|
|
833f1ce735 | ||
|
|
66b8de9c83 | ||
|
|
aaf85166de | ||
|
|
7554deef30 | ||
|
|
f0fc8c27d3 | ||
|
|
ef763d0f0b | ||
|
|
395346fe57 | ||
|
|
2ab76240d3 | ||
|
|
bb64223155 | ||
|
|
30263f6d35 | ||
|
|
848e0486b7 | ||
|
|
cdd817669a | ||
|
|
9d85f05b01 | ||
|
|
5dbc969b46 | ||
|
|
2a6ef5287b | ||
|
|
a4210dbaee | ||
|
|
b78c2ee8c8 | ||
|
|
f5090d2624 | ||
|
|
fee1cd9867 | ||
|
|
ee72ce8cf7 | ||
|
|
ad861d4c9d | ||
|
|
868315aef0 | ||
|
|
798833140d | ||
|
|
d3a7348ad9 | ||
|
|
5fc6b714a6 | ||
|
|
e43a2efcdb | ||
|
|
c80cb5986f | ||
|
|
22723b6f1e | ||
|
|
9bb5f5af0d | ||
|
|
3b39ff4318 | ||
|
|
60fc982cb6 | ||
|
|
9b62a35760 | ||
|
|
f74b302dc2 | ||
|
|
18ac38963d | ||
|
|
006ebe692d | ||
|
|
4f4be666eb | ||
|
|
e3621f5057 | ||
|
|
f9c8cb7877 | ||
|
|
3c6ec521d5 | ||
|
|
fe68af5307 | ||
|
|
93db190308 | ||
|
|
9897559e3f | ||
|
|
8a060b2904 | ||
|
|
3dd8bcb419 | ||
|
|
5e1fde7c22 | ||
|
|
0aae5ba077 | ||
|
|
8f59a370aa | ||
|
|
4aa671b71a | ||
|
|
4ccd07718d | ||
|
|
09f7702b96 | ||
|
|
3e9e1d6321 | ||
|
|
d1cd74b243 | ||
|
|
7d6e45ef7c | ||
|
|
045d7aae50 | ||
|
|
7bf4dfeff3 | ||
|
|
d41916b5c3 | ||
|
|
9a50fe1497 | ||
|
|
69a0c925b8 | ||
|
|
3fcc8b19ba | ||
|
|
ee492092a7 | ||
|
|
9e0386563f | ||
|
|
be934c0347 | ||
|
|
066ca3926a | ||
|
|
9a45c0701b | ||
|
|
decbd611a0 | ||
|
|
d8198c8c0e | ||
|
|
084318b8c4 | ||
|
|
403fbd7296 | ||
|
|
a6908fac16 | ||
|
|
4008ba56fc | ||
|
|
e8e4b93a94 | ||
|
|
8e9961a945 | ||
|
|
f86a0c8c9a | ||
|
|
156e86afa4 | ||
|
|
3dbe37c694 | ||
|
|
439612bf56 | ||
|
|
4d05008283 | ||
|
|
ae172741e1 | ||
|
|
b95c8a4d95 | ||
|
|
b17e4ed50c | ||
|
|
0e76dafe42 | ||
|
|
51e93669cb | ||
|
|
10dd9c5aee | ||
|
|
0165560f70 | ||
|
|
9feca3e11e | ||
|
|
8dd91b14d3 | ||
|
|
5aac7939db | ||
|
|
42435d110b | ||
|
|
bf51933358 | ||
|
|
7e2d6ef06f | ||
|
|
0591b31388 | ||
|
|
b8a6a387ee | ||
|
|
540a4a73d5 | ||
|
|
903d9c13f3 | ||
|
|
0177a4b6c9 | ||
|
|
f29bcff4da | ||
|
|
9616aa6e5a | ||
|
|
d66fe50a10 | ||
|
|
e3a248585e | ||
|
|
fe680e47ce | ||
|
|
230806eaf2 | ||
|
|
6f024293e0 | ||
|
|
5d4dac690c | ||
|
|
2df393886a | ||
|
|
6eeba8cfb4 | ||
|
|
9b698ce0d6 | ||
|
|
cfb032797f | ||
|
|
3e6902236c | ||
|
|
a4bea46a35 | ||
|
|
37dcf385e5 | ||
|
|
2c9f68f42b | ||
|
|
1f9d8c1e9d | ||
|
|
00fc2950d9 | ||
|
|
54d063167e | ||
|
|
673596013e | ||
|
|
7b96109920 | ||
|
|
b7704b917e | ||
|
|
85f8fd0533 | ||
|
|
9ca98a6d39 | ||
|
|
d217fd7a92 | ||
|
|
46061442e7 | ||
|
|
3c1c850c02 | ||
|
|
5425ecc1aa | ||
|
|
8ba2dfa76a | ||
|
|
69d588cf2a | ||
|
|
37806afd2d | ||
|
|
022723829a | ||
|
|
80d03a1e5b | ||
|
|
2547e35b0a | ||
|
|
3cfac6d430 | ||
|
|
3918d69587 | ||
|
|
e53ba8fcf5 | ||
|
|
dd32c5307f | ||
|
|
83e19ca469 | ||
|
|
e4ec1b3de8 | ||
|
|
820ec9d3be | ||
|
|
0fd152b286 | ||
|
|
a5b1177b68 | ||
|
|
993fe3ef0f | ||
|
|
a985c99059 | ||
|
|
327b0b8734 | ||
|
|
5d1f7bf058 | ||
|
|
9a36e897be | ||
|
|
635b947e32 | ||
|
|
1f6ababb63 | ||
|
|
592aae3696 | ||
|
|
6b688ed614 | ||
|
|
77547226ce | ||
|
|
76da34760c | ||
|
|
c30c8cb471 | ||
|
|
d1638f1185 | ||
|
|
7d99f8b021 | ||
|
|
8dc213227b | ||
|
|
a656f887c8 | ||
|
|
afdb8705e9 | ||
|
|
6720aa9c42 | ||
|
|
aca258a8a9 | ||
|
|
aaadf721e3 | ||
|
|
a46d2e2b06 | ||
|
|
684a6303b3 | ||
|
|
669786595d | ||
|
|
9a063e38d1 | ||
|
|
9a11e76458 | ||
|
|
826c2f4517 | ||
|
|
58f1db1bc8 | ||
|
|
451563b950 | ||
|
|
e66a6c8c8d | ||
|
|
16ef041b5d | ||
|
|
71b79f008d | ||
|
|
b7f3d01633 | ||
|
|
ad155fbbd7 | ||
|
|
c3e2b3c323 | ||
|
|
1ceebf8a01 | ||
|
|
c4d8e0be18 | ||
|
|
a535978352 | ||
|
|
06ec6b0fca | ||
|
|
38b3e73622 | ||
|
|
926a5a825f | ||
|
|
9ac7773b7f | ||
|
|
5817e478d1 | ||
|
|
7c2425a518 | ||
|
|
421b9e2819 | ||
|
|
3fad770510 | ||
|
|
6a8a6551fc | ||
|
|
df23b0f86c | ||
|
|
5a350431bd | ||
|
|
cb71ad5a60 | ||
|
|
7899e99852 | ||
|
|
f12b6fa67c | ||
|
|
1d2aa4db61 | ||
|
|
a16150c7e2 | ||
|
|
09c8f972eb | ||
|
|
6c5f97d0d0 | ||
|
|
17109bc253 | ||
|
|
9b96f81327 | ||
|
|
6da6abdb55 | ||
|
|
10a0c43872 | ||
|
|
743ad4f296 | ||
|
|
f5d0345feb | ||
|
|
8901cf8625 | ||
|
|
5fddcfaefe | ||
|
|
b7893fc158 | ||
|
|
6ca0cd4337 | ||
|
|
84ec0c27bf | ||
|
|
f2d8f38315 | ||
|
|
422a1374e0 | ||
|
|
ee10fe17f0 | ||
|
|
741eafea5f | ||
|
|
8880a5827a | ||
|
|
440e7d2a87 | ||
|
|
abb06c6e40 | ||
|
|
18812bfc03 | ||
|
|
150179def7 | ||
|
|
bd51d8f2dd | ||
|
|
5c02b72413 | ||
|
|
6a1b167472 | ||
|
|
7d1317634e | ||
|
|
b77077f4d1 | ||
|
|
5d81c29cc4 | ||
|
|
06e85d5eaf | ||
|
|
2c549ae205 | ||
|
|
ab2943e2ff | ||
|
|
91ae1a6c03 | ||
|
|
9bb4d1377a | ||
|
|
46fde2bde2 | ||
|
|
549a0ea313 | ||
|
|
39a9a3478f | ||
|
|
45d9a09485 | ||
|
|
f562690612 | ||
|
|
b29152e3b9 | ||
|
|
b328f57bc3 | ||
|
|
5040eb5d84 | ||
|
|
9b5f5b8651 | ||
|
|
d887eb8dc2 | ||
|
|
d801d27dbc | ||
|
|
ca236d098d | ||
|
|
524185a68e | ||
|
|
8af2af24a5 | ||
|
|
845da0ed8f | ||
|
|
c4f20b656e | ||
|
|
94ed68bc76 | ||
|
|
c1bc6adfaa | ||
|
|
2c7200f542 | ||
|
|
ab595dec0f | ||
|
|
ad8ae05f37 | ||
|
|
1896f8a330 | ||
|
|
c8e12ca01d | ||
|
|
9f112a1a7a | ||
|
|
6821fbcfba | ||
|
|
9e67f53b91 | ||
|
|
ecb9028f9f | ||
|
|
55e4b76bb2 | ||
|
|
82e8b5232d | ||
|
|
d183fa3095 | ||
|
|
d62e443b36 | ||
|
|
817f0cd6c8 | ||
|
|
c8b5757303 | ||
|
|
634a766347 | ||
|
|
662fcb81c9 | ||
|
|
77f7c8df8d | ||
|
|
2a7f9f3546 | ||
|
|
f2a0b3d2e2 | ||
|
|
d350ac3feb | ||
|
|
7ee5fe011b | ||
|
|
da8afe359d | ||
|
|
dcb4160909 | ||
|
|
4537b89da6 | ||
|
|
e50927b6c9 | ||
|
|
ffc7bda443 | ||
|
|
f453904165 | ||
|
|
562d460d75 | ||
|
|
a6225060f1 | ||
|
|
b77b3a7ade | ||
|
|
54c9820ed9 | ||
|
|
5fe4e09b68 | ||
|
|
8e21b3c9a6 | ||
|
|
e0c3c80ebc | ||
|
|
2416de1421 | ||
|
|
f5904392e9 | ||
|
|
193bfd3a4d | ||
|
|
bfceb0d7f9 | ||
|
|
121cd054ef | ||
|
|
c90e42aaa7 | ||
|
|
20704ffab7 | ||
|
|
fba250d4a2 | ||
|
|
5911b5bf2d | ||
|
|
0fdc280cdd | ||
|
|
7c151b212b | ||
|
|
ad450a7dfb | ||
|
|
89532d3a92 | ||
|
|
c6ffacd1db | ||
|
|
191bd7dc9a | ||
|
|
b30face031 | ||
|
|
4cbf616d30 | ||
|
|
c65801c5a9 | ||
|
|
06b902e33f | ||
|
|
ea4ee23fa2 | ||
|
|
84d3b7a389 | ||
|
|
80848fc040 | ||
|
|
e98ebb5739 | ||
|
|
f0513221d7 | ||
|
|
a70459d10b | ||
|
|
b09e11bc69 | ||
|
|
983e8d39da | ||
|
|
5239b20089 | ||
|
|
8d3027dffa | ||
|
|
d1280a3de9 | ||
|
|
d7ad12dde4 | ||
|
|
e19f05b79b | ||
|
|
97abe0f0c0 | ||
|
|
388b7456d2 | ||
|
|
2c4287b6df | ||
|
|
ff8d3dc591 | ||
|
|
c2e90914b7 | ||
|
|
12debcb05e | ||
|
|
c528f36507 | ||
|
|
3411a481f7 | ||
|
|
32f7481787 | ||
|
|
cc9117f729 | ||
|
|
99a269f8b4 | ||
|
|
0190f4ae1e | ||
|
|
d0efaceb97 | ||
|
|
cdf8121a04 | ||
|
|
1d22578c6c | ||
|
|
07f05e972e | ||
|
|
6b4d371723 | ||
|
|
3b2cd0dd1a | ||
|
|
d533a65f56 | ||
|
|
5b383af736 | ||
|
|
21244d9793 | ||
|
|
a136cafe98 | ||
|
|
bea4f0d2f4 | ||
|
|
77ca3dc99c | ||
|
|
7abae15a6b | ||
|
|
6e2e63a983 | ||
|
|
8bef5d0d62 | ||
|
|
41777fb0fa | ||
|
|
ac2a1e5b50 | ||
|
|
045a581069 | ||
|
|
54619d4033 | ||
|
|
81a578fd6b | ||
|
|
36e88f5ddd | ||
|
|
ac99494e44 | ||
|
|
361dc69029 | ||
|
|
75a9293bf9 | ||
|
|
c1c67306fd | ||
|
|
82ab8a8785 | ||
|
|
7b38ac9749 | ||
|
|
9791957cd5 | ||
|
|
93bc99460e | ||
|
|
a1d0b2709a | ||
|
|
3536c927da | ||
|
|
2762d9abbe | ||
|
|
37a1ab0c0b | ||
|
|
e485640da4 | ||
|
|
fd8afc1dce | ||
|
|
fff8e79afb | ||
|
|
8040f28bc5 | ||
|
|
25090f64b3 | ||
|
|
9e31b9d344 | ||
|
|
91f45d9c8a | ||
|
|
842e6f1643 | ||
|
|
80eeb688c1 | ||
|
|
c4fb12ee8d | ||
|
|
4f886e7334 | ||
|
|
6e70d9e4b6 | ||
|
|
e9d283e13a | ||
|
|
3934849550 | ||
|
|
9fa78718c7 | ||
|
|
4b0f16d496 | ||
|
|
66c64a29ee | ||
|
|
532e42213d | ||
|
|
f7977fb102 | ||
|
|
55edadf86f | ||
|
|
6369bf64cd | ||
|
|
c421be6c90 | ||
|
|
640735cebe | ||
|
|
6d844c5900 | ||
|
|
35e1c7ac41 | ||
|
|
eec18fccb4 | ||
|
|
f8323f8636 | ||
|
|
14117c303d | ||
|
|
03012ac5a1 | ||
|
|
cc8c0d4ecb | ||
|
|
a9e0a897a1 | ||
|
|
5db30ab47d | ||
|
|
6a12134489 | ||
|
|
f345b54d04 | ||
|
|
6e4cc222cb | ||
|
|
7d09ff89ee | ||
|
|
ca1fd1b140 | ||
|
|
1a956b6ba1 | ||
|
|
df3f983d96 | ||
|
|
c8782d18eb | ||
|
|
500d282340 | ||
|
|
ccdcdc7d1b | ||
|
|
440333125c | ||
|
|
1586085c7f | ||
|
|
11745de9d9 | ||
|
|
db4ce1f506 | ||
|
|
e1061a8b46 | ||
|
|
a171600d1d | ||
|
|
b19b7539a8 | ||
|
|
b6b33ad6d3 | ||
|
|
3a13d1e0be | ||
|
|
f0105939bf | ||
|
|
11a31e476b | ||
|
|
3df6499fb8 | ||
|
|
09db0892dd | ||
|
|
8330582493 | ||
|
|
b5b193076e | ||
|
|
ffd8fcd598 | ||
|
|
8178a6c949 | ||
|
|
0b03b902be | ||
|
|
ac2e3a23b9 | ||
|
|
ec38e96884 | ||
|
|
d5035bad62 | ||
|
|
e8b4003933 | ||
|
|
995c702b07 | ||
|
|
b34454f5b3 | ||
|
|
489cab2738 | ||
|
|
e06782d5e7 | ||
|
|
d77c4bbb2d | ||
|
|
9b53a95d8e | ||
|
|
cd1846a313 | ||
|
|
df9f29caef | ||
|
|
ffcbb89b7e | ||
|
|
05123db93c | ||
|
|
c818a9fb4e | ||
|
|
43c53174c5 | ||
|
|
cb313d5378 | ||
|
|
c277138959 | ||
|
|
4003a955ee | ||
|
|
61ee9755ad | ||
|
|
50508b1d0c | ||
|
|
f22c26a6cd | ||
|
|
ba103c56a2 | ||
|
|
37cd82913f | ||
|
|
97d1f5fd15 | ||
|
|
d13749b2fc | ||
|
|
2640244d35 | ||
|
|
28fdc34543 | ||
|
|
6a12c6f799 | ||
|
|
9a204008ba | ||
|
|
bdfc078487 | ||
|
|
a3e7fc7de7 | ||
|
|
b11f67964c | ||
|
|
95741daeb4 | ||
|
|
5774517fce | ||
|
|
405535d4ce | ||
|
|
92fe2a8f5f | ||
|
|
597b7b0628 | ||
|
|
38cf54593e | ||
|
|
6ed16d9356 | ||
|
|
deaf46a07d | ||
|
|
fddac1c507 | ||
|
|
4526b44778 | ||
|
|
15b0d43412 | ||
|
|
777d289979 | ||
|
|
2fcaab0010 | ||
|
|
a2f1f73107 | ||
|
|
ea9793b2e1 | ||
|
|
851b9271a5 | ||
|
|
06dfa6f160 | ||
|
|
2c59ea8a2e | ||
|
|
575936473d | ||
|
|
ffdc7aa7a6 | ||
|
|
cc22c2ad79 | ||
|
|
2074cde6cf | ||
|
|
18cbc7bc48 | ||
|
|
21c5f8dc6d | ||
|
|
a641a27bd4 | ||
|
|
efe3790dd3 | ||
|
|
6778da05d6 | ||
|
|
d7d597cfd8 | ||
|
|
722161271e | ||
|
|
55439fe34b | ||
|
|
0eca3a92e3 | ||
|
|
1769e6a2f0 | ||
|
|
0b708a2574 | ||
|
|
f7b1148bed | ||
|
|
fa9a22b960 | ||
|
|
0b24ffb91f | ||
|
|
22858769e4 | ||
|
|
9dedc4d95c | ||
|
|
16e5d6692d | ||
|
|
eebdbabae9 | ||
|
|
caf8fa2ebf | ||
|
|
58083866d0 | ||
|
|
8092761d7b | ||
|
|
6a65ea8c3a | ||
|
|
b9921e21b9 | ||
|
|
0fb0b5197e | ||
|
|
20c3580394 | ||
|
|
1bd10cfee6 | ||
|
|
394f61b8ce | ||
|
|
a93283337e | ||
|
|
e71d10fd4d | ||
|
|
f410a95081 | ||
|
|
bede89dba6 | ||
|
|
b0daf992b2 | ||
|
|
605a2c87ae | ||
|
|
661362c89c | ||
|
|
587b06768f | ||
|
|
862be9fb3d | ||
|
|
68a4c77f5b | ||
|
|
6b8f3fd206 | ||
|
|
1426112f95 | ||
|
|
0204c522bb | ||
|
|
d1787b73db | ||
|
|
d16efadc00 | ||
|
|
7593ba8623 | ||
|
|
c4d65e45da | ||
|
|
60b4105665 | ||
|
|
e6d04550ca | ||
|
|
e0870473b2 | ||
|
|
07d2043081 | ||
|
|
03a7b19228 | ||
|
|
7c70954892 | ||
|
|
192caba631 | ||
|
|
d32b2a4771 | ||
|
|
c438dadc5c | ||
|
|
f8b7008f7c | ||
|
|
9558b2c222 | ||
|
|
5a14b1c5c5 | ||
|
|
9b560b8a41 | ||
|
|
c6af9908e7 | ||
|
|
67fb1df352 | ||
|
|
87592fdfe2 | ||
|
|
beb3311a62 | ||
|
|
e1d7ba5915 | ||
|
|
bf0141a753 | ||
|
|
210ff7d318 | ||
|
|
f50c65f124 | ||
|
|
b5ba210fd5 | ||
|
|
9dea44ef6d | ||
|
|
3e339cde89 | ||
|
|
01eb56e45a | ||
|
|
225e48f632 | ||
|
|
97e86fb2da | ||
|
|
a85cd65775 | ||
|
|
625ff8531f | ||
|
|
7e4929e004 | ||
|
|
310c8530eb | ||
|
|
856076079e | ||
|
|
863069e2c6 | ||
|
|
46a67d30af | ||
|
|
b05fcad7a7 | ||
|
|
9eeb17fa82 | ||
|
|
33be0fbea7 | ||
|
|
6171b4254d | ||
|
|
7e0e29ef17 | ||
|
|
6d25ae5e0c | ||
|
|
9c5acb7ea3 | ||
|
|
832b65ccea | ||
|
|
adca6e9c55 | ||
|
|
aedcf0f897 | ||
|
|
89a3b9a07e | ||
|
|
784ee94108 | ||
|
|
494517a990 | ||
|
|
59d2f88e41 | ||
|
|
c5b3352326 | ||
|
|
e57b137aef | ||
|
|
23f73b3ecf | ||
|
|
2fa853dce5 | ||
|
|
e975c3b212 | ||
|
|
df0d061c7a | ||
|
|
916234977a | ||
|
|
e650f8930d | ||
|
|
31bbe6eb0f | ||
|
|
f1f7525aa5 | ||
|
|
2be9abfb1c | ||
|
|
4b940f66fd | ||
|
|
41b4eb97f0 | ||
|
|
8b7d28354e | ||
|
|
2bcb0abbb8 | ||
|
|
f9d015346e | ||
|
|
2bdbf240a9 | ||
|
|
6920ec6c54 | ||
|
|
5d4166a368 | ||
|
|
306ca4d3eb | ||
|
|
3bc7d4061b | ||
|
|
ae42768e5f | ||
|
|
c98ccbe513 | ||
|
|
bd81a0323c | ||
|
|
bf7bd8dcd1 | ||
|
|
4159a11ea3 | ||
|
|
da8c11aaae | ||
|
|
dccf5f6842 | ||
|
|
8c9ec0724e | ||
|
|
9aec9200f1 | ||
|
|
d7d85d1eb6 | ||
|
|
0642b0033b | ||
|
|
7f46876a5d | ||
|
|
29951fdbb7 | ||
|
|
51f4a5e8a0 | ||
|
|
92492ecdd0 | ||
|
|
ba48d162af | ||
|
|
af8d2f2948 | ||
|
|
37ba583b05 | ||
|
|
05b3774c28 | ||
|
|
24e4dc68b7 | ||
|
|
2c9f284c1e | ||
|
|
80a563b4e4 | ||
|
|
b7d61c8daf | ||
|
|
c8bec51869 | ||
|
|
f436b4310a | ||
|
|
f78434985a | ||
|
|
33685e1474 | ||
|
|
abf78a0727 | ||
|
|
bc81d243ba | ||
|
|
c8c6df73a9 | ||
|
|
202dd7590d | ||
|
|
639107b7db | ||
|
|
872e068470 | ||
|
|
0eef4c49f6 | ||
|
|
0240cc578c | ||
|
|
80ca48418a |
130
.agents/skills/autoreview/SKILL.md
Normal file
130
.agents/skills/autoreview/SKILL.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: autoreview
|
||||
description: "Autoreview closeout: local dirty changes, PR branch vs main, parallel tests."
|
||||
---
|
||||
|
||||
# Autoreview
|
||||
|
||||
Run Codex's built-in code review as a closeout check. This is code review (`codex review`), not Guardian `auto_review` approval routing.
|
||||
|
||||
Codex native review mode performs best and is recommended. Non-Codex reviewers are fallback/second-opinion paths that receive a generated diff prompt, not the full Codex review-mode runtime.
|
||||
|
||||
Use when:
|
||||
- user asks for Codex review / autoreview / second-model review
|
||||
- after non-trivial code edits, before final/commit/ship
|
||||
- reviewing a local branch or PR branch after fixes
|
||||
|
||||
## Contract
|
||||
|
||||
- Treat review output as advisory. Never blindly apply it.
|
||||
- Verify every finding by reading the real code path and adjacent files.
|
||||
- Read dependency docs/source/types when the finding depends on external behavior.
|
||||
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- Keep going until the selected review path returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the review helper.
|
||||
- Default to Codex review. If Codex is unavailable or exits with an error, the helper falls back to the first configured CLI from `claude -p`, `pi -p`, `opencode run`, `droid exec`, or `copilot`. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
|
||||
- Stop as soon as the review command/helper exits 0 with no accepted/actionable findings. Do not run an extra direct `codex review` just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
|
||||
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
|
||||
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
|
||||
- Do not push just to review. Push only when the user requested push/ship/PR update.
|
||||
|
||||
## Pick Target
|
||||
|
||||
Dirty local work:
|
||||
|
||||
```bash
|
||||
codex review --uncommitted
|
||||
```
|
||||
|
||||
Use this only when the patch is actually unstaged/staged/untracked in the
|
||||
current checkout. For committed, pushed, or PR work, point Codex at the commit
|
||||
or branch diff instead; do not force `--mode local` / `--uncommitted` just
|
||||
because the helper docs mention dirty work first. A clean `--uncommitted` review
|
||||
only proves there is no local patch.
|
||||
|
||||
Branch/PR work:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
codex review --base origin/main
|
||||
```
|
||||
|
||||
Do not pass an inline prompt with `--base`; current CLI rejects `--base` + `[PROMPT]` even though help text is ambiguous. If custom instructions are needed, run the plain base review first, then do a local/manual follow-up pass.
|
||||
|
||||
If an open PR exists, use its actual base:
|
||||
|
||||
```bash
|
||||
base=$(gh pr view --json baseRefName --jq .baseRefName)
|
||||
codex review --base "origin/$base"
|
||||
```
|
||||
|
||||
Committed single change:
|
||||
|
||||
```bash
|
||||
codex review --commit HEAD
|
||||
```
|
||||
|
||||
or with the helper:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/autoreview --mode commit --commit HEAD
|
||||
```
|
||||
|
||||
Use commit review for already-landed or already-pushed work on `main`. Reviewing
|
||||
clean `main` against `origin/main` is usually an empty diff after push. For a
|
||||
small stack, review each commit explicitly or review the branch before merging
|
||||
with `--base`.
|
||||
|
||||
## Parallel Closeout
|
||||
|
||||
Format first if formatting can change line locations. Then it is OK to run tests and review in parallel:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/autoreview --parallel-tests "<focused test command>"
|
||||
```
|
||||
|
||||
Tradeoff: tests may force code changes that stale the review. If tests or review lead to code edits, rerun the affected tests and rerun review until no accepted/actionable findings remain. Once that rerun exits cleanly, stop; do not spend another long review cycle on redundant confirmation.
|
||||
|
||||
## Context Efficiency
|
||||
|
||||
Codex review is usually noisy. Default to a subagent filter when subagents are available. Ask it to run the review and return only:
|
||||
- actionable findings it accepts
|
||||
- findings it rejects, with one-line reason
|
||||
- exact files/tests to rerun
|
||||
|
||||
Run inline only for tiny changes or when subagents are unavailable.
|
||||
|
||||
## Helper
|
||||
|
||||
Bundled helper:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/autoreview --help
|
||||
```
|
||||
|
||||
The helper:
|
||||
- chooses dirty `--uncommitted` first
|
||||
- otherwise uses current PR base if `gh pr view` works
|
||||
- otherwise uses `origin/main` for non-main branches
|
||||
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
|
||||
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
|
||||
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto` means Codex first
|
||||
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is configured CLI fallback
|
||||
- falls back only when Codex is unavailable or exits nonzero, not when Codex reports findings
|
||||
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
|
||||
- supports `--dry-run`, `--parallel-tests`, and commit refs
|
||||
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access` by default
|
||||
- keeps accepting `--full-access`; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt out
|
||||
- still accepts legacy `CODEX_REVIEW_*` env vars when the matching `AUTOREVIEW_*` var is unset
|
||||
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
|
||||
|
||||
## Final Report
|
||||
|
||||
Include:
|
||||
- review command used
|
||||
- tests/proof run
|
||||
- findings accepted/rejected, briefly why
|
||||
- the clean review result from the final helper/review run, or why a remaining finding was consciously rejected
|
||||
|
||||
Do not run another Codex review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean.
|
||||
630
.agents/skills/autoreview/scripts/autoreview
Executable file
630
.agents/skills/autoreview/scripts/autoreview
Executable file
@@ -0,0 +1,630 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: autoreview [options]
|
||||
|
||||
Options:
|
||||
--mode auto|local|branch|commit
|
||||
Target selection. Default: auto.
|
||||
--base REF Base ref for branch review. Default: PR base or origin/main.
|
||||
--commit REF Commit ref for commit review. Default: HEAD.
|
||||
--reviewer codex|claude|pi|opencode|droid|copilot|auto
|
||||
Review engine. Default: Codex with configured fallback on error.
|
||||
--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none
|
||||
Fallback when Codex is unavailable or exits nonzero. Default: auto.
|
||||
--codex-bin PATH Codex binary. Default: codex.
|
||||
--claude-bin PATH Claude binary. Default: claude.
|
||||
--pi-bin PATH Pi binary. Default: pi.
|
||||
--opencode-bin PATH OpenCode binary. Default: opencode.
|
||||
--droid-bin PATH Droid binary. Default: droid.
|
||||
--copilot-bin PATH GitHub Copilot binary. Default: copilot.
|
||||
--full-access Keep yolo/full-access mode enabled. Default.
|
||||
--no-yolo Run nested Codex review with normal sandbox/approval prompts.
|
||||
--output FILE Also save output to file.
|
||||
--parallel-tests CMD Run review and test command concurrently.
|
||||
--dry-run Print selected commands, do not run.
|
||||
-h, --help Show help.
|
||||
|
||||
Modes:
|
||||
local codex review --uncommitted
|
||||
branch codex review --base <base>
|
||||
commit codex review --commit <commit>
|
||||
auto dirty tree -> local, else PR/current branch -> branch
|
||||
EOF
|
||||
}
|
||||
|
||||
mode=auto
|
||||
base_ref=
|
||||
commit_ref=HEAD
|
||||
reviewer=${AUTOREVIEW_REVIEWER:-${CODEX_REVIEW_REVIEWER:-auto}}
|
||||
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-auto}}
|
||||
codex_bin=${CODEX_BIN:-codex}
|
||||
claude_bin=${CLAUDE_BIN:-claude}
|
||||
pi_bin=${PI_BIN:-pi}
|
||||
opencode_bin=${OPENCODE_BIN:-opencode}
|
||||
droid_bin=${DROID_BIN:-droid}
|
||||
copilot_bin=${COPILOT_BIN:-copilot}
|
||||
codex_args=()
|
||||
yolo=${AUTOREVIEW_YOLO:-${CODEX_REVIEW_YOLO:-1}}
|
||||
output=${AUTOREVIEW_OUTPUT:-${CODEX_REVIEW_OUTPUT:-}}
|
||||
parallel_tests=
|
||||
dry_run=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
mode=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--base)
|
||||
base_ref=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--commit)
|
||||
commit_ref=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--reviewer)
|
||||
reviewer=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--fallback-reviewer)
|
||||
fallback_reviewer=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--codex-bin)
|
||||
codex_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--claude-bin)
|
||||
claude_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--pi-bin)
|
||||
pi_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--opencode-bin)
|
||||
opencode_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--droid-bin)
|
||||
droid_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--copilot-bin)
|
||||
copilot_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--full-access)
|
||||
yolo=1
|
||||
shift
|
||||
;;
|
||||
--no-yolo)
|
||||
yolo=0
|
||||
shift
|
||||
;;
|
||||
--output)
|
||||
output=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--parallel-tests)
|
||||
parallel_tests=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$yolo" in
|
||||
0|false|False|FALSE|no|No|NO|off|Off|OFF) ;;
|
||||
*) codex_args+=(--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access) ;;
|
||||
esac
|
||||
|
||||
case "$mode" in
|
||||
auto|local|branch|commit) ;;
|
||||
*)
|
||||
echo "invalid --mode: $mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$reviewer" in
|
||||
auto|codex|claude|pi|opencode|droid|copilot) ;;
|
||||
*)
|
||||
echo "invalid --reviewer: $reviewer" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$fallback_reviewer" in
|
||||
auto|claude|pi|opencode|droid|copilot|none) ;;
|
||||
*)
|
||||
echo "invalid --fallback-reviewer: $fallback_reviewer" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
|
||||
current_branch=$(git branch --show-current 2>/dev/null || true)
|
||||
dirty=false
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
dirty=true
|
||||
fi
|
||||
|
||||
pr_url=
|
||||
if [[ -z "$base_ref" && "$mode" != local ]] && command -v gh >/dev/null 2>&1; then
|
||||
if pr_lines=$(gh pr view --json baseRefName,url --jq '[.baseRefName, .url] | @tsv' 2>/dev/null); then
|
||||
base_name=${pr_lines%%$'\t'*}
|
||||
pr_url=${pr_lines#*$'\t'}
|
||||
if [[ -n "$base_name" ]]; then
|
||||
base_ref="origin/$base_name"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$base_ref" ]]; then
|
||||
base_ref=origin/main
|
||||
fi
|
||||
|
||||
review_kind=
|
||||
if [[ "$mode" == local || ( "$mode" == auto && "$dirty" == true ) ]]; then
|
||||
review_kind=local
|
||||
elif [[ "$mode" == commit ]]; then
|
||||
review_kind=commit
|
||||
elif [[ "$mode" == branch || ( "$mode" == auto && -n "$current_branch" && "$current_branch" != "main" ) ]]; then
|
||||
review_kind=branch
|
||||
else
|
||||
echo "no review target: clean main checkout and no forced mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$review_kind" == local ]]; then
|
||||
review_cmd=("$codex_bin" "${codex_args[@]}" review --uncommitted)
|
||||
elif [[ "$review_kind" == commit ]]; then
|
||||
review_cmd=("$codex_bin" "${codex_args[@]}" review --commit "$commit_ref")
|
||||
else
|
||||
review_cmd=("$codex_bin" "${codex_args[@]}" review --base "$base_ref")
|
||||
fi
|
||||
|
||||
printf 'autoreview target: %s\n' "$review_kind"
|
||||
printf 'branch: %s\n' "${current_branch:-detached}"
|
||||
if [[ -n "$pr_url" ]]; then
|
||||
printf 'pr: %s\n' "$pr_url"
|
||||
fi
|
||||
if [[ "$reviewer" == auto ]]; then
|
||||
printf 'reviewer: codex\n'
|
||||
else
|
||||
printf 'reviewer: %s\n' "$reviewer"
|
||||
fi
|
||||
case "$reviewer" in
|
||||
codex|auto) ;;
|
||||
*)
|
||||
printf 'note: Codex native review mode is the recommended and best-supported review path; %s uses a generated diff prompt.\n' "$reviewer"
|
||||
;;
|
||||
esac
|
||||
if [[ "$reviewer" == auto || "$reviewer" == codex ]]; then
|
||||
printf 'review:'
|
||||
printf ' %q' "${review_cmd[@]}"
|
||||
printf '\n'
|
||||
else
|
||||
printf 'review: %s prompt review\n' "$reviewer"
|
||||
fi
|
||||
if [[ -n "$parallel_tests" ]]; then
|
||||
printf 'tests: %s\n' "$parallel_tests"
|
||||
fi
|
||||
if [[ "$review_kind" == branch ]]; then
|
||||
printf 'fetch: git fetch origin --quiet\n'
|
||||
fi
|
||||
if [[ -n "$output" ]]; then
|
||||
printf 'output: %s\n' "$output"
|
||||
fi
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$review_kind" == branch ]]; then
|
||||
git fetch origin --quiet || {
|
||||
echo "warning: git fetch origin failed; reviewing with existing refs" >&2
|
||||
}
|
||||
fi
|
||||
|
||||
review_output=$output
|
||||
review_output_is_temp=false
|
||||
if [[ -z "$review_output" ]]; then
|
||||
review_output=$(mktemp)
|
||||
review_output_is_temp=true
|
||||
fi
|
||||
mkdir -p "$(dirname "$review_output")"
|
||||
: > "$review_output"
|
||||
|
||||
cleanup() {
|
||||
if [[ "${review_output_is_temp:-false}" == true && -n "${review_output:-}" ]]; then
|
||||
rm -f "$review_output"
|
||||
fi
|
||||
if [[ -n "${prompt_file:-}" ]]; then
|
||||
rm -f "$prompt_file"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
run_review() {
|
||||
mkdir -p "$(dirname "$review_output")"
|
||||
"${review_cmd[@]}" 2>&1 | tee "$review_output"
|
||||
}
|
||||
|
||||
diff_for_review() {
|
||||
case "$review_kind" in
|
||||
local)
|
||||
git -C "$repo_root" diff --stat
|
||||
git -C "$repo_root" diff --cached --stat
|
||||
git -C "$repo_root" diff --find-renames
|
||||
git -C "$repo_root" diff --cached --find-renames
|
||||
while IFS= read -r untracked_file; do
|
||||
[[ -n "$untracked_file" ]] || continue
|
||||
git -C "$repo_root" diff --no-index -- /dev/null "$untracked_file" || true
|
||||
done < <(git -C "$repo_root" ls-files --others --exclude-standard)
|
||||
;;
|
||||
commit)
|
||||
git -C "$repo_root" show --find-renames --stat --format=fuller "$commit_ref"
|
||||
git -C "$repo_root" show --find-renames --format=medium "$commit_ref"
|
||||
;;
|
||||
branch)
|
||||
git -C "$repo_root" diff --find-renames --stat "$base_ref"...HEAD
|
||||
git -C "$repo_root" diff --find-renames "$base_ref"...HEAD
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_prompt_file() {
|
||||
prompt_file=$(mktemp)
|
||||
{
|
||||
cat <<EOF
|
||||
You are performing a closeout code review for the current repository.
|
||||
|
||||
Review target: $review_kind
|
||||
Branch: ${current_branch:-detached}
|
||||
Base: ${base_ref:-}
|
||||
Commit: ${commit_ref:-}
|
||||
|
||||
Rules:
|
||||
- Review the proposed code change as a closeout reviewer.
|
||||
- Focus on the diff below. If your CLI exposes read-only repository tools, inspect surrounding code and tests to verify findings; never modify files.
|
||||
- Do not modify files.
|
||||
- Report only discrete, actionable issues introduced by this change.
|
||||
- Prioritize correctness, regressions, security, data loss, performance cliffs, and missing tests that would catch a real bug.
|
||||
- Do not report pre-existing issues, speculative risks, broad rewrites, style nits, changelog gaps, or findings that depend on unstated assumptions.
|
||||
- Identify the concrete scenario where the issue appears, and keep the line reference as small as possible.
|
||||
- A finding should overlap changed code or clearly cite changed code as the cause.
|
||||
- For each accepted/actionable finding, use exactly this format:
|
||||
[P<0-3>] Short title
|
||||
File: path:line
|
||||
Why: one sentence
|
||||
Fix: one sentence
|
||||
- If no accepted/actionable findings, output exactly:
|
||||
autoreview clean: no accepted/actionable findings reported
|
||||
|
||||
Diff:
|
||||
EOF
|
||||
diff_for_review
|
||||
} > "$prompt_file" || return
|
||||
}
|
||||
|
||||
reviewer_output_has_clean_marker() {
|
||||
local path=$1
|
||||
grep -Eq '^[^[:alnum:]]*autoreview clean: no accepted/actionable findings reported[[:space:]]*$' "$path"
|
||||
}
|
||||
|
||||
run_prompt_reviewer() {
|
||||
local selected=$1
|
||||
local copilot_prompt=
|
||||
local prompt_bytes=0
|
||||
local reviewer_output
|
||||
local status=0
|
||||
|
||||
if ! build_prompt_file; then
|
||||
rm -f "${prompt_file:-}"
|
||||
prompt_file=
|
||||
return 1
|
||||
fi
|
||||
reviewer_output=$(mktemp)
|
||||
mkdir -p "$(dirname "$review_output")"
|
||||
|
||||
case "$selected" in
|
||||
claude)
|
||||
if ! command -v "$claude_bin" >/dev/null 2>&1; then
|
||||
echo "fallback reviewer unavailable: $claude_bin" >&2
|
||||
status=127
|
||||
elif printf 'fallback: claude -p\n' | tee -a "$review_output"; then
|
||||
"$claude_bin" --tools "" --no-session-persistence -p < "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=$?
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
;;
|
||||
pi)
|
||||
if ! command -v "$pi_bin" >/dev/null 2>&1; then
|
||||
echo "fallback reviewer unavailable: $pi_bin" >&2
|
||||
status=127
|
||||
elif printf 'fallback: pi -p\n' | tee -a "$review_output"; then
|
||||
"$pi_bin" --no-tools --no-session -p < "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=$?
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
;;
|
||||
opencode)
|
||||
if ! command -v "$opencode_bin" >/dev/null 2>&1; then
|
||||
echo "fallback reviewer unavailable: $opencode_bin" >&2
|
||||
status=127
|
||||
elif printf 'fallback: opencode run\n' | tee -a "$review_output"; then
|
||||
"$opencode_bin" run --pure --dir "$repo_root" \
|
||||
"Review the attached prompt file. Do not modify files." \
|
||||
--file "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=$?
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
;;
|
||||
droid)
|
||||
if ! command -v "$droid_bin" >/dev/null 2>&1; then
|
||||
echo "fallback reviewer unavailable: $droid_bin" >&2
|
||||
status=127
|
||||
elif printf 'fallback: droid exec\n' | tee -a "$review_output"; then
|
||||
"$droid_bin" exec --cwd "$repo_root" -f "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=$?
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
;;
|
||||
copilot)
|
||||
if ! command -v "$copilot_bin" >/dev/null 2>&1; then
|
||||
echo "fallback reviewer unavailable: $copilot_bin" >&2
|
||||
status=127
|
||||
elif printf 'fallback: copilot\n' | tee -a "$review_output"; then
|
||||
prompt_bytes=$(wc -c < "$prompt_file" | tr -d '[:space:]')
|
||||
if (( prompt_bytes > 120000 )); then
|
||||
echo "copilot reviewer unavailable: generated prompt is too large for copilot -p; use codex, droid, or another file/stdin-capable reviewer" \
|
||||
2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=1
|
||||
else
|
||||
copilot_prompt=$(< "$prompt_file")
|
||||
"$copilot_bin" -C "$repo_root" --available-tools=none --stream off --output-format text --silent \
|
||||
-p "$copilot_prompt" \
|
||||
2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=$?
|
||||
fi
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "unsupported prompt reviewer: $selected" >&2
|
||||
status=2
|
||||
;;
|
||||
esac
|
||||
if [[ "$status" == 0 ]]; then
|
||||
if grep -Eq '\[P[0-3]\]' "$reviewer_output"; then
|
||||
status=1
|
||||
elif ! grep -q '[^[:space:]]' "$reviewer_output"; then
|
||||
status=1
|
||||
elif ! reviewer_output_has_clean_marker "$reviewer_output"; then
|
||||
status=1
|
||||
fi
|
||||
fi
|
||||
rm -f "$reviewer_output"
|
||||
rm -f "$prompt_file"
|
||||
prompt_file=
|
||||
return "$status"
|
||||
}
|
||||
|
||||
run_selected_review() {
|
||||
local selected=$1
|
||||
case "$selected" in
|
||||
codex)
|
||||
if ! command -v "$codex_bin" >/dev/null 2>&1; then
|
||||
echo "codex reviewer unavailable: $codex_bin" >&2
|
||||
return 127
|
||||
fi
|
||||
run_review
|
||||
;;
|
||||
claude|pi|opencode|droid|copilot)
|
||||
run_prompt_reviewer "$selected"
|
||||
;;
|
||||
*)
|
||||
echo "unsupported reviewer: $selected" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
fallback_reviewer_is_available() {
|
||||
local selected=$1
|
||||
case "$selected" in
|
||||
claude) command -v "$claude_bin" >/dev/null 2>&1 ;;
|
||||
pi) command -v "$pi_bin" >/dev/null 2>&1 ;;
|
||||
opencode) command -v "$opencode_bin" >/dev/null 2>&1 ;;
|
||||
droid) command -v "$droid_bin" >/dev/null 2>&1 ;;
|
||||
copilot) command -v "$copilot_bin" >/dev/null 2>&1 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_auto_fallback_review() {
|
||||
local selected
|
||||
if [[ "$fallback_reviewer" != auto ]]; then
|
||||
run_selected_review "$fallback_reviewer"
|
||||
return $?
|
||||
fi
|
||||
|
||||
for selected in claude pi opencode droid copilot; do
|
||||
if fallback_reviewer_is_available "$selected"; then
|
||||
run_selected_review "$selected"
|
||||
return $?
|
||||
fi
|
||||
done
|
||||
|
||||
echo "fallback reviewer unavailable: no configured fallback CLI found" >&2
|
||||
return 127
|
||||
}
|
||||
|
||||
run_auto_review() {
|
||||
run_selected_review codex
|
||||
local status=$?
|
||||
if [[ "$status" == 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
if (( status > 128 && status < 192 )); then
|
||||
return "$status"
|
||||
fi
|
||||
if review_output_has_findings; then
|
||||
return "$status"
|
||||
fi
|
||||
if [[ "$fallback_reviewer" == none ]]; then
|
||||
return "$status"
|
||||
fi
|
||||
if [[ "$fallback_reviewer" == auto ]]; then
|
||||
printf 'autoreview warning: codex exited %s; trying configured fallback reviewers\n' "$status" >&2
|
||||
else
|
||||
printf 'autoreview warning: codex exited %s; falling back to %s\n' "$status" "$fallback_reviewer" >&2
|
||||
fi
|
||||
run_auto_fallback_review
|
||||
}
|
||||
|
||||
elapsed_since() {
|
||||
local started_at=$1
|
||||
local finished_at
|
||||
finished_at=$(date +%s)
|
||||
printf '%s\n' "$((finished_at - started_at))"
|
||||
}
|
||||
|
||||
format_elapsed() {
|
||||
local seconds=$1
|
||||
if (( seconds < 60 )); then
|
||||
printf '%ss\n' "$seconds"
|
||||
else
|
||||
printf '%sm%ss\n' "$((seconds / 60))" "$((seconds % 60))"
|
||||
fi
|
||||
}
|
||||
|
||||
review_output_empty() {
|
||||
[[ ! -s "$review_output" ]] || ! grep -q '[^[:space:]]' "$review_output"
|
||||
}
|
||||
|
||||
review_findings_text() {
|
||||
if grep -Fxq 'codex' "$review_output"; then
|
||||
awk '
|
||||
$0 == "codex" {
|
||||
capture = 1
|
||||
output = $0 ORS
|
||||
next
|
||||
}
|
||||
capture {
|
||||
output = output $0 ORS
|
||||
}
|
||||
END {
|
||||
printf "%s", output
|
||||
}
|
||||
' "$review_output"
|
||||
return
|
||||
fi
|
||||
cat "$review_output"
|
||||
}
|
||||
|
||||
review_output_has_findings() {
|
||||
review_findings_text | grep -Eq '\[P[0-3]\]'
|
||||
}
|
||||
|
||||
report_clean_review_or_fail() {
|
||||
local elapsed_text
|
||||
elapsed_text=$(format_elapsed "${review_elapsed_seconds:-0}")
|
||||
|
||||
if review_output_has_findings; then
|
||||
printf 'autoreview complete after %s\n' "$elapsed_text"
|
||||
printf 'autoreview findings: accepted/actionable findings reported\n'
|
||||
return 1
|
||||
fi
|
||||
if review_output_empty; then
|
||||
printf 'autoreview complete after %s; no output\n' "$elapsed_text"
|
||||
return 1
|
||||
fi
|
||||
printf 'autoreview complete after %s\n' "$elapsed_text"
|
||||
printf 'autoreview clean: no accepted/actionable findings reported\n'
|
||||
}
|
||||
|
||||
if [[ -z "$parallel_tests" ]]; then
|
||||
review_started_at=$(date +%s)
|
||||
set +e
|
||||
if [[ "$reviewer" == auto ]]; then
|
||||
run_auto_review
|
||||
else
|
||||
run_selected_review "$reviewer"
|
||||
fi
|
||||
review_status=$?
|
||||
review_elapsed_seconds=$(elapsed_since "$review_started_at")
|
||||
set -e
|
||||
if [[ "$review_status" == 0 ]]; then
|
||||
report_clean_review_or_fail
|
||||
exit $?
|
||||
fi
|
||||
exit "$review_status"
|
||||
fi
|
||||
|
||||
review_status_file=$(mktemp)
|
||||
review_elapsed_file=$(mktemp)
|
||||
tests_status_file=$(mktemp)
|
||||
|
||||
(
|
||||
set +e
|
||||
review_started_at=$(date +%s)
|
||||
if [[ "$reviewer" == auto ]]; then
|
||||
run_auto_review
|
||||
else
|
||||
run_selected_review "$reviewer"
|
||||
fi
|
||||
status=$?
|
||||
elapsed=$(elapsed_since "$review_started_at")
|
||||
printf '%s\n' "$status" > "$review_status_file"
|
||||
printf '%s\n' "$elapsed" > "$review_elapsed_file"
|
||||
) &
|
||||
review_pid=$!
|
||||
|
||||
(
|
||||
set +e
|
||||
bash -lc "$parallel_tests"
|
||||
status=$?
|
||||
printf '%s\n' "$status" > "$tests_status_file"
|
||||
) &
|
||||
tests_pid=$!
|
||||
|
||||
wait "$review_pid" || true
|
||||
wait "$tests_pid" || true
|
||||
|
||||
review_status=$(cat "$review_status_file")
|
||||
review_elapsed_seconds=$(cat "$review_elapsed_file")
|
||||
tests_status=$(cat "$tests_status_file")
|
||||
rm -f "$review_status_file" "$review_elapsed_file" "$tests_status_file"
|
||||
|
||||
printf 'autoreview exit: %s\n' "$review_status"
|
||||
printf 'tests exit: %s\n' "$tests_status"
|
||||
|
||||
if [[ "$review_status" != 0 || "$tests_status" != 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
report_clean_review_or_fail
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
name: codex-review
|
||||
description: "Codex code review closeout: local dirty changes, PR branch vs main, parallel tests."
|
||||
---
|
||||
|
||||
# Codex Review
|
||||
|
||||
Run Codex's built-in code review as a closeout check. This is code review (`codex review`), not Guardian `auto_review` approval routing.
|
||||
|
||||
Use when:
|
||||
- user asks for Codex review / autoreview / second-model review
|
||||
- after non-trivial code edits, before final/commit/ship
|
||||
- reviewing a local branch or PR branch after fixes
|
||||
|
||||
## Contract
|
||||
|
||||
- Treat review output as advisory. Never blindly apply it.
|
||||
- Verify every finding by reading the real code path and adjacent files.
|
||||
- Read dependency docs/source/types when the finding depends on external behavior.
|
||||
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- Keep going until Codex review returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun Codex review.
|
||||
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
|
||||
- Do not push just to review. Push only when the user requested push/ship/PR update.
|
||||
|
||||
## Pick Target
|
||||
|
||||
Dirty local work:
|
||||
|
||||
```bash
|
||||
codex review --uncommitted
|
||||
```
|
||||
|
||||
Branch/PR work:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
codex review --base origin/main
|
||||
```
|
||||
|
||||
Do not pass an inline prompt with `--base`; current CLI rejects `--base` + `[PROMPT]` even though help text is ambiguous. If custom instructions are needed, run the plain base review first, then do a local/manual follow-up pass.
|
||||
|
||||
If an open PR exists, use its actual base:
|
||||
|
||||
```bash
|
||||
base=$(gh pr view --json baseRefName --jq .baseRefName)
|
||||
codex review --base "origin/$base"
|
||||
```
|
||||
|
||||
Committed single change:
|
||||
|
||||
```bash
|
||||
codex review --commit HEAD
|
||||
```
|
||||
|
||||
## Parallel Closeout
|
||||
|
||||
Format first if formatting can change line locations. Then it is OK to run tests and review in parallel:
|
||||
|
||||
```bash
|
||||
scripts/codex-review --parallel-tests "<focused test command>"
|
||||
```
|
||||
|
||||
Tradeoff: tests may force code changes that stale the review. If tests or review lead to code edits, rerun the affected tests and rerun review until no accepted/actionable findings remain.
|
||||
|
||||
## Context Efficiency
|
||||
|
||||
Codex review is usually noisy. Default to a subagent filter when subagents are available. Ask it to run the review and return only:
|
||||
- actionable findings it accepts
|
||||
- findings it rejects, with one-line reason
|
||||
- exact files/tests to rerun
|
||||
|
||||
Run inline only for tiny changes or when subagents are unavailable.
|
||||
|
||||
## Helper
|
||||
|
||||
Bundled helper:
|
||||
|
||||
```bash
|
||||
~/.codex/skills/codex-review/scripts/codex-review --help
|
||||
```
|
||||
|
||||
If installed from `agent-scripts`, path is:
|
||||
|
||||
```bash
|
||||
/Users/steipete/Projects/agent-scripts/skills/codex-review/scripts/codex-review --help
|
||||
```
|
||||
|
||||
The helper:
|
||||
- chooses dirty `--uncommitted` first
|
||||
- otherwise uses current PR base if `gh pr view` works
|
||||
- otherwise uses `origin/main` for non-main branches
|
||||
- writes only to stdout unless `--output` or `CODEX_REVIEW_OUTPUT` is set
|
||||
- supports `--dry-run` and `--parallel-tests`
|
||||
|
||||
## Final Report
|
||||
|
||||
Include:
|
||||
- review command used
|
||||
- tests/proof run
|
||||
- findings accepted/rejected, briefly why
|
||||
- final clean review command, or why a remaining finding was consciously rejected
|
||||
@@ -1,188 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: codex-review [options]
|
||||
|
||||
Options:
|
||||
--mode auto|local|branch Target selection. Default: auto.
|
||||
--base REF Base ref for branch review. Default: PR base or origin/main.
|
||||
--codex-bin PATH Codex binary. Default: codex.
|
||||
--output FILE Also save output to file.
|
||||
--parallel-tests CMD Run review and test command concurrently.
|
||||
--dry-run Print selected commands, do not run.
|
||||
-h, --help Show help.
|
||||
|
||||
Modes:
|
||||
local codex review --uncommitted
|
||||
branch codex review --base <base>
|
||||
auto dirty tree -> local, else PR/current branch -> branch
|
||||
EOF
|
||||
}
|
||||
|
||||
mode=auto
|
||||
base_ref=
|
||||
codex_bin=${CODEX_BIN:-codex}
|
||||
output=${CODEX_REVIEW_OUTPUT:-}
|
||||
parallel_tests=
|
||||
dry_run=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
mode=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--base)
|
||||
base_ref=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--codex-bin)
|
||||
codex_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
output=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--parallel-tests)
|
||||
parallel_tests=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$mode" in
|
||||
auto|local|branch) ;;
|
||||
*)
|
||||
echo "invalid --mode: $mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
git rev-parse --show-toplevel >/dev/null
|
||||
|
||||
current_branch=$(git branch --show-current 2>/dev/null || true)
|
||||
dirty=false
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
dirty=true
|
||||
fi
|
||||
|
||||
pr_url=
|
||||
if [[ -z "$base_ref" && "$mode" != local ]] && command -v gh >/dev/null 2>&1; then
|
||||
if pr_lines=$(gh pr view --json baseRefName,url --jq '[.baseRefName, .url] | @tsv' 2>/dev/null); then
|
||||
base_name=${pr_lines%%$'\t'*}
|
||||
pr_url=${pr_lines#*$'\t'}
|
||||
if [[ -n "$base_name" ]]; then
|
||||
base_ref="origin/$base_name"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$base_ref" ]]; then
|
||||
base_ref=origin/main
|
||||
fi
|
||||
|
||||
review_kind=
|
||||
if [[ "$mode" == local || ( "$mode" == auto && "$dirty" == true ) ]]; then
|
||||
review_kind=local
|
||||
elif [[ "$mode" == branch || ( "$mode" == auto && -n "$current_branch" && "$current_branch" != "main" ) ]]; then
|
||||
review_kind=branch
|
||||
else
|
||||
echo "no review target: clean main checkout and no forced mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$review_kind" == local ]]; then
|
||||
review_cmd=("$codex_bin" review --uncommitted)
|
||||
else
|
||||
review_cmd=("$codex_bin" review --base "$base_ref")
|
||||
fi
|
||||
|
||||
printf 'codex-review target: %s\n' "$review_kind"
|
||||
printf 'branch: %s\n' "${current_branch:-detached}"
|
||||
if [[ -n "$pr_url" ]]; then
|
||||
printf 'pr: %s\n' "$pr_url"
|
||||
fi
|
||||
printf 'review:'
|
||||
printf ' %q' "${review_cmd[@]}"
|
||||
printf '\n'
|
||||
if [[ -n "$parallel_tests" ]]; then
|
||||
printf 'tests: %s\n' "$parallel_tests"
|
||||
fi
|
||||
if [[ "$review_kind" == branch ]]; then
|
||||
printf 'fetch: git fetch origin --quiet\n'
|
||||
fi
|
||||
if [[ -n "$output" ]]; then
|
||||
printf 'output: %s\n' "$output"
|
||||
fi
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$review_kind" == branch ]]; then
|
||||
git fetch origin --quiet || {
|
||||
echo "warning: git fetch origin failed; reviewing with existing refs" >&2
|
||||
}
|
||||
fi
|
||||
|
||||
run_review() {
|
||||
if [[ -n "$output" ]]; then
|
||||
mkdir -p "$(dirname "$output")"
|
||||
"${review_cmd[@]}" 2>&1 | tee "$output"
|
||||
else
|
||||
"${review_cmd[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -z "$parallel_tests" ]]; then
|
||||
run_review
|
||||
exit $?
|
||||
fi
|
||||
|
||||
review_status_file=$(mktemp)
|
||||
tests_status_file=$(mktemp)
|
||||
|
||||
(
|
||||
set +e
|
||||
run_review
|
||||
status=$?
|
||||
printf '%s\n' "$status" > "$review_status_file"
|
||||
) &
|
||||
review_pid=$!
|
||||
|
||||
(
|
||||
set +e
|
||||
bash -lc "$parallel_tests"
|
||||
status=$?
|
||||
printf '%s\n' "$status" > "$tests_status_file"
|
||||
) &
|
||||
tests_pid=$!
|
||||
|
||||
wait "$review_pid" || true
|
||||
wait "$tests_pid" || true
|
||||
|
||||
review_status=$(cat "$review_status_file")
|
||||
tests_status=$(cat "$tests_status_file")
|
||||
rm -f "$review_status_file" "$tests_status_file"
|
||||
|
||||
printf 'codex-review exit: %s\n' "$review_status"
|
||||
printf 'tests exit: %s\n' "$tests_status"
|
||||
|
||||
if [[ "$review_status" != 0 || "$tests_status" != 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,23 +1,32 @@
|
||||
---
|
||||
name: crabbox
|
||||
description: Use Crabbox for OpenClaw remote validation across Linux, macOS, Windows, and WSL2. Default to the repo Crabbox config, use brokered AWS for normal broad proof, and keep Blacksmith Testbox as an explicit opt-in or outage diagnostic path.
|
||||
description: Use the Crabbox wrapper for OpenClaw remote validation across Linux, macOS, Windows, and WSL2, including delegated Blacksmith Testbox proof. Report the actual provider and id.
|
||||
---
|
||||
|
||||
# Crabbox
|
||||
|
||||
Use Crabbox when OpenClaw needs remote Linux proof for broad tests, CI-parity
|
||||
checks, secrets, hosted services, Docker/E2E/package lanes, warmed reusable
|
||||
boxes, sync timing, logs/results, cache inspection, or lease cleanup.
|
||||
Use the Crabbox wrapper when OpenClaw needs remote Linux proof for broad tests,
|
||||
CI-parity checks, secrets, hosted services, Docker/E2E/package lanes, warmed
|
||||
reusable boxes, sync timing, logs/results, cache inspection, or lease cleanup.
|
||||
|
||||
Default backend: the repo `.crabbox.yaml`, currently brokered AWS. Do not
|
||||
override it to Blacksmith unless the user explicitly asks for Blacksmith proof,
|
||||
the task is specifically about Testbox behavior, or AWS/brokered Crabbox is the
|
||||
broken layer.
|
||||
Crabbox is the transport/orchestration surface. The actual backend can be:
|
||||
|
||||
Blacksmith Testbox is a delegated fallback, not the default router. If a
|
||||
Blacksmith run queues, fails capacity, fails auth, or cannot allocate, stop
|
||||
after one real attempt and switch to the repo default or report the blocker.
|
||||
Do not retry Blacksmith in a loop.
|
||||
- brokered AWS Crabbox: direct provider, `provider=aws`, lease ids like
|
||||
`cbx_...`, `syncDelegated=false`
|
||||
- Blacksmith Testbox through Crabbox: delegated provider,
|
||||
`provider=blacksmith-testbox`, ids like `tbx_...`, `syncDelegated=true`
|
||||
|
||||
For OpenClaw maintainer broad `pnpm` gates, Blacksmith Testbox through the
|
||||
Crabbox wrapper is acceptable and often preferred when the standing Testbox
|
||||
rules apply. Do not describe those runs as "AWS Crabbox"; report them as
|
||||
Testbox-through-Crabbox with the `tbx_...` id and Actions run.
|
||||
|
||||
Use the repo `.crabbox.yaml` brokered AWS path when the task specifically needs
|
||||
direct AWS Crabbox behavior, persistent direct-provider leases, `--fresh-pr`,
|
||||
`--full-resync`, environment forwarding, capture/download support, or provider
|
||||
comparison. Use `--provider blacksmith-testbox` when the task needs OpenClaw
|
||||
maintainer Testbox proof, prepared CI environment, broad/heavy pnpm gates, or
|
||||
the user asks for Testbox/Blacksmith.
|
||||
|
||||
## First Checks
|
||||
|
||||
@@ -34,10 +43,15 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
|
||||
|
||||
- OpenClaw scripts prefer `../crabbox/bin/crabbox` when present. The user PATH
|
||||
shim can be stale.
|
||||
- Check `.crabbox.yaml` for repo defaults and honor them. For normal Linux
|
||||
validation, omit `--provider` so the wrapper uses brokered AWS.
|
||||
- Pass `--provider blacksmith-testbox` only for explicit Blacksmith/Testbox
|
||||
work or a deliberate comparison.
|
||||
- Check `.crabbox.yaml` for direct-provider defaults. Omitting `--provider`
|
||||
means brokered AWS today.
|
||||
- For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with
|
||||
`--provider blacksmith-testbox` or the repo Testbox helpers when the standing
|
||||
Testbox policy applies.
|
||||
- Always report the actual provider and id. `cbx_...` means AWS Crabbox;
|
||||
`tbx_...` means Blacksmith Testbox through Crabbox. If the output only says
|
||||
`blacksmith testbox list`, use `blacksmith testbox list --all` before
|
||||
concluding no box exists.
|
||||
- If a warm direct-provider lease smells stale, retry with `--full-resync`
|
||||
(alias `--fresh-sync`) before replacing the lease. This resets the remote
|
||||
workdir, skips the fingerprint fast path, reseeds Git when possible, and
|
||||
@@ -64,6 +78,22 @@ Use these only when the task needs an existing non-Linux host. OpenClaw broad
|
||||
Linux validation uses the repo Crabbox config unless a provider is explicitly
|
||||
requested.
|
||||
|
||||
When the user explicitly asks for brokered macOS runners, use Crabbox AWS
|
||||
macOS only after confirming the deployed coordinator supports EC2 Mac host
|
||||
lifecycle/image routes and the operator has AWS EC2 Mac Dedicated Host quota
|
||||
and IAM. Prefer `CRABBOX_HOST_ID` for a known Crabbox-managed Dedicated Host,
|
||||
or run the no-spend preflight first:
|
||||
|
||||
```sh
|
||||
crabbox admin hosts quota --provider aws --target macos --region eu-west-1 --type mac2.metal --json
|
||||
crabbox admin hosts allocate --provider aws --target macos --region eu-west-1 --type mac2.metal --dry-run --json
|
||||
CRABBOX_MACOS_TYPES=all scripts/macos-host-region-preflight.sh
|
||||
```
|
||||
|
||||
Do not silently substitute AWS macOS for normal OpenClaw Linux proof. Report
|
||||
paid-host blockers as quota, IAM, coordinator deployment, or host availability
|
||||
instead of falling back to local macOS.
|
||||
|
||||
Crabbox supports static SSH targets:
|
||||
|
||||
```sh
|
||||
@@ -83,11 +113,10 @@ Crabbox supports static SSH targets:
|
||||
with `../crabbox/bin/crabbox run --help`, config/flag tests, and the Crabbox
|
||||
Go test suite.
|
||||
|
||||
## Default Brokered AWS Backend
|
||||
## Direct Brokered AWS Backend
|
||||
|
||||
Use this for `pnpm check`, `pnpm check:changed`, `pnpm test`,
|
||||
`pnpm test:changed`, Docker/E2E/live/package gates, or anything likely to fan
|
||||
out across many Vitest projects.
|
||||
Use this when the task needs direct AWS Crabbox semantics rather than the
|
||||
prepared Blacksmith Testbox CI environment.
|
||||
|
||||
Changed gate:
|
||||
|
||||
@@ -124,9 +153,9 @@ pnpm crabbox:run -- \
|
||||
|
||||
Read the JSON summary. Useful fields:
|
||||
|
||||
- `provider`: should normally be `aws`
|
||||
- `provider`: `aws`
|
||||
- `leaseId`: `cbx_...`
|
||||
- `syncDelegated`: should normally be `false`
|
||||
- `syncDelegated`: `false`
|
||||
- `commandPhases`: populated when the command prints `CRABBOX_PHASE:<name>`
|
||||
- `commandMs` / `totalMs`
|
||||
- `exitCode`
|
||||
@@ -138,6 +167,41 @@ cleanup when a run fails, is interrupted, or the command output is unclear:
|
||||
../crabbox/bin/crabbox list --provider aws
|
||||
```
|
||||
|
||||
## Blacksmith Testbox Through Crabbox
|
||||
|
||||
Use this for OpenClaw maintainer broad/heavy `pnpm` gates when the prepared CI
|
||||
environment is the right proof surface:
|
||||
|
||||
```sh
|
||||
node scripts/crabbox-wrapper.mjs run \
|
||||
--provider blacksmith-testbox \
|
||||
--blacksmith-org openclaw \
|
||||
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
|
||||
--blacksmith-job check \
|
||||
--blacksmith-ref main \
|
||||
--idle-timeout 90m \
|
||||
--ttl 240m \
|
||||
--timing-json \
|
||||
-- \
|
||||
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 OPENCLAW_TESTBOX=1 OPENCLAW_TESTBOX_REMOTE_RUN=1 pnpm check:changed
|
||||
```
|
||||
|
||||
Read the JSON summary and the Testbox line. Useful fields:
|
||||
|
||||
- `provider`: `blacksmith-testbox`
|
||||
- `leaseId`: `tbx_...`
|
||||
- `syncDelegated`: `true`
|
||||
- `syncPhases`: delegated/skipped because Blacksmith owns checkout/sync
|
||||
- Actions run URL/id from the Testbox output
|
||||
- `exitCode`
|
||||
|
||||
`blacksmith testbox list` may hide hydrating or ready boxes. Use:
|
||||
|
||||
```sh
|
||||
blacksmith testbox list --all
|
||||
blacksmith testbox status <tbx_id>
|
||||
```
|
||||
|
||||
## Observability Flags
|
||||
|
||||
Use these on debugging runs before inventing ad hoc logging:
|
||||
@@ -223,6 +287,13 @@ Use the smallest Crabbox lane that proves the reported user path, not just the
|
||||
touched code. Aim for one after-fix E2E proof before commenting, closing, or
|
||||
opening a PR for a user-visible bug.
|
||||
|
||||
When the user says "test in Crabbox", do not simply copy tests to the remote
|
||||
box and run them there. Crabbox is for remote real-scenario proof: copy or
|
||||
install OpenClaw as the user would, run the same setup/update/CLI/Gateway/API
|
||||
call that failed, and capture behavior from that entrypoint. For regressions or
|
||||
bug reports, prove the broken state first when feasible, then run the same
|
||||
scenario after the fix.
|
||||
|
||||
Pick the lane by symptom:
|
||||
|
||||
- Docker/setup/install bug: build a package tarball and run the matching
|
||||
@@ -244,8 +315,9 @@ Pick the lane by symptom:
|
||||
|
||||
Efficient flow:
|
||||
|
||||
1. Reproduce or prove the pre-fix symptom when feasible. If the issue cannot be
|
||||
reproduced, capture the exact command and observed behavior instead.
|
||||
1. Reproduce or prove the pre-fix symptom from the real user-facing entrypoint
|
||||
when feasible. If the issue cannot be reproduced, capture the exact command
|
||||
and observed behavior instead.
|
||||
2. Patch locally and run narrow local tests for edit speed.
|
||||
3. Run one Crabbox E2E command that starts from the user-facing entrypoint:
|
||||
package install, Docker setup, onboarding, channel add, gateway start, or
|
||||
@@ -353,18 +425,18 @@ Common desktop flow:
|
||||
|
||||
```sh
|
||||
../crabbox/bin/crabbox warmup --provider hetzner --desktop --browser --class standard --idle-timeout 60m --ttl 240m
|
||||
../crabbox/bin/crabbox desktop launch --provider hetzner --id <cbx_id-or-slug> --browser --url https://example.com --webvnc --open
|
||||
../crabbox/bin/crabbox desktop launch --provider hetzner --id <cbx_id-or-slug> --browser --url https://example.com --webvnc --open --take-control
|
||||
```
|
||||
|
||||
Useful WebVNC commands:
|
||||
|
||||
```sh
|
||||
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --open
|
||||
../crabbox/bin/crabbox webvnc daemon start --provider hetzner --id <cbx_id-or-slug> --open
|
||||
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --open --take-control
|
||||
../crabbox/bin/crabbox webvnc daemon start --provider hetzner --id <cbx_id-or-slug> --open --take-control
|
||||
../crabbox/bin/crabbox webvnc daemon status --provider hetzner --id <cbx_id-or-slug>
|
||||
../crabbox/bin/crabbox webvnc daemon stop --provider hetzner --id <cbx_id-or-slug>
|
||||
../crabbox/bin/crabbox webvnc status --provider hetzner --id <cbx_id-or-slug>
|
||||
../crabbox/bin/crabbox webvnc reset --provider hetzner --id <cbx_id-or-slug> --open
|
||||
../crabbox/bin/crabbox webvnc reset --provider hetzner --id <cbx_id-or-slug> --open --take-control
|
||||
../crabbox/bin/crabbox desktop doctor --provider hetzner --id <cbx_id-or-slug>
|
||||
../crabbox/bin/crabbox desktop click --provider hetzner --id <cbx_id-or-slug> --x 640 --y 420
|
||||
../crabbox/bin/crabbox desktop paste --provider hetzner --id <cbx_id-or-slug> --text "user@example.com"
|
||||
@@ -377,6 +449,32 @@ Useful WebVNC commands:
|
||||
browser/app inside the visible session, bridges the lease into the authenticated
|
||||
WebVNC portal, and opens the portal. Keep browsers windowed for human QA; use
|
||||
`--fullscreen` only for capture/video workflows.
|
||||
For human handoff, include `--take-control` so the opened portal viewer gets
|
||||
keyboard/mouse control automatically instead of landing as an observer.
|
||||
|
||||
Human handoff preflight:
|
||||
|
||||
- Do not assume a visible desktop or launched browser means the repo CLI/app is
|
||||
installed, built, or on the interactive terminal's `PATH`.
|
||||
- Before handing WebVNC to a human tester, prove the expected command from the
|
||||
same kept lease and from a neutral directory such as `~`.
|
||||
- If the handoff needs repo-local code, sync/build/link it explicitly on that
|
||||
lease. Source-tree CLIs often need build output before a symlink works.
|
||||
- Prefer a real `command -v <expected-command> && <expected-command> --version`
|
||||
check over a repo-root-only `pnpm ...` command.
|
||||
|
||||
Generic handoff repair pattern:
|
||||
|
||||
```sh
|
||||
../crabbox/bin/crabbox run --id <cbx_id-or-slug> --full-resync --shell -- \
|
||||
"set -euo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
sudo ln -sf \"\$PWD/<cli-entry>\" /usr/local/bin/<expected-command>
|
||||
cd ~
|
||||
command -v <expected-command>
|
||||
<expected-command> --version"
|
||||
```
|
||||
|
||||
## If Crabbox Fails
|
||||
|
||||
|
||||
44
.agents/skills/discrawl/SKILL.md
Normal file
44
.agents/skills/discrawl/SKILL.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: discrawl
|
||||
description: "Discord archive: search, sync freshness, DMs, channel slices, SQL counts, and Discrawl repo work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/discrawl
|
||||
requires:
|
||||
bins:
|
||||
- discrawl
|
||||
install:
|
||||
- kind: go
|
||||
module: github.com/openclaw/discrawl/cmd/discrawl@latest
|
||||
bins:
|
||||
- discrawl
|
||||
---
|
||||
|
||||
# Discrawl
|
||||
|
||||
Use local Discord archive data before live Discord APIs. Check freshness for recent/current questions:
|
||||
|
||||
```bash
|
||||
discrawl status --json
|
||||
discrawl doctor
|
||||
```
|
||||
|
||||
Refresh only when stale or asked:
|
||||
|
||||
```bash
|
||||
discrawl sync --source wiretap
|
||||
discrawl sync
|
||||
```
|
||||
|
||||
Query with bounded slices:
|
||||
|
||||
```bash
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl search --limit 20 "query"
|
||||
discrawl messages --channel '#maintainers' --days 7 --all
|
||||
discrawl dms --last 20
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select count(*) from messages;"
|
||||
```
|
||||
|
||||
Report absolute date spans, channel/DM names, counts, and known gaps. Use read-only SQL for exact counts/rankings. Never use `--unsafe --confirm` unless the user explicitly requests a reviewed DB mutation.
|
||||
|
||||
Boundaries: bot sync needs configured Discord bot credentials. Wiretap reads local Discord Desktop artifacts only; do not extract user tokens, call Discord as the user, or write to Discord storage. Git-share snapshots must not include secrets or `@me` DM rows.
|
||||
4
.agents/skills/discrawl/agents/openai.yaml
Normal file
4
.agents/skills/discrawl/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Discrawl"
|
||||
short_description: "Search local Discord archives and freshness"
|
||||
default_prompt: "Use $discrawl to search local Discord archives, check freshness, inspect DMs or channel slices, and report exact date spans and source gaps."
|
||||
@@ -1,68 +1,50 @@
|
||||
---
|
||||
name: gitcrawl
|
||||
description: Use gitcrawl for OpenClaw issue and PR archive search, duplicate discovery, related-thread clustering, and local GitHub mirror freshness checks.
|
||||
description: "GitHub archive: issue/PR search, sync freshness, duplicate clusters, gh-shim PR status, and Gitcrawl repo work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/gitcrawl
|
||||
requires:
|
||||
bins:
|
||||
- gitcrawl
|
||||
install:
|
||||
- kind: go
|
||||
module: github.com/openclaw/gitcrawl/cmd/gitcrawl@latest
|
||||
bins:
|
||||
- gitcrawl
|
||||
---
|
||||
|
||||
# Gitcrawl
|
||||
|
||||
Use this skill before live GitHub search when triaging OpenClaw issues or PRs.
|
||||
|
||||
`gitcrawl` is the local candidate-discovery layer. It is fast, includes open and closed threads, and can surface duplicate attempts, related issues, and already-landed fixes. It is not the final source of truth for comments, labels, merges, closes, or current CI.
|
||||
|
||||
## Default Flow
|
||||
|
||||
1. Check local state:
|
||||
Use local GitHub issue/PR archives before live GitHub search. Check freshness first:
|
||||
|
||||
```bash
|
||||
gitcrawl doctor --json
|
||||
```
|
||||
|
||||
2. Read the target from the local archive:
|
||||
Find candidates:
|
||||
|
||||
```bash
|
||||
gitcrawl threads openclaw/openclaw --numbers <issue-or-pr-number> --include-closed --json
|
||||
```
|
||||
|
||||
3. Find related candidates:
|
||||
|
||||
```bash
|
||||
gitcrawl neighbors openclaw/openclaw --number <issue-or-pr-number> --limit 12 --json
|
||||
gitcrawl search openclaw/openclaw --query "<scope or title keywords>" --mode hybrid --limit 20 --json
|
||||
gitcrawl search issues "query" -R openclaw/openclaw --state open --json number,title,url
|
||||
gitcrawl clusters openclaw/openclaw --sort size --min-size 5
|
||||
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id>
|
||||
```
|
||||
|
||||
4. Inspect relevant clusters:
|
||||
For PR triage, start cached and go live only before mutation/merge decisions:
|
||||
|
||||
```bash
|
||||
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
|
||||
gitcrawl gh pr status <number-or-url> -R openclaw/openclaw --compact
|
||||
gitcrawl gh pr view <number-or-url> -R openclaw/openclaw --json number,title,state,url,isDraft,headRef,headSha
|
||||
gitcrawl gh --live pr status <number-or-url> -R openclaw/openclaw --compact
|
||||
```
|
||||
|
||||
5. Verify anything actionable with live GitHub and the checkout:
|
||||
Use live `gh` plus checkout proof before commenting, labeling, closing, reopening, merging, or filing a PR review:
|
||||
|
||||
```bash
|
||||
gh pr view <number> --json number,title,state,mergedAt,body,files,comments,reviews,statusCheckRollup
|
||||
gh issue view <number> --json number,title,state,body,comments,closedAt
|
||||
```
|
||||
|
||||
## Freshness Rules
|
||||
|
||||
- Treat `gitcrawl` as stale if `doctor` shows no target thread, an old `last_sync_at`, missing embeddings for neighbor/search commands, or a clearly wrong open/closed state.
|
||||
- If stale data blocks the decision, refresh the portable store first:
|
||||
|
||||
```bash
|
||||
gitcrawl init --portable-store git@github.com:openclaw/gitcrawl-store.git --json
|
||||
```
|
||||
|
||||
- Run expensive update commands such as `gitcrawl sync --include-comments` only when the user asked to update the local store or stale data is blocking the decision.
|
||||
- The sync default is all GitHub thread states; pass `--state open`, `--state closed`, or `--state all` only when a task requires a narrower or explicit scope.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Use `gitcrawl` for candidates, clusters, and historical context.
|
||||
- Use `gh`, `gh api`, and the current checkout for live state before commenting, labeling, closing, reopening, merging, or filing a PR review.
|
||||
- Do not close or label based only on `gitcrawl` similarity. Require matching problem intent plus live verification.
|
||||
- If `gitcrawl` is unavailable, say so and fall back to targeted `gh search` rather than blocking normal maintainer work.
|
||||
Report absolute dates, repo names, issue/PR numbers, cluster ids, and source gaps. Do not close/label from similarity alone; require matching intent plus live verification.
|
||||
|
||||
44
.agents/skills/graincrawl/SKILL.md
Normal file
44
.agents/skills/graincrawl/SKILL.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: graincrawl
|
||||
description: "Granola archive: search, sync freshness, notes, transcripts, panels, SQL counts, and Graincrawl repo work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/graincrawl
|
||||
requires:
|
||||
bins:
|
||||
- graincrawl
|
||||
install:
|
||||
- kind: go
|
||||
module: github.com/vincentkoc/graincrawl/cmd/graincrawl@latest
|
||||
bins:
|
||||
- graincrawl
|
||||
---
|
||||
|
||||
# Graincrawl
|
||||
|
||||
Use local Granola archive data first. Check freshness for recent/current questions:
|
||||
|
||||
```bash
|
||||
graincrawl doctor --json
|
||||
graincrawl status --json
|
||||
```
|
||||
|
||||
Refresh only when stale or asked:
|
||||
|
||||
```bash
|
||||
graincrawl sync --source private-api
|
||||
graincrawl sync --source desktop-cache
|
||||
```
|
||||
|
||||
Query with bounded reads:
|
||||
|
||||
```bash
|
||||
graincrawl search "query"
|
||||
graincrawl notes --json
|
||||
graincrawl note get <id>
|
||||
graincrawl transcripts get <id>
|
||||
graincrawl panels get <id>
|
||||
graincrawl --json sql "select count(*) as notes from notes;"
|
||||
```
|
||||
|
||||
Report absolute date spans, note titles, source gaps, and transcript/panel availability. Use read-only SQL for exact counts/rankings. Before encrypted source debugging, run explicit unlock/secrets checks; do not surprise-prompt Keychain.
|
||||
4
.agents/skills/graincrawl/agents/openai.yaml
Normal file
4
.agents/skills/graincrawl/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Graincrawl"
|
||||
short_description: "Search local Granola notes and transcripts"
|
||||
default_prompt: "Use $graincrawl to search local Granola notes, transcripts, and panels, check freshness, and report exact date spans and source gaps."
|
||||
42
.agents/skills/notcrawl/SKILL.md
Normal file
42
.agents/skills/notcrawl/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: notcrawl
|
||||
description: "Notion archive: search, sync freshness, pages/databases, Markdown exports, SQL counts, and Notcrawl repo work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/notcrawl
|
||||
requires:
|
||||
bins:
|
||||
- notcrawl
|
||||
install:
|
||||
- kind: go
|
||||
module: github.com/vincentkoc/notcrawl/cmd/notcrawl@latest
|
||||
bins:
|
||||
- notcrawl
|
||||
---
|
||||
|
||||
# Notcrawl
|
||||
|
||||
Use local Notion archive data before browsing or live Notion API calls. Check freshness for recent/current questions:
|
||||
|
||||
```bash
|
||||
notcrawl doctor
|
||||
notcrawl status --json
|
||||
```
|
||||
|
||||
Refresh only when stale or asked:
|
||||
|
||||
```bash
|
||||
notcrawl sync --source desktop
|
||||
notcrawl sync --source api
|
||||
```
|
||||
|
||||
Query with bounded reads:
|
||||
|
||||
```bash
|
||||
notcrawl search "query"
|
||||
notcrawl databases
|
||||
notcrawl report
|
||||
notcrawl sql "select count(*) from pages;"
|
||||
```
|
||||
|
||||
Report workspace/teamspace, page/database titles, absolute date spans, counts, and known gaps. Use read-only SQL only; never mutate the archive. API mode requires `NOTION_TOKEN`; do not assume token availability.
|
||||
4
.agents/skills/notcrawl/agents/openai.yaml
Normal file
4
.agents/skills/notcrawl/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Notcrawl"
|
||||
short_description: "Search local Notion archives and freshness"
|
||||
default_prompt: "Use $notcrawl to search local Notion pages and databases, check freshness, inspect exports, and report exact date spans and source gaps."
|
||||
@@ -24,6 +24,36 @@ gitcrawl search openclaw/openclaw --query "<scope or title keywords>" --mode hyb
|
||||
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
|
||||
```
|
||||
|
||||
## Claim specific review targets
|
||||
|
||||
When a maintainer asks Codex to review, triage, fix, or land a specific OpenClaw issue/PR, check assignment before deep work.
|
||||
|
||||
- Identify the requesting maintainer's GitHub login. In this environment, default Peter to `steipete`; if another maintainer is clearly the requester, use that maintainer's bare login.
|
||||
- Read current assignees with live `gh issue view` / `gh pr view`; `gitcrawl` is not enough for assignment state.
|
||||
- If unassigned, assign the requester before deep review. This is allowed for specific requested targets; do not auto-assign broad discovery candidates or shortlists.
|
||||
- If assigned to someone else, say so clearly before analysis and include assignment age:
|
||||
- fresh: assigned within 6h; treat as actively owned unless user explicitly asks to continue or reassign
|
||||
- stale: assigned 6h+ ago; treat as ownership hint, not a hard block; continue only with that caveat
|
||||
- If assigned to requester plus others, mention co-assignees and continue.
|
||||
- If assignment event time is unavailable, say `assigned, time unknown`; treat as assigned, not stale.
|
||||
- Never remove or replace assignees unless explicitly asked.
|
||||
|
||||
Assignment time proof:
|
||||
|
||||
```bash
|
||||
gh api "repos/openclaw/openclaw/issues/<number>/timeline" --paginate \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--jq '[.[] | select(.event=="assigned") | {assignee:.assignee.login, assigner:.assigner.login, actor:.actor.login, created_at}]'
|
||||
```
|
||||
|
||||
Use the newest `assigned` event for each current assignee. Issue timeline events expose `created_at`; GitHub GraphQL `AssignedEvent.createdAt` is also valid when REST pagination is awkward.
|
||||
|
||||
Claim command for issues or PRs:
|
||||
|
||||
```bash
|
||||
gh api -X POST "repos/openclaw/openclaw/issues/<number>/assignees" -f 'assignees[]=<login>' >/dev/null
|
||||
```
|
||||
|
||||
## Surface opener identity
|
||||
|
||||
- For every reviewed, triaged, closed, or landed issue/PR, show the opener's human name when available, GitHub login, and account age.
|
||||
@@ -217,6 +247,7 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
|
||||
not correctness findings.
|
||||
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
|
||||
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
|
||||
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
|
||||
- When landing or merging any PR, follow the global `/landpr` process.
|
||||
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
|
||||
- Keep commit messages concise and action-oriented.
|
||||
|
||||
@@ -34,10 +34,10 @@ Supports single or multiple alerts. For multiple alerts, process in ascending or
|
||||
For each alert:
|
||||
|
||||
1. **Identify** — `fetch-alert` + `fetch-content` to get metadata and body
|
||||
2. **Decide** — Agent reads the body file, identifies all secrets, produces redacted version
|
||||
3. **Redact** — `redact-body` for issue/PR body; skip for comments (delete directly)
|
||||
2. **Decide** — Agent reads the body file, identifies whether plaintext secrets remain, and produces a redacted version only when needed
|
||||
3. **Redact** — `redact-body-if-needed` for issue/PR body; skip for comments (delete directly)
|
||||
4. **Purge** — `delete-comment` + `recreate-comment` for comments; cannot purge body history
|
||||
5. **Notify** — `notify` posts the right template per location type
|
||||
5. **Notify** — `notify` posts the right template per location type, unless the current issue/PR body is already redacted
|
||||
6. **Resolve** — `resolve` closes the alert
|
||||
7. **Summary** — `summary` prints formatted results
|
||||
|
||||
@@ -81,11 +81,20 @@ The `fetch-content` output includes:
|
||||
The agent reads the body file from `fetch-content` output and:
|
||||
|
||||
1. Identifies ALL secrets in the content (there may be more than the alert flagged)
|
||||
2. Replaces each secret with `[REDACTED <secret_type>]` — **no partial values, no prefix/suffix**
|
||||
3. Saves the redacted content to a new temp file
|
||||
2. Determines whether any plaintext credential remains in the current body
|
||||
3. Replaces each remaining secret with `[REDACTED <secret_type>]` — **no partial values, no prefix/suffix**
|
||||
4. Saves the redacted content to a new temp file
|
||||
|
||||
This is the only step that requires semantic understanding. Everything else is mechanical.
|
||||
|
||||
For `issue_body` and `pull_request_body`: if the current body has already been redacted by the author and no plaintext credential remains, **do not post a public notification comment**. Resolve the alert with a maintainer-only resolution comment such as:
|
||||
|
||||
```bash
|
||||
node secret-scanning.mjs resolve <ALERT_NUMBER> revoked "Current issue/PR body is already redacted; no public notification posted."
|
||||
```
|
||||
|
||||
This avoids creating a fresh public pointer to historical sensitive content.
|
||||
|
||||
## Step 3: Redact
|
||||
|
||||
### For comments (issue_comment / PR comments)
|
||||
@@ -95,9 +104,11 @@ This is the only step that requires semantic understanding. Everything else is m
|
||||
### For issue_body / pull_request_body
|
||||
|
||||
```bash
|
||||
node secret-scanning.mjs redact-body <issue|pr> <NUMBER> <redacted-body-file>
|
||||
node secret-scanning.mjs redact-body-if-needed <issue|pr> <NUMBER> <current-body-file> <redacted-body-file> <result-file>
|
||||
```
|
||||
|
||||
Use the `body_file` from `fetch-content` as `<current-body-file>`. The command writes `notify_required` to `<result-file>` and only PATCHes the body when the redacted file differs from the current body.
|
||||
|
||||
## Step 4: Purge Edit History
|
||||
|
||||
### Comments — Delete and Recreate
|
||||
@@ -134,10 +145,12 @@ The recreated comment should follow this format:
|
||||
<redacted original content>
|
||||
```
|
||||
|
||||
### issue_body / pull_request_body — Cannot Purge
|
||||
### issue_body / pull_request_body — Cannot Purge Edit History
|
||||
|
||||
Editing creates an edit history revision with the pre-edit plaintext. This cannot be cleared via API.
|
||||
|
||||
Do not advise authors publicly to delete/recreate issues or close/reopen PRs. That can draw attention to historical content. Keep purge guidance maintainer-only.
|
||||
|
||||
**Output to maintainer terminal only (never in public comments):**
|
||||
|
||||
```
|
||||
@@ -155,12 +168,13 @@ Cannot clean. Notify author to delete branch or force-push (for unmerged PRs).
|
||||
## Step 5: Notify
|
||||
|
||||
```bash
|
||||
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID]
|
||||
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID|BODY_REDACTION_RESULT_FILE]
|
||||
```
|
||||
|
||||
- For non-discussion types, `<TARGET>` is the issue/PR number.
|
||||
- For `discussion_comment`, `<TARGET>` is the `discussion_node_id` returned by `fetch-content`.
|
||||
- For reply-style `discussion_comment` locations, pass the optional `reply_to_node_id` from `fetch-content` so the notification stays in the same thread.
|
||||
- For `issue_body` and `pull_request_body`, pass the `<result-file>` from `redact-body-if-needed`. The script skips notification when `notify_required` is `false` and refuses body notifications without this file.
|
||||
|
||||
Secret types are comma-separated: `"Discord Bot Token,Feishu App Secret"`
|
||||
|
||||
@@ -170,6 +184,8 @@ The script picks the right template:
|
||||
- **body types**: "your issue/PR description … redacted in place"
|
||||
- **commit**: "code you committed"
|
||||
|
||||
For `issue_body` and `pull_request_body`, only notify when the current body still contained plaintext and maintainers redacted it. If the user already redacted the current body, skip this step and resolve silently.
|
||||
|
||||
## Step 6: Resolve
|
||||
|
||||
```bash
|
||||
@@ -178,7 +194,7 @@ node secret-scanning.mjs resolve <ALERT_NUMBER>
|
||||
node secret-scanning.mjs resolve <ALERT_NUMBER> revoked "Custom comment"
|
||||
```
|
||||
|
||||
Resolution is `revoked` by default. As maintainers we cannot control whether users rotate — our responsibility is to redact + notify. The `revoked` means "this secret should be considered leaked", not "I confirmed it was revoked".
|
||||
Resolution is `revoked` by default. As maintainers we cannot control whether users rotate — our responsibility is to remove current plaintext exposure and notify only when public notification is useful. The `revoked` means "this secret should be considered leaked", not "I confirmed it was revoked".
|
||||
|
||||
## Step 7: Summary
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const REPO = "openclaw/openclaw";
|
||||
const REPO_URL = `https://github.com/${REPO}`;
|
||||
@@ -50,6 +51,34 @@ function ghGraphQL(query, options = {}) {
|
||||
return gh(["api", "graphql", "-f", `query=${query}`], options);
|
||||
}
|
||||
|
||||
function isBodyLocationType(locationType) {
|
||||
return locationType === "issue_body" || locationType === "pull_request_body";
|
||||
}
|
||||
|
||||
export function decideBodyRedaction(currentBody, redactedBody) {
|
||||
const bodyChanged = String(currentBody) !== String(redactedBody);
|
||||
return {
|
||||
body_changed: bodyChanged,
|
||||
notify_required: bodyChanged,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadBodyRedactionResult(locationType, resultFile) {
|
||||
if (!isBodyLocationType(locationType)) {
|
||||
return { notify_required: true };
|
||||
}
|
||||
if (!resultFile) {
|
||||
fail("Body notifications require a redaction result file from redact-body-if-needed");
|
||||
}
|
||||
if (!fs.existsSync(resultFile)) fail(`File not found: ${resultFile}`);
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(resultFile, "utf8"));
|
||||
if (typeof result.notify_required !== "boolean") {
|
||||
fail(`Invalid redaction result file: missing boolean notify_required in ${resultFile}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function failOnGraphQLFailure(result, message) {
|
||||
if (result?.gh_failed) {
|
||||
const details = (
|
||||
@@ -470,6 +499,43 @@ function cmdRedactBody(kind, number, bodyFile) {
|
||||
console.log(JSON.stringify({ ok: true, kind, number: Number(number) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>
|
||||
* PATCH only when the agent-produced redacted body differs from the current body.
|
||||
*/
|
||||
function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile, resultFile) {
|
||||
if (!kind || !number || !currentBodyFile || !redactedBodyFile || !resultFile) {
|
||||
fail(
|
||||
"Usage: redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>",
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(currentBodyFile)) fail(`File not found: ${currentBodyFile}`);
|
||||
if (!fs.existsSync(redactedBodyFile)) fail(`File not found: ${redactedBodyFile}`);
|
||||
|
||||
const currentBody = fs.readFileSync(currentBodyFile, "utf8");
|
||||
const redactedBody = fs.readFileSync(redactedBodyFile, "utf8");
|
||||
const decision = decideBodyRedaction(currentBody, redactedBody);
|
||||
const result = {
|
||||
ok: true,
|
||||
kind,
|
||||
number: Number(number),
|
||||
...decision,
|
||||
};
|
||||
|
||||
if (decision.body_changed) {
|
||||
const endpoint =
|
||||
kind === "pr" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`;
|
||||
gh(["api", endpoint, "-X", "PATCH", "-F", `body=@${redactedBodyFile}`]);
|
||||
result.redacted = true;
|
||||
} else {
|
||||
result.redacted = false;
|
||||
result.reason = "current_body_already_redacted";
|
||||
}
|
||||
|
||||
fs.writeFileSync(resultFile, `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* delete-comment <comment-id>
|
||||
* Delete a comment (and all its edit history).
|
||||
@@ -555,6 +621,17 @@ function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
|
||||
|
||||
const types = secretTypes.split(",").map((s) => s.trim());
|
||||
const typeList = types.map((t, i) => `${i + 1}. **${t}**`).join("\n");
|
||||
const redactionResult = loadBodyRedactionResult(locationType, replyToNodeId);
|
||||
if (isBodyLocationType(locationType) && !redactionResult.notify_required) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: "current_body_already_redacted",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let locationDesc;
|
||||
let actionDesc;
|
||||
@@ -758,12 +835,13 @@ function cmdSummary(jsonFile) {
|
||||
|
||||
// ─── Dispatch ───────────────────────────────────────────────────────────────
|
||||
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
const args = [];
|
||||
|
||||
const commands = {
|
||||
export const commands = {
|
||||
"fetch-alert": () => cmdFetchAlert(args[0]),
|
||||
"fetch-content": () => cmdFetchContent(args[0]),
|
||||
"redact-body": () => cmdRedactBody(args[0], args[1], args[2]),
|
||||
"redact-body-if-needed": () => cmdRedactBodyIfNeeded(args[0], args[1], args[2], args[3], args[4]),
|
||||
"delete-comment": () => cmdDeleteComment(args[0]),
|
||||
"delete-discussion-comment": () => cmdDeleteDiscussionComment(args[0]),
|
||||
"recreate-comment": () => cmdRecreateComment(args[0], args[1]),
|
||||
@@ -774,26 +852,37 @@ const commands = {
|
||||
summary: () => cmdSummary(args[0]),
|
||||
};
|
||||
|
||||
if (!command || !commands[command]) {
|
||||
console.error(
|
||||
[
|
||||
"Usage: node secret-scanning.mjs <command> [args]",
|
||||
"",
|
||||
"Commands:",
|
||||
" fetch-alert <number> Fetch alert metadata + locations",
|
||||
" fetch-content '<location-json>' Fetch content for a location",
|
||||
" redact-body <issue|pr> <n> <file> PATCH body with redacted file",
|
||||
" delete-comment <comment-id> Delete a comment",
|
||||
" delete-discussion-comment <node-id> Delete a discussion comment (GraphQL)",
|
||||
" recreate-comment <issue-n> <file> Create replacement comment",
|
||||
" recreate-discussion-comment <disc-node-id> <file> [reply-to-node-id] Create discussion comment (GraphQL)",
|
||||
" notify <target> <author> <type> <types> [reply-to-node-id] Post notification",
|
||||
" resolve <n> [resolution] [comment] Close alert",
|
||||
" list-open List open alerts",
|
||||
" summary <json-file> Print formatted summary",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const [command, ...commandArgs] = argv;
|
||||
args.length = 0;
|
||||
args.push(...commandArgs);
|
||||
|
||||
if (!command || !commands[command]) {
|
||||
console.error(
|
||||
[
|
||||
"Usage: node secret-scanning.mjs <command> [args]",
|
||||
"",
|
||||
"Commands:",
|
||||
" fetch-alert <number> Fetch alert metadata + locations",
|
||||
" fetch-content '<location-json>' Fetch content for a location",
|
||||
" redact-body <issue|pr> <n> <file> PATCH body with redacted file",
|
||||
" redact-body-if-needed <issue|pr> <n> <current-file> <redacted-file> <result-file> PATCH body only if redaction changed it",
|
||||
" delete-comment <comment-id> Delete a comment",
|
||||
" delete-discussion-comment <node-id> Delete a discussion comment (GraphQL)",
|
||||
" recreate-comment <issue-n> <file> Create replacement comment",
|
||||
" recreate-discussion-comment <disc-node-id> <file> [reply-to-node-id] Create discussion comment (GraphQL)",
|
||||
" notify <target> <author> <type> <types> [reply-to-node-id|body-result-file] Post notification",
|
||||
" resolve <n> [resolution] [comment] Close alert",
|
||||
" list-open List open alerts",
|
||||
" summary <json-file> Print formatted summary",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
commands[command]();
|
||||
}
|
||||
|
||||
commands[command]();
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main();
|
||||
}
|
||||
|
||||
@@ -24,8 +24,11 @@ Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
- normal source checkout, one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
|
||||
- Codex worktree or linked/sparse checkout, one/few explicit files: `node scripts/run-vitest.mjs <path-or-filter>`
|
||||
- Codex worktree or linked/sparse checkout, changed gates or anything broad:
|
||||
`node scripts/crabbox-wrapper.mjs run ... --shell -- "pnpm check:changed"`
|
||||
and let `.crabbox.yaml` choose the provider
|
||||
use the Crabbox wrapper with the provider that matches the proof surface.
|
||||
For maintainer heavy `pnpm` gates, that is usually delegated Blacksmith
|
||||
Testbox through Crabbox, e.g. `node scripts/crabbox-wrapper.mjs run
|
||||
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
|
||||
Crabbox proof, omit `--provider` and let `.crabbox.yaml` choose AWS.
|
||||
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
|
||||
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
|
||||
2. Reproduce narrowly before fixing.
|
||||
@@ -46,13 +49,18 @@ Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
`node scripts/run-vitest.mjs` for tiny local proof, `node
|
||||
scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
|
||||
after the relevant remote or node-wrapper proof is already clean.
|
||||
- For remote proof, use Crabbox first and omit `--provider` unless a specific
|
||||
provider is being tested. The repo Crabbox config routes normal broad proof to
|
||||
brokered AWS. Blacksmith Testbox is explicit opt-in; if it queues, fails
|
||||
capacity, or cannot allocate, retry once through the default Crabbox route or
|
||||
report the Testbox blocker. Reuse only an id/slug created in this operator
|
||||
session; `blacksmith testbox list` is diagnostics only, not a shared work
|
||||
queue.
|
||||
- For remote proof, use the Crabbox wrapper first, but name the actual backend.
|
||||
Direct AWS Crabbox uses `provider=aws` and `cbx_...` ids. Delegated
|
||||
Blacksmith Testbox through Crabbox uses `provider=blacksmith-testbox`,
|
||||
`syncDelegated=true`, and `tbx_...` ids. Both satisfy "remote proof" when the
|
||||
requested proof surface allows either.
|
||||
- Do not infer "no Testbox is running" from plain `blacksmith testbox list`.
|
||||
Use `blacksmith testbox list --all` or `blacksmith testbox status <tbx_id>`
|
||||
before reporting cloud state.
|
||||
- Reuse only an id/slug created in this operator session unless explicitly
|
||||
coordinating with another lane. If Testbox queues, fails capacity, or cannot
|
||||
allocate, report the blocker or switch to direct AWS Crabbox only when that
|
||||
still proves the requested surface.
|
||||
|
||||
## Local Test Shortcuts
|
||||
|
||||
|
||||
41
.agents/skills/slacrawl/SKILL.md
Normal file
41
.agents/skills/slacrawl/SKILL.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: slacrawl
|
||||
description: "Slack archive: search, sync freshness, threads/DMs, SQL counts, and Slacrawl repo work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/slacrawl
|
||||
requires:
|
||||
bins:
|
||||
- slacrawl
|
||||
install:
|
||||
- kind: go
|
||||
module: github.com/vincentkoc/slacrawl/cmd/slacrawl@latest
|
||||
bins:
|
||||
- slacrawl
|
||||
---
|
||||
|
||||
# Slacrawl
|
||||
|
||||
Use local Slack archive data first. Check freshness for recent/current questions:
|
||||
|
||||
```bash
|
||||
slacrawl doctor
|
||||
slacrawl status --json
|
||||
```
|
||||
|
||||
Refresh only when stale or asked:
|
||||
|
||||
```bash
|
||||
slacrawl sync --source desktop
|
||||
slacrawl sync --source api --latest-only
|
||||
```
|
||||
|
||||
Query with bounded slices:
|
||||
|
||||
```bash
|
||||
slacrawl search --limit 20 "query"
|
||||
slacrawl messages --since 7d --limit 50
|
||||
slacrawl sql "select count(*) from messages;"
|
||||
```
|
||||
|
||||
Report workspace/channel names, absolute date spans, counts, and token/source limits. Use read-only SQL for exact counts/rankings. API sync and full thread/DM hydration require Slack tokens; do not assume they exist.
|
||||
4
.agents/skills/slacrawl/agents/openai.yaml
Normal file
4
.agents/skills/slacrawl/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Slacrawl"
|
||||
short_description: "Search local Slack archives and freshness"
|
||||
default_prompt: "Use $slacrawl to search local Slack archives, check freshness, inspect channel or DM slices, and report exact date spans and token/source limits."
|
||||
@@ -16,8 +16,11 @@ Hard limits:
|
||||
- Do not finish with tiny, cropped-wrong, off-bottom, or sidebar-heavy GIFs.
|
||||
- Do not invent a generic proof. The proof must match the PR behavior.
|
||||
- Do not force GIFs for internal-only, workflow-only, test-only, docs-only, or
|
||||
otherwise non-visual PRs. A no-visual-proof manifest is a successful outcome
|
||||
when GIFs would be misleading.
|
||||
otherwise non-visual PRs. A no-visual-proof manifest is a successful workflow
|
||||
outcome when GIFs would be misleading, but it is not proof that the PR passed.
|
||||
- Keep public-facing manifest summaries short and user-domain. Do not mention
|
||||
harness internals, mock-provider limits, secret/trust boundaries, local paths,
|
||||
transcript seeding, or workflow implementation details in the summary.
|
||||
|
||||
Inputs are provided as environment variables:
|
||||
|
||||
@@ -42,9 +45,10 @@ Required workflow:
|
||||
before/after. If it does not, write
|
||||
`${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with `comparison.pass: true`, no
|
||||
artifacts, and a summary that starts with
|
||||
`Mantis did not generate before/after GIFs because`. Include the concrete
|
||||
reason in the summary. Use this manifest shape and do not create worktrees
|
||||
or start Crabbox for this case:
|
||||
`Mantis did not generate before/after GIFs because`. Include a short
|
||||
public reason, such as `the PR changes internal session bookkeeping rather
|
||||
than Telegram-visible behavior`. Use this manifest shape and do not create
|
||||
worktrees or start Crabbox for this case:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -73,6 +77,14 @@ Required workflow:
|
||||
}
|
||||
```
|
||||
|
||||
If the PR appears visual but proof is blocked by Telegram Desktop session
|
||||
state, authorization, credentials, Crabbox, or another capture-infrastructure
|
||||
issue, do not describe it as a no-visual PR. Write a manifest with
|
||||
`comparison.pass: false`, skipped lanes, no artifacts, and a summary that
|
||||
starts with `Mantis could not capture Telegram Desktop proof because`. The
|
||||
publisher will keep that out of PR comments so the failure stays in the
|
||||
workflow logs and artifacts.
|
||||
|
||||
4. Decide what Telegram message, mock model response, command, callback, button,
|
||||
media, or sequence best proves the PR. Use `MANTIS_INSTRUCTIONS` as extra
|
||||
maintainer guidance, not as a replacement for reading the PR.
|
||||
@@ -134,4 +146,6 @@ Expected final state:
|
||||
`Main` and `This PR`.
|
||||
- No-visual-proof manifests contain no artifacts and have `comparison.pass:
|
||||
true`.
|
||||
- Capture-infrastructure failure manifests contain no artifacts and have
|
||||
`comparison.pass: false`.
|
||||
- The worktree can be dirty only under `.artifacts/`.
|
||||
|
||||
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
@@ -101,7 +101,9 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qa-lab/**"
|
||||
- "qa/scenarios/**"
|
||||
- "docs/concepts/qa-e2e-automation.md"
|
||||
- "docs/concepts/personal-agent-benchmark-pack.md"
|
||||
- "docs/channels/qa-channel.md"
|
||||
"channel: signal":
|
||||
- changed-files:
|
||||
|
||||
230
.github/workflows/ci.yml
vendored
230
.github/workflows/ci.yml
vendored
@@ -20,6 +20,8 @@ on:
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
paths-ignore:
|
||||
- "CHANGELOG.md"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -38,7 +40,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
|
||||
@@ -301,7 +303,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
PRE_COMMIT_HOME: .cache/pre-commit-security-fast
|
||||
@@ -394,7 +396,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -419,7 +421,7 @@ jobs:
|
||||
permissions: {}
|
||||
needs: [security-scm-fast, security-dependency-audit]
|
||||
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify fast security jobs
|
||||
@@ -641,6 +643,15 @@ jobs:
|
||||
echo "${name}-result=${results[$name]}" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
|
||||
failures=0
|
||||
for name in channels core-support-boundary gateway-watch; do
|
||||
if [ "${results[$name]}" = "failure" ]; then
|
||||
echo "::error title=${name} failed::${name} failed"
|
||||
failures=1
|
||||
fi
|
||||
done
|
||||
exit "$failures"
|
||||
|
||||
- name: Upload gateway watch regression artifacts
|
||||
if: always() && needs.preflight.outputs.run_check_additional == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -828,28 +839,6 @@ jobs:
|
||||
EOF
|
||||
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:plugins
|
||||
|
||||
checks-fast-plugin-contracts:
|
||||
permissions:
|
||||
contents: read
|
||||
name: checks-fast-contracts-plugins
|
||||
needs: [preflight, checks-fast-plugin-contracts-shard]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_plugin_contracts_shards == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify plugin contract shards
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.checks-fast-plugin-contracts-shard.result }}
|
||||
run: |
|
||||
if [ "$SHARD_RESULT" = "cancelled" ]; then
|
||||
echo "Plugin contract shards were cancelled, usually because a newer commit superseded this run." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SHARD_RESULT" != "success" ]; then
|
||||
echo "Plugin contract shards failed: $SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checks-fast-channel-contracts-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -934,35 +923,13 @@ jobs:
|
||||
EOF
|
||||
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels
|
||||
|
||||
checks-fast-channel-contracts:
|
||||
permissions:
|
||||
contents: read
|
||||
name: checks-fast-contracts-channels
|
||||
needs: [preflight, checks-fast-channel-contracts-shard]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_fast == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify channel contract shards
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.checks-fast-channel-contracts-shard.result }}
|
||||
run: |
|
||||
if [ "$SHARD_RESULT" = "cancelled" ]; then
|
||||
echo "Channel contract shards were cancelled, usually because a newer commit superseded this run." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SHARD_RESULT" != "success" ]; then
|
||||
echo "Channel contract shards failed: $SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checks-fast-protocol:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "checks-fast-protocol"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1021,38 +988,6 @@ jobs:
|
||||
- name: Run protocol check
|
||||
run: pnpm protocol:check
|
||||
|
||||
checks:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, build-artifacts]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_matrix) }}
|
||||
steps:
|
||||
- name: Verify ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
env:
|
||||
TASK: ${{ matrix.task }}
|
||||
CHANNELS_RESULT: ${{ needs.build-artifacts.outputs['channels-result'] }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TASK" in
|
||||
channels)
|
||||
if [ "$CHANNELS_RESULT" != "success" ]; then
|
||||
echo "Channel tests failed in build-artifacts: $CHANNELS_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported checks task: $TASK" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
checks-node-compat:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -1240,63 +1175,6 @@ jobs:
|
||||
}
|
||||
EOF
|
||||
|
||||
checks-node-core-test-dist-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, build-artifacts]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_node_core_dist == 'true' && needs.build-artifacts.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_dist_matrix) }}
|
||||
steps:
|
||||
- name: Verify Node test shard
|
||||
env:
|
||||
CORE_SUPPORT_BOUNDARY_RESULT: ${{ needs.build-artifacts.outputs['core-support-boundary-result'] }}
|
||||
SHARD_NAME: ${{ matrix.shard_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$SHARD_NAME" in
|
||||
core-support-boundary)
|
||||
if [ "$CORE_SUPPORT_BOUNDARY_RESULT" != "success" ]; then
|
||||
echo "Core support boundary shard failed in build-artifacts: $CORE_SUPPORT_BOUNDARY_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported built-artifact shard: $SHARD_NAME" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
checks-node-core-test:
|
||||
permissions:
|
||||
contents: read
|
||||
name: checks-node-core
|
||||
needs: [preflight, checks-node-core-test-nondist-shard, checks-node-core-test-dist-shard]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify node test shards
|
||||
env:
|
||||
DIST_SHARD_RESULT: ${{ needs.checks-node-core-test-dist-shard.result }}
|
||||
NONDIST_SHARD_RESULT: ${{ needs.checks-node-core-test-nondist-shard.result }}
|
||||
RUN_DIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_dist }}
|
||||
RUN_NONDIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_nondist }}
|
||||
run: |
|
||||
if [ "$RUN_NONDIST_SHARDS" = "true" ] && [ "$NONDIST_SHARD_RESULT" != "success" ]; then
|
||||
echo "Node non-dist test shards failed: $NONDIST_SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RUN_DIST_SHARDS" = "true" ] && [ "$DIST_SHARD_RESULT" != "success" ]; then
|
||||
echo "Node dist test shards failed: $DIST_SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Types, lint, and format check shards.
|
||||
check-shard:
|
||||
permissions:
|
||||
@@ -1312,7 +1190,7 @@ jobs:
|
||||
include:
|
||||
- check_name: check-preflight-guards
|
||||
task: preflight-guards
|
||||
runner: ubuntu-24.04
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-prod-types
|
||||
task: prod-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -1321,16 +1199,16 @@ jobs:
|
||||
runner: blacksmith-16vcpu-ubuntu-2404
|
||||
- check_name: check-dependencies
|
||||
task: dependencies
|
||||
runner: ubuntu-24.04
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-policy-guards
|
||||
task: policy-guards
|
||||
runner: ubuntu-24.04
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-test-types
|
||||
task: test-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-strict-smoke
|
||||
task: strict-smoke
|
||||
runner: ubuntu-24.04
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
@@ -1442,24 +1320,6 @@ jobs:
|
||||
path: .artifacts/deadcode
|
||||
if-no-files-found: ignore
|
||||
|
||||
check:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check"
|
||||
needs: [preflight, check-shard]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify check shards
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.check-shard.result }}
|
||||
run: |
|
||||
if [ "$SHARD_RESULT" != "success" ]; then
|
||||
echo "Check shards failed: $SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check-additional-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -1637,59 +1497,13 @@ jobs:
|
||||
|
||||
exit "$failures"
|
||||
|
||||
check-additional:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-additional"
|
||||
needs: [preflight, check-additional-shard, build-artifacts]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify additional check shards
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
|
||||
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
|
||||
GATEWAY_RESULT: ${{ needs.build-artifacts.outputs.gateway-watch-result }}
|
||||
run: |
|
||||
if [ "$SHARD_RESULT" != "success" ]; then
|
||||
echo "Additional check shards failed: $SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
|
||||
echo "Build artifact job failed: $BUILD_ARTIFACTS_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$GATEWAY_RESULT" != "success" ]; then
|
||||
echo "Gateway topology check failed: $GATEWAY_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-smoke:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "build-smoke"
|
||||
needs: [preflight, build-artifacts]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify build smoke
|
||||
env:
|
||||
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
|
||||
run: |
|
||||
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
|
||||
echo "Build smoke checks failed in build-artifacts: $BUILD_ARTIFACTS_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||
check-docs:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_check_docs == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1763,7 +1577,7 @@ jobs:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_skills_python_job == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-6' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-7' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "1"
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
|
||||
1
.github/workflows/docs.yml
vendored
1
.github/workflows/docs.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
paths:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "!CHANGELOG.md"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -638,6 +638,7 @@ jobs:
|
||||
name: Run package Telegram E2E
|
||||
needs: [resolve_target, prepare_release_package]
|
||||
if: ${{ always() && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '' || (inputs.rerun_group == 'all' && inputs.release_profile == 'full')) }}
|
||||
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 120 || 60 }}
|
||||
outputs:
|
||||
@@ -955,6 +956,8 @@ jobs:
|
||||
|
||||
if [[ "$NPM_TELEGRAM_RESULT" == "skipped" && -z "${NPM_TELEGRAM_RUN_ID// }" ]]; then
|
||||
check_child "npm_telegram" "" 0 || failed=1
|
||||
elif [[ "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 0 || echo "::warning::npm_telegram is advisory for Tideclaw alpha validation."
|
||||
else
|
||||
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 1 || failed=1
|
||||
fi
|
||||
|
||||
9
.github/workflows/mantis-discord-smoke.yml
vendored
9
.github/workflows/mantis-discord-smoke.yml
vendored
@@ -33,8 +33,11 @@ jobs:
|
||||
authorize_actor:
|
||||
name: Authorize workflow actor
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
@@ -48,14 +51,18 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.setFailed(
|
||||
core.notice(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
validate_selected_ref:
|
||||
name: Validate selected ref
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
selected_revision: ${{ steps.validate.outputs.selected_revision }}
|
||||
|
||||
@@ -46,15 +46,17 @@ jobs:
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
contains(github.event.comment.body, '@Mantis') ||
|
||||
contains(github.event.comment.body, '@mantis') ||
|
||||
contains(github.event.comment.body, '/mantis')
|
||||
contains(github.event.comment.body, '@openclaw-mantis') ||
|
||||
contains(github.event.comment.body, '/openclaw-mantis')
|
||||
)
|
||||
)
|
||||
}}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
@@ -68,14 +70,18 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.setFailed(
|
||||
core.notice(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
resolve_request:
|
||||
name: Resolve Mantis request
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
|
||||
@@ -121,7 +127,7 @@ jobs:
|
||||
|
||||
const normalized = body.toLowerCase();
|
||||
const requested =
|
||||
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
|
||||
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
|
||||
normalized.includes("discord") &&
|
||||
normalized.includes("status") &&
|
||||
normalized.includes("reaction");
|
||||
@@ -342,8 +348,8 @@ jobs:
|
||||
--repo-root "$repo_root" \
|
||||
--output-dir "$output_dir" \
|
||||
--provider-mode live-frontier \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--model openai/gpt-5.5 \
|
||||
--alt-model openai/gpt-5.5 \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
@@ -567,3 +573,44 @@ jobs:
|
||||
--artifact-url "$ARTIFACT_URL" \
|
||||
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
||||
--request-source "$REQUEST_SOURCE"
|
||||
|
||||
clear_issue_comment_reaction:
|
||||
name: Clear Mantis command reaction
|
||||
needs: [resolve_request, validate_refs, run_status_reactions]
|
||||
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const commentId = context.payload.comment?.id;
|
||||
if (!commentId) {
|
||||
core.info("No issue comment id found; skipping reaction cleanup.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
per_page: 100,
|
||||
});
|
||||
const eyes = reactions.filter(
|
||||
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
|
||||
);
|
||||
for (const reaction of eyes) {
|
||||
await github.rest.reactions.deleteForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
reaction_id: reaction.id,
|
||||
});
|
||||
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
|
||||
}
|
||||
if (eyes.length === 0) {
|
||||
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
|
||||
}
|
||||
|
||||
@@ -46,15 +46,17 @@ jobs:
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
contains(github.event.comment.body, '@Mantis') ||
|
||||
contains(github.event.comment.body, '@mantis') ||
|
||||
contains(github.event.comment.body, '/mantis')
|
||||
contains(github.event.comment.body, '@openclaw-mantis') ||
|
||||
contains(github.event.comment.body, '/openclaw-mantis')
|
||||
)
|
||||
)
|
||||
}}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
@@ -68,14 +70,18 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.setFailed(
|
||||
core.notice(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
resolve_request:
|
||||
name: Resolve Mantis request
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
|
||||
@@ -121,7 +127,7 @@ jobs:
|
||||
|
||||
const normalized = body.toLowerCase();
|
||||
const requested =
|
||||
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
|
||||
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
|
||||
normalized.includes("discord") &&
|
||||
normalized.includes("thread") &&
|
||||
(normalized.includes("attachment") ||
|
||||
@@ -589,3 +595,44 @@ jobs:
|
||||
run: |
|
||||
echo "Mantis comparison failed." >&2
|
||||
exit 1
|
||||
|
||||
clear_issue_comment_reaction:
|
||||
name: Clear Mantis command reaction
|
||||
needs: [resolve_request, validate_candidate, run_thread_attachment]
|
||||
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const commentId = context.payload.comment?.id;
|
||||
if (!commentId) {
|
||||
core.info("No issue comment id found; skipping reaction cleanup.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
per_page: 100,
|
||||
});
|
||||
const eyes = reactions.filter(
|
||||
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
|
||||
);
|
||||
for (const reaction of eyes) {
|
||||
await github.rest.reactions.deleteForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
reaction_id: reaction.id,
|
||||
});
|
||||
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
|
||||
}
|
||||
if (eyes.length === 0) {
|
||||
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
|
||||
}
|
||||
|
||||
13
.github/workflows/mantis-slack-desktop-smoke.yml
vendored
13
.github/workflows/mantis-slack-desktop-smoke.yml
vendored
@@ -64,8 +64,11 @@ jobs:
|
||||
authorize_actor:
|
||||
name: Authorize workflow actor
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
@@ -79,14 +82,18 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.setFailed(
|
||||
core.notice(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
validate_ref:
|
||||
name: Validate candidate ref
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
candidate_revision: ${{ steps.validate.outputs.candidate_revision }}
|
||||
@@ -274,8 +281,8 @@ jobs:
|
||||
--credential-role ci \
|
||||
--provider-mode live-frontier \
|
||||
--hydrate-mode "$HYDRATE_MODE" \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--model openai/gpt-5.5 \
|
||||
--alt-model openai/gpt-5.5 \
|
||||
--fast \
|
||||
--scenario "$SCENARIO_ID" \
|
||||
"${keep_args[@]}" \
|
||||
|
||||
209
.github/workflows/mantis-telegram-desktop-proof.yml
vendored
209
.github/workflows/mantis-telegram-desktop-proof.yml
vendored
@@ -3,6 +3,8 @@ name: Mantis Telegram Desktop Proof
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned Mantis label trigger; trusted base workflow validates refs before checkout/use
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -25,6 +27,14 @@ on:
|
||||
description: Optional existing Crabbox desktop lease id or slug to reuse
|
||||
required: false
|
||||
type: string
|
||||
publish_artifact_name:
|
||||
description: Optional existing proof artifact name to publish without recapturing
|
||||
required: false
|
||||
type: string
|
||||
publish_run_id:
|
||||
description: Workflow run id that owns publish_artifact_name; required with publish_artifact_name
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -47,6 +57,11 @@ jobs:
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'labeled' &&
|
||||
github.event.label.name == 'mantis: telegram-visible-proof'
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
@@ -58,11 +73,20 @@ jobs:
|
||||
)
|
||||
}}
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "pull_request_target") {
|
||||
core.info(`Accepted Mantis label trigger from ${context.actor}.`);
|
||||
core.setOutput("authorized", "true");
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
const { owner, repo } = context.repo;
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
@@ -73,14 +97,18 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.setFailed(
|
||||
core.notice(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
resolve_request:
|
||||
name: Resolve Mantis request
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
|
||||
@@ -88,8 +116,11 @@ jobs:
|
||||
crabbox_provider: ${{ steps.resolve.outputs.crabbox_provider }}
|
||||
instructions: ${{ steps.resolve.outputs.instructions }}
|
||||
lease_id: ${{ steps.resolve.outputs.lease_id }}
|
||||
publish_artifact_name: ${{ steps.resolve.outputs.publish_artifact_name }}
|
||||
publish_run_id: ${{ steps.resolve.outputs.publish_run_id }}
|
||||
pr_number: ${{ steps.resolve.outputs.pr_number }}
|
||||
request_source: ${{ steps.resolve.outputs.request_source }}
|
||||
should_run: ${{ steps.resolve.outputs.should_run }}
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
@@ -105,31 +136,70 @@ jobs:
|
||||
|
||||
const inputs = context.payload.inputs ?? {};
|
||||
const prNumber =
|
||||
eventName === "workflow_dispatch" ? inputs.pr_number : String(context.payload.issue?.number ?? "");
|
||||
eventName === "workflow_dispatch"
|
||||
? inputs.pr_number
|
||||
: eventName === "pull_request_target"
|
||||
? String(context.payload.pull_request?.number ?? "")
|
||||
: String(context.payload.issue?.number ?? "");
|
||||
if (!prNumber) {
|
||||
core.setFailed("Mantis Telegram desktop proof requires a pull request.");
|
||||
return;
|
||||
}
|
||||
|
||||
const body =
|
||||
eventName === "workflow_dispatch"
|
||||
? inputs.instructions || ""
|
||||
: eventName === "issue_comment"
|
||||
? context.payload.comment?.body || ""
|
||||
: "";
|
||||
if (eventName === "issue_comment") {
|
||||
const normalized = body.toLowerCase();
|
||||
const requestedDesktopProof =
|
||||
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
|
||||
(normalized.includes("desktop proof") ||
|
||||
normalized.includes("desktop-proof") ||
|
||||
normalized.includes("telegram desktop") ||
|
||||
normalized.includes("native telegram") ||
|
||||
normalized.includes("visible proof") ||
|
||||
normalized.includes("visible-proof") ||
|
||||
normalized.includes("telegram-visible-proof"));
|
||||
if (!requestedDesktopProof) {
|
||||
core.notice("Comment mentioned Mantis but did not request Telegram desktop proof.");
|
||||
setOutput("should_run", "false");
|
||||
setOutput("baseline_ref", "");
|
||||
setOutput("candidate_ref", "");
|
||||
setOutput("pr_number", "");
|
||||
setOutput("instructions", "");
|
||||
setOutput("crabbox_provider", "");
|
||||
setOutput("lease_id", "");
|
||||
setOutput("publish_artifact_name", "");
|
||||
setOutput("publish_run_id", "");
|
||||
setOutput("request_source", "unsupported_issue_comment");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: Number(prNumber),
|
||||
});
|
||||
const body = eventName === "workflow_dispatch" ? inputs.instructions || "" : context.payload.comment?.body || "";
|
||||
const provider = inputs.crabbox_provider || "aws";
|
||||
if (!["aws", "hetzner"].includes(provider)) {
|
||||
core.setFailed(`Unsupported Crabbox provider for Mantis Telegram desktop proof: ${provider}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setOutput("should_run", "true");
|
||||
setOutput("baseline_ref", pr.base.sha);
|
||||
setOutput("candidate_ref", pr.head.sha);
|
||||
setOutput("pr_number", String(pr.number));
|
||||
setOutput("instructions", body);
|
||||
setOutput("crabbox_provider", provider);
|
||||
setOutput("lease_id", inputs.crabbox_lease_id || "");
|
||||
setOutput("publish_artifact_name", inputs.publish_artifact_name || "");
|
||||
setOutput("publish_run_id", inputs.publish_run_id || "");
|
||||
setOutput("request_source", eventName);
|
||||
|
||||
if (eventName === "issue_comment") {
|
||||
@@ -144,6 +214,7 @@ jobs:
|
||||
validate_refs:
|
||||
name: Validate selected refs
|
||||
needs: resolve_request
|
||||
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name == ''
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
baseline_revision: ${{ steps.validate.outputs.baseline_revision }}
|
||||
@@ -222,6 +293,7 @@ jobs:
|
||||
run_telegram_desktop_proof:
|
||||
name: Run agentic native Telegram proof
|
||||
needs: [resolve_request, validate_refs]
|
||||
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name == ''
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 360
|
||||
environment: qa-live-shared
|
||||
@@ -386,6 +458,7 @@ jobs:
|
||||
codex-home: /tmp/mantis-codex-home-${{ github.run_id }}
|
||||
safety-strategy: unprivileged-user
|
||||
codex-user: codex
|
||||
allow-bot-users: clawsweeper[bot]
|
||||
|
||||
- name: Inspect Mantis evidence manifest
|
||||
id: inspect
|
||||
@@ -466,3 +539,133 @@ jobs:
|
||||
run: |
|
||||
echo "Mantis Telegram desktop proof failed: comparison=${COMPARISON_STATUS:-unset}." >&2
|
||||
exit 1
|
||||
|
||||
publish_existing_telegram_desktop_proof:
|
||||
name: Publish existing native Telegram proof
|
||||
needs: resolve_request
|
||||
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name != ''
|
||||
runs-on: ubuntu-24.04
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
- name: Checkout harness ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download existing proof artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PUBLISH_ARTIFACT_NAME: ${{ needs.resolve_request.outputs.publish_artifact_name }}
|
||||
PUBLISH_RUN_ID: ${{ needs.resolve_request.outputs.publish_run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${PUBLISH_RUN_ID:-}" ]]; then
|
||||
echo "publish_run_id is required when publish_artifact_name is set." >&2
|
||||
exit 1
|
||||
fi
|
||||
run_id="$PUBLISH_RUN_ID"
|
||||
gh run download "$run_id" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--name "$PUBLISH_ARTIFACT_NAME" \
|
||||
--dir "$MANTIS_OUTPUT_DIR"
|
||||
|
||||
artifacts_json="$(
|
||||
gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts"
|
||||
)"
|
||||
artifact_id="$(jq -r --arg name "$PUBLISH_ARTIFACT_NAME" '.artifacts[] | select(.name == $name) | .id' <<<"$artifacts_json" | head -n 1)"
|
||||
if [[ -z "$artifact_id" || "$artifact_id" == "null" ]]; then
|
||||
echo "Could not resolve artifact id for '${PUBLISH_ARTIFACT_NAME}' in run ${run_id}." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PUBLISH_RUN_ID=${run_id}" >> "$GITHUB_ENV"
|
||||
echo "PUBLISH_ARTIFACT_URL=https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts/${artifact_id}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create Mantis GitHub App token
|
||||
id: mantis_app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: ${{ github.event.repository.name }}
|
||||
permission-issues: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Comment PR with inline QA evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
|
||||
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
|
||||
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
|
||||
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
|
||||
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
|
||||
MANTIS_ARTIFACT_R2_REGION: auto
|
||||
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
|
||||
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
|
||||
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="$MANTIS_OUTPUT_DIR"
|
||||
if [[ ! -f "$root/mantis-evidence.json" ]]; then
|
||||
echo "Downloaded artifact does not contain ${root}/mantis-evidence.json." >&2
|
||||
exit 1
|
||||
fi
|
||||
node scripts/mantis/publish-pr-evidence.mjs \
|
||||
--manifest "$root/mantis-evidence.json" \
|
||||
--target-pr "$TARGET_PR" \
|
||||
--artifact-root "mantis/telegram-desktop/pr-${TARGET_PR}/published-${PUBLISH_RUN_ID}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
|
||||
--marker "<!-- mantis-telegram-desktop-proof -->" \
|
||||
--artifact-url "$PUBLISH_ARTIFACT_URL" \
|
||||
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${PUBLISH_RUN_ID}" \
|
||||
--request-source "$REQUEST_SOURCE"
|
||||
|
||||
clear_issue_comment_reaction:
|
||||
name: Clear Mantis command reaction
|
||||
needs: [resolve_request, validate_refs, run_telegram_desktop_proof]
|
||||
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const commentId = context.payload.comment?.id;
|
||||
if (!commentId) {
|
||||
core.info("No issue comment id found; skipping reaction cleanup.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
per_page: 100,
|
||||
});
|
||||
const eyes = reactions.filter(
|
||||
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
|
||||
);
|
||||
for (const reaction of eyes) {
|
||||
await github.rest.reactions.deleteForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
reaction_id: reaction.id,
|
||||
});
|
||||
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
|
||||
}
|
||||
if (eyes.length === 0) {
|
||||
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
|
||||
}
|
||||
|
||||
70
.github/workflows/mantis-telegram-live.yml
vendored
70
.github/workflows/mantis-telegram-live.yml
vendored
@@ -56,15 +56,17 @@ jobs:
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
contains(github.event.comment.body, '@Mantis') ||
|
||||
contains(github.event.comment.body, '@mantis') ||
|
||||
contains(github.event.comment.body, '/mantis')
|
||||
contains(github.event.comment.body, '@openclaw-mantis') ||
|
||||
contains(github.event.comment.body, '/openclaw-mantis')
|
||||
)
|
||||
)
|
||||
}}
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
@@ -78,14 +80,18 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.setFailed(
|
||||
core.notice(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
resolve_request:
|
||||
name: Resolve Mantis request
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
candidate_ref: ${{ steps.resolve.outputs.candidate_ref }}
|
||||
@@ -133,9 +139,18 @@ jobs:
|
||||
}
|
||||
|
||||
const normalized = body.toLowerCase();
|
||||
const requestedDesktopProof =
|
||||
normalized.includes("desktop proof") ||
|
||||
normalized.includes("desktop-proof") ||
|
||||
normalized.includes("telegram desktop") ||
|
||||
normalized.includes("native telegram") ||
|
||||
normalized.includes("visible proof") ||
|
||||
normalized.includes("visible-proof") ||
|
||||
normalized.includes("telegram-visible-proof");
|
||||
const requested =
|
||||
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
|
||||
normalized.includes("telegram");
|
||||
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
|
||||
normalized.includes("telegram") &&
|
||||
!requestedDesktopProof;
|
||||
if (!requested) {
|
||||
core.notice("Comment mentioned Mantis but did not request Telegram live QA.");
|
||||
setOutput("should_run", "false");
|
||||
@@ -379,7 +394,7 @@ jobs:
|
||||
output_rel=".artifacts/qa-e2e/mantis/telegram-live"
|
||||
root="$candidate_repo/$output_rel"
|
||||
echo "output_dir=${root}" >> "$GITHUB_OUTPUT"
|
||||
model="${OPENCLAW_CI_OPENAI_MODEL:-openai/gpt-5.4}"
|
||||
model="${OPENCLAW_CI_OPENAI_MODEL:-openai/gpt-5.5}"
|
||||
|
||||
scenario_args=()
|
||||
if [[ -n "${SCENARIO_INPUT// }" ]]; then
|
||||
@@ -525,3 +540,44 @@ jobs:
|
||||
run: |
|
||||
echo "Mantis Telegram live failed: comparison=${COMPARISON_STATUS:-unset} telegram_exit=${TELEGRAM_EXIT:-unset}." >&2
|
||||
exit 1
|
||||
|
||||
clear_issue_comment_reaction:
|
||||
name: Clear Mantis command reaction
|
||||
needs: [resolve_request, validate_ref, run_telegram_live]
|
||||
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const commentId = context.payload.comment?.id;
|
||||
if (!commentId) {
|
||||
core.info("No issue comment id found; skipping reaction cleanup.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
per_page: 100,
|
||||
});
|
||||
const eyes = reactions.filter(
|
||||
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
|
||||
);
|
||||
for (const reaction of eyes) {
|
||||
await github.rest.reactions.deleteForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
reaction_id: reaction.id,
|
||||
});
|
||||
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
|
||||
}
|
||||
if (eyes.length === 0) {
|
||||
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
|
||||
}
|
||||
|
||||
11
.github/workflows/npm-telegram-beta-e2e.yml
vendored
11
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -40,8 +40,18 @@ on:
|
||||
description: Optional comma-separated Telegram scenario ids
|
||||
required: false
|
||||
type: string
|
||||
advisory:
|
||||
description: Treat package Telegram failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
advisory:
|
||||
description: Treat package Telegram failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
package_spec:
|
||||
description: Published OpenClaw package spec to test when no artifact is supplied
|
||||
required: true
|
||||
@@ -100,6 +110,7 @@ jobs:
|
||||
run_package_telegram_e2e:
|
||||
name: Run package Telegram E2E
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
permissions:
|
||||
|
||||
@@ -86,8 +86,18 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
advisory:
|
||||
description: Treat failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
advisory:
|
||||
description: Treat failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
ref:
|
||||
description: Public OpenClaw ref to validate (tag, branch, or full commit SHA)
|
||||
required: true
|
||||
@@ -186,11 +196,12 @@ env:
|
||||
PNPM_VERSION: "11.0.8"
|
||||
OPENCLAW_REPOSITORY: openclaw/openclaw
|
||||
TSX_VERSION: "4.21.0"
|
||||
OPENCLAW_CROSS_OS_OPENAI_MODEL: ${{ inputs.openai_model || vars.OPENCLAW_CROSS_OS_OPENAI_MODEL || 'openai/gpt-5.4' }}
|
||||
OPENCLAW_CROSS_OS_OPENAI_MODEL: ${{ inputs.openai_model || vars.OPENCLAW_CROSS_OS_OPENAI_MODEL || 'openai/gpt-5.5' }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-24.04
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
outputs:
|
||||
baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }}
|
||||
baseline_spec: ${{ steps.baseline.outputs.value }}
|
||||
@@ -513,6 +524,7 @@ jobs:
|
||||
cross_os_release_checks:
|
||||
name: "${{ matrix.display_name }} / ${{ matrix.suite_label }}"
|
||||
needs: prepare
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
|
||||
@@ -97,8 +97,18 @@ on:
|
||||
- beta
|
||||
- stable
|
||||
- full
|
||||
advisory:
|
||||
description: Treat failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
advisory:
|
||||
description: Treat failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
ref:
|
||||
description: Ref, tag, or SHA to validate
|
||||
required: true
|
||||
@@ -455,6 +465,7 @@ jobs:
|
||||
validate_release_live_cache:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'live-cache')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
@@ -505,6 +516,7 @@ jobs:
|
||||
validate_repo_e2e:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e && inputs.live_suite_filter == ''
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
|
||||
env:
|
||||
@@ -534,6 +546,7 @@ jobs:
|
||||
validate_special_e2e:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'openshell-e2e')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
@@ -608,6 +621,7 @@ jobs:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
|
||||
name: Docker E2E (${{ matrix.label }})
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
@@ -876,6 +890,7 @@ jobs:
|
||||
plan_docker_lane_groups:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.docker_lanes != ''
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -903,6 +918,7 @@ jobs:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_docker_lane_groups]
|
||||
if: inputs.docker_lanes != ''
|
||||
name: Docker E2E targeted lanes (${{ matrix.group.label }})
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -1112,6 +1128,7 @@ jobs:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image]
|
||||
if: inputs.include_openwebui && !inputs.include_release_path_suites && inputs.docker_lanes == ''
|
||||
name: Docker E2E (openwebui)
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -1239,6 +1256,7 @@ jobs:
|
||||
prepare_docker_e2e_image:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_release_path_suites || inputs.include_openwebui || inputs.docker_lanes != ''
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
|
||||
permissions:
|
||||
@@ -1483,6 +1501,7 @@ jobs:
|
||||
prepare_live_test_image:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-') || startsWith(inputs.live_suite_filter, 'docker-live-models'))
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
@@ -1556,6 +1575,7 @@ jobs:
|
||||
name: Docker live models (${{ matrix.provider_label }})
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
@@ -1708,6 +1728,7 @@ jobs:
|
||||
name: Docker live models (selected providers)
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers != '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -1883,6 +1904,7 @@ jobs:
|
||||
validate_live_provider_suites:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || (startsWith(inputs.live_suite_filter, 'native-live-') && !startsWith(inputs.live_suite_filter, 'native-live-extensions-media') && inputs.live_suite_filter != 'native-live-extensions-a-k'))
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
@@ -1911,7 +1933,7 @@ jobs:
|
||||
- suite_id: native-live-src-gateway-profiles-anthropic-opus
|
||||
suite_group: native-live-src-gateway-profiles-anthropic
|
||||
label: Native live gateway profiles Anthropic Opus
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7,anthropic/claude-opus-4-6 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 30
|
||||
profile_env_only: false
|
||||
advisory: true
|
||||
@@ -2204,6 +2226,7 @@ jobs:
|
||||
name: Docker live suites (${{ matrix.label }})
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-'))
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
@@ -2423,6 +2446,7 @@ jobs:
|
||||
name: Live media suites (${{ matrix.label }})
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'native-live-extensions-media') || inputs.live_suite_filter == 'native-live-extensions-a-k')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
container:
|
||||
image: ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04
|
||||
|
||||
126
.github/workflows/openclaw-npm-release.yml
vendored
126
.github/workflows/openclaw-npm-release.yml
vendored
@@ -88,6 +88,28 @@ jobs:
|
||||
ref: ${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate Tideclaw alpha preflight target
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
env:
|
||||
RELEASE_REF: ${{ inputs.tag }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_REF}" == *"-alpha."* && ! "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Tideclaw alpha preflight runs must target an alpha prerelease tag or SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
echo "Tideclaw alpha preflight runs must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
|
||||
exit 1
|
||||
fi
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if ! git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
echo "Alpha preflight target must be reachable from ${alpha_branch}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
@@ -191,7 +213,7 @@ jobs:
|
||||
id: packed_tarball
|
||||
env:
|
||||
OPENCLAW_PREPACK_PREPARED: "1"
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_REF: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
DEPENDENCY_EVIDENCE_DIR: ${{ steps.dependency_evidence.outputs.dir }}
|
||||
run: |
|
||||
@@ -259,6 +281,11 @@ jobs:
|
||||
fi
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||
if [[ "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
RELEASE_TAG="v${PACKAGE_VERSION}"
|
||||
else
|
||||
RELEASE_TAG="${RELEASE_REF}"
|
||||
fi
|
||||
TARBALL_NAME="$(basename "$PACK_PATH")"
|
||||
TARBALL_SHA256="$(sha256sum "$PACK_PATH" | awk '{print $1}')"
|
||||
ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight"
|
||||
@@ -290,6 +317,7 @@ jobs:
|
||||
);
|
||||
NODE
|
||||
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify prepared npm tarball install
|
||||
env:
|
||||
@@ -312,6 +340,14 @@ jobs:
|
||||
path: ${{ steps.dependency_evidence.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload dependency release evidence tag alias
|
||||
if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-release-dependency-evidence-${{ steps.packed_tarball.outputs.release_tag }}
|
||||
path: ${{ steps.dependency_evidence.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload prepared npm publish bundle
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -319,19 +355,33 @@ jobs:
|
||||
path: ${{ steps.packed_tarball.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload prepared npm publish bundle tag alias
|
||||
if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-npm-preflight-${{ steps.packed_tarball.outputs.release_tag }}
|
||||
path: ${{ steps.packed_tarball.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
validate_publish_request:
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require main or release workflow ref for publish
|
||||
- name: Require trusted workflow ref for publish
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
|
||||
echo "Real publish runs must be dispatched from main or release/YYYY.M.D. Use preflight_only=true for other branch validation."
|
||||
tideclaw_alpha_publish=false
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
tideclaw_alpha_publish=true
|
||||
fi
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]]; then
|
||||
echo "Real publish runs must be dispatched from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -387,6 +437,28 @@ jobs:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate Tideclaw alpha publish target
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" == *"-alpha."* ]]; then
|
||||
echo "Tideclaw alpha publish runs must target an alpha prerelease tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
echo "Tideclaw alpha publish runs must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
|
||||
exit 1
|
||||
fi
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if ! git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
echo "Alpha publish tag must be reachable from ${alpha_branch}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
@@ -427,13 +499,45 @@ jobs:
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);'
|
||||
|
||||
- name: Download prepared npm tarball
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-npm-preflight-${{ inputs.tag }}
|
||||
path: preflight-tarball
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.preflight_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
download_preflight_artifact() {
|
||||
local preferred_name fallback_name
|
||||
preferred_name="openclaw-npm-preflight-${RELEASE_TAG}"
|
||||
rm -rf preflight-tarball
|
||||
mkdir -p preflight-tarball
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${preferred_name}" \
|
||||
--dir preflight-tarball; then
|
||||
echo "Downloaded ${preferred_name}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact."
|
||||
mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \
|
||||
--jq '.artifacts[] | select(.expired != true) | .name' |
|
||||
grep '^openclaw-npm-preflight-' || true)
|
||||
if [[ "${#matches[@]}" != "1" ]]; then
|
||||
echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2
|
||||
printf 'Available preflight candidates:\n' >&2
|
||||
printf -- '- %s\n' "${matches[@]:-<none>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fallback_name="${matches[0]}"
|
||||
gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${fallback_name}" \
|
||||
--dir preflight-tarball
|
||||
echo "Downloaded fallback preflight artifact ${fallback_name}."
|
||||
}
|
||||
|
||||
download_preflight_artifact
|
||||
|
||||
- name: Download full release validation manifest
|
||||
uses: actions/download-artifact@v8
|
||||
|
||||
27
.github/workflows/openclaw-performance.yml
vendored
27
.github/workflows/openclaw-performance.yml
vendored
@@ -30,8 +30,8 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
live_gpt54:
|
||||
description: Run the live OpenAI GPT 5.4 agent-turn lane
|
||||
live_openai_candidate:
|
||||
description: Run the live OpenAI GPT 5.5 agent-turn lane
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
@@ -57,7 +57,7 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
OCM_VERSION: v0.2.15
|
||||
KOVA_REPOSITORY: openclaw/Kova
|
||||
PERFORMANCE_MODEL_ID: gpt-5.4
|
||||
PERFORMANCE_MODEL_ID: gpt-5.5
|
||||
|
||||
jobs:
|
||||
kova:
|
||||
@@ -82,8 +82,8 @@ jobs:
|
||||
deep_profile: "true"
|
||||
live: "false"
|
||||
include_filters: "scenario:fresh-install scenario:gateway-performance scenario:agent-cold-warm-message"
|
||||
- lane: live-gpt54
|
||||
title: Kova live OpenAI GPT 5.4 agent turn
|
||||
- lane: live-openai-candidate
|
||||
title: Kova live OpenAI GPT 5.5 agent turn
|
||||
auth: live
|
||||
repeat: "1"
|
||||
deep_profile: "false"
|
||||
@@ -119,9 +119,9 @@ jobs:
|
||||
run_lane=false
|
||||
reason="deep_profile input is false"
|
||||
fi
|
||||
if [[ "$LANE_ID" == "live-gpt54" && "${{ github.event_name }}" != "schedule" && "${{ inputs.live_gpt54 || 'false' }}" != "true" ]]; then
|
||||
if [[ "$LANE_ID" == "live-openai-candidate" && "${{ github.event_name }}" != "schedule" && "${{ inputs.live_openai_candidate || 'false' }}" != "true" ]]; then
|
||||
run_lane=false
|
||||
reason="live_gpt54 input is false"
|
||||
reason="live_openai_candidate input is false"
|
||||
fi
|
||||
echo "run=$run_lane" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$run_lane" != "true" ]]; then
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
chmod 0755 "$HOME/.local/bin/kova"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Pin Kova OpenAI model to GPT 5.4
|
||||
- name: Pin Kova OpenAI model to GPT 5.5
|
||||
if: steps.lane.outputs.run == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "OPENAI_API_KEY is not configured; live GPT 5.4 lane will be skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "OPENAI_API_KEY is not configured; live GPT 5.5 lane will be skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
kova setup --ci --json
|
||||
@@ -561,7 +561,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" == "5" ]]; then
|
||||
exit 1
|
||||
{
|
||||
echo "### Clawgrit report publish skipped"
|
||||
echo
|
||||
echo "Kova artifacts were uploaded, but publishing the optional clawgrit report failed after ${attempt} attempts."
|
||||
echo "Check the \`CLAWGRIT_REPORTS_TOKEN\` secret or the reports repository permissions."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::warning::Kova artifacts uploaded, but optional clawgrit report publish failed after ${attempt} attempts."
|
||||
exit 0
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
git -C "$reports_root" fetch --depth=1 origin main
|
||||
|
||||
219
.github/workflows/openclaw-release-checks.yml
vendored
219
.github/workflows/openclaw-release-checks.yml
vendored
@@ -113,13 +113,21 @@ jobs:
|
||||
release_package_spec: ${{ steps.inputs.outputs.release_package_spec }}
|
||||
package_acceptance_package_spec: ${{ steps.inputs.outputs.package_acceptance_package_spec }}
|
||||
steps:
|
||||
- name: Require main or release workflow ref for release checks
|
||||
- name: Require trusted workflow ref for release checks
|
||||
env:
|
||||
RELEASE_REF: ${{ inputs.ref }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]]; then
|
||||
echo "Release checks must be dispatched from main, release/YYYY.M.D, or a Full Release Validation release-ci/<sha>-<timestamp> ref so workflow logic and secrets stay controlled." >&2
|
||||
tideclaw_alpha_check=false
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
workflow_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
if [[ "${RELEASE_REF}" == *"-alpha."* || "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ || "${RELEASE_REF}" == "${workflow_branch}" || "${RELEASE_REF}" == "refs/heads/${workflow_branch}" ]]; then
|
||||
tideclaw_alpha_check=true
|
||||
fi
|
||||
fi
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]] && [[ "${tideclaw_alpha_check}" != "true" ]]; then
|
||||
echo "Release checks must be dispatched from main, release/YYYY.M.D, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -219,6 +227,25 @@ jobs:
|
||||
fi
|
||||
echo "sha=${selected_sha,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate Tideclaw alpha target matches workflow branch
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
working-directory: workflow
|
||||
env:
|
||||
SELECTED_SHA: ${{ steps.ref.outputs.sha }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
echo "Tideclaw alpha release checks must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
|
||||
exit 1
|
||||
fi
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if ! git merge-base --is-ancestor "${SELECTED_SHA}" "refs/remotes/origin/${alpha_branch}"; then
|
||||
echo "Alpha release target ${SELECTED_SHA} must be reachable from ${alpha_branch}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Capture selected inputs
|
||||
id: inputs
|
||||
env:
|
||||
@@ -507,6 +534,7 @@ jobs:
|
||||
permissions: read-all
|
||||
uses: ./.github/workflows/openclaw-cross-os-release-checks-reusable.yml
|
||||
with:
|
||||
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
provider: ${{ needs.resolve_target.outputs.provider }}
|
||||
mode: ${{ needs.resolve_target.outputs.mode }}
|
||||
@@ -515,7 +543,7 @@ jobs:
|
||||
candidate_file_name: openclaw-current.tgz
|
||||
candidate_version: ${{ needs.prepare_release_package.outputs.package_version }}
|
||||
candidate_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
|
||||
openai_model: openai/gpt-5.4
|
||||
openai_model: openai/gpt-5.5
|
||||
ubuntu_runner: ubuntu-24.04
|
||||
windows_runner: windows-2025
|
||||
macos_runner: macos-26
|
||||
@@ -538,6 +566,7 @@ jobs:
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
include_repo_e2e: true
|
||||
include_release_path_suites: false
|
||||
@@ -603,6 +632,7 @@ jobs:
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
include_repo_e2e: false
|
||||
include_release_path_suites: true
|
||||
@@ -623,6 +653,7 @@ jobs:
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/package-acceptance.yml
|
||||
with:
|
||||
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
workflow_ref: ${{ github.ref_name }}
|
||||
source: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec != '' || needs.resolve_target.outputs.release_package_spec != '') && 'npm' || 'artifact' }}
|
||||
package_spec: ${{ needs.resolve_target.outputs.package_acceptance_package_spec || needs.resolve_target.outputs.release_package_spec || 'openclaw@beta' }}
|
||||
@@ -633,7 +664,7 @@ jobs:
|
||||
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
|
||||
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
|
||||
telegram_mode: mock-openai
|
||||
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-reply-chain-exact-marker,telegram-stream-final-single-message,telegram-long-final-reuses-preview,telegram-mention-gating
|
||||
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-long-final-reuses-preview,telegram-mention-gating
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -697,9 +728,9 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- lane: candidate
|
||||
output_dir: gpt54
|
||||
output_dir: openai-candidate
|
||||
- lane: baseline
|
||||
output_dir: opus46
|
||||
output_dir: anthropic-baseline
|
||||
env:
|
||||
QA_PARITY_CONCURRENCY: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
@@ -745,7 +776,7 @@ jobs:
|
||||
;;
|
||||
baseline)
|
||||
model="anthropic/claude-opus-4-7"
|
||||
alt_model="anthropic/claude-sonnet-4-7"
|
||||
alt_model="anthropic/claude-sonnet-4-6"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown QA parity lane: ${QA_PARITY_LANE}" >&2
|
||||
@@ -814,8 +845,8 @@ jobs:
|
||||
run: |
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
|
||||
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--baseline-label anthropic/claude-opus-4-7 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
@@ -829,6 +860,152 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
qa_lab_runtime_parity_release_checks:
|
||||
name: Run QA Lab runtime parity lane
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
|
||||
continue-on-error: true
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
QA_PARITY_CONCURRENCY: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
OPENAI_API_KEY: ""
|
||||
ANTHROPIC_API_KEY: ""
|
||||
OPENCLAW_LIVE_OPENAI_KEY: ""
|
||||
OPENCLAW_LIVE_ANTHROPIC_KEY: ""
|
||||
OPENCLAW_LIVE_GEMINI_KEY: ""
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ""
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run runtime parity lane
|
||||
id: runtime_parity_lane
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "openai/gpt-5.5-alt" \
|
||||
--runtime-pair pi,codex \
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity"
|
||||
|
||||
- name: Run standard runtime parity tier
|
||||
if: ${{ always() && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--runtime-parity-tier standard \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "openai/gpt-5.5-alt" \
|
||||
--runtime-pair pi,codex \
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity-standard"
|
||||
|
||||
- name: Generate runtime parity report
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--runtime-axis \
|
||||
--summary .artifacts/qa-e2e/runtime-parity/qa-suite-summary.json \
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-report
|
||||
|
||||
- name: Generate standard runtime parity report
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--runtime-axis \
|
||||
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
|
||||
|
||||
- name: Upload runtime parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
runtime_tool_coverage_release_checks:
|
||||
name: Enforce QA Lab runtime tool coverage
|
||||
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
|
||||
if: always() && contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
env:
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download runtime parity artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
|
||||
- name: Enforce standard runtime tool coverage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa coverage \
|
||||
--repo-root . \
|
||||
--tools \
|
||||
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
|
||||
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
|
||||
|
||||
- name: Upload runtime tool coverage artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/runtime-parity-standard-report/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
qa_live_matrix_release_checks:
|
||||
name: Run QA Lab live Matrix lane
|
||||
needs: [resolve_target]
|
||||
@@ -1108,6 +1285,9 @@ jobs:
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
concurrency:
|
||||
group: qa-live-whatsapp-shared
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -1304,6 +1484,8 @@ jobs:
|
||||
- package_acceptance_release_checks
|
||||
- qa_lab_parity_lane_release_checks
|
||||
- qa_lab_parity_report_release_checks
|
||||
- qa_lab_runtime_parity_release_checks
|
||||
- runtime_tool_coverage_release_checks
|
||||
- qa_live_matrix_release_checks
|
||||
- qa_live_telegram_release_checks
|
||||
- qa_live_discord_release_checks
|
||||
@@ -1316,9 +1498,15 @@ jobs:
|
||||
steps:
|
||||
- name: Verify release check results
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
tideclaw_alpha=false
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
tideclaw_alpha=true
|
||||
fi
|
||||
for item in \
|
||||
"prepare_release_package=${{ needs.prepare_release_package.result }}" \
|
||||
"install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \
|
||||
@@ -1328,6 +1516,8 @@ jobs:
|
||||
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
|
||||
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
|
||||
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
|
||||
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
|
||||
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \
|
||||
"qa_live_matrix_release_checks=${{ needs.qa_live_matrix_release_checks.result }}" \
|
||||
"qa_live_telegram_release_checks=${{ needs.qa_live_telegram_release_checks.result }}" \
|
||||
"qa_live_discord_release_checks=${{ needs.qa_live_discord_release_checks.result }}" \
|
||||
@@ -1337,6 +1527,15 @@ jobs:
|
||||
name="${item%%=*}"
|
||||
result="${item#*=}"
|
||||
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
|
||||
if [[ "$tideclaw_alpha" == "true" ]]; then
|
||||
case "$name" in
|
||||
prepare_release_package|install_smoke_release_checks) ;;
|
||||
*)
|
||||
echo "::warning::${name} ended with ${result}; Tideclaw alpha treats non-package-safety release-check lanes as advisory."
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$name" == qa_* ]]; then
|
||||
echo "::warning::${name} ended with ${result}; QA release-check lanes are advisory and do not block release validation."
|
||||
continue
|
||||
|
||||
245
.github/workflows/openclaw-release-publish.yml
vendored
245
.github/workflows/openclaw-release-publish.yml
vendored
@@ -15,6 +15,10 @@ on:
|
||||
description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true
|
||||
required: false
|
||||
type: string
|
||||
npm_telegram_run_id:
|
||||
description: Optional successful NPM Telegram Beta E2E run id to include in final release evidence
|
||||
required: false
|
||||
type: string
|
||||
npm_dist_tag:
|
||||
description: npm dist-tag for the OpenClaw package
|
||||
required: true
|
||||
@@ -76,6 +80,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
|
||||
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
env:
|
||||
@@ -110,8 +115,12 @@ jobs:
|
||||
echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
|
||||
echo "publish_openclaw_npm=true requires dispatching this workflow from main or release/YYYY.M.D." >&2
|
||||
tideclaw_alpha_publish=false
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
tideclaw_alpha_publish=true
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then
|
||||
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PLUGIN_PUBLISH_SCOPE}" == "selected" && -z "${PLUGINS}" ]]; then
|
||||
@@ -131,14 +140,43 @@ jobs:
|
||||
esac
|
||||
|
||||
- name: Download OpenClaw npm preflight manifest
|
||||
id: preflight_artifact
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-npm-preflight-${{ inputs.tag }}
|
||||
path: ${{ runner.temp }}/openclaw-npm-preflight-manifest
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.preflight_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
preferred_name="openclaw-npm-preflight-${RELEASE_TAG}"
|
||||
preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest"
|
||||
rm -rf "${preflight_dir}"
|
||||
mkdir -p "${preflight_dir}"
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${preferred_name}" \
|
||||
--dir "${preflight_dir}"; then
|
||||
echo "name=${preferred_name}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact."
|
||||
mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \
|
||||
--jq '.artifacts[] | select(.expired != true) | .name' |
|
||||
grep '^openclaw-npm-preflight-' || true)
|
||||
if [[ "${#matches[@]}" != "1" ]]; then
|
||||
echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2
|
||||
printf 'Available preflight candidates:\n' >&2
|
||||
printf -- '- %s\n' "${matches[@]:-<none>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fallback_name="${matches[0]}"
|
||||
gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${fallback_name}" \
|
||||
--dir "${preflight_dir}"
|
||||
echo "name=${fallback_name}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download full release validation manifest
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
@@ -245,7 +283,10 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate release tag is reachable from main or release branch
|
||||
- name: Validate release tag is reachable from a trusted release branch
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
WORKFLOW_REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
@@ -259,7 +300,17 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
echo "Release tag must point to a commit reachable from main or release/*." >&2
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then
|
||||
if [[ ! "${WORKFLOW_REF_NAME}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
echo "Alpha publish tags must be dispatched from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch --no-tags origin "+refs/heads/${WORKFLOW_REF_NAME}:refs/remotes/origin/${WORKFLOW_REF_NAME}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${WORKFLOW_REF_NAME}"; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "Release tag must point to a commit reachable from main, release/*, or the matching Tideclaw alpha branch for alpha prereleases." >&2
|
||||
exit 1
|
||||
|
||||
- name: Summarize release target
|
||||
@@ -293,6 +344,12 @@ jobs:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
cache-key-suffix: release-publish
|
||||
|
||||
- name: Dispatch publish workflows
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -306,6 +363,9 @@ jobs:
|
||||
PLUGINS: ${{ inputs.plugins }}
|
||||
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
|
||||
WAIT_FOR_CLAWHUB: ${{ inputs.wait_for_clawhub && 'true' || 'false' }}
|
||||
PREFLIGHT_ARTIFACT_NAME: ${{ needs.resolve_release_target.outputs.preflight_artifact_name }}
|
||||
NPM_TELEGRAM_RUN_ID: ${{ inputs.npm_telegram_run_id }}
|
||||
POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -314,7 +374,10 @@ jobs:
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id
|
||||
before_json="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
before_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
|
||||
-F event=workflow_dispatch \
|
||||
-F per_page=100 \
|
||||
--jq '[.workflow_runs[].id]')"
|
||||
|
||||
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
|
||||
printf '%s\n' "$dispatch_output" >&2
|
||||
@@ -327,8 +390,10 @@ jobs:
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
BEFORE_IDS="$before_json" gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
|
||||
-F event=workflow_dispatch \
|
||||
-F per_page=50 \
|
||||
--jq '.workflow_runs | map({databaseId:.id, createdAt:.created_at}) | map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
@@ -349,6 +414,73 @@ jobs:
|
||||
printf '%s\n' "${run_id}"
|
||||
}
|
||||
|
||||
print_pending_deployments() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local pending_json
|
||||
|
||||
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
|
||||
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "${workflow} pending environment approval:"
|
||||
while IFS=$'\t' read -r env_id env_name can_approve; do
|
||||
echo "- env=${env_name} canApprove=${can_approve}"
|
||||
echo " approve: gh api -X POST repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments -F 'environment_ids[]=${env_id}' -f state=approved -f comment='Approve release gate'"
|
||||
done < <(printf '%s' "${pending_json}" | jq -r '.[] | [.environment.id, .environment.name, .current_user_can_approve] | @tsv')
|
||||
}
|
||||
|
||||
approve_pending_deployments() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local pending_json approved
|
||||
|
||||
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
|
||||
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
approved=0
|
||||
while IFS=$'\t' read -r env_id env_name; do
|
||||
if [[ -z "${env_id}" ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "${workflow}: approving pending environment ${env_name} (${env_id})"
|
||||
gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \
|
||||
-F "environment_ids[]=${env_id}" \
|
||||
-f state=approved \
|
||||
-f comment="Approve release gate from OpenClaw Release Publish wrapper" >/dev/null
|
||||
approved=1
|
||||
done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv')
|
||||
|
||||
if [[ "${approved}" == "1" ]]; then
|
||||
echo "${workflow}: approved available pending environment gates"
|
||||
fi
|
||||
}
|
||||
|
||||
print_failed_run_summary() {
|
||||
local run_id="$1"
|
||||
local failed_json
|
||||
|
||||
failed_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs \
|
||||
--jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {databaseId, name, conclusion, url}' || true)"
|
||||
if [[ -z "${failed_json}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Failed child job summary:"
|
||||
printf '%s\n' "${failed_json}"
|
||||
while IFS=$'\t' read -r job_id job_name; do
|
||||
if [[ -z "${job_id}" ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "--- ${job_name} (${job_id}) log tail ---"
|
||||
gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --job "${job_id}" --log 2>/dev/null |
|
||||
tail -200 || true
|
||||
done < <(printf '%s\n' "${failed_json}" | jq -r '[.databaseId, .name] | @tsv' 2>/dev/null || true)
|
||||
}
|
||||
|
||||
wait_for_run() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
@@ -366,6 +498,8 @@ jobs:
|
||||
state="${status}:${updated_at}"
|
||||
if [[ "$state" != "$last_state" ]]; then
|
||||
echo "${workflow} still ${status} (updated ${updated_at}): ${url}"
|
||||
print_pending_deployments "${workflow}" "${run_id}"
|
||||
approve_pending_deployments "${workflow}" "${run_id}"
|
||||
last_state="$state"
|
||||
fi
|
||||
sleep 30
|
||||
@@ -393,7 +527,7 @@ jobs:
|
||||
echo "- ${workflow}: ${conclusion} in ${duration_label} (${url})"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -466,17 +600,18 @@ jobs:
|
||||
}
|
||||
|
||||
upload_dependency_evidence_release_asset() {
|
||||
local release_version download_dir asset_path asset_name
|
||||
local release_version download_dir asset_path asset_name artifact_name
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
download_dir="${RUNNER_TEMP}/openclaw-release-dependency-evidence-asset"
|
||||
asset_name="openclaw-${release_version}-dependency-evidence.zip"
|
||||
asset_path="${RUNNER_TEMP}/${asset_name}"
|
||||
artifact_name="${PREFLIGHT_ARTIFACT_NAME:-openclaw-npm-preflight-${RELEASE_TAG}}"
|
||||
|
||||
rm -rf "${download_dir}" "${asset_path}"
|
||||
mkdir -p "${download_dir}"
|
||||
gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "openclaw-npm-preflight-${RELEASE_TAG}" \
|
||||
--name "${artifact_name}" \
|
||||
--dir "${download_dir}"
|
||||
|
||||
if [[ ! -d "${download_dir}/dependency-evidence" ]]; then
|
||||
@@ -492,6 +627,42 @@ jobs:
|
||||
echo "- Dependency evidence asset: \`${asset_name}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
verify_published_release() {
|
||||
local release_version evidence_path
|
||||
local -a verify_args
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
|
||||
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
|
||||
|
||||
verify_args=(
|
||||
release:verify-beta
|
||||
--
|
||||
"${release_version}"
|
||||
--tag "${RELEASE_TAG}"
|
||||
--dist-tag "${RELEASE_NPM_DIST_TAG}"
|
||||
--repo "${GITHUB_REPOSITORY}"
|
||||
--workflow-ref "${CHILD_WORKFLOW_REF}"
|
||||
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
|
||||
--plugin-npm-run "${plugin_npm_run_id}"
|
||||
--plugin-clawhub-run "${plugin_clawhub_run_id}"
|
||||
--openclaw-npm-run "${openclaw_npm_run_id}"
|
||||
--evidence-out "${evidence_path}"
|
||||
)
|
||||
if [[ -n "${PLUGINS// }" ]]; then
|
||||
verify_args+=(--plugins "${PLUGINS}")
|
||||
fi
|
||||
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
|
||||
verify_args+=(--npm-telegram-run "${NPM_TELEGRAM_RUN_ID}")
|
||||
fi
|
||||
|
||||
pnpm "${verify_args[@]}"
|
||||
{
|
||||
echo "- Postpublish verification: passed"
|
||||
echo "- Postpublish evidence: \`${evidence_path}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
{
|
||||
echo "### Publish sequence"
|
||||
echo
|
||||
@@ -500,11 +671,11 @@ jobs:
|
||||
echo "- Release SHA: \`${TARGET_SHA}\`"
|
||||
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
echo "- OpenClaw npm publish: starts after plugin npm succeeds; ClawHub may still be running"
|
||||
echo "- OpenClaw npm publish: starts after plugin npm succeeds; final verification waits for ClawHub"
|
||||
else
|
||||
echo "- OpenClaw npm publish: skipped by input"
|
||||
fi
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" || "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
echo "- Workflow completion waits for ClawHub"
|
||||
else
|
||||
echo "- Workflow completion does not wait for ClawHub; monitor the dispatched ClawHub run separately"
|
||||
@@ -546,7 +717,7 @@ jobs:
|
||||
|
||||
clawhub_result=""
|
||||
clawhub_pid=""
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" || "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
|
||||
@@ -565,23 +736,39 @@ jobs:
|
||||
fi
|
||||
|
||||
failed=0
|
||||
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
|
||||
failed=1
|
||||
fi
|
||||
openclaw_failed=0
|
||||
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
|
||||
failed=1
|
||||
openclaw_failed=1
|
||||
fi
|
||||
if [[ -n "${openclaw_result}" && -f "${openclaw_result}" && "$(cat "${openclaw_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
openclaw_failed=1
|
||||
fi
|
||||
|
||||
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
|
||||
create_or_update_github_release
|
||||
upload_dependency_evidence_release_asset
|
||||
fi
|
||||
|
||||
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -n "${openclaw_result}" && -f "${openclaw_result}" && "$(cat "${openclaw_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
|
||||
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
|
||||
verify_published_release
|
||||
fi
|
||||
if [[ "${failed}" != "0" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "${openclaw_npm_run_id}" ]]; then
|
||||
create_or_update_github_release
|
||||
upload_dependency_evidence_release_asset
|
||||
fi
|
||||
- name: Upload postpublish evidence
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
|
||||
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
|
||||
if-no-files-found: ignore
|
||||
|
||||
17
.github/workflows/package-acceptance.yml
vendored
17
.github/workflows/package-acceptance.yml
vendored
@@ -93,8 +93,18 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
advisory:
|
||||
description: Treat acceptance failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
advisory:
|
||||
description: Treat acceptance failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_ref:
|
||||
description: Trusted repo ref for workflow scripts and Docker E2E harness
|
||||
required: false
|
||||
@@ -509,6 +519,7 @@ jobs:
|
||||
needs: resolve_package
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
advisory: ${{ inputs.advisory }}
|
||||
ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }}
|
||||
include_repo_e2e: false
|
||||
include_release_path_suites: ${{ needs.resolve_package.outputs.include_release_path_suites == 'true' }}
|
||||
@@ -573,6 +584,7 @@ jobs:
|
||||
if: needs.resolve_package.outputs.telegram_enabled == 'true'
|
||||
uses: ./.github/workflows/npm-telegram-beta-e2e.yml
|
||||
with:
|
||||
advisory: ${{ inputs.advisory }}
|
||||
package_spec: ${{ inputs.package_spec }}
|
||||
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
|
||||
package_label: openclaw@${{ needs.resolve_package.outputs.package_version }}
|
||||
@@ -599,6 +611,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
advisory="${{ inputs.advisory }}"
|
||||
failed=0
|
||||
for item in \
|
||||
"resolve_package=${RESOLVE_RESULT}" \
|
||||
@@ -608,6 +621,10 @@ jobs:
|
||||
name="${item%%=*}"
|
||||
result="${item#*=}"
|
||||
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
|
||||
if [[ "$advisory" == "true" && "$name" != "resolve_package" ]]; then
|
||||
echo "::warning::${name} ended with ${result}; package acceptance is advisory for this caller."
|
||||
continue
|
||||
fi
|
||||
echo "::error::${name} ended with ${result}"
|
||||
failed=1
|
||||
fi
|
||||
|
||||
28
.github/workflows/plugin-clawhub-release.yml
vendored
28
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -16,7 +16,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
description: Commit SHA on main or a release branch to publish from; defaults to the workflow ref
|
||||
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -82,7 +82,9 @@ jobs:
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate ref is on main or a release branch
|
||||
- name: Validate ref is on a trusted publish branch
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git merge-base --is-ancestor HEAD origin/main; then
|
||||
@@ -93,7 +95,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
echo "Plugin ClawHub publishes must target a commit reachable from main or release/*." >&2
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "Plugin ClawHub publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
|
||||
exit 1
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
@@ -168,6 +177,19 @@ jobs:
|
||||
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
|
||||
exit 1
|
||||
|
||||
- name: Validate Tideclaw alpha plugin channels
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
invalid="$(
|
||||
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
if [[ -n "${invalid}" ]]; then
|
||||
echo "Tideclaw alpha ClawHub publishes may only publish alpha plugin versions." >&2
|
||||
printf '%s\n' "${invalid}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify OpenClaw ClawHub package ownership
|
||||
if: steps.plan.outputs.has_candidates == 'true'
|
||||
env:
|
||||
|
||||
28
.github/workflows/plugin-npm-release.yml
vendored
28
.github/workflows/plugin-npm-release.yml
vendored
@@ -25,7 +25,7 @@ on:
|
||||
- selected
|
||||
- all-publishable
|
||||
ref:
|
||||
description: Commit SHA on main or a release branch to publish from (copy from the preview run)
|
||||
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from
|
||||
required: true
|
||||
type: string
|
||||
plugins:
|
||||
@@ -71,7 +71,9 @@ jobs:
|
||||
id: ref
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate ref is on main or a release branch
|
||||
- name: Validate ref is on a trusted publish branch
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
@@ -85,7 +87,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
echo "Plugin npm publishes must target a commit reachable from main or release/*." >&2
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "Plugin npm publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
|
||||
exit 1
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
@@ -151,6 +160,19 @@ jobs:
|
||||
echo "Already published / skipped:"
|
||||
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json
|
||||
|
||||
- name: Validate Tideclaw alpha plugin channels
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
invalid="$(
|
||||
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-npm-release-plan.json
|
||||
)"
|
||||
if [[ -n "${invalid}" ]]; then
|
||||
echo "Tideclaw alpha plugin npm publishes may only publish alpha plugin versions." >&2
|
||||
printf '%s\n' "${invalid}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_npm
|
||||
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
|
||||
131
.github/workflows/qa-live-transports-convex.yml
vendored
131
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -60,13 +60,17 @@ jobs:
|
||||
authorize_actor:
|
||||
name: Authorize workflow actor
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "schedule") {
|
||||
core.info("Scheduled default-branch QA run; actor permission check is only required for manual dispatch.");
|
||||
core.setOutput("authorized", "true");
|
||||
return;
|
||||
}
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -79,14 +83,18 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.setFailed(
|
||||
core.notice(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
validate_selected_ref:
|
||||
name: Validate selected ref
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
selected_revision: ${{ steps.validate.outputs.selected_revision }}
|
||||
@@ -178,6 +186,8 @@ jobs:
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run OpenAI candidate lane
|
||||
@@ -188,7 +198,7 @@ jobs:
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model openai/gpt-5.5-alt \
|
||||
--output-dir .artifacts/qa-e2e/gpt54
|
||||
--output-dir .artifacts/qa-e2e/openai-candidate
|
||||
|
||||
- name: Run Opus 4.7 lane
|
||||
run: |
|
||||
@@ -197,15 +207,15 @@ jobs:
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model anthropic/claude-opus-4-7 \
|
||||
--alt-model anthropic/claude-sonnet-4-7 \
|
||||
--output-dir .artifacts/qa-e2e/opus46
|
||||
--alt-model anthropic/claude-sonnet-4-6 \
|
||||
--output-dir .artifacts/qa-e2e/anthropic-baseline
|
||||
|
||||
- name: Generate parity report
|
||||
run: |
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
|
||||
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--baseline-label anthropic/claude-opus-4-7 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
@@ -219,6 +229,96 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
run_live_runtime_token_efficiency:
|
||||
name: Run live runtime token-efficiency lane
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
environment: qa-live-shared
|
||||
env:
|
||||
QA_PARITY_CONCURRENCY: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "Missing required OPENAI_API_KEY." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run live runtime parity lane
|
||||
id: run_lane
|
||||
shell: bash
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_LIVE_OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
output_dir=".artifacts/qa-e2e/runtime-token-efficiency-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
pnpm openclaw qa suite \
|
||||
--repo-root . \
|
||||
--provider-mode live-frontier \
|
||||
--runtime-parity-tier standard \
|
||||
--runtime-parity-tier live-only \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--runtime-pair pi,codex \
|
||||
--fast \
|
||||
--allow-failures \
|
||||
--output-dir "${output_dir}/runtime-suite"
|
||||
|
||||
- name: Generate live runtime token-efficiency report
|
||||
if: always() && steps.run_lane.outcome != 'skipped' && steps.run_lane.outcome != 'cancelled'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--runtime-axis \
|
||||
--token-efficiency \
|
||||
--summary "${{ steps.run_lane.outputs.output_dir }}/runtime-suite/qa-suite-summary.json" \
|
||||
--output-dir "${{ steps.run_lane.outputs.output_dir }}/runtime-report"
|
||||
|
||||
- name: Upload live runtime token-efficiency artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qa-live-runtime-token-efficiency-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
run_live_matrix:
|
||||
name: Run Matrix live QA lane
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
@@ -254,6 +354,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane
|
||||
@@ -338,6 +440,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane shard
|
||||
@@ -420,6 +524,8 @@ jobs:
|
||||
require_var OPENCLAW_QA_CONVEX_SECRET_CI
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Telegram live lane
|
||||
@@ -513,6 +619,8 @@ jobs:
|
||||
require_var OPENCLAW_QA_CONVEX_SECRET_CI
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Discord live lane
|
||||
@@ -547,8 +655,8 @@ jobs:
|
||||
--repo-root . \
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--model openai/gpt-5.5 \
|
||||
--alt-model openai/gpt-5.5 \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
@@ -568,6 +676,9 @@ jobs:
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
concurrency:
|
||||
group: qa-live-whatsapp-shared
|
||||
cancel-in-progress: false
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
@@ -606,6 +717,8 @@ jobs:
|
||||
require_var OPENCLAW_QA_CONVEX_SECRET_CI
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run WhatsApp live lane
|
||||
@@ -699,6 +812,8 @@ jobs:
|
||||
require_var OPENCLAW_QA_CONVEX_SECRET_CI
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Slack live lane
|
||||
|
||||
4
.github/workflows/workflow-sanity.yml
vendored
4
.github/workflows/workflow-sanity.yml
vendored
@@ -2,8 +2,12 @@ name: Workflow Sanity
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "CHANGELOG.md"
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "CHANGELOG.md"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -35,14 +35,18 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
|
||||
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
|
||||
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
|
||||
- New seams: backward-compatible, documented, versioned. Third-party plugins exist.
|
||||
- Fix shape: prefer bounded owner-boundary refactors over local patches/shims when they remove stale abstractions, duplicate policy, or wrong ownership.
|
||||
- Compat default: no new internal shims, aliases, fallback APIs, or legacy names just to reduce diff. Migrate callers and delete old paths.
|
||||
- Public plugin API is the only compat exception: document/version breaks, aggressively deprecate unused SDK surface, and migrate ALL bundled/internal plugins to the modern API in the same change.
|
||||
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
|
||||
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
|
||||
- Do not fix repeated request-time discovery with scattered caches. Move the canonical fact earlier; reuse prepared runtime objects; delete duplicate lookup branches.
|
||||
- Inline code comments: brief notes for tricky, bug-prone, or previously buggy logic.
|
||||
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
|
||||
- Protocol version bumps: explicit owner confirmation only; never automatic/generated.
|
||||
- Config contract: exported types, schema/help, metadata, baselines, docs aligned. Retired public keys stay retired; compat in raw migration/doctor only.
|
||||
- Prompt cache: deterministic ordering for maps/sets/registries/plugin lists/files/network results before model/tool payloads. Preserve old transcript bytes when possible.
|
||||
- Agent tool schema cleanup: remove stale args cleanly; no hidden compat for model-facing params just to avoid churn.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -63,12 +67,13 @@ Skills own workflows; root owns hard policy and routing.
|
||||
## Validation
|
||||
|
||||
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
|
||||
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
|
||||
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
|
||||
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
|
||||
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
|
||||
- One/few files local. If a local command fans out, stop and move broad proof to Crabbox/Testbox.
|
||||
- Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow.
|
||||
- Pre-land/pre-commit code changes: use `$codex-review` until no accepted/actionable findings remain, unless equivalent manual review already done, trivial/docs-only, or user opts out.
|
||||
- Pre-land/pre-commit code changes: use `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already done, trivial/docs-only, or user opts out.
|
||||
- If proof is blocked, say exactly what is missing and why.
|
||||
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
|
||||
- Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed.
|
||||
|
||||
495
CHANGELOG.md
495
CHANGELOG.md
@@ -1,4 +1,4 @@
|
||||
# Changelog
|
||||
# Changelog
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
@@ -6,42 +6,410 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.
|
||||
- Skills: rename the repo-local Codex closeout review skill and helper to `autoreview` while preserving the Codex-first fallback behavior.
|
||||
- Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.
|
||||
- Browser: surface pending and recently handled modal dialogs in snapshots, return `blockedByDialog` when an action opens a modal, and allow `browser dialog --dialog-id` to answer pending dialogs.
|
||||
- Agents/tools: shorten built-in tool descriptions and schema hints across media, messaging, sessions, cron, Gateway, web, image/PDF, TTS, nodes, and plan tools while preserving routing guardrails.
|
||||
- Skills: add node inspector debugging, fused diagram generation, and throwaway spike workflow skills.
|
||||
- CLI/plugins: add `defineToolPlugin` plus `openclaw plugins build`, `validate`, and `init` for typed simple tool plugins with generated manifest metadata, optional tool declarations, and context factories.
|
||||
- Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads.
|
||||
- Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`.
|
||||
- Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.
|
||||
- Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated.
|
||||
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.
|
||||
- QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. Fixes #80338; refs #80337. Thanks @100yenadmin.
|
||||
- QA-Lab: add `openclaw qa suite --runtime-parity-tier` and wire the standard Codex-vs-Pi tier into release checks separately from optional/live-only/soak lanes. Fixes #80337. Thanks @100yenadmin.
|
||||
- QA-Lab: add a live-only Codex Pi-shaped Read vocabulary canary so runtime parity catches native workspace-read prompt compatibility drift. (#80323) Thanks @100yenadmin.
|
||||
- QA-Lab: add live-only harness self-health scenarios for plugin hook crashes, manifest contract errors, and WebChat direct-reply self-message routing. (#80323) Thanks @100yenadmin.
|
||||
- QA-Lab: add runtime tool fixture scenarios and coverage reporting for Codex-native workspace tools, OpenClaw dynamic tools, and optional plugin-backed tools. Fixes #80173. Thanks @100yenadmin.
|
||||
- QA-Lab: expose runtime tool fixture coverage through `openclaw qa coverage --tools`, with optional suite-summary evaluation for parity gate artifacts. Thanks @100yenadmin.
|
||||
- QA-Lab: schedule a live-frontier Codex-vs-Pi runtime token-efficiency artifact lane in the all-lanes QA workflow. Fixes #80175. Thanks @100yenadmin.
|
||||
- QA-Lab: hard-gate required OpenClaw dynamic runtime-tool drift in the standard Codex-vs-Pi tier with a blocking release-check verifier and publish the tool coverage report artifact. Fixes #80339; refs #80319. Thanks @100yenadmin.
|
||||
- QA-Lab: add the personal-agent approval-denial scenario so the benchmark pack verifies denied local reads stop cleanly without tool progress or fixture leaks. (#83150) Thanks @iFiras-Max1.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow `system.which` timeout warnings.
|
||||
- GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with `invalid_request_body`. Fixes #83220. Thanks @galiniliev.
|
||||
- QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage.
|
||||
- Core/plugins: harden clawpatch-reported edge cases across gateway auth cleanup, Claude session id paths, plugin activation policy, apply-patch hunk handling, diagnostic redaction, and plugin metadata validation.
|
||||
- Mac app: prefer explicit private/Tailscale/LAN Gateway endpoints over SSH tunnels, preserve legacy loopback tunnel configs, persist transport choices, and show captured SSH stderr when tunneling really fails.
|
||||
- Gateway/sessions: keep ACP/acpx and runtime child sessions visible in configured-only session lists when their owner or parent session belongs to a configured agent.
|
||||
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected, and keep the Settings sidebar toggle in the leading titlebar area.
|
||||
- Mac app: allow longer Gateway and Context errors to wrap in the menu instead of truncating the useful failure detail.
|
||||
- Gateway/webchat: hide internal runtime-context and other `display: false` transcript messages from Chat history and live message events. Fixes #83216. Thanks @EmpireCreator.
|
||||
- CLI/help: keep `gateway`, `doctor`, `status`, and `health` help registration out of action/runtime imports so subcommand `--help` stays lightweight in constrained terminals. Fixes #83228. Thanks @dfguerrerom.
|
||||
- Cron/Discord: keep explicit announce runs in message-tool-only source-reply mode so scheduled agent turns post once instead of also echoing through automatic visible replies. Fixes #83261. Thanks @Theralley.
|
||||
- Telegram: preserve forum-topic origin targets in inbound, audio-preflight, and skipped-message hook contexts so follow-up delivery stays bound to the originating topic. Fixes #83302. Thanks @M00zyx.
|
||||
- Telegram: retry HTTP 421 Misdirected Request send failures on a fresh fallback transport so transient edge-node routing errors no longer drop outbound replies. Fixes #48892. (#48908) Thanks @MarsDoge.
|
||||
- Telegram: fail topic sends closed when Telegram reports `message thread not found` instead of retrying without `message_thread_id` into the base chat. Refs #83302.
|
||||
- Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.
|
||||
- OpenAI/Codex: stop rejecting available `openai-codex` GPT-5.1, GPT-5.2, and GPT-5.3 model refs during config validation, while keeping removed Spark aliases suppressed. Fixes #83303.
|
||||
- Plugins/xAI: complete OAuth-backed xAI login and sidecar auth fixes, including guarded loopback callback CORS handling, video generation polling/defaults, and native-host User-Agent attribution. (#83322) Thanks @Jaaneek.
|
||||
- Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.
|
||||
- Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.
|
||||
- Codex app-server: keep recent context-engine messages when oversized projected history is truncated, so short follow-ups in long channel sessions do not fall back to stale earlier turns. (#83127) Thanks @VACInc.
|
||||
- Feishu: return bound subagent delivery origins from session thread setup so Feishu subagent completions route back to the same DM or topic. (#83190) Thanks @100menotu001.
|
||||
- CLI/update: tailor post-update Gateway recovery hints by platform, showing systemd, LaunchAgent, Scheduled Task, or generic service-manager guidance instead of macOS-only recovery text. (#83096) Thanks @rubencu.
|
||||
- Plugins: apply a default 15-second timeout to legacy `before_agent_start` hooks so hung plugin handlers no longer block agent startup. Fixes #48534. (#83136) Thanks @therahul-yo.
|
||||
- Feishu: refresh inbound session delivery context for DM, group, and broadcast turns so later replies do not inherit stale WebChat routing. Fixes #78274.
|
||||
- Agents/subagents: require the initial subagent registry save before reporting spawn accepted, returning a spawn error instead of losing an untracked run when the registry write fails. (#83146) Thanks @yetval.
|
||||
- QA-Lab/qa-channel: attach redacted agent tool-start traces to outbound `QaBusMessage` records so scenarios can assert actual tool use instead of relying only on reply text. Fixes #67637. Thanks @100yenadmin.
|
||||
- QA-Lab: fail live runtime parity reports when assistant-message usage is missing, preventing `0 vs 0` live token rows from being reported as passing proof. Fixes #80411. Thanks @100yenadmin.
|
||||
- QA-Lab: add a runtime token-efficiency sidecar report that classifies Codex savings separately from regressions and fails only positive Codex-over-Pi live token deltas above threshold. Fixes #81093. Thanks @100yenadmin.
|
||||
- QA-Lab: fail Codex-backed OpenAI live runtime-pair runs before launching isolated workers when no portable Codex auth is available, while staging API-key fallbacks and configured Codex keys for isolated QA agents. Fixes #80412. Thanks @100yenadmin.
|
||||
- QA-Lab: refresh parity gates, mock frontier fixtures, model scenarios, and workflow artifact lanes to compare GPT-5.5 against Claude Opus 4.7. Fixes #74262. Thanks @100yenadmin.
|
||||
- QA-Lab: make mock parity dispatch provider-aware for source discovery and subagent scenarios so OpenAI and Anthropic lanes no longer share identical canned plans. Fixes #64879. Thanks @100yenadmin.
|
||||
- QA-Lab: stop returning Control UI bearer tokens from unauthenticated bootstrap payloads and bind Docker harness ports to loopback-only host addresses. (#66355) Thanks @pgondhi987.
|
||||
- Mac app: avoid a SwiftUI metadata crash when rendering the Cron Jobs settings pane.
|
||||
- Agents/subagents: preserve run-mode keep subagent registry entries past the session sweep TTL, so kept subagent runs remain visible after cleanup completes. Fixes #83132. (#83168) Thanks @yetval.
|
||||
- Agents/OpenAI streams: yield via `setTimeout(0)` instead of `setImmediate` between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462.
|
||||
- Agents/Codex: keep legacy `oauthRef`-backed OAuth profiles usable while `openclaw doctor --fix` migrates them back to inline credentials, without creating new sidecar credentials. (#83312) Thanks @joshavant.
|
||||
- CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable.
|
||||
- CLI/doctor: seed Control UI allowed origins when migrating legacy non-loopback gateway bind host aliases like `0.0.0.0`. Fixes #83286. Thanks @giodl73-repo.
|
||||
- CLI/plugins: ship the bundled memory CLI as a package entry so package-installed `openclaw memory` commands register correctly.
|
||||
- CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.
|
||||
- Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.
|
||||
- Gateway/restart: keep ordinary unmanaged SIGUSR1/config restarts in-process instead of detach-spawning an orphaned child, preserving custom supervisor PID tracking while leaving update restarts on the fresh-process path. Fixes #65668.
|
||||
- CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.
|
||||
- Telegram: keep isolated long polling below the hard `getUpdates` request guard so idle bot accounts with high `timeoutSeconds` do not false-disconnect and restart-loop. Fixes #83264. Thanks @riccodecarvalho.
|
||||
- Providers/Google: preserve and recover Gemini 3 tool-call thought signatures during native replay so function-calling turns no longer fail with missing `thought_signature` 400s. Fixes #72879. (#80358) Thanks @abnershang.
|
||||
- Telegram: skip transcript-only delivery mirrors and gateway-injected rows when resolving latest assistant text, preventing retained previews from replacing final replies with stale fragments. Fixes #83159. (#83362) Thanks @joshavant.
|
||||
- Memory/QMD: keep lexical search on raw hyphenated queries while normalizing semantic QMD sub-searches, avoiding fallback to the builtin index for dashed identifiers and dates. Fixes #81328.
|
||||
- Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded `memory index` warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521.
|
||||
- Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin.
|
||||
- Gateway: continue restarting remaining channels when one hot-reload channel restart fails, while still reporting aggregate reload failure and rolling back plugin pre-replace stops. Fixes #83054. Thanks @zqchris.
|
||||
- Gateway/secrets: split the lightweight secrets runtime state and auth-store cache from the full secrets runtime and take a startup fast path when the gateway startup config has no SecretRef values, speeding up secrets startup while preserving cleanup and refresh semantics.
|
||||
- Codex app-server: rotate oversized native Codex threads before resume and cap dynamic tool-result text entering native Codex sessions, preventing stale oversized context from surviving OpenClaw compaction. (#82981) Thanks @hansolo949.
|
||||
- Gateway/restart: drain pending replies and active chat runs during restart shutdown before sockets and channels close, aborting timed-out chat runs through the normal cleanup path. (#69121) Thanks @alexlomt.
|
||||
- Agents/Codex: use the Codex runtime context window for OpenAI-model preflight compaction and memory flush checks, so GPT-5.5 Codex sessions compact before hitting the smaller native context limit. Fixes #82982. Thanks @vliuyt.
|
||||
- QA-Lab: clean orphaned gateway temp roots when a suite parent exits and wait on gateway plus transport readiness after config restarts, reducing stale `qa-channel` noise from interrupted runs. Fixes #65506. Thanks @100yenadmin.
|
||||
- QA-Lab: wake qa-bus long polls that arrive with stale future cursors after a bus restart, preserving reconnect readiness for harness clients. (#67142) Thanks @hxy91819.
|
||||
- QA-Lab: stage Multipass transfer scripts under OpenClaw's preferred temp root instead of raw OS temp paths, keeping the VM runner inside temp-path guardrails. (#64098) Thanks @ImLukeF.
|
||||
- Agents/replies: keep surviving reply media and append a warning when other media references fail, so partial media normalization no longer drops failures silently. Thanks @Jerry-Xin.
|
||||
- Config/models: accept `thinkingFormat: "together"` in model compat config so Together routes can opt into the Together-specific thinking response shape.
|
||||
- Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.7.1, bringing Codex hook approval compatibility, pre-tool command wrapping fixes, and Rolldown/Vitest output compaction improvements into the OpenClaw plugin.
|
||||
- Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.
|
||||
- Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.
|
||||
- Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.
|
||||
- Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward `music_generate` audio creation instead of lyric-only replies, and reserve `lyrics` for exact sung words.
|
||||
- Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.
|
||||
- Codex/app-server: keep bound conversation sessions on the owning agent runtime so native Codex control and follow-up turns do not fall back to the default agent client. Fixes #82954. (#82993)
|
||||
- CLI/infer: run gateway model probes in fresh explicit sessions so one-shot provider checks do not inherit default agent transcript state. (#82861) Thanks @Kaspre.
|
||||
- Providers/Together: send video-generation requests to Together's v2 video API even when shared text-model config still points at the v1 base URL. (#82992)
|
||||
- Browser CLI: preserve browser-level options on nested commands, skip option values during lazy command registration, and keep long-running wait/download/dialog hooks open for their advertised wait window.
|
||||
- CLI/sessions: accept `openclaw sessions list` as an alias for `openclaw sessions`, matching other list-style commands. Fixes #81139. (#81163) Thanks @YB0y.
|
||||
- Channels/stream previews: widen compact progress draft lines and cut prose at word boundaries while preserving command/path suffixes, with `streaming.progress.maxLineChars` for channel-specific tuning.
|
||||
- CLI/plugins: have `openclaw plugins doctor` warn when a configured runtime needs a missing owner plugin, sharing the same install mapping as `openclaw doctor --fix`. Fixes #81326. (#81674) Thanks @Zavianx.
|
||||
- Agents/Codex: route OpenAI runs that resolve to `openai-codex` through the Codex provider and bootstrap OpenClaw's stored OAuth profile into the Codex harness when the harness owns transport, so `openai/*` model refs no longer fail with `No API key found for openai-codex` despite an existing Codex OAuth profile. (#82864) Thanks @ragesaq.
|
||||
- Agents/ACP: distinguish prompt-submitted and runtime-active child stalls from true interactive waits, including redacted proxy-env diagnostics for Codex ACP no-output runs. Fixes #44810.
|
||||
- Agents/memory: explain that memory-triggered compaction exposes only `read` and append-only `write` when configured core tools are unavailable in `tools.allow` warnings. Fixes #82941. Thanks @galiniliev.
|
||||
- Agents/OpenAI: preserve deterministic tool payload ordering for prompt-cache reuse across OpenAI Responses and chat completions calls. (#82940) Thanks @galiniliev.
|
||||
- ACP/Codex: honor terminal ACP turn results so failed Codex/acpx runs are not recorded as successful after only progress text. Fixes #79522. Thanks @dudaefj.
|
||||
- Telegram: warn when a media group drops photos that fail to download, including albums where every photo is skipped. Fixes #55216. (#82987) Thanks @eldar702.
|
||||
- Agents/skills: apply the full effective tool policy pipeline to inline `command-dispatch: tool` skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)
|
||||
- Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.
|
||||
- Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving `default` instead of silently dropping it.
|
||||
- Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram `/verbose` stays visible when command events arrive only at completion.
|
||||
- Codex/Telegram: deliver Codex verbose tool summaries in direct message-tool-only turns while suppressing message-send and activity-log noise. (#83186) Thanks @kurplunkin.
|
||||
- Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.
|
||||
- Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting.
|
||||
- Gateway/protocol: restore Gateway WS protocol v4 and keep `message.action` room-event metadata on the existing `inboundTurnKind` wire field while preserving internal inbound-event classification.
|
||||
- Agents/tools: prefer non-webchat session-key routes when the message tool has stale webchat context, so message-tool-only replies keep delivering to the originating channel. Fixes #82911. (#83004) Thanks @joshavant.
|
||||
- Channels: keep direct-message last-route writes on isolated `per-channel-peer` sessions instead of contaminating the agent main session with channel delivery context. Fixes #36614. Thanks @aspenas.
|
||||
- Mac app: move the Settings sidebar toggle into the native titlebar and tighten the General pane width.
|
||||
- Mac app: keep visited Settings panes mounted so switching tabs no longer blanks and reloads their content.
|
||||
- Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.
|
||||
- Codex: sanitize inline image payloads before Codex app-server and OpenAI Responses replay, and clear poisoned Codex thread bindings after invalid image errors. Fixes #82878.
|
||||
- Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.
|
||||
- Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)
|
||||
- Providers/Together: update PI runtime packages to 0.74.1 and emit Together-style `reasoning.enabled`/`max_tokens` controls for reasoning-capable OpenAI-completions models.
|
||||
- Agents/diagnostics: split slow embedded-run `attempt-dispatch` startup summaries into workspace, prompt, runtime-plan, and final dispatch subspans so traces identify the delayed setup phase. Fixes #82782. (#82783) Thanks @galiniliev.
|
||||
- Agents/Codex: flatten nested tool-result middleware blocks into bounded text so successful message sends are no longer replaced with `Tool output unavailable due to post-processing error`. Fixes #82912. Thanks @joeykrug.
|
||||
- CLI/media: accept HTTP(S) URLs in `openclaw infer image describe --file`, fetching remote images through the guarded media path instead of treating URLs as local files. Fixes #82837. (#82854) Thanks @neeravmakwana.
|
||||
- Agents/subagents: keep session-backed parent runs active when the child wait call times out before the child session has actually settled, so late subagent completions are reconciled instead of being lost. Fixes #82787. Thanks @ramitrkar-hash.
|
||||
- Control UI: advertise shared Gateway protocol constants in browser connect frames, fixing protocol mismatch handshakes after protocol constant drift. Fixes #82882. Thanks @galiniliev.
|
||||
- Gateway: add rollback protocol-mismatch diagnostics, including client protocol ranges in Gateway logs and deep status/doctor hints for stale client processes. Fixes #82841. (#82908)
|
||||
- Agents/subagents: keep successful keep-mode completion payloads pending after final-delivery retry exhaustion, so requester recovery no longer loses final subagent results. Fixes #82583. (#82999) Thanks @joshavant.
|
||||
- Gateway/auth: allow same-host trusted-proxy callers to use the documented local direct `gateway.auth.password` fallback after revisiting the #78684 fail-closed policy, while keeping token fallback rejected and forwarded-header requests on the trusted-proxy path. Fixes #82607. (#82953) Thanks @joshavant.
|
||||
- Agents/subagents: wait for queued completion handoffs to reach the parent transcript before marking them announced, preventing busy parent runs from cleaning up before observing child results. Fixes #82913. (#83039) Thanks @joshavant.
|
||||
- Agents/subagents: route group/channel subagent completions through message-tool-only handoffs when required and keep active-requester wake failures from dropping completion delivery. Fixes #82803. Thanks @galiniliev, @yozakura-ava, and @moeedahmed.
|
||||
- Memory-core: scan persisted memory source sessions on startup, comparing on-disk transcripts against the index and marking only missing/newer/resized files dirty for incremental sync. Fixes #82341. (#82341) Thanks @giodl73-repo.
|
||||
- Telegram: keep the top-level default account in the account list when named accounts or bindings are added alongside top-level credentials, preserving default polling while still letting named-only configs resolve to a single account. Fixes #82794. (#82794) Thanks @giodl73-repo.
|
||||
- CLI/models: reuse command-scoped plugin metadata across model listing, provider catalog, auth, and synthetic-auth checks, restoring fast `openclaw models` runs for plugin-heavy installs. Fixes #82881. (#83033) Thanks @joshavant.
|
||||
- CLI/channels: show configured official external channels such as Discord in `openclaw channels list` when their plugin package is missing, including the install and doctor repair command instead of reporting no configured channels. Fixes #82813.
|
||||
- Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827.
|
||||
- Agents/tools: keep the `message` tool available in embedded runs when it is explicitly allowed through `tools.alsoAllow` or runtime tool allowlists, so channel plugins with custom reply delivery can still use configured message sends. Fixes #82833. Thanks @cn1313113.
|
||||
- WhatsApp: honor forced document delivery for outbound image, GIF, and video media so `forceDocument`/`asDocument` sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.
|
||||
- WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as `file.pdf` and `file.csv` instead of an extensionless `file`. Thanks @mcaxtr.
|
||||
- Process/diagnostics: report active lane blockers in lane wait warnings so `queueAhead=0` no longer hides commands waiting behind active work. Fixes #82791. (#82792) Thanks @galiniliev.
|
||||
- Process/diagnostics: stop counting the active processing turn as queued backlog in liveness warnings so transient max-only event-loop spikes do not surface as gateway warnings.
|
||||
- Agents/replies: classify provider conversation-state rejections and return a clear message-channel error instead of auto-resetting or falling back to a generic runner failure. (#82616) Thanks @dutifulbob.
|
||||
- Browser plugin: trust managed Chrome CDP diagnostics when launch HTTP probes race cold-start readiness, avoiding false startup failures. Fixes #82904. (#82986) Thanks @kmanan and @hclsys.
|
||||
- Android: prompt before replacing a changed Gateway TLS thumbprint, showing the old and new SHA-256 fingerprints so users can accept expected certificate rotations instead of hard failing on pin mismatch. (#83077) Thanks @sliekens.
|
||||
- CLI/status: render extra gateway-like service diagnostics as warning/info output instead of error output. Fixes #46930. (#82922) thanks @giodl73-repo.
|
||||
- Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23.
|
||||
- Plugin SDK: bundle `openclaw/plugin-sdk/zod` into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local `zod` symlink. Fixes #78398. (#78515) Thanks @ggzeng.
|
||||
- Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg.
|
||||
|
||||
## 2026.5.17
|
||||
|
||||
### Changes
|
||||
|
||||
- Control UI: move settings-only destinations into the Settings workspace and add sidebar recent-session shortcuts plus a one-click new-session action.
|
||||
- Control UI: speed up scoped settings pages by loading required config before schema refreshes, caching burst schema responses, and opening Communications on lighter message settings first.
|
||||
- Control UI: simplify the Cron Jobs workspace with modal job creation, collapsed filters, and an empty state aimed at first-time setup.
|
||||
- Security/audit: add `security.audit.suppressions` for intentionally accepted audit findings, keeping suppressed matches out of the active summary while preserving them in JSON output with an active suppression notice. (#76949) Thanks @100menotu001.
|
||||
- Agents/subagents: label delegated task and subagent completion handoffs as ready for parent review, and tell requester agents to review/verify results before calling them done. (#78985) Thanks @100menotu001.
|
||||
- Providers/media: add fal and OpenRouter music-generation providers for the shared `music_generate` tool, including fal MiniMax/ACE/Stable Audio endpoints and OpenRouter Lyria audio output.
|
||||
- Maintainer tooling: warn before running JS package commands on raw Crabbox AWS boxes, pointing maintainers to Actions hydration or Blacksmith Testbox for CI-like proof.
|
||||
- Control UI: show provider quota usage in the Overview card and Chat header, and recover stale Chat in-progress state after missed terminal events. (#82647)
|
||||
- Mac app remote setup can now be preconfigured from `openclaw-mac configure-remote`, skips onboarding when config is already complete, supports direct LAN/Tailnet gateway URLs, allows private same-origin Control UI loads, and owns the SSH tunnel process when SSH is selected.
|
||||
- Providers/xAI: add xAI Grok OAuth login for SuperGrok subscribers, letting `xai/*` models and xAI media/tool providers authenticate without `XAI_API_KEY`.
|
||||
- CLI/cron: add `openclaw cron run --wait` with timeout and poll interval controls, plus exact `cron.runs --run-id` filtering so automation can block on one queued manual run. (#81929) Thanks @ificator.
|
||||
- Maintainer tooling: route Crabbox skill defaults through the repo brokered AWS config, leaving Blacksmith Testbox as an explicit opt-in instead of the broad-proof default.
|
||||
- CLI/onboarding: localize the setup wizard and bundled channel setup flows for English, Simplified Chinese, and Traditional Chinese. (#80645) Thanks @GaosCode.
|
||||
- Agents/skills: cache hydrated `resolvedSkills` across warm gateway turns while keying reuse by the redacted effective config, reducing redundant skill snapshot rebuilds without crossing config-gated skill boundaries. (#81451) Thanks @solodmd.
|
||||
- Telegram/group chat: add opt-in `messages.groupChat.ambientTurns: "room_event"` handling so always-on ambient chatter can run as quiet room context and speak visibly only via the message tool. (#81317) Thanks @obviyus.
|
||||
- Group chat: add core inbound event classification with opt-in `messages.groupChat.unmentionedInbound: "room_event"`, so always-on unmentioned room chatter can run as quiet context and speak visibly only via the message tool. (#81317) Thanks @obviyus.
|
||||
- Codex/context engines: bind thread-bootstrap projection epochs to Codex app-server threads, carry redacted tool-result context into fresh threads, and rotate backend threads when projection state changes. (#82351) Thanks @jalehman.
|
||||
- Agents/media: run `image_generate` through the shared async media-generation task lifecycle in session-backed chats, with task status, duplicate guarding, and message-tool completion delivery matching music/video.
|
||||
- Gateway: add opt-in restart trace logs for restart signal, active-work drain, close, next-start, ready, and memory spans. (#82396) Thanks @samzong.
|
||||
- Gateway/performance: split startup benchmark HTTP-listen timing from full gateway-ready timing and add post-bind plugin and sidecar diagnostics to restart-readiness traces. (#82603) Thanks @samzong.
|
||||
- QA-Lab: add a deterministic local personal-agent scenario pack covering reminders, threaded replies, scoped memory recall, redaction, and safe tool followthrough. (#78219) Thanks @iFiras-Max1.
|
||||
- QA-Lab: add `--pack personal-agent` for `openclaw qa suite` so maintainers can run the accepted personal-agent scenario pack by selector. (#82760) Thanks @iFiras-Max1.
|
||||
- QA-Lab: add a private Codex-vs-Pi runtime parity axis with runtime-pair suite runs, parity reports, and release-check wiring. (#80238) Thanks @100yenadmin.
|
||||
- Slack: add Slack assistant thread lifecycle support with assistant view manifest entries, suggested prompts, thread-scoped assistant sessions, and Slack-provided assistant context. Fixes #80787. Thanks @mobybot27.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Codex/app-server: cover `/btw` side-question native hooks and app-server command approvals without relying on unsupported turn-scoped hook config. (#82559) Thanks @Kaspre.
|
||||
- Gateway/Docker: fail closed for non-loopback gateway starts without explicit shared-secret or trusted-proxy auth, and stop the image default command from bypassing config validation. Fixes #82865. (#82866) Thanks @coygeek.
|
||||
- Agents/followups: route queued followup turns through CLI runtime backends instead of embedded harness lookup, preventing `claude-cli`/`google-gemini-cli` followups from failing before delivery. Fixes #82847. (#82857) Thanks @hclsys.
|
||||
- CLI/sessions: let `openclaw sessions cleanup --fix-missing` prune malformed rows with unresolvable transcript metadata instead of throwing. Fixes #80970. (#82745) Thanks @IWhatsskill.
|
||||
- Gateway/usage: refresh large session usage summaries in the background and reuse durable transcript metadata so `sessions.usage` no longer blocks Gateway requests on full transcript rescans. Fixes #82773. (#82778) Thanks @hclsys.
|
||||
- CLI/MiniMax media: let `openclaw infer image describe --file` accept HTTP(S) image URLs without treating them as local paths, and keep automatic MiniMax image understanding routed through `MiniMax-VL-01` even when legacy MiniMax M2.x chat metadata claims image input. Fixes #82837. Thanks @mGaolin.
|
||||
- TUI: restore the submitted draft when chat is busy instead of clearing it or queueing another run. Fixes #45326. (#82774) Thanks @hyspacex.
|
||||
- Cron/memory: treat claimed `before_agent_reply` cron hooks as execution progress, so long memory dreaming promotion jobs are not aborted by the isolated-run pre-execution watchdog. Fixes #82811.
|
||||
- Discord: recover transcript-backed full answers when progress-mode final payloads are ellipsis-truncated, so long replies fall back to normal chunked delivery instead of replacing the preview with a shortened message. Fixes #82807. Thanks @blueberry6401.
|
||||
- Browser plugin: redact attach-details from Chrome MCP diagnostics and keep raw Chrome launch error output around long enough to surface in user reports without leaking sensitive paths.
|
||||
- System prompts: clarify MEMORY guidance over generic TTS hints in the embedded speech-core/system-prompt scaffolding so agents prefer memory-store usage over speech defaults. Fixes #81930. Thanks @giodl73-repo.
|
||||
- Agents/auth: include the checked credential source in missing API key errors, so users can see which env var, profile, or config path to fix. Fixes #82785. Thanks @loeclos.
|
||||
- Providers/GitHub Copilot: hash Responses replay item ids with sha256 instead of a weak 32-bit hash and build same-provider Copilot tool-call ids distinctly, so concurrent tool-call replays no longer collide and reject follow-up turns.
|
||||
- Agents/replay: normalize malformed assistant replay content before transport conversion while preserving empty-stop replay repair, so bad provider history no longer crashes with non-iterable content. Fixes #43795. (#82748) Thanks @IWhatsskill.
|
||||
- Gateway/macOS: write LaunchAgent stdout under `~/Library/Logs/openclaw`, suppress stderr, and attach stdin to `/dev/null` so launchd startup avoids symlinked state-dir log failures and silent module-evaluation hangs. Fixes #40207 and #46153. Thanks @dhruvkelawala and @frankr.
|
||||
- CLI/configure: let model-only section setup enter provider auth directly instead of first asking where the Gateway runs, unblocking OAuth/token setup in terminals where that unrelated prompt is unresponsive. Fixes #39223. Thanks @LevityLeads.
|
||||
- Providers/Anthropic-messages: extract `reasoning_content` from `thinking` blocks during assistant replay so proxy providers that route through the Anthropic-messages transport preserve reasoning context across tool-call follow-up turns. Thanks @Sunnyone2three.
|
||||
- Agents/GitHub Copilot: normalize replayed Responses tool-call IDs before dispatch so resumed sessions with historical overlong tool IDs continue instead of failing Copilot schema validation. (#82750) Thanks @galiniliev.
|
||||
- CLI/infer: resolve plugin-scoped web search and fetch SecretRefs on the exact command credential surface, keeping non-selected and unrelated plugin secrets inactive. Fixes #82621. (#82699) Thanks @leno23.
|
||||
- Providers/Anthropic Vertex: resolve installed provider public surfaces from package-local `dist/`, restoring `anthropic-vertex/*` model calls after plugin externalization. Fixes #82781. Thanks @0L1v3DaD.
|
||||
- Gateway/exec approvals: bind path-shaped allowlists, safe-bin trust, skill auto-allow, Allow Always persistence, and approval audit metadata to the executable realpath so symlinked binaries cannot keep approvals after retargeting. Fixes #45595. Thanks @jasonftl.
|
||||
- Mac app: reorganize Settings around a grouped sidebar, with separate Connection and Exec Approvals pages so everyday permissions and app toggles are easier to scan.
|
||||
- Mac app: redraw the animated menu bar critter to match the rounded app mascot with antennae, side arms, two feet, and smoother template rendering.
|
||||
- Mac app: cache settings config schema/drafts and load channel config in parallel with channel probes, making repeated Channels and Config tab switches responsive over remote tunnels.
|
||||
- Control UI: negotiate the Gateway protocol from shared constants so rebuilt dashboards connect to current gateways instead of reporting a protocol mismatch.
|
||||
- Mac app: let menu gateway/session error text wrap across a few lines and stop rebuilding dynamic Context/Gateway menu rows while the menu is open, reducing flicker.
|
||||
- QA-Lab: expose Codex runtime tools during private parity runs and treat completed structural/tool-shape runtime drift as advisory, while preserving real runtime failures as lane blockers.
|
||||
- Mac app: make device pairing approval sheets friendlier, with concise Mac/device copy, shortened identifiers, friendly scope labels, and Approve as the primary action.
|
||||
- Providers/Qwen: honor session thinking level for `qwen-chat-template` payloads so `/think off` disables nested llama.cpp chat-template thinking controls. Fixes #82768. Thanks @bfox55.
|
||||
- Feishu/wiki: reject numeric wiki space IDs before creating Lark clients and keep numeric-looking IDs documented as quoted opaque strings, preventing JavaScript precision loss in knowledge base calls. Fixes #45301. (#82769) Thanks @hyspacex.
|
||||
- Control UI: simplify Talk settings to Voice, Model, and Sensitivity defaults, with provider, transport, exact VAD, and timing controls behind Advanced.
|
||||
- Telegram: let catch-all mention patterns match captionless group photos, so media-only group messages reach the agent when the group is intentionally configured to respond to all messages. Fixes #44833. (#82756) Thanks @IWhatsskill.
|
||||
- Gateway/pairing: reject forged loopback Control UI origins from non-local proxy paths, and keep mobile pairing setup on Tailscale bind mode pointing users to Tailscale Serve/Funnel instead of cleartext tailnet WebSockets.
|
||||
- Telegram/Gateway: persist isolated polling offsets only after main-thread dispatch and preserve gateway caller scopes for Telegram message actions, fixing consumed-but-unrouted polling updates and recursive CLI send scope approvals. Fixes #82277. (#82705) Thanks @udaymanish6.
|
||||
- Memory-core: abort timed-out embedding provider calls so remote embedding HTTP requests do not continue running after memory query or indexing timeouts. Fixes #82732. Thanks @adityarya24.
|
||||
- Channels/stream previews: contain rejected background draft-stream flushes so preview send failures do not surface as fatal unhandled rejections. Fixes #82712. (#82713) Thanks @coygeek.
|
||||
- Codex/app-server: keep shared native app-server clients isolated per agent runtime key so starting one agent no longer closes another agent's active Codex turn. Fixes #82758. Thanks @PashaGanson.
|
||||
- Providers/OpenAI Codex: include base `gpt-5.5` and `gpt-5.4` reasoning metadata in the bundled Codex catalog so `/think xhigh` remains available for those models. Fixes #82744.
|
||||
- Providers/OpenAI Codex: keep the native hook relay as the final Codex app-server thread config patch so hook-backed approvals stay enabled even when lower-priority config disables hooks. Thanks @solomonneas.
|
||||
- Providers/MiniMax: declare CN endpoint auth aliases in the plugin manifest so `minimax-cn` and `minimax-portal-cn` reuse the correct base auth profiles instead of falling back to unrelated models after 401s. Fixes #63823. Thanks @kamusis.
|
||||
- Secrets/audit: treat `$VAR` auth-profile values as env SecretRefs and stop reporting env-ref credentials as plaintext, including mixed `keyRef` plus env-ref profile states. Fixes #53998. Thanks @schirloc and @artwalker.
|
||||
- Agents/model fallback: suppress fallback notices when the active OpenAI Codex runtime reports the same canonical OpenAI model.
|
||||
- Agents/music generation: remove model-controlled request timeouts, default internal provider requests to five minutes, and keep configured timeouts at a 120-second floor.
|
||||
- Cron: let isolated best-effort deliveries send the parent result immediately while fire-and-forget subagents keep running, avoiding false run timeouts. Fixes #44428. Thanks @amknight.
|
||||
- Agents/media generation: stop logging delivered failure summaries as missing message-tool delivery when no generated media was expected.
|
||||
- Agents/sessions: prioritize manual user turns ahead of queued cron and maintenance work in the same session lane, so visible follow-ups no longer wait behind background runs. Fixes #82764. (#82765) Thanks @galiniliev.
|
||||
- Agents/edit tool: honor `file_path` and related path aliases when resolving edit-recovery targets, so post-write errors no longer surface false edit failures after the file actually changed. Fixes #81909. Thanks @giodl73-repo.
|
||||
- QQBot: treat only explicit truthy `QQBOT_DEBUG` values as enabling debug logs, so false-like values such as `0` no longer expose debug output. Fixes #82644. (#82697) Thanks @leno23.
|
||||
- Agents/session_status: resolve implicit no-arg status lookups against the live run session, so `/think` changes report the current thinking level instead of stale sandbox state. Fixes #82669. (#82696) Thanks @leno23.
|
||||
- Discord: keep progress drafts visible for message-tool-only guild replies under the default coding tool profile. Fixes #82747. Thanks @eliranwong.
|
||||
- Agents: prefer current structured assistant final answers when assembling final reply payloads, reducing reliance on streamed preview fragments after channel transcript recovery. (#82850) Thanks @joshavant.
|
||||
- Discord: keep unmentioned room-event history until a visible Discord send succeeds, so quiet ambient context does not disappear before message-tool delivery. (#82573) Thanks @obviyus.
|
||||
- CLI/setup: order the model/auth provider picker as OpenAI, Anthropic, xAI, Google, then the remaining providers alphabetically.
|
||||
- Diagnostics/usage/voice-call: treat explicit zero and non-finite limits as empty results and reject invalid voice-call numeric CLI flags. Fixes #82646, #82650, #82651, and #82653. (#82679) Thanks @leno23.
|
||||
- CLI/config: avoid redundant startup config/plugin checks for the guided `openclaw config` flow and show progress while source checkout CLI artifacts build or load.
|
||||
- Config/Mac app: accept `gateway.remote.remotePort` in core config validation so Mac SSH remote setup stays compatible with the CLI.
|
||||
- Gateway/diagnostics: add opt-in critical memory pressure stability snapshots with gateway logs, V8 heap, cgroup, active-resource, and redacted large session-file evidence. Fixes #82518.
|
||||
- Doctor/Gateway: avoid treating unrelated macOS LaunchAgents as legacy gateways just because their environment values mention old checkout paths.
|
||||
- Gateway/heartbeat: defer heartbeat runs while the target reply operation is queued or active, preventing heartbeat prompts from interleaving with WebChat responses before the streaming lane starts. Fixes #82722. Thanks @Andy-Xie-1145.
|
||||
- CLI/setup: collapse raw gateway config keys in existing-config summaries into friendly `Model` and `Gateway` rows.
|
||||
- CLI/config: show concise human config-write output with an indented backup path instead of printing checksum-heavy overwrite audit details by default.
|
||||
- Skills/onboarding: hide brew-only dependency installers in Linux containers without Homebrew and show container-specific guidance instead of a broken install path. Fixes #14593. Thanks @amknight.
|
||||
- CLI/docs: call the canonical lowercase docs MCP search tool and surface MCP errors instead of returning empty search results. Fixes #82702. (#82704) Thanks @hclsys.
|
||||
- QA-Lab: add gateway log sentinels for plugin hook failures, Codex app-server stalls/timeouts, cron allowlist drift, live quota blockers, and direct-reply self-message transcripts so harness proof fails on self-health regressions. (#80323) Thanks @100yenadmin.
|
||||
- QA-Lab: ignore heartbeat-only operational transcripts when capturing runtime parity cells so background checks cannot replace the scenario reply. (#80323) Thanks @100yenadmin.
|
||||
- QA-Lab: pin threaded-memory parity runs to `memory-core`, keep bundled plugin resolution enabled for QA commands, and retry transient session-store lock reads. (#72045) Thanks @WuKongAI-CMU.
|
||||
- QA-Lab/qa-channel: keep mock memory ranking, inbound media notes, and opened-file realpath checks stable for mock OpenAI qa-channel runs. (#66826) Thanks @gumadeiras.
|
||||
- Gateway/exec approvals: wait for accepted async approval follow-up runs instead of direct-fallback sending duplicate completions when retries use different nonce keys. Fixes #82711. (#82717) Thanks @udaymanish6.
|
||||
- Agents/subagents: mark completed subagent handoffs as ready for parent review so requester agents verify results and continue required follow-up work before reporting done. (#82724) Thanks @100menotu001.
|
||||
- QA-Lab: validate Capture saved views loaded from browser storage so malformed local state cannot poison Capture inspector filters or layout controls. (#77722) Thanks @AsaZhou923.
|
||||
- Agents/performance: reuse prepared plugin manifest metadata across local CLI turns, model catalog normalization, auth lookups, and tool capability checks, restoring fast pre-provider startup for plugin-heavy installs. Thanks @shakkernerd.
|
||||
- CLI/config: add `--dry-run` support to `openclaw config unset`, with `--json` output and allow-exec validation parity with `config set`/`config patch` dry-run handling. (#81895) Thanks @giodl73-repo.
|
||||
- CLI/infer: resolve command SecretRefs before local provider-backed capability runs, so web search/fetch and other local infer commands can use plugin-scoped credential refs. Fixes #82621. (#82798) Thanks @joshavant.
|
||||
- Memory-core: retry disabled dreaming cron cleanup until cron is available after startup, so persisted managed dreaming jobs are removed after restart. Fixes #82383. (#82389) Thanks @neeravmakwana.
|
||||
- Providers/xAI: keep retired Grok 3, Grok 4 Fast, Grok 4.1 Fast, and Grok Code slugs out of model pickers while preserving compatibility resolution for existing configs.
|
||||
- Providers/xAI: replace the retired `grok-imagine-image-pro` image model with `grok-imagine-image-quality` in the bundled image-generation provider and docs. (#81399) Thanks @KateWilkins.
|
||||
- Providers/OAuth: let browser-hosted identity provider pages read successful localhost callback responses, preventing xAI Grok OAuth from showing a false connection failure after OpenClaw completes login.
|
||||
- Gateway/security: reject malformed HTTP and WebSocket request targets with the existing auth failure response instead of letting invalid URL parsing crash the Gateway. Fixes GHSA-6hc3-f4rg-377m.
|
||||
- Browser/CDP: redact credential-bearing Chrome MCP and managed Chrome launch diagnostics, and require exact loopback entries before treating `NO_PROXY` as already covering local CDP proxy bypasses.
|
||||
- Gateway/auth: reuse prepared startup auth SecretRef snapshots when the gateway startup config is unchanged, avoiding duplicate runtime secret preparation. (#82991) Thanks @samzong.
|
||||
- Gateway/diagnostics: redact credential-bearing gateway target URLs and client diagnostics while preserving raw connection URLs for programmatic use, so connect-failure logs no longer surface embedded tokens.
|
||||
- Gateway/auth: honor `OPENCLAW_GATEWAY_TOKEN` as the remote interactive fallback when no remote token is configured, keeping remote TUI setup aligned with documented auth precedence.
|
||||
- Providers/xAI: continue polling video generations while xAI reports in-flight jobs as `pending`, so Grok video requests no longer fail before the final `done` response. (#82610) Thanks @Manzojunior.
|
||||
- Logs: redact raw Basic auth and named security headers from `logs.tail` output before returning lines to read-scoped clients. Fixes #66832. Thanks @Magicray1217.
|
||||
- CLI/gateway: emit structured JSON for gateway transport close/timeout failures when `--json` is requested by health, gateway health, and devices list commands. Fixes #79108. Thanks @TurboTheTurtle.
|
||||
- Agents/Telegram: retry Bedrock non-visible terminal turns and mark non-deliverable attempts as trajectory errors instead of silent success. Fixes #82394. (#82905) Thanks @joshavant.
|
||||
- Telegram: normalize announce group targets via a new `resolveSessionTarget` channel hook so scheduled announcements resolve consistently against the same Telegram session conversation registry as inbound turns. Fixes #81229. Thanks @giodl73-repo.
|
||||
- QA/RTT: let `pnpm rtt` lease Convex-backed Telegram credentials while preserving RTT sample counts, sample timeouts, and result stats on the RTT harness path.
|
||||
- Discord: bind delayed gateway `identify` retries to the originating socket generation so retries triggered after a reconnect do not identify against a fresh socket. Fixes #82225. Thanks @giodl73-repo.
|
||||
- ACP/control plane: refresh cached runtime handles when agent config changes so ACP sessions stop using stale runtimes after `agents.defaults` edits. Fixes #82237. Thanks @giodl73-repo.
|
||||
- Gateway/sessions: scope session data lookups by agent id so multi-agent gateway state cannot cross-leak session records across configured agents. (#81386) Thanks @pgondhi987.
|
||||
- Gateway/restart: mark active main sessions as restart-aborted before forced restarts so startup recovery can resume interrupted turns instead of leaving them stranded as running. Fixes #82433. (#82772) Thanks @joshavant.
|
||||
- Gateway/heartbeat: report heartbeat runner failures with background-specific copy instead of foreground `/new` recovery guidance. Fixes #82708. (#82848) Thanks @joshavant.
|
||||
- Agents/media: require generated music/video completion agents to use the message tool for visible delivery and stop merging generated image attachments into message-tool-only source reply mirrors, avoiding direct fallback posts that can duplicate media the model already sent.
|
||||
- Agents/media: accept generated media attachments on internal completion events and report delivery-loss failures as errors, so completed background music/video tasks do not disappear after provider success.
|
||||
- Matrix/approvals: release in-flight reaction bindings when the channel approval handler stops mid-delivery, preventing stale approval targets after restart. Fixes #82485. (#82482) Thanks @Feelw00.
|
||||
- Matrix/E2EE: stop requesting MSC4222 `state_after` sync responses so homeservers with incomplete state-after data do not leave fresh encrypted rooms without outbound room encryptors. Fixes #82515. Thanks @nickdecooman.
|
||||
- TUI: update the displayed model in real time when an auto-fallback resolution swaps in a different model mid-turn, so the status line reflects the actual model handling the run. Fixes #82296. Thanks @giodl73-repo.
|
||||
- Gateway/sessions: estimate context usage from local/OpenAI-compatible transcripts when provider usage telemetry is missing, so status no longer shows empty usage for real local-model sessions. Fixes #73990. (#82317) Thanks @giodl73-repo.
|
||||
- Update/installers: override npm `min-release-age` quarantine for OpenClaw-managed package installs, so `openclaw update`, plugin updates, and hosted installer scripts can install the requested latest release immediately.
|
||||
- Agents/sessions: preserve fresh post-compaction token snapshots across stale usage updates, preventing repeated auto-compaction after every message. Fixes #82576. (#82578) Thanks @njuboy11.
|
||||
- Agents/replies: preserve active inbound reply context at the LLM boundary so Discord referenced-message turns do not answer from stale session history. Fixes #82608. (#82801) Thanks @joshavant.
|
||||
- Agents/sessions: expose session transcript lock stale and max-hold tuning, and release the embedded run's coarse transcript lock before model I/O while locking persistence and cleanup separately. Fixes #13744. Thanks @amknight.
|
||||
- Agents/OpenAI Responses: log redacted diagnostics for detail-less `response.failed` events while preserving failed response ids, so operators can correlate provider-side failures. Fixes #82558.
|
||||
- Agents/OpenRouter: strip non-replayable Anthropic/xAI reasoning provenance tags from follow-up requests, preventing poisoned thinking signatures from breaking second turns. Fixes #82335. (#82380) Thanks @hclsys.
|
||||
- Providers/xAI: send configurable reasoning effort only for Grok 4.3, preserving xAI's default low reasoning while omitting unsupported controls for Grok 4.20 reasoning models. (#81227) Thanks @jason-allen-oneal.
|
||||
- Image generation: raise Google, OpenRouter, and xAI hosted provider default timeouts to 180 seconds so slow hosted image requests have more time to complete. (#75337)
|
||||
- Agents/auth: redact OAuth refresh failure causes against in-memory, attempted, and reloaded credentials before generic token masking while ensuring failed ACP dispatch cleanup closes initialized runtimes.
|
||||
- Google/Gemini CLI OAuth: add provider-owned refresh support for `google-gemini-cli` so expired Gemini CLI tokens refresh in OpenClaw instead of falling through to the generic unknown-provider path. Fixes #42541. Thanks @jason-allen-oneal.
|
||||
- Agents/Anthropic transport: replay `reasoning_content` from compatible thinking blocks for Xiaomi/MiMo-style Anthropic Messages routes, preventing follow-up turns from losing required reasoning context. Fixes #81261. Thanks @Sunnyone2three.
|
||||
- Telegram: cache successful startup bot identity by account and token fingerprint for up to 24 hours, so restarts can skip redundant `getMe` probes during Telegram API slow periods without permanently pinning renamed bots. Refs #82525.
|
||||
- Telegram: keep streamed text replies in place when delayed TTS audio arrives, sending the audio as a follow-up instead of deleting the preview. Fixes #82570. (#82820) Thanks @joshavant.
|
||||
- Channels/TTS: deliver TTS supplements across live-preview channels without duplicating text replies, covering WebChat, Telegram, Discord, Slack, Mattermost, and Matrix. (#82935) Thanks @joshavant.
|
||||
- Gateway/sessions: discard stale metadata when recreating dead main session rows, so replacement sessions do not inherit old labels or transcript paths.
|
||||
- Codex app-server: mark native context compaction completion events as successful, preventing false "Compaction incomplete" notices after successful Codex-managed compaction. Fixes #82470. (#81593) Thanks @Kyzcreig.
|
||||
- Codex app-server: keep long-running turns alive while current-turn approvals, user input, dynamic tools, and notifications make progress, and carry that progress into the outer run timeout. (#82601) Thanks @100yenadmin.
|
||||
- Gateway/channels: hand off traced channel account startup outside the startup diagnostic phase so long-lived channel tasks do not keep liveness warnings pinned to channel startup. Refs #82398.
|
||||
- Gateway/restart: queue restart and shutdown signals received while the gateway startup loop is still returning its server handle, so startup-time restarts are not dropped during update churn. (#82660) Thanks @samzong.
|
||||
- Gateway/restart: carry operator restart intent reasons into macOS LaunchAgent restart traces, so cascade diagnostics identify `gateway.restart` instead of a bare SIGTERM.
|
||||
- GitHub Copilot: route device-login requests through the plugin SSRF guard with a GitHub-only policy.
|
||||
- Group/channel replies: keep message-tool-preferred final replies private when the agent misses the message tool, and log suppressed payload metadata in the gateway debug log for quieter diagnosis.
|
||||
- Gateway/WebChat: route image attachments through a configured vision-capable `imageModel` plan before inlining images, and carry that image-model fallback chain through runtime retries. (#82524) Thanks @frankekn.
|
||||
- macOS app: open the Dashboard in a native WebKit window with standard macOS traffic-light controls, keep the Dock icon visible by default, and reuse the app's connected gateway auth for automatic Control UI login.
|
||||
- WebChat: show progress while manual `/compact` is running by streaming a session operation event to subscribed Control UI clients. Fixes #82407. Thanks @Conan-Scott.
|
||||
- Codex app-server: limit canonical OpenAI Codex app-server attribution rewrites to local transcript and trajectory records, leaving runtime/tool routing on the selected OpenAI model metadata so OpenAI API-key backup profiles keep their billing path.
|
||||
- Codex app-server: hide native tool-search control tools from dynamic tool exposure while preserving the message tool.
|
||||
- Android/chat: make bare and markdown URLs in chat messages tappable by preserving Compose URL annotations in rendered markdown. Fixes #82187. (#82392) Thanks @neeravmakwana.
|
||||
- Plugins/doctor: migrate legacy top-level plugin `tools` declarations into `contracts.tools`, so `openclaw doctor --fix` repairs local plugins for the manifest tool contract. (#81112) Thanks @100yenadmin.
|
||||
- Slack: guide agents to use stable `<@USER_ID>` mention tokens from context instead of plain `@name` text, so user mentions link and notify correctly. Fixes #82090. (#82152) Thanks @neeravmakwana.
|
||||
- Auth: serialize provider login writes through the auth-profile lock for OpenAI Codex, Anthropic, Cloudflare AI Gateway, GitHub Copilot, and z.ai, preserving upsert semantics so a live Gateway cannot overwrite freshly refreshed OAuth credentials with an expired in-memory snapshot.
|
||||
- Auth/Codex: remove runtime support for `oauthRef` sidecar-backed OAuth profiles and add a doctor repair that migrates affected Codex profiles back to inline `auth-profiles.json` credentials. (#82777) Thanks @joshavant.
|
||||
- Slack: keep DM thread replies on the main direct-message session instead of routing them to invisible thread-scoped sessions. Refs #82390. (#82418) Thanks @kagura-agent.
|
||||
- Auth/macOS: avoid creating the OAuth profile master key in Keychain automatically, falling back to the file-backed secret key so headless agents do not trigger a Keychain prompt.
|
||||
- Codex app-server: release raw assistant completions when `turn/completed` is missing while keeping commentary/status items as progress, preventing completed Codex runs from hanging until timeout. Fixes #82343. (#82403) Thanks @IWhatsskill.
|
||||
- Codex app-server: keep a bounded terminal guard after post-tool raw assistant completions so missing `turn/completed` events fail fast instead of leaving embedded runs stuck. Fixes #82775. (#82816) Thanks @joshavant.
|
||||
- Agents/sessions: remove the transient `*.bak-<pid>-<ts>` backup written by `repairSessionFileIfNeeded` once the atomic replace succeeds, so a stuck session with a persistently malformed JSONL line no longer accumulates one snapshot per repair invocation. Fixes #80960. (#80969) Thanks @100yenadmin. Co-authored by @tynamite.
|
||||
- CLI/status: show plain empty-state messages instead of empty Channels and Sessions tables when no channels or sessions exist.
|
||||
- CLI/dashboard: probe Gateway readiness before handing out the dashboard URL, prompting to start or install the managed service when the Gateway is stopped and printing recovery commands instead of opening a dead browser tab.
|
||||
- CLI/dashboard: treat Gateway `device identity required` probes as proof that the dashboard listener is reachable, so `openclaw dashboard` can still open the Control UI.
|
||||
- CLI: hide decorative startup and status emoji on terminals that are unlikely to render them correctly, keeping semantic message and identity emoji intact.
|
||||
- CLI/gateway: recover the Linux user systemd bus environment when `openclaw dashboard` starts the Gateway from stripped desktop shells such as VNC terminals.
|
||||
- Gateway/WebSocket: log expected startup `1013 gateway starting` retry closes at debug instead of warn while preserving WARN for unexpected pre-connect failures. Fixes #76361. (#82457) Thanks @IWhatsskill.
|
||||
- Providers/Xiaomi: strip synthetic empty array `items` from MiMo tool schemas while preserving typed array items, avoiding strict OpenAI-compatible schema rejection.
|
||||
- Telegram: send the transcript-backed full final answer after progress-mode tool drafts when the dispatcher final payload is an ellipsis-truncated snapshot. Fixes #82409. Thanks @PashaGanson.
|
||||
- Providers/Ollama: omit truthy native `think` payloads for models marked non-reasoning while preserving supported thinking models and explicit `think: false`. (#82445) Thanks @leno23.
|
||||
- Update/channels: preserve pre-update channel config through package-swap doctor and post-core plugin repair so externalized channel upgrades do not drop configured chat channels. Fixes #82533. Thanks @imbaig.
|
||||
- Update/doctor: repair configured externalized plugin installs during legacy 2026.4.x upgrades so configured Discord channels remain available after 2026.5.x package updates. Fixes #82813. (#82859) Thanks @joshavant.
|
||||
- CLI/context engines: bootstrap and finalize non-legacy context engines for CLI turns while preserving transcript snapshots and deferred maintenance ownership. (#81869) Thanks @sahilsatralkar.
|
||||
- Telegram: persist polling updates through restart replay so queued same-topic messages resume in order instead of losing context after a gateway restart. (#82256) Thanks @VACInc.
|
||||
- Gateway/Gmail: abort in-flight Gmail watcher startup and hot-reload restarts before shutdown so reloads cannot spawn `gog serve` after the Gateway is closing. Thanks @frankekn.
|
||||
- Agents/Codex: fall back to the embedded PI runner when OpenAI's implicit Codex harness preference cannot find a registered Codex plugin, preventing OpenAI-compatible gateway requests from failing with an unregistered harness error. Fixes #82437.
|
||||
- Agents/OpenAI: honor `openai-codex:*` entries placed ahead of API-key backups in `auth.order.openai` for explicit OpenAI PI runs, and accept `models auth login --provider openai-codex --device-code` for headless sign-in. Fixes #82521. (#82605)
|
||||
- CLI/channels: install missing externalized same-id channel plugins during `channels add --channel <id>`, so recovery for WhatsApp and other externalized stock channels does not require a separate `plugins enable` step. Fixes #82533.
|
||||
- Windows node install: launch the node host through a hidden Windows launcher so login startup does not leave a persistent `cmd` window open. Fixes #81254.
|
||||
- MCP plugin tools: forward host MCP `tools/call` `AbortSignal` through `createPluginToolsMcpHandlers().callTool` into plugin `tool.execute`, so host cancellation actually cancels in-flight plugin tool calls instead of letting them run to completion. Fixes #82424. (#82443) Thanks @joshavant.
|
||||
- Agents/sandbox: honor explicit Docker sandbox env variables with credential-looking names during container creation, and recreate affected sandbox containers when the effective env policy changes. Fixes #82695. (#82763) Thanks @joshavant.
|
||||
- Plugins: accept deprecated `api.on("deactivate")` registrations as a dated compatibility alias for `gateway_stop`, so external plugin cleanup handlers run on Gateway shutdown while authors get migration guidance.
|
||||
- Plugins: resolve bundled entry, dist-runtime, package-state, and public artifact paths from packaged roots, so bundled plugin probes and hardlinked public surfaces no longer fall back to source files or fail during restart. Fixes #78462. Fixes #75797. Refs #76865. Thanks @ginishuh and @ymebosma.
|
||||
- Media: ignore image MIME and filename hints when bytes sniff as generic containers, so zip/octet-stream payloads mislabeled as images do not become local image media or keep image file extensions when staged.
|
||||
- Update/doctor: avoid materializing `groupAllowFrom` for channel schemas that reject it, so package-swap doctor repairs do not fail on externalized Slack configs.
|
||||
- Gateway/media: prevent image filenames from overriding generic non-image byte sniffing, so zip/octet-stream payloads mislabeled as images are offloaded or rejected before they become inline image attachments.
|
||||
- Plugins/web search: downgrade stale optional provider installs to warnings so Gateway and doctor repair paths keep running after startup provider selection. Refs #82313. Thanks @crackmac.
|
||||
- Telegram/Gateway: route targeted Telegram `/stop@bot` messages onto the control lane without cached bot metadata and match gateway stop requests across raw/canonical session aliases. (#82298) Thanks @VACInc.
|
||||
- MS Teams/media: sniff inline `data:image/*` attachment bytes before staging them, skipping payloads that are not actually images.
|
||||
- WebChat/media: require trusted local-media provenance before preserving local audio reply paths for display, so untrusted audio-looking paths go through normal staging and read-policy checks.
|
||||
- WebChat: trust local Auto-TTS audio on block-streamed replies, including ACP-dispatched tails, so synthesized browser audio renders instead of being silently dropped. Fixes #82628. (#82701) Thanks @leno23.
|
||||
- Agents/tool media: preserve trusted local-media provenance when merging generated tool attachments into final reply payloads, so trusted audio/media survives outbound display normalization.
|
||||
- Anthropic/Claude CLI: write model-scoped `claude-cli` runtime policy when reusing local Claude CLI auth, so upgraded Telegram and Dashboard gateway turns keep using the CLI backend instead of falling through to Anthropic API billing. Fixes #82344. Thanks @amknight.
|
||||
- Update: let package-swap `doctor --fix` persist core config repairs while plugin schemas are still converging, preventing update failures on externalized channel configs.
|
||||
- Update: carry plugin-validation bypasses into config mutation pre-write reads, so package update doctor repairs can finish while externalized plugin schemas are converging.
|
||||
- Update/doctor: keep plugin-validation bypasses on the top-level `$include` config write path, so package repair can update included plugin config files without flattening them into the root config.
|
||||
- Agents/subagents: warn and continue completion announce cleanup when lifecycle cleanup fails, preventing ended subagent runs from becoming silent ghosts. Fixes #82306. Thanks @SebTardif.
|
||||
- Telegram: let authorized text `/stop` commands use the fast-abort path before queued agent work, so active turns stop immediately instead of processing the abort after the turn finishes; foreign-bot `/stop@otherbot` mentions now stay on the regular topic lane instead of being routed into our control lane. Fixes #82162. Thanks @civiltox.
|
||||
- Sessions: drop persisted entries with invalid session ids and strip malformed transcript file metadata before hydrating session runtime state.
|
||||
- Auth/device: normalize malformed persisted device-auth token metadata before returning or preserving token entries.
|
||||
- Pairing: skip malformed persisted pending pairing requests before approving valid channel pairing codes.
|
||||
- Commitments: strip malformed optional reminder scope metadata from persisted commitments before matching pending follow-ups.
|
||||
- Config persistence: normalize malformed auth profile credential fields/state, skip JSON-valid garbage transcript checkpoint rows, and let `openclaw doctor --fix` remove unrepairable cron job rows.
|
||||
- Cron: skip persisted job rows with malformed schedule or payload shapes in memory, leaving the store for `openclaw doctor --fix` instead of hydrating them into runtime state.
|
||||
- Cron: keep legacy string schedules and blank system-event jobs available for runtime repair/skip handling instead of dropping them as malformed persisted rows.
|
||||
- Task persistence: drop malformed array/scalar requester-origin JSON from task and task-flow SQLite sidecars instead of restoring it as delivery metadata.
|
||||
- Agents/timeouts: clarify model idle-timeout errors and docs so provider `timeoutSeconds` is shown as bounded by the whole agent/run timeout ceiling.
|
||||
- Agents/OpenAI streams: yield cooperatively while processing bursty Completions and Responses chunks, keeping aborts, channel liveness timers, and startup heartbeats responsive under noisy model output. Refs #82462.
|
||||
- Media/images: avoid broad model/plugin discovery while preparing image requests, preventing Windows event-loop stalls that could block Telegram polling. Fixes #82338. (#82799) Thanks @joshavant.
|
||||
- Release tooling: align the published launcher Node floor, `npm start`, package script checks, sharded lint locking, Vitest root project coverage, and plugin-SDK declaration build cache metadata so release/package validation does not silently skip or ship stale surfaces.
|
||||
- Cron/agents: honor configured subagent model fallbacks for isolated scheduled runs and forward that fallback policy into embedded agent timeout failover. Fixes #74985. Thanks @chrisgwynne.
|
||||
- Codex app-server/MCP: scope user MCP servers to specific OpenClaw agent ids through an optional `mcp.servers.<name>.codex.agents` list and accept `codex.defaultToolsApprovalMode` (`auto`/`prompt`/`approve`) for native Codex approval defaults; OpenClaw strips the `codex` block before handing `mcp_servers` config to Codex. (#82180) Thanks @sercada.
|
||||
- Agents/OpenAI Responses: clamp `input_tokens - cached_tokens` at zero and reconstruct `totalTokens` from input + output + cached components so Responses-API streams report consistent usage when providers under-report `input_tokens` relative to `cached_tokens`.
|
||||
- Agents: mark adapter-caught tool execution failures as error tool results in embedded Pi sessions, so models can retry recoverable edit failures instead of seeing a successful tool result. Fixes #81546. (#81564) Thanks @najef1979-code and @MonkeyLeeT.
|
||||
- Plugins: reject malformed `package.json` `openclaw.extensions` metadata during install, discovery, and post-update payload smoke instead of silently dropping invalid entries.
|
||||
- Plugins: reject package metadata records whose `package.json` resolves outside the plugin root instead of trusting persisted or reconstructed registry snapshots.
|
||||
- Plugins: ignore malformed persisted package channel/install metadata instead of crashing catalog reconstruction or leaking invalid install hints.
|
||||
- Plugin releases: reject package `files` negations that would omit advertised package-local runtime entries from npm plugin tarballs.
|
||||
- Media/files: sniff `input_file` bytes before trusting declared MIME headers, rejecting spoofed image or zip payloads before they become agent-visible text.
|
||||
- Plugins/dependencies: scrub stale managed-root `openclaw` ownership metadata without deleting a linked active host package, preventing plugin installs from downgrading npm-global hosts. Fixes #79462. Thanks @lisandromachado.
|
||||
- Gateway/update: keep shutdown hook-runner imports on a stable dist entry and ship a legacy chunk alias so package swaps do not strand running gateways on missing shutdown chunks. Fixes #81819. Thanks @najef1979-code.
|
||||
- Config persistence: ignore malformed array/scalar auth profile, cron job state, and session store entries instead of hydrating them into numeric profile ids, crashed cron rows, or invalid session records.
|
||||
- Config persistence: strip malformed pending final-delivery session fields on load so replay/recovery paths skip poisoned reply metadata instead of crashing on raw objects.
|
||||
- Config persistence: strip malformed plugin extension state and promoted session-slot ownership on load so corrupted session rows do not leak poisoned plugin metadata into replay/projection paths.
|
||||
- Gateway/sessions: ignore malformed compaction checkpoint rows during session projection so corrupted stores do not crash session list/describe responses or show bogus checkpoint counts.
|
||||
- Gateway/sessions: keep reachable transcript history when imported tree transcripts reference missing or legacy parent rows, preventing session history reads from going empty after a partial import.
|
||||
- Trajectory export: report incomplete transcript parent chains and stop cyclic branch walks so malformed imports cannot hang `/export-trajectory`.
|
||||
- Session replay: skip malformed user/assistant-shaped transcript rows during silent session resets instead of copying invalid entries into the fresh transcript.
|
||||
- Transcript state: skip malformed persisted JSONL entries before compaction/rewrite helpers choose the active leaf.
|
||||
- Backup verify: report malformed archive manifests with a stable error instead of leaking raw JSON parser details.
|
||||
- Session export: report skipped malformed transcript JSONL rows instead of silently omitting them from exported HTML archives.
|
||||
- Providers: reject malformed successful Runway, BytePlus, and Ollama embedding responses with provider-owned errors instead of raw parser/type failures, silent bad vectors, or long bogus polling.
|
||||
- Providers/images: reject malformed successful OpenAI-compatible, OpenAI, Google, fal, and OpenRouter image responses with provider-owned errors instead of raw shape failures, silent invalid base64 skips, or empty image results.
|
||||
- Providers/videos: reject malformed successful xAI, OpenRouter, and fal video create, poll, and result responses with provider-owned errors instead of raw parser failures or long bogus polling.
|
||||
- Providers/videos: let selected-model capability overlays clear inherited `providerOptions`, so fallback skips models that explicitly accept no provider-specific options instead of forwarding unsupported knobs.
|
||||
- TTS/providers: honor preferred provider aliases when routing model override directives, so alias-selected speech providers receive unqualified `[[tts:*]]` overrides.
|
||||
- Providers/audio: reject malformed successful OpenAI-compatible, ElevenLabs, and Deepgram speech responses with provider-owned errors instead of raw parser failures, wrong-shaped transcripts, or JSON/text bodies treated as audio.
|
||||
- Providers/embeddings: reject malformed successful OpenAI-compatible, Google Gemini, and Amazon Bedrock embedding responses instead of silently returning empty or coerced vectors.
|
||||
- Providers/catalogs: reject malformed successful LM Studio, GitHub Copilot, DeepInfra, Vercel AI Gateway, and Kilocode model-list responses with provider-owned errors instead of raw parser/type failures or silent fallback catalogs.
|
||||
- Providers/polling: reject array, null, or scalar successful operation status responses with provider-owned malformed JSON errors instead of waiting until timeout.
|
||||
- ACPX/Codex: reap plugin-local Codex ACP adapter orphans on startup after wrapper crashes while keeping direct adapter commands out of launch-lease injection. Fixes #82364. (#82459) Thanks @joshavant.
|
||||
- Agents/model fallback: periodically probe the configured primary for auto-pinned fallback sessions, announce fallback/recovery transitions, and clear the pin when it recovers, preventing sessions from staying on a fallback model indefinitely. Fixes #82544. Thanks @crpol.
|
||||
- Telegram: send presentation-only payloads by rendering fallback text and inline buttons instead of treating them as empty. Fixes #82404. (#82449) Thanks @joshavant.
|
||||
- Providers/Kimi: preserve Kimi Coding `reasoning_content` replay and backfill assistant tool-call placeholders when thinking is enabled, so `kimi-for-coding` follow-up tool turns no longer fail after prior tool use. Fixes #82161. Thanks @amknight.
|
||||
- Providers/search tools: reject malformed successful xAI, Gemini, and Kimi web/code search responses with provider-owned errors instead of silent `No response` payloads or ungrounded fallback state.
|
||||
- Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export.
|
||||
- Voice calls: persist rejected inbound-call replay keys so duplicate carrier webhook retries stay ignored after a Gateway restart.
|
||||
- Config/doctor: copy fallback-enabled channel `allowFrom` entries into explicit `groupAllowFrom` allowlists during `openclaw doctor --fix`, preserving current group access without adding runtime fallback-transition flags.
|
||||
- Config/doctor: replace source-only official Brave and Slack plugin installs from trusted catalog metadata during `openclaw doctor --fix`, unblocking externalized stock plugin recovery after upgrade. (#82425) Thanks @joshavant.
|
||||
- Config/memory: warn instead of rejecting configs that select the official external `memory-lancedb` slot before the plugin is installed, with an explicit no-persistent-memory startup warning and install hint. Fixes #82428. (#82438) Thanks @giodl73-repo.
|
||||
- Agents/bootstrap: ignore stale completed root `BOOTSTRAP.md` context after workspace setup cleanup fails, preventing channel agent turns from treating it as a directory. (#82463) Thanks @joshavant.
|
||||
- Update/doctor: re-enable the Codex plugin during `openclaw doctor --fix` when configured OpenAI agent models require the Codex runtime, preventing upgraded configs from failing with an unregistered Codex harness. Fixes #82368. (#82502) Thanks @joshavant.
|
||||
- Configure: show one OpenAI provider entry with ChatGPT/Codex sign-in and API key choices, and keep browsed Codex models in the saved `/model` picker allowlist.
|
||||
- Agents/model fallback: preserve auto fallback chains across deferred config reloads when session fallback provenance survives but `modelOverrideSource` is missing. Fixes #81982. Thanks @joshavant.
|
||||
- Hooks: raise bounded gateway lifecycle hook wait budgets to 5 seconds for shutdown and 10 seconds for pre-restart, giving short restart notification handlers time to finish before shutdown continues. (#82273) Thanks @bryanbaer.
|
||||
@@ -51,6 +419,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron: keep failed isolated-agent runs from marking successful result delivery when only the failure notification was delivered. Fixes #72985. Thanks @Allenbluff.
|
||||
- Discord: validate message-read results before normalizing channel history and report unexpected payloads with a Discord boundary error instead of `map is not a function`. Fixes #82252. Thanks @jessewunderlich.
|
||||
- Agents/runtime: apply `agents.defaults.models["provider/*"].agentRuntime` as provider-wide model runtime policy while preserving exact model runtime precedence. Fixes #82243. Thanks @rendrag-git.
|
||||
- Model picker: show the effective Codex runtime first for official OpenAI routes while keeping Pi available as an alternate and preserving Pi-first custom OpenAI-compatible providers. Fixes #82269. Thanks @rendrag-git.
|
||||
- Agents/auto-reply: restrict `NO_REPLY` prompt guidance to automatic group/channel replies, remove legacy silent-reply rewrites, and suppress accidental direct-chat silent tokens instead of delivering fallback text. Fixes #82254. Thanks @absol89.
|
||||
- Telegram: retain a longer partial-stream preview when a final callback only carries an ellipsis-truncated snapshot, preventing the visible answer and transcript mirror from being replaced by the short preview. Fixes #82239. Thanks @crash2kx.
|
||||
- Telegram/active-memory: run blocking memory recall through the Telegram provider for direct-message turns even when the hook context carries the raw chat id, preventing embedded recall from launching against an invalid numeric channel. Fixes #82177. Thanks @cslash-zz.
|
||||
@@ -59,7 +428,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: drain queued outbound deliveries after polling reconnect confirms fresh `getUpdates` activity, so stale-socket and network recovery do not leave failed replies stranded. Fixes #50040. Refs #82175. Thanks @dmitriiforpost-commits and @shellyrocklobster.
|
||||
- Gateway/model auth: abort active provider runs when saved auth is removed through the Gateway control plane, refresh live runtime auth snapshots, and surface `stopReason: "auth-revoked"` to clients. Fixes #81987. (#82346) Thanks @joshavant.
|
||||
- Codex app-server: keep the raw tool-output idle watchdog armed after `custom_tool_call_output` notifications, so post-tool stream silence fails fast instead of waiting for the terminal idle timeout. Fixes #82274. (#82378) Thanks @joshavant.
|
||||
- Codex app-server: enforce OpenClaw `before_tool_call` policy for Codex-native app-server shell and approval paths, preventing native tool execution from bypassing plugin policy. Fixes #82372. (#82496) Thanks @joshavant.
|
||||
- Telegram: mark isolated polling ingress unhealthy when a spooled inbound backlog stalls while Bot API polling still succeeds, so gateway/channel health no longer stays green after Telegram DM processing wedges. Fixes #82175. Thanks @shellyrocklobster.
|
||||
- Telegram: drop expired approval callbacks from isolated polling after approval id expiry so stale inline-button updates do not retry forever across restarts. Fixes #82347. (#82455) Thanks @joshavant.
|
||||
- Agents: strip Gemini/Gemma `<final>` tags with attributes or self-closing syntax from delivered replies, including strict final-tag streaming enforcement. Fixes #65867. Thanks @grizdum.
|
||||
- macOS/update: disarm legacy `ai.openclaw.update.*` LaunchAgents when `openclaw update` starts from one, preventing KeepAlive relaunch loops that repeatedly restart the Gateway and replay update continuations. Fixes #82167. Thanks @DougButdorf.
|
||||
- Agents/replay: strip internal runtime-context metadata and `NO_REPLY` sentinels from provider replay and pending final-delivery recovery so restart and heartbeat resumes do not feed control text back to the model. Fixes #76629. Thanks @fuyizheng3120, @bryan-chx, and @cael-dandelion-cult.
|
||||
@@ -77,6 +448,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/OpenRouter: stop adding empty DeepSeek V4 `reasoning_content` placeholders to assistant tool-call replay messages and strip empty replay artifacts before follow-up Chat Completions requests, so `openrouter/deepseek/deepseek-v4-pro` no longer fails after tool use. Fixes #82150. (#82158) Thanks @luyao618 and @Suquir0.
|
||||
- OpenAI-compatible providers: honor streaming-usage compatibility metadata when deciding whether to send `stream_options.include_usage`, while keeping bundled Volcengine routes opted in to Ark streaming usage. Refs #44845. (#82181) Thanks @xuruiray.
|
||||
- Gateway/approvals: treat `turnSourceTo` as optional in `canBridgeNoDeviceChatApprovalFromBackend`, matching the existing optional handling of `turnSourceAccountId` and `turnSourceThreadId`. Channels without a recipient concept (webchat, control-ui) leave `turnSourceTo` null on both the approval snapshot and the replay params, so the prior required-string check rejected every backend replay with `APPROVAL_CLIENT_MISMATCH`. Cross-channel replay is still gated by the required `turnSourceChannel` and `sessionKey` checks. Fixes #82132. (#82136) Thanks @ottodeng.
|
||||
- OC Path: add `openclaw path set --dry-run --diff` so addressed edits can be reviewed as a unified diff before writing.
|
||||
|
||||
- Cron: load runtime plugins before isolated cron model and delivery resolution so external channels can be selected for scheduled runs. (#82111) Thanks @medns.
|
||||
- Cron: mirror successful direct scheduled deliveries into the resolved destination session transcript while preserving isolated-delivery awareness policy. (#80786) Thanks @cavit99.
|
||||
- Cron: preserve rotated transcript identity after session-bound scheduled runs compact, so `sessionTarget: "current"` keeps the next user message on the same conversation. Fixes #82164. Thanks @weissfl.
|
||||
@@ -97,6 +470,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers: preserve required `reasoning_content` replay for Kimi K2.6/K2 thinking and MiMo V2.6 OpenAI-compatible tool-call follow-up turns while keeping the stock OpenAI/Qwen strip path intact. Fixes #82139. Thanks @yimao.
|
||||
- Memory search: stop using chokidar write-stability polling for memory and QMD watchers so large Markdown extraPath trees no longer build up regular file descriptors; changed files now settle through the existing debounced sync queue. Fixes #77327 and #78224. (#81802) Thanks @frankekn, @loyur, and @JanPlessow.
|
||||
- Message tool: rename the Discord channel-create schema field exposed to models from `type` to `channelType`, avoiding NVIDIA NIM JSON Schema parser failures while still accepting legacy `type` tool calls. (#78920) Thanks @YashSaliya.
|
||||
- Feishu: send CardKit streaming cards as delivered deltas and retry failed updates, preventing duplicated or dropped streamed text. Fixes #82417. (#82419) Thanks @hclsys.
|
||||
- WhatsApp: accept `group:`-prefixed group JIDs for outbound targets so `whatsapp:group:<jid>@g.us` resolves to the canonical group JID. Thanks @mcaxtr.
|
||||
- Gateway/Gmail: stop queued post-ready Gmail sidecars before hot reload and abort stale Tailscale setup, so cancelled watcher restarts cannot rewrite an old public hook target or report abort-killed commands as success. (#82395) Thanks @samzong.
|
||||
|
||||
## 2026.5.14
|
||||
|
||||
@@ -114,7 +490,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/plugins: add a descriptor-backed gateway method registry so plugin-owned RPC methods carry scope metadata, preserve hidden core collision checks, and keep advertised method lists separate from internal core handlers. (#82063)
|
||||
- Gateway/startup: add owner-level startup trace attribution for auth, plugin loading, lookup counts, and plugin sidecar services. (#81738) Thanks @samzong.
|
||||
- Plugins/hooks: expose the resolved effective `contextTokenBudget` plus source/reference metadata on `llm_output` and sanitized `model_call_*` hook events/contexts so plugin cost and context-health alerts can use agent-level context caps. Fixes #64327. Thanks @BunsDev.
|
||||
- Channels/status reactions: wire `StatusReactionController` into WhatsApp message turns (queued → thinking → tool → done/error lifecycle, on par with Telegram and Discord), add `deploy`/`build`/`concierge` emoji categories with tool-token routing, and replace the status reaction defaults with self-explanatory emoji (🧠 thinking, 🛠️ tool, 💻 coding, 🌐 web, ⏳ stallSoft, ⚠️ stallHard, ✅ done, ❌ error, 🗜️ compacting) so stall and lifecycle reactions read as status indicators instead of emotional commentary. Fixes #59077. (#80612) Thanks @gado-ships-it.
|
||||
- Channels/status reactions: wire `StatusReactionController` into WhatsApp message turns (queued → thinking → tool → done/error lifecycle, on par with Telegram and Discord), add `deploy`/`build`/`concierge` emoji categories with tool-token routing, and replace the status reaction defaults with self-explanatory emoji (🧠thinking, ðŸ› ï¸ tool, 💻 coding, 🌠web, â³ stallSoft, âš ï¸ stallHard, ✅ done, ⌠error, ðŸ—œï¸ compacting) so stall and lifecycle reactions read as status indicators instead of emotional commentary. Fixes #59077. (#80612) Thanks @gado-ships-it.
|
||||
- Control UI: add a browser-local Text size setting in Appearance and Quick Settings, scaling chat and dense UI text while keeping inputs above the mobile Safari focus-zoom threshold. Fixes #8547. Thanks @BunsDev.
|
||||
- Gateway/plugins: add a default-off `admin-http-rpc` plugin for selected control-plane methods, with security docs and no core endpoint config. (#81806) Thanks @liorb-mountapps.
|
||||
- Docs: add a dedicated ds4 provider page with local DeepSeek V4 Flash config, on-demand startup, context sizing, and live verification steps.
|
||||
@@ -201,7 +577,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Codex account/status: hide empty rate-limit buckets and show server-reported usage-limit blocks without calling them available.
|
||||
- Auto-reply/Claude CLI: bridge CLI-runtime assistant text-delta agent events into the chat reasoning preview through `onReasoningStream`, mirroring the existing assistant-text (#76914) and tool-event (#80046) bridges and adding gating so non-CLI runtimes are unaffected. Thanks @anagnorisis2peripeteia and @pashpashpash.
|
||||
- Mantis: keep QA evidence in Actions artifacts only and stop publishing evidence files to Git-backed artifact branches.
|
||||
- CLI/migrate: handle delayed Codex plugin marketplace responses so warnings, next-steps, and conflict states render with ⚠️ glyphs and post-install migration retries the marketplace fetch instead of silently skipping plugin items. (#81625) Thanks @sjf.
|
||||
- CLI/migrate: handle delayed Codex plugin marketplace responses so warnings, next-steps, and conflict states render with âš ï¸ glyphs and post-install migration retries the marketplace fetch instead of silently skipping plugin items. (#81625) Thanks @sjf.
|
||||
- Channels/Weixin: bump the bundled `@tencent-weixin/openclaw-weixin` external entry to `2.4.3` (from `2.4.1`) so onboarding and `openclaw channels add` install the current Tencent Weixin (personal WeChat) plugin release. (#81730) Thanks @scotthuang.
|
||||
- CLI: lazy-load model, plugin, and device runtime helpers and keep channel option help on generated startup metadata or generic fallback text so parent/help output renders without importing those runtime paths.
|
||||
- CLI: route `plugins list --json` through the parsed command fast path and cover it in response budgets so plugin JSON inventory avoids full CLI registration work.
|
||||
@@ -299,7 +675,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/migrate: hide per-item source/plugin hints on non-conflicting Codex skill and plugin selection prompts, keeping the hint text reserved for rows that actually need attention. Thanks @sjf.
|
||||
- Codex harness: treat high-confidence app-server OAuth refresh invalidation as a terminal auth-profile failure, stopping repeated raw token-refresh errors without turning entitlement or usage-limit payloads into re-auth prompts.
|
||||
- CLI/migrate: humanize Codex conflict-status messaging across the migrate UI so selection prompts and plan/result rows say "Codex skill already installed in workspace" instead of surfacing internal `MIGRATION_REASON_*` codes. Thanks @sjf.
|
||||
- CLI/migrate: render migrate result rows with distinct glyphs for manual-review (🔍) and archive (📖) items instead of the misleading "skipped" and "migrated" checkmarks, so users can see which entries still need attention versus which were filed away. Thanks @sjf.
|
||||
- CLI/migrate: render migrate result rows with distinct glyphs for manual-review (ðŸ”) and archive (📖) items instead of the misleading "skipped" and "migrated" checkmarks, so users can see which entries still need attention versus which were filed away. Thanks @sjf.
|
||||
- CLI/migrate: split Codex migrate output into separate preview and result phases so the Before plan and After result render through clack with independently tunable copy. Thanks @sjf.
|
||||
- Codex app-server: project bundle and user MCP servers into Codex threads, rotate threads when an MCP server is disabled, scope bundle MCP injection to bundled servers, and resend user MCP config on resume so MCP changes take effect mid-session without restarting the agent. (#81551) Thanks @jalehman.
|
||||
- Codex migration: invoke the managed Codex binary instead of a stale system `codex` for source-config migration plans, so users running the bundled Codex runtime get plan output that matches the binary the gateway will actually use. (#81582) Thanks @fuller-stack-dev.
|
||||
@@ -333,12 +709,30 @@ Docs: https://docs.openclaw.ai
|
||||
- OpenAI plugin: clarify remote Codex OAuth login copy so tunneled users know sign-in may finish automatically before they paste the redirect URL. (#81301) Thanks @rubencu.
|
||||
- SGLang: preserve replayed reasoning history for OpenAI-compatible chat completions, keeping thinking-capable local models from losing prior reasoning turns. (#81091) Thanks @akrimm702.
|
||||
- Plugins/install: derive managed peer dependency pins from npm's lockfile planner instead of recursively scanning `node_modules`, while keeping OpenClaw host peers out of managed root ownership and preserving active root-managed runtimes. Thanks @fuller-stack-dev.
|
||||
- OC Path: restore YAML/YML/.lobster support through the bundled YAML document parser and add `$first` positional addressing alongside `$last`.
|
||||
- Control UI/WebChat: keep short assistant replies clear of in-bubble copy/open action buttons by applying the existing reserved action spacing in the grouped chat renderer. Fixes #79509. (#81244) Thanks @JARVIS-Glasses.
|
||||
- Codex harness: make the live test wrapper portable to Windows and defer locked temp cleanup so native Windows and WSL2 live runs complete.
|
||||
- Telegram: discard legacy long-poll update offsets that cannot be tied to the current bot token, so token rotation no longer leaves bots silently skipping new messages. (#80671) Thanks @sxxtony.
|
||||
- browser: enforce navigation checks for act interactions [AI]. (#81070) Thanks @pgondhi987.
|
||||
- Validate node exec event provenance [AI]. (#81071) Thanks @pgondhi987.
|
||||
- Gateway: keep active reply runs visible to stuck-session diagnostics and clear no-active-work recovery state, preventing stale queued lanes after compaction or tool failures. Fixes #80677. (#81302)
|
||||
- Codex app-server: rotate incompatible context-engine-managed native threads so Lossless-managed sessions do not resume stale hidden Codex history. (#81223) Thanks @jalehman.
|
||||
- Codex cron: execute scheduled command-style automation payloads before workspace bootstrap or memory review, preserving existing isolated cron jobs after Codex harness migration. (#81510) Thanks @jalehman.
|
||||
- Plugin LLM completions: honor Codex agent-runtime policy for canonical OpenAI model refs, so context-engine summarizers can use Codex OAuth instead of requiring direct `OPENAI_API_KEY` auth. (#81511) Thanks @jalehman.
|
||||
- Gateway/OpenAI HTTP: return OpenAI-compatible 400 errors for invalid sampling params and provider validation failures instead of collapsing them to 500s. (#81275) Thanks @Lellansin.
|
||||
- Telegram: publish plugin and skill command description localizations to native command menus while filtering unsupported locale codes and preserving Telegram command limits. (#81351) Thanks @jzakirov.
|
||||
- Limit hook CLI tool authority [AI]. (#81065) Thanks @pgondhi987.
|
||||
- Require admin scope for node device token management [AI]. (#81067) Thanks @pgondhi987.
|
||||
- Restrict chat sender allowlist matching [AI]. (#80898) Thanks @pgondhi987.
|
||||
- Update: suppress the false newer-config warning during restart health probing after an update handoff, while keeping future-version mutation guards intact. (#78652)
|
||||
- Claude CLI: clear a reused stored session id after aborts or non-expired failover errors so the next turn does not resume a poisoned CLI session. Fixes #78785.
|
||||
- Sessions: redact persisted tool result detail metadata before writing transcripts so diagnostic secrets do not survive tool output redaction. (#80444) Thanks @nimbleenigma.
|
||||
- Codex runtime: allow the official installed `@openclaw/codex` package to use its private task-runtime and MCP projection SDK helpers, fixing `MODULE_NOT_FOUND` during migrated OpenAI/Codex beta runs.
|
||||
- Codex migration: make Enter activate the highlighted checkbox row before continuing, so `Skip for now` and bulk-selection rows work even when planned items start preselected.
|
||||
- Link understanding: fetch page content through the SSRF guard before running configured CLI summarizers, preventing curl/wget-style link fetchers from reaching private redirect or DNS-rebound targets.
|
||||
- fix: harden safe-bin argument validation [AI]. (#80999) Thanks @pgondhi987.
|
||||
- Codex/status: align `/codex status` rate-limit wording with `/status` by showing remaining quota and compact reset durations instead of used quota and raw ISO timestamps. Thanks @MatthewSchleder.
|
||||
- Mattermost: log a structured `mattermost no-visible-reply` diagnostic when a substantive (non-reasoning) final reply payload reaches `deliverMattermostReplyPayload` but the underlying `deliverTextOrMediaReply` returns `"empty"` — previously the run completed with a misleading `delivered reply to <channel>` log even though no Mattermost API send happened, masking silent completions in channel/thread contexts. No behavior change; the diagnostic surfaces the failure so operators can detect it instead of seeing the agent appear to go silent. Fixes #80501. Thanks @robbyproc87.
|
||||
- Mattermost: log a structured `mattermost no-visible-reply` diagnostic when a substantive (non-reasoning) final reply payload reaches `deliverMattermostReplyPayload` but the underlying `deliverTextOrMediaReply` returns `"empty"` — previously the run completed with a misleading `delivered reply to <channel>` log even though no Mattermost API send happened, masking silent completions in channel/thread contexts. No behavior change; the diagnostic surfaces the failure so operators can detect it instead of seeing the agent appear to go silent. Fixes #80501. Thanks @robbyproc87.
|
||||
- Telegram: limit concurrent startup `getMe` probes across multi-account bots so large Telegram configs do not fan out all account probes at once during gateway startup. Refs #80695. (#80986) Thanks @stainlu.
|
||||
- fix(config): reject auto-managed meta.lastTouched\* paths in config set/unset (#80856). Thanks @ai-hpc
|
||||
- Test state: seed isolated auth-profile secret keys for generated homes, preventing helper-backed proof runs from falling back to host Keychain secrets. (#81393) Thanks @altaywtf.
|
||||
@@ -350,7 +744,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway: throttle assistant/thinking agent event fanout during streaming bursts without dropping buffered deltas. (#80335) Thanks @samzong.
|
||||
- Models: restore authenticated CLI runtime providers in the `/models` picker while keeping legacy runtime aliases hidden from setup/default model choices. Closes #81212. (#81239) Thanks @anagnorisis2peripeteia.
|
||||
- Changelog gates: reject bot/app handles as `Thanks` attribution and require explicit human credit for bot/app-authored changelog entries. (#81357) Thanks @hxy91819.
|
||||
- Agents/heartbeat: fix seven layered issues that broke multi-agent heartbeat cadence — (1) fan out the scheduler broadcast wake across agents in parallel via `Promise.all` instead of awaiting each `runOnce` sequentially, so one agent doing real work no longer starves every later agent in iteration order; (2) scope `skipWhenBusy` to lanes attributable to the firing agent via session-key parsing of `session:agent:<id>:…` / `nested:agent:<id>:…` lane names, instead of consulting the global `subagent` lane, so a single stuck subagent on one agent no longer silently disables every other agent's heartbeat; (3) always append workspace `HEARTBEAT.md` directives (everything outside an optional `tasks:` block) to the dispatch prompt, so prose-runbook `HEARTBEAT.md` files reach the model directly instead of being silently dropped unless periodic tasks are declared; (4) race the initial stream-establishment promise inside `streamWithIdleTimeout` against the same watchdog timer that previously only guarded inter-token gaps, so SDK requests stuck at TCP/TLS handshake or before the first response byte no longer hang indefinitely (the stalled-session diagnostic's `recovery=none` case); (5) emit an `openclaw doctor` warning when `heartbeat.session` pins a session key that has no entry in the agent's session store, so silently-dropped heartbeat deliveries surface at config-validation time; (6) also route the commitment-only task dispatch path (tasks configured, none due) through `appendHeartbeatFileDirectives` so prose directives outside the `tasks:` block reach the model on this path as well; (7) wrap the synchronous `baseFn(...)` invocation inside `streamWithIdleTimeout` in a try/catch that clears the connect watchdog timer before rethrowing, so a provider stream function that throws during setup no longer leaves a live timer that can fire `onIdleTimeout` later with a stale error and keep the process open past the real failure. Thanks @zeroaltitude.
|
||||
- Agents/heartbeat: fix seven layered issues that broke multi-agent heartbeat cadence — (1) fan out the scheduler broadcast wake across agents in parallel via `Promise.all` instead of awaiting each `runOnce` sequentially, so one agent doing real work no longer starves every later agent in iteration order; (2) scope `skipWhenBusy` to lanes attributable to the firing agent via session-key parsing of `session:agent:<id>:…` / `nested:agent:<id>:…` lane names, instead of consulting the global `subagent` lane, so a single stuck subagent on one agent no longer silently disables every other agent's heartbeat; (3) always append workspace `HEARTBEAT.md` directives (everything outside an optional `tasks:` block) to the dispatch prompt, so prose-runbook `HEARTBEAT.md` files reach the model directly instead of being silently dropped unless periodic tasks are declared; (4) race the initial stream-establishment promise inside `streamWithIdleTimeout` against the same watchdog timer that previously only guarded inter-token gaps, so SDK requests stuck at TCP/TLS handshake or before the first response byte no longer hang indefinitely (the stalled-session diagnostic's `recovery=none` case); (5) emit an `openclaw doctor` warning when `heartbeat.session` pins a session key that has no entry in the agent's session store, so silently-dropped heartbeat deliveries surface at config-validation time; (6) also route the commitment-only task dispatch path (tasks configured, none due) through `appendHeartbeatFileDirectives` so prose directives outside the `tasks:` block reach the model on this path as well; (7) wrap the synchronous `baseFn(...)` invocation inside `streamWithIdleTimeout` in a try/catch that clears the connect watchdog timer before rethrowing, so a provider stream function that throws during setup no longer leaves a live timer that can fire `onIdleTimeout` later with a stale error and keep the process open past the real failure. Thanks @zeroaltitude.
|
||||
- Matrix: stop running `npm install`/`pnpm install` at runtime from a parent-derived plugin path; missing Matrix runtime dependencies now fail with repair guidance instead of mutating the wrong `node_modules` tree. Fixes #80758. (#80876) Thanks @kinjitakabe.
|
||||
- Agents/memory-flush: surface non-abort memory-flush failures (provider timeout, transport error, generic agent failure) as visible reply payloads so the outer reply loop short-circuits and isolated cron runs propagate the error into `meta.error` instead of completing silently with `status: "ok"` and an empty payload. Previously only the specific "Memory flush writes are restricted to ..." message was surfaced. Fixes #80755. Thanks @nailujac.
|
||||
- Channels/loop-guard: enforce shared per-pair bot loop protection in the core channel-turn kernel, with Discord, Slack, Matrix, and Google Chat supplying bot-pair facts where they can reliably identify accepted bot-authored messages. The generic guard keys on `(scope, conversation, participant pair)`, suppresses every additional bot-to-bot event in either direction once a pair crosses the configured budget, and lifts suppression after `cooldownSeconds`. Defaults are `maxEventsPerWindow: 20`, `windowSeconds: 60`, and `cooldownSeconds: 60` whenever a channel lets bot-authored messages reach dispatch; they can be set globally via `channels.defaults.botLoopProtection` and overridden per channel/account or supported per-conversation config. Fixes #58789. Thanks @pandadev66.
|
||||
@@ -414,7 +808,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugin SDK: remove the owner-specific `provider-auth-login` public subpath after moving Chutes, GitHub Copilot, and OpenAI Codex auth flows back to provider-owned modules.
|
||||
- Plugin SDK: remove provider-specific model, stream, and xAI compatibility helpers from public exports after moving bundled callers to provider-owned modules.
|
||||
- Plugin SDK: expose runtime-supplied active model metadata to native plugin tool factories for diagnostics and plugin-owned policy decisions. Fixes #77857. Thanks @jamiezigelbaum.
|
||||
- Plugin SDK/runtime: add `api.runtime.llm.completeStructured(...)` for host-owned structured plugin inference with optional image inputs, JSON/schema validation, auth-profile selection, and the same model/agent override trust gates as `api.runtime.llm.complete`.
|
||||
- QA/Mantis: add Telegram live PR evidence automation with Convex-leased credentials, Crabbox transcript capture, motion GIF previews, and inline PR comments.
|
||||
- QA/Mantis: add a Telegram desktop scenario builder that leases Crabbox, installs native Telegram Desktop, configures an OpenClaw Telegram gateway with leased bot credentials, and records VNC screenshot/video artifacts.
|
||||
- Discord/voice: add realtime voice diagnostics for speaker turns, playback resets, barge-in detection, and audio cutoff analysis.
|
||||
@@ -441,7 +834,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Codex app-server: mirror native Codex subagent spawn lifecycle events into Task Registry so app-server child agents appear in task/status surfaces without relying on transcript text. (#79512) Thanks @mbelinky.
|
||||
- Skills: add `skills.load.allowSymlinkTargets` so intentional symlinked skill folders can resolve into trusted sibling repos without disabling root containment.
|
||||
- Agents/tools: add core Tool Search so agents can search and call large OpenClaw, MCP, and client tool catalogs through one compact PI bridge.
|
||||
- Doctor: warn when a per-agent model config omits the `fallbacks` key and `agents.defaults.model.fallbacks` is non-empty. Covers both string-form (`"model": "..."`) and partial-object form (`"model": { "primary": "..." }`) — both silently clobber the defaults chain at runtime. Use `"fallbacks": []` to explicitly opt out of fallbacks, or add `"fallbacks": [...]` to inherit or override. Fixes #79369.
|
||||
- Doctor: warn when a per-agent model config omits the `fallbacks` key and `agents.defaults.model.fallbacks` is non-empty. Covers both string-form (`"model": "..."`) and partial-object form (`"model": { "primary": "..." }`) — both silently clobber the defaults chain at runtime. Use `"fallbacks": []` to explicitly opt out of fallbacks, or add `"fallbacks": [...]` to inherit or override. Fixes #79369.
|
||||
- Chat commands: add `/think default` and `/fast default` to clear session overrides and inherit configured/provider defaults. (#79385) Thanks @VACInc.
|
||||
- Dependencies: refresh workspace dependency pins and lockfile, including `@openai/codex` `0.130.0`, `acpx` `0.7.0`, AWS SDK `3.1044.0`, OpenTelemetry `0.217.0`, `typebox` `1.1.38`, `vite` `8.0.11`, `oxfmt` `0.48.0`, and `oxlint` `1.63.0`, and update the Codex harness model snapshot for the new bundled app-server catalog.
|
||||
- Plugins/install: add guarded plugin install overrides so onboarding and repair tests can route specific plugins to registry specs or local `npm pack` artifacts via environment variables.
|
||||
@@ -616,6 +1009,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents: honor `OPENCLAW_WORKSPACE_DIR` when resolving the default agent workspace, preserving explicit config precedence while keeping env-backed deployments out of the system prompt fallback path. Fixes #66786.
|
||||
- Doctor/Codex: stop warning that the message tool is unavailable for source-reply paths where OpenClaw grants `message` at runtime, keeping update and doctor output aligned with the OpenAI happy path. Thanks @pashpashpash.
|
||||
- Channels/Weixin: bump the external Weixin catalog entry to `@tencent-weixin/openclaw-weixin@2.4.3` with the matching package integrity. (#81730) Thanks @scotthuang.
|
||||
- Agents/subagents: apply `agents.defaults.subagents.model` before target agent primary models during `sessions_spawn`, so model-scoped runtimes such as `claude-cli` stay attached to default child runs. Fixes #81395. (#81783) Thanks @joshavant.
|
||||
@@ -654,6 +1048,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Codex app-server: keep per-agent `CODEX_HOME` isolation without rewriting `HOME` by default, so Codex-run subprocesses can still find normal user-home config, tokens, and CLI state unless the launch explicitly overrides `HOME`. Thanks @pashpashpash.
|
||||
- iMessage: stop sending visible `<media:image>` placeholder text for media-only native image sends while preserving the internal echo key that prevents self-echo duplicate replies. (#81209) Thanks @homer-byte.
|
||||
- Agents/sessions: create configured agent main sessions before first `sessions_send` or gateway send, so agent-to-agent messages no longer fail when the target agent has not started yet.
|
||||
- Google models: honor configured `reasoning: false` when resolving thinking policy, preventing non-thinking Google/Gemma models from advertising `thinking=medium`. Fixes #81424.
|
||||
- gateway: pass Talk session scope to resolver [AI]. (#81379) Thanks @pgondhi987.
|
||||
- Gateway protocol: require v4 clients and stream explicit chat `deltaText`/`replace` frames so SDK clients can consume assistant updates without local diffing. (#80725) Thanks @samzong.
|
||||
- GitHub Copilot: exchange OAuth tokens for Copilot API tokens on image understanding requests and route Gemini image payloads through Chat Completions, fixing Copilot Gemini image descriptions. (#80393, #80442) Thanks @afunnyhy.
|
||||
@@ -698,6 +1093,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Require admin scope for node device token management [AI]. (#81067) Thanks @pgondhi987.
|
||||
- Restrict chat sender allowlist matching [AI]. (#80898) Thanks @pgondhi987.
|
||||
- Update: suppress the false newer-config warning during restart health probing after an update handoff, while keeping future-version mutation guards intact. (#78652)
|
||||
- Bundled MCP: inline local `$ref` parameter schemas before exposing tools, so Notion-style `oneOf` inputs validate through the bridge. Fixes #78737.
|
||||
- Sessions: redact persisted tool result detail metadata before writing transcripts so diagnostic secrets do not survive tool output redaction. (#80444) Thanks @nimbleenigma.
|
||||
- Codex runtime: allow the official installed `@openclaw/codex` package to use its private task-runtime and MCP projection SDK helpers, fixing `MODULE_NOT_FOUND` during migrated OpenAI/Codex beta runs.
|
||||
- Codex migration: make Enter activate the highlighted checkbox row before continuing, so `Skip for now` and bulk-selection rows work even when planned items start preselected.
|
||||
@@ -730,7 +1126,7 @@ Docs: https://docs.openclaw.ai
|
||||
- WhatsApp: externalize the channel as a ClawHub/npm plugin outside the core npm runtime bundle, and bump Baileys to `7.0.0-rc11` so libsignal resolves from the registry instead of a GitHub tarball.
|
||||
- WhatsApp: keep optional audio decoding dependencies local to the external plugin so the core npm install no longer pulls WhatsApp-only media helpers.
|
||||
- Build: skip copied metadata for bundled plugins that are excluded from build entries, preventing update/status rebuilds from advertising missing QQ Bot runtime files. (#80925)
|
||||
- Control UI/sessions: nest subagent sessions under their parent session in the session picker dropdown using a visual `└─ ` prefix, making the parent-child relationship clear. Fixes #77628. (#78623) Thanks @chinar-amrutkar.
|
||||
- Control UI/sessions: nest subagent sessions under their parent session in the session picker dropdown using a visual `└─ ` prefix, making the parent-child relationship clear. Fixes #77628. (#78623) Thanks @chinar-amrutkar.
|
||||
- Auto-reply: surface a visible error when the configured model backend fails and fallback produces no visible reply, while preserving intentional silent turns and side-effect-only deliveries. (#80917) Thanks @dutifulbob.
|
||||
- Agents/exec: skip redundant heartbeat wake-ups for subagent session exec completions, preventing spurious LLM invocations on parent sessions. Fixes #66748. (#66749) Thanks @ggzeng.
|
||||
- Provider streams: keep OpenAI-compatible SSE and JSON fallback streams draining across split chunks and fail Azure Responses streams with a bounded first-event diagnostic instead of stalling. Refs #80926. (#80927) Thanks @galiniliev and @CaptainTimon.
|
||||
@@ -944,7 +1340,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside provider auth config patches so setup-emitted provider catalogs test `google/gemini-3.1-pro-preview`.
|
||||
- GitHub Copilot: mint short-lived Copilot API tokens with the same `vscode-chat` integration identity used by runtime requests, and refresh legacy cached tokens missing that identity so image-capable Copilot models no longer inherit the `copilot-language-server` scope. Fixes #79946, #80074. Thanks @TurboTheTurtle.
|
||||
- Plugins/doctor: drop stale managed npm install records when `openclaw doctor --fix` removes npm packages that shadow bundled plugins, so the rebuilt registry no longer resurrects the removed package metadata.
|
||||
- Doctor: warn when a per-agent model config omits the `fallbacks` key and `agents.defaults.model.fallbacks` is non-empty. Covers both string-form (`"model": "..."`) and partial-object form (`"model": { "primary": "..." }`) — both silently clobber the defaults chain at runtime. Use `"fallbacks": []` to explicitly opt out of fallbacks, or add `"fallbacks": [...]` to inherit or override. Fixes #79369. Thanks @Kaspre.
|
||||
- Doctor: warn when a per-agent model config omits the `fallbacks` key and `agents.defaults.model.fallbacks` is non-empty. Covers both string-form (`"model": "..."`) and partial-object form (`"model": { "primary": "..." }`) — both silently clobber the defaults chain at runtime. Use `"fallbacks": []` to explicitly opt out of fallbacks, or add `"fallbacks": [...]` to inherit or override. Fixes #79369. Thanks @Kaspre.
|
||||
- Discord/voice: reuse or suppress late realtime consult tool calls without stealing newer speaker context or speaking forced fallback answers twice.
|
||||
- Discord/voice: skip likely incomplete realtime forced-consult transcript fragments and non-actionable closings so stale partial speech does not queue delayed answers over the next turn.
|
||||
- Discord/voice: keep realtime forced consults from clearing active exact-speech playback, so back-to-back voice answers queue instead of cutting each other off.
|
||||
@@ -1056,6 +1452,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/compaction: cap summarization output reserve tokens to the selected model's `maxTokens` so 1M-context Anthropic compactions do not request more output than the API permits. Fixes #54383.
|
||||
- Control UI/login: replace raw connection failures with structured, actionable login guidance for auth, pairing, insecure HTTP, origin, protocol, and transport failures. Thanks @BunsDev.
|
||||
- Agents/tools: fail `exec host=node` before `system.run` when the selected node is known to be disconnected, with an actionable reconnect message instead of a raw node invoke failure. Thanks @BunsDev.
|
||||
- Agents/tool-result guard: ignore internal tool-result `details` when estimating model-visible context, so large diagnostic metadata no longer triggers unnecessary truncation or compaction even though the provider boundary already strips `details` before model conversion. (#75525) Thanks @zqchris.
|
||||
- Agents/models: accept legacy `anthropic-cli/*` model refs as Claude CLI runtime refs instead of failing model resolution with `Unknown model`. Thanks @BunsDev.
|
||||
- Agents/tools: keep restrictive-profile tool-section warnings scoped to the configured sections whose tools are still missing from `alsoAllow`, so already re-allowed filesystem tools do not make exec-only fixes look broader than they are. Thanks @BunsDev.
|
||||
- Agents/tools: avoid warning messaging-only agents about inherited global `tools.exec` or `tools.fs` sections when the agent profile did not configure those tool sections itself. Thanks @BunsDev.
|
||||
@@ -1087,7 +1484,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/groups: instruct group-chat agents to stay silent when a message is addressed to someone else, replying only when invited or correcting key facts. (#78615)
|
||||
- Discord/groups: tell Discord-channel agents to wrap bare URLs as `<https://example.com>` so link previews do not expand into uninvited embeds. (#78614)
|
||||
- Agents/fallback: fail fast on session write-lock timeouts instead of trying fallback models for local file contention. Fixes #66646. Thanks @sallyom.
|
||||
- Browser/SSRF: stop closing user-owned Chrome tabs when a read-only operation (snapshot/screenshot/interactions) is rejected by the SSRF guard — only OpenClaw-initiated navigations now close on policy denial. Thanks @scotthuang.
|
||||
- Browser/SSRF: stop closing user-owned Chrome tabs when a read-only operation (snapshot/screenshot/interactions) is rejected by the SSRF guard — only OpenClaw-initiated navigations now close on policy denial. Thanks @scotthuang.
|
||||
- iMessage: stage native inbound attachments into OpenClaw-managed media and convert HEIC/HEIF images to JPEG before dispatch, so image tools can read photos sent over native iMessage without requiring BlueBubbles.
|
||||
- Agents/Gateway: throttle and cap live exec command-output events so noisy tool runs cannot flood Gateway WebSocket clients or starve RPC handling. (#78645) Thanks @joshavant.
|
||||
- Memory Wiki: skip empty and whitespace-only source pages when refreshing generated Related blocks, preventing blank pages from being rewritten into Related-only stubs. Fixes #78121. Thanks @amknight.
|
||||
@@ -1154,9 +1551,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/CLI: load the install-records ledger when listing channel-catalog entries, so npm-installed third-party channel plugins resolve through `openclaw channels login`/`channels add` instead of failing with `Unsupported channel`. (#77269) Thanks @pumpkinxing1.
|
||||
- Memory wiki/Security: enforce session visibility on shared-memory `wiki_search` and `wiki_get` so sandboxed subagents cannot read transcript content from sibling or parent sessions. Fixes GHSA-72fw-cqh5-f324. Thanks @zsxsoft.
|
||||
- Exec approvals: enforce allowlist `argPattern` argument restrictions on Linux and macOS as well as Windows, so an entry like `{ pattern: "python3", argPattern: "^safe\.py$" }` no longer silently relaxes to a path-only match on non-Windows hosts. (#75143) Thanks @eleqtrizit.
|
||||
- Security/exec allowlist: collapse `.` and `..` segments in wildcard exec allowlist match targets and canonicalize absolute executable path candidates before regex matching, so a target like `/usr/bin/../../bin/sh` no longer string-matches a `/usr/bin/**` allowlist entry while resolving outside the declared root. (#75723) Thanks @eleqtrizit and @zsxsoft.
|
||||
- Agents/compaction: disable Pi auto-compaction whenever OpenClaw effectively owns safeguard compaction, including provider-backed safeguard mode, so Pi and OpenClaw no longer fight over long-session compaction. Fixes #73003. (#73839) Thanks @bradhallett.
|
||||
- Telegram/streaming: finalize text replies by stopping the edited stream message instead of sending a second answer bubble, so Telegram turns cannot duplicate the streamed final response. (#77947) Thanks @obviyus.
|
||||
- web_search/Brave: fix provider selection when Brave is installed as an external plugin and `tools.web.search.provider: "brave"` is explicitly configured — a redundant provider re-resolution at startup could race and return an empty list, causing a spurious `WEB_SEARCH_PROVIDER_INVALID_AUTODETECT` warning and treating the explicitly configured provider as absent. Fixes #77676. Thanks @openperf.
|
||||
- web_search/Brave: fix provider selection when Brave is installed as an external plugin and `tools.web.search.provider: "brave"` is explicitly configured — a redundant provider re-resolution at startup could race and return an empty list, causing a spurious `WEB_SEARCH_PROVIDER_INVALID_AUTODETECT` warning and treating the explicitly configured provider as absent. Fixes #77676. Thanks @openperf.
|
||||
- Doctor/plugins: discover doctor contracts from load-path channel plugins during `openclaw doctor --fix`, so plugin-owned legacy config repair runs before validation. (#77477) Thanks @jalehman.
|
||||
- Dependencies: bump transitive `basic-ftp` to 5.3.1 so the runtime lockfile no longer includes the vulnerable 5.3.0 build flagged by the production dependency audit. (#78637) Thanks @sallyom.
|
||||
- Hooks/cron: log returned `/hooks/agent` isolated-run errors and failed cron jobs with cron diagnostic summaries, so rejected `payload.model` values are visible instead of looking like accepted-but-missing runs. Fixes #78597. (#78655) Thanks @kevinslin.
|
||||
@@ -1331,9 +1729,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/performance: let unscoped model catalog and manifest-contract readers reuse the current workspace-compatible plugin metadata snapshot, avoiding repeated cold plugin metadata scans on hot control-plane paths while preserving env/config/workspace compatibility checks. (#77519, #77532)
|
||||
- Config/plugin auto-enable: prefer the claiming plugin manifest id over a built-in channel alias when auto-allowlisting a configured channel, so WeCom/Yuanbao-style aliases resolve to the installed plugin id. Thanks @Beandon13.
|
||||
- Secrets/apply: preserve auth-profile `keyRef` and `tokenRef` fields when scrubbing provider-target secrets, so the canonical SecretRef metadata survives `secrets apply` without keeping plaintext values. Thanks @Beandon13.
|
||||
- Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys.
|
||||
- Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys.
|
||||
- Secrets/external channel contracts: also look in `<rootDir>/dist/` when resolving the `secret-contract-api` sidecar, so npm-published externalized channel plugins (e.g. `@openclaw/discord` since 2026.5.2) whose compiled artifacts live under `dist/` actually contribute their channel SecretRef contracts to the runtime snapshot. Without this, env-backed `channels.discord.token` SecretRefs silently failed to resolve at gateway start on 2026.5.3, leaving the channel `not configured` even though #76449 had landed the generic external-contract loader. Thanks @mogglemoss.
|
||||
- Models/auth: add `openclaw models auth list [--provider <id>] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc.
|
||||
- Models/auth: add `openclaw models auth list [--provider <id>] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments†path. Thanks @vincentkoc.
|
||||
- Control UI/header: show the active agent name in dashboard breadcrumbs without adding the current session key, keeping non-chat views oriented without crowding the topbar.
|
||||
- Control UI/cron: make the New Job sidebar collapsible so the jobs list can reclaim space while keeping the form one click away. Thanks @BunsDev.
|
||||
- Gateway/startup: keep model-catalog test helpers, run-session lookup code, QR pairing helpers, and TypeBox memory-tool schema construction out of hot startup import paths, reducing default gateway benchmark plugin-load and memory pressure.
|
||||
@@ -1658,6 +2056,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Telegram: preserve URL inline keyboard buttons in shared presentation rendering. Fixes #76255. Thanks @clawSean.
|
||||
- Update: repair doctor-migratable legacy config before persisting `openclaw update --channel ...`, so old Slack/Telegram streaming keys do not block switching to beta after a package update. Thanks @vincentkoc.
|
||||
- Plugins/bundles: preserve explicit `activation` metadata from Codex, Cursor, and Claude bundle manifests in registry records, so bundle startup opt-outs are not treated as legacy implicit startup sidecars. (#75133) Thanks @100menotu001.
|
||||
- Web fetch: late-bind `web_fetch` config and provider fallback metadata from the active runtime snapshot, matching `web_search` so long-lived tools do not use stale fetch provider settings. Thanks @vincentkoc.
|
||||
- Plugins/discovery: demote the source-only TypeScript runtime check on already-installed `origin: "global"` plugin packages from a config-blocking error to a warning and let the runtime fall through to the TypeScript source via jiti, so a single broken installed package no longer blocks `plugins install` for unrelated plugins; install-time rejection of newly-installed source-only packages is unchanged. Thanks @romneyda.
|
||||
- Providers/OpenAI Codex: stop the OAuth progress spinner before showing the manual redirect paste prompt, so callback timeouts do not spam `Browser callback did not finish` across terminals.
|
||||
@@ -1835,7 +2234,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/plugins: reject missing plugin ids before config writes in `plugins enable` and `plugins disable` so a typo no longer persists a stale config entry. (#73554) Thanks @ai-hpc.
|
||||
- Agents/sessions: preserve delivered trailing assistant replies during session-file repair so Telegram/WebChat history is not rewritten to drop already-delivered responses. Fixes #76329. Thanks @obviyus.
|
||||
- Gateway/chat history: preserve oversized transcript turns as explicit omitted-message placeholders while avoiding large JSONL parse stalls. Thanks @Marvinthebored and @vincentkoc.
|
||||
- CLI/doctor: load the configured memory-slot plugin when resolving memory diagnostics so bundled `memory-core` no longer triggers a false “no active memory plugin” warning on standalone `doctor` / `status` runs. Fixes #76367. Thanks @neeravmakwana.
|
||||
- CLI/doctor: load the configured memory-slot plugin when resolving memory diagnostics so bundled `memory-core` no longer triggers a false “no active memory plugin†warning on standalone `doctor` / `status` runs. Fixes #76367. Thanks @neeravmakwana.
|
||||
- Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206.
|
||||
- Agents/idle-timeout: add a cost-runaway breaker to the outer embedded-run retry loop that halts further attempts after 5 consecutive idle timeouts without completed model progress, so a wedged provider can no longer fan paid model calls out across the same run; completed text or tool-call progress resets the breaker, but partial tool-argument token dribbles do not. Fixes #76293. Thanks @ThePuma312.
|
||||
- Heartbeats/Codex: align structured heartbeat prompts with actual `heartbeat_respond` tool availability, stop sending legacy `HEARTBEAT_OK` when the tool exists, and keep tool-disabled commitment check-ins on the legacy ack path. Thanks @pashpashpash and @vincentkoc.
|
||||
@@ -1914,7 +2313,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack: allow draft preview streaming in top-level DMs when `replyToMode` is `off` while keeping Slack native streaming and assistant thread status gated on reply threads. Fixes #56480. (#56544) Thanks @HangGlidersRule.
|
||||
- Control UI/chat: remove the delete-confirm popover outside-click listener on every dismiss path, so Cancel, Delete, outside clicks, and same-button toggles no longer leave stale document listeners behind. Refs #75590 and #69982. Thanks @Ricardo-M-L.
|
||||
- Memory-core: treat exhausted file watcher limits as non-fatal for builtin memory auto-sync while preserving fatal handling for unrelated disk-full errors. (#73357) Thanks @solodmd.
|
||||
- Providers/Ollama: restore catalog context-window forwarding as `num_ctx` for native `/api/chat` requests; fixes tool selection and context truncation regressions on models with catalog entries (qwen3, llama3, gemma3, …) when no explicit `params.num_ctx` was configured. Fixes #76117. (#76181) Thanks @openperf.
|
||||
- Providers/Ollama: restore catalog context-window forwarding as `num_ctx` for native `/api/chat` requests; fixes tool selection and context truncation regressions on models with catalog entries (qwen3, llama3, gemma3, …) when no explicit `params.num_ctx` was configured. Fixes #76117. (#76181) Thanks @openperf.
|
||||
- Plugins/install: pin npm plugin installs to the verified resolved version and reject package-lock version or integrity drift, so mutable tags cannot race integrity checks into accepting a different artifact. Thanks @Lucenx9.
|
||||
- Exec approvals: preserve trusted elevated defaults across approved command follow-up runs so same-turn elevated `on`/`ask` commands request a fresh approval instead of reporting elevated as unavailable. Fixes #75832. Thanks @jameyedwards and @bitloi.
|
||||
|
||||
@@ -2260,7 +2659,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack: publish a safe default App Home tab view on `app_home_opened` and include the Home tab event in setup manifests. Fixes #11655; refs #52020. Thanks @TinyTb.
|
||||
- Slack: keep track of bot-participated threads across restarts, so ongoing threaded conversations can continue auto-replying after the Gateway is restarted. Thanks @amknight.
|
||||
- Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok.
|
||||
- BlueBubbles: add opt-in `channels.bluebubbles.replyContextApiFallback` that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through `mergeAccountConfig`; routed through the typed `BlueBubblesClient` so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (`p:0/<guid>`) are stripped before the request; concurrent webhooks for the same `replyToId` coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends `sanitizeForLog` to redact `?password=…`/`?token=…` query params and `Authorization:` headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris.
|
||||
- BlueBubbles: add opt-in `channels.bluebubbles.replyContextApiFallback` that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through `mergeAccountConfig`; routed through the typed `BlueBubblesClient` so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (`p:0/<guid>`) are stripped before the request; concurrent webhooks for the same `replyToId` coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends `sanitizeForLog` to redact `?password=…`/`?token=…` query params and `Authorization:` headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris.
|
||||
- CLI/proxy: add `openclaw proxy validate` so operators can verify effective proxy configuration, proxy reachability, and expected allow/deny destination behavior before deploying proxy-routed OpenClaw commands. (#73438) Thanks @jesse-merhi.
|
||||
- Agents/Codex: default Codex app-server dynamic tools to native-first, keeping OpenClaw integration tools while leaving file, patch, exec, and process ownership to the Codex harness. (#75308) Thanks @pashpashpash.
|
||||
- Agents/Codex: default Codex-harness direct source replies to the OpenClaw `message` tool when visible reply delivery is not explicitly configured, keeping channel-visible output as a deliberate tool call. (#75765) Thanks @pashpashpash.
|
||||
@@ -2566,7 +2965,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Web fetch: add a documented `tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange` opt-in and thread it through cache keys and DNS/IP checks so trusted fake-IP proxy stacks using `fc00::/7` can work without broad private-network access. Fixes #74351. Thanks @jeffrey701.
|
||||
- OpenAI Codex: restore `/verbose full` persistence and app-server tool-output forwarding, and retry Gateway E2E temp-home cleanup so debug runs do not regress on stale validation or cleanup flakes. Thanks @vincentkoc.
|
||||
- Anthropic/Meridian: preserve text and thinking content seeded on `content_block_start` in anthropic-messages streams, so `[thinking, text]` replies no longer persist as empty turns or trigger empty-response fallbacks. Fixes #74410. Thanks @vyctorbrzezowski.
|
||||
- Channels/Matrix: complete the cross-signing handshake on `openclaw matrix verify confirm-sas` so the operator's other Matrix device clears its `Verifying…` loop instead of staying stuck after the agent confirms. (#74542) Thanks @nklock.
|
||||
- Channels/Matrix: complete the cross-signing handshake on `openclaw matrix verify confirm-sas` so the operator's other Matrix device clears its `Verifying…` loop instead of staying stuck after the agent confirms. (#74542) Thanks @nklock.
|
||||
- CLI/status: honor channel-specific model context-window overrides when reporting effective context, so channel-scoped sessions reflect the active window in `openclaw status`. Thanks @HemantSudarshan.
|
||||
- Sandbox/Docker: tolerate Docker daemon unavailability when sandbox mode is off, so doctor and preflight checks no longer fail on installs that do not run the Docker daemon. Fixes #73671. Thanks @kaseonedge.
|
||||
- Control UI/mobile: persist mobile chat settings through Lit-managed state and route mobile navigation through the same view-state path so chat panel toggles survive transitions on small viewports. Thanks @BunsDev.
|
||||
@@ -3079,7 +3478,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @vincentkoc.
|
||||
- WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.
|
||||
- Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez.
|
||||
- TTS/BlueBubbles: pre-transcode synthesized MP3 audio to opus-in-CAF (mono, 24 kHz — validated against macOS 15.x Messages.app's native voice-memo CAF descriptor) on macOS hosts before handing the file to BlueBubbles, so iMessage renders the result as a native voice-memo bubble with proper duration and waveform UI instead of a plain file attachment. Adds an opt-in `tts.voice.preferAudioFileFormat` channel capability and a magic-byte sniff for the CAF container so the host-local-media validator (which uses `file-type` and didn't recognize CAF natively) can verify the pre-transcoded buffer. Channels that don't opt in are unaffected. (#72586) Fixes #72506. Thanks @omarshahine.
|
||||
- TTS/BlueBubbles: pre-transcode synthesized MP3 audio to opus-in-CAF (mono, 24 kHz — validated against macOS 15.x Messages.app's native voice-memo CAF descriptor) on macOS hosts before handing the file to BlueBubbles, so iMessage renders the result as a native voice-memo bubble with proper duration and waveform UI instead of a plain file attachment. Adds an opt-in `tts.voice.preferAudioFileFormat` channel capability and a magic-byte sniff for the CAF container so the host-local-media validator (which uses `file-type` and didn't recognize CAF natively) can verify the pre-transcoded buffer. Channels that don't opt in are unaffected. (#72586) Fixes #72506. Thanks @omarshahine.
|
||||
- Feishu: retry WebSocket startup failures with monitor-owned backoff while preserving SDK-local heartbeat defaults, so persistent-connection startup failures no longer leave the monitor hung. Fixes #68766; related #42354 and #55532. Thanks @alex-xuweilong, @120106835, @sirfengyu, and @tianhaocui.
|
||||
- Cron: normalize isolated job tool allowlists before granting the narrow self-removal cron tool path, keeping scheduled jobs aligned with shared tool policy normalization. (#73028) Thanks @jalehman.
|
||||
|
||||
@@ -3180,7 +3579,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels/setup: treat bundled channel plugins as already bundled during `channels add` and onboarding, enabling them without writing redundant `plugins.load.paths` entries or path install records. Fixes #72740. Thanks @iCodePoet.
|
||||
- WhatsApp: honor gateway `HTTPS_PROXY` / `HTTP_PROXY` env vars for QR-login WebSocket connections, while respecting `NO_PROXY`, so proxied networks no longer fall back to direct `mmg.whatsapp.net` connections that time out with 408. Fixes #72547; supersedes #72692. Thanks @mebusw and @SymbolStar.
|
||||
- Bonjour: default mDNS advertisements to the system hostname when it is DNS-safe, avoiding `openclaw.local` probing conflicts and Gateway restart loops on hosts such as `Lobster` or `ubuntu`. Fixes #72355 and #72689; supersedes #72694. Thanks @mscheuerlein-bot, @gcusms, @moyuwuhen601, @pavan987, @zml-0912, @hhq365, and @SymbolStar.
|
||||
- Agents/OpenAI-compatible: retry replay-safe empty `stop` turns once for `openai-completions` endpoints, so transient empty local backend responses no longer surface as “Agent couldn't generate a response” when a continuation succeeds, and restore `openclaw agent --model` for one-shot CLI runs. Fixes #72751. Thanks @moooV252.
|
||||
- Agents/OpenAI-compatible: retry replay-safe empty `stop` turns once for `openai-completions` endpoints, so transient empty local backend responses no longer surface as “Agent couldn't generate a response†when a continuation succeeds, and restore `openclaw agent --model` for one-shot CLI runs. Fixes #72751. Thanks @moooV252.
|
||||
- Git hooks: skip ignored staged paths when formatting and restaging pre-commit files, so merge commits no longer abort when `.gitignore` newly ignores staged merged content. Fixes #72744. Thanks @100yenadmin.
|
||||
- Memory-core/dreaming: add a supported `dreaming.model` knob for Dream Diary narrative subagents, wired through phase config and the existing plugin subagent model-override trust gate. Refs #65963. Thanks @esqandil and @mjamiv.
|
||||
- Agents/Anthropic: remove trailing assistant prefill payloads when extended thinking is enabled, so Opus 4.7/Sonnet 4.6 requests do not fail Anthropic's user-final-turn validation. Fixes #72739. Thanks @superandylin.
|
||||
@@ -4075,7 +4474,7 @@ Docs: https://docs.openclaw.ai
|
||||
- WhatsApp/onboarding: keep first-run setup entry loading off the Baileys runtime dependency path, so packaged QuickStart installs can show WhatsApp setup before runtime deps are staged. Fixes #70932.
|
||||
- Block streaming: suppress final assembled text after partial block-delivery aborts when the already-sent text chunks exactly cover the final reply, preventing duplicate replies without dropping unrelated short messages. Fixes #70921.
|
||||
- Codex harness/Windows: resolve npm-installed `codex.cmd` shims through PATHEXT before starting the native app-server, so `codex/*` models work without a manual `.exe` shim. Fixes #70913.
|
||||
- Slack/groups: classify MPIM group DMs as group chat context and suppress verbose tool/plan progress on Slack non-DM surfaces, so internal "Working…" traces no longer leak into rooms. Fixes #70912.
|
||||
- Slack/groups: classify MPIM group DMs as group chat context and suppress verbose tool/plan progress on Slack non-DM surfaces, so internal "Working…" traces no longer leak into rooms. Fixes #70912.
|
||||
- Agents/replay: stop OpenAI/Codex transcript replay from synthesizing missing tool results while still preserving synthetic repair on Anthropic, Gemini, and Bedrock transport-owned sessions. (#61556) Thanks @VictorJeon and @vincentkoc.
|
||||
- Telegram/media replies: parse remote markdown image syntax into outbound media payloads on the final reply path, so Telegram group chats stop falling back to plain-text image URLs when the model or a tool emits `` instead of a `MEDIA:` token. (#66191) Thanks @apezam and @vincentkoc.
|
||||
- Agents/WebChat: surface non-retryable provider failures such as billing, auth, and rate-limit errors from the embedded runner instead of logging `surface_error` and leaving webchat with no rendered error. Fixes #70124. (#70848) Thanks @truffle-dev.
|
||||
@@ -4208,7 +4607,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/SDK retry: cap long `Retry-After` sleeps in Stainless-based Anthropic/OpenAI model SDKs so 60s+ retry windows surface immediately for OpenClaw failover instead of blocking the run. (#68474) Thanks @jetd1.
|
||||
- Agents/TTS: preserve spoken text in TTS tool results while defusing reply directives in transcript content, so future turns remember voice replies without treating spoken `MEDIA:` or voice tags as delivery metadata. (#68869) Thanks @zqchris.
|
||||
- Providers/OpenAI: harden Voice Call realtime transcription against OpenAI Realtime session-update drift, forward language and prompt hints, and add live coverage for realtime STT.
|
||||
- Agents/Pi embedded runs: suppress the "⚠️ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (`stopReason=stop`). Real failure modes (tool errors, provider `stopReason=error`, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396. (#70425) Thanks @neeravmakwana.
|
||||
- Agents/Pi embedded runs: suppress the "âš ï¸ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (`stopReason=stop`). Real failure modes (tool errors, provider `stopReason=error`, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396. (#70425) Thanks @neeravmakwana.
|
||||
- Gateway/Linux: wrap gateway-managed supervisor, PTY, MCP stdio, and browser child processes in a tiny `/bin/sh` shim that raises the child's own `oom_score_adj` on Linux, so under cgroup memory pressure the kernel prefers transient workers over the long-lived gateway. Opt out with `OPENCLAW_CHILD_OOM_SCORE_ADJ=0`. Fixes #70404. (#70419) Thanks @neeravmakwana.
|
||||
- Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like `functions.<name>:<index>`) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a `sanitizeToolCallIds` opt-out to the shared `openai-compatible` replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314.
|
||||
- Dependencies/security: override transitive `uuid` to `14.0.0`, clearing the runtime advisory across dependencies.
|
||||
@@ -4474,7 +4873,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/isolated-agent: preserve explicit `delivery.mode: "none"` message targets for isolated runs without inheriting implicit `last` routing, so agent-initiated Telegram sends keep their authored destination while bare `mode:none` jobs stay targetless. (#69153) Thanks @davehappyminion and @nikilster.
|
||||
- Cron/isolated-agent: keep `delivery.mode: "none"` account-only or thread-only configs from inheriting a stale implicit recipient, so isolated runs only resolve message routing when the job authored an explicit `to` target. (#69163) Thanks @davehappyminion and @nikilster.
|
||||
- Gateway/TUI: retry session history while the local gateway is still finishing startup, so `openclaw tui` reconnects no longer fail on transient `chat.history unavailable during gateway startup` errors. (#69164) Thanks @shakkernerd.
|
||||
- BlueBubbles/reactions: fall back to `love` when an agent reacts with an emoji outside the iMessage tapback set (`love`/`like`/`dislike`/`laugh`/`emphasize`/`question`), so wider-vocabulary model reactions like `👀` still produce a visible tapback instead of failing the whole reaction request. Configured ack reactions still validate strictly via the new `normalizeBlueBubblesReactionInputStrict` path. (#64693) Thanks @zqchris.
|
||||
- BlueBubbles/reactions: fall back to `love` when an agent reacts with an emoji outside the iMessage tapback set (`love`/`like`/`dislike`/`laugh`/`emphasize`/`question`), so wider-vocabulary model reactions like `👀` still produce a visible tapback instead of failing the whole reaction request. Configured ack reactions still validate strictly via the new `normalizeBlueBubblesReactionInputStrict` path. (#64693) Thanks @zqchris.
|
||||
- BlueBubbles: prefer iMessage over SMS when both chats exist for the same handle, honor explicit `sms:` targets, and never silently downgrade iMessage-available recipients. (#61781) Thanks @rmartin.
|
||||
- Telegram/setup: require numeric `allowFrom` user IDs during setup instead of offering unsupported `@username` DM resolution, and point operators to `from.id`/`getUpdates` for discovery. (#69191) Thanks @obviyus.
|
||||
- GitHub Copilot/onboarding: default GitHub Copilot setup to `claude-opus-4.6` and keep the bundled default model list aligned, so new Copilot setups no longer start on the older `gpt-4o` default. (#69207) Thanks @obviyus.
|
||||
@@ -4501,7 +4900,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/CDP: allow the selected remote CDP profile host for CDP health and control checks without widening browser navigation SSRF policy, so WSL-to-Windows Chrome endpoints no longer appear offline under strict defaults. Fixes #68108. (#68207) Thanks @Mlightsnow.
|
||||
- Codex: stop cumulative app-server token totals from being treated as fresh context usage, so session status no longer reports inflated context percentages after long Codex threads. (#64669) Thanks @cyrusaf.
|
||||
- Browser/CDP: add phase-specific CDP readiness diagnostics and normalize loopback WebSocket host aliases, so Windows browser startup failures surface whether HTTP discovery, WebSocket discovery, SSRF validation, or the `Browser.getVersion` health check failed.
|
||||
- Browser/CDP: discover Chrome’s real DevTools websocket from bare `ws://host:port` attach-only roots before declaring the profile down, while still falling back to direct websocket providers that do not expose `/json/version`. Fixes #68027. (#68715) Thanks @visionik.
|
||||
- Browser/CDP: discover Chrome’s real DevTools websocket from bare `ws://host:port` attach-only roots before declaring the profile down, while still falling back to direct websocket providers that do not expose `/json/version`. Fixes #68027. (#68715) Thanks @visionik.
|
||||
|
||||
## 2026.4.18
|
||||
|
||||
@@ -4527,7 +4926,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/discovery: reuse bundled and global plugin discovery results across workspace cache misses so Windows multi-workspace startup stops redoing the shared synchronous scan. (#67940) Thanks @obviyus.
|
||||
- Bundled plugins/install: keep staged bundled plugin runtime imports resolving through the packaged Plugin SDK while omitting checkout-only aliases from the dist inventory, so published installs do not fail on repo-local paths.
|
||||
- Plugins/webhooks: enforce synchronous plugin registration with full rollback of failed plugin side effects, and cache SecretRef-backed webhook auth per route so plugin startup and inbound webhook auth stay deterministic. (#67941) Thanks @obviyus.
|
||||
- Telegram/polling transport: give the Telegram undici dispatcher pool bounded keep-alive defaults and an explicit lifecycle. Previously every recoverable network error and stall watchdog trip silently replaced the transport, abandoning the old dispatcher pool and its sockets; long-running gateway processes accumulated hundreds of ESTABLISHED connections to `api.telegram.org`, saturating per-IP upstream proxy quotas and causing the actively-used outbound proxy node to time out while every other node still tested healthy. Transports now expose `close()`, `TelegramPollingTransportState` destroys the stale transport on dirty-rebuild, and `TelegramPollingSession` disposes the transport when polling exits — backed by a strict per-origin pool cap on every constructed `Agent`, `ProxyAgent`, and `EnvHttpProxyAgent` as defence in depth (#69476).
|
||||
- Telegram/polling transport: give the Telegram undici dispatcher pool bounded keep-alive defaults and an explicit lifecycle. Previously every recoverable network error and stall watchdog trip silently replaced the transport, abandoning the old dispatcher pool and its sockets; long-running gateway processes accumulated hundreds of ESTABLISHED connections to `api.telegram.org`, saturating per-IP upstream proxy quotas and causing the actively-used outbound proxy node to time out while every other node still tested healthy. Transports now expose `close()`, `TelegramPollingTransportState` destroys the stale transport on dirty-rebuild, and `TelegramPollingSession` disposes the transport when polling exits — backed by a strict per-origin pool cap on every constructed `Agent`, `ProxyAgent`, and `EnvHttpProxyAgent` as defence in depth (#69476).
|
||||
- Telegram/polling: publish successful `getUpdates` calls as account health liveness, avoid false stall restarts after recoverable `getUpdates` errors, and force Telegram API dispatchers to HTTP/1.1 so stalled polling recovers instead of sitting connected-but-dead (#69476).
|
||||
- Telegram/ACP bindings: drop persisted DM bindings that still point at missing or failed ACP sessions on restart, while preserving plugin-owned bindings and uncertain store reads. (#67822) Thanks @chinar-amrutkar.
|
||||
- Telegram/streaming: keep a transient preview on the same Telegram message when auto-compaction retries an in-flight answer, so streamed replies no longer appear duplicated after compaction. (#66939) Thanks @rubencu.
|
||||
@@ -4535,7 +4934,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory-core: preserve stored vector dimensions during read-only recovery so memory indexes do not lose vector metadata while repairing read-only state.
|
||||
- Reply/block streaming: preserve post-stream incomplete-turn error payloads after block streaming already emitted content, so users get the warning instead of silence. (#67991) Thanks @obviyus.
|
||||
- Telegram/streaming: clear the compaction replay guard after visible non-final boundaries so a post-tool assistant reply rotates to a fresh preview instead of editing the pre-compaction message. (#67993) Thanks @obviyus.
|
||||
- Matrix: fix `sessions_spawn --thread` subagent session spawning — thread binding creation, cleanup on session end, and completion-message delivery target resolution now work end-to-end. (#67643) Thanks @eejohnso-ops and @gumadeiras.
|
||||
- Matrix: fix `sessions_spawn --thread` subagent session spawning — thread binding creation, cleanup on session end, and completion-message delivery target resolution now work end-to-end. (#67643) Thanks @eejohnso-ops and @gumadeiras.
|
||||
- Slack/streaming: resolve native streaming recipient teams from the inbound user when available, with a monitor-team fallback, so DM and shared-workspace streams target the right recipient more reliably.
|
||||
- macOS/webchat: enable Undo and Redo in the composer text input by turning on the native `NSTextView` undo manager. (#34962) Thanks @tylerbittner.
|
||||
- macOS/remote SSH: require an already-trusted host key on the macOS remote command, gateway probe, port tunnel, and pairing probe paths by switching `StrictHostKeyChecking=accept-new` to `StrictHostKeyChecking=yes` and centralizing the shared SSH option fragments in `CommandResolver`, so first-time macOS remote connections no longer silently accept an unknown host key and must be trusted ahead of time via `~/.ssh/known_hosts`. (#68199) Thanks @drobison00.
|
||||
@@ -4623,10 +5022,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/failover: treat HTML provider error pages as upstream transport failures for CDN-style 5xx responses without misclassifying embedded body text as API rate limits, while still preserving auth remediation for HTML 401/403 pages and proxy remediation for HTML 407 pages. (#67642) Thanks @stainlu.
|
||||
- Gateway/skills: bump the cached skills-snapshot version whenever a config write touches `skills.*` (for example `skills.allowBundled`, `skills.entries.<id>.enabled`, or `skills.profile`). Existing agent sessions persist a `skillsSnapshot` in `sessions.json` that reuses the skill list frozen at session creation; without this invalidation, removing a bundled skill from the allowlist left the old snapshot live and the model kept calling the disabled tool, producing `Tool <name> not found` loops that ran until the embedded-run timeout. (#67401) Thanks @xantorres.
|
||||
- Agents/tool-loop: enable the unknown-tool stream guard by default. Previously `resolveUnknownToolGuardThreshold` returned `undefined` unless `tools.loopDetection.enabled` was explicitly set to `true`, which left the protection off in the default configuration. A hallucinated or removed tool (for example `himalaya` after it was dropped from `skills.allowBundled`) would then loop "Tool X not found" attempts until the full embedded-run timeout. The guard has no false-positive surface because it only triggers on tools that are objectively not registered in the run, so it now stays on regardless of `tools.loopDetection.enabled` and still accepts `tools.loopDetection.unknownToolThreshold` as a per-run override (default 10). (#67401) Thanks @xantorres.
|
||||
- TUI/streaming: add a client-side streaming watchdog to `tui-event-handlers` so the `streaming · Xm Ys` activity indicator resets to `idle` after 30s of delta silence on the active run. Guards against lost or late `state: "final"` chat events (WS reconnects, gateway restarts, etc.) leaving the TUI stuck on `streaming` indefinitely; a new system log line surfaces the reset so users know to send a new message to resync. The window is configurable via the new `streamingWatchdogMs` context option (set to `0` to disable), and the handler now exposes a `dispose()` that clears the pending timer on shutdown. (#67401) Thanks @xantorres.
|
||||
- Extensions/lmstudio: add exponential backoff to the inference-preload wrapper so an LM Studio model-load failure (for example the built-in memory guardrail rejecting a load because the swap is saturated) no longer produces a WARN line every ~2s for every chat request. The wrapper now records consecutive preload failures per `(baseUrl, modelKey, contextLength)` tuple with a 5s → 10s → 20s → … → 5min cooldown and skips the preload step entirely while a cooldown is active, letting chat requests proceed directly to the stream (the model is often already loaded via the LM Studio UI). The combined `preload failed` log line now reports consecutive-failure count and remaining cooldown so operators can act on the real issue instead of drowning in repeated warnings. (#67401) Thanks @xantorres.
|
||||
- TUI/streaming: add a client-side streaming watchdog to `tui-event-handlers` so the `streaming · Xm Ys` activity indicator resets to `idle` after 30s of delta silence on the active run. Guards against lost or late `state: "final"` chat events (WS reconnects, gateway restarts, etc.) leaving the TUI stuck on `streaming` indefinitely; a new system log line surfaces the reset so users know to send a new message to resync. The window is configurable via the new `streamingWatchdogMs` context option (set to `0` to disable), and the handler now exposes a `dispose()` that clears the pending timer on shutdown. (#67401) Thanks @xantorres.
|
||||
- Extensions/lmstudio: add exponential backoff to the inference-preload wrapper so an LM Studio model-load failure (for example the built-in memory guardrail rejecting a load because the swap is saturated) no longer produces a WARN line every ~2s for every chat request. The wrapper now records consecutive preload failures per `(baseUrl, modelKey, contextLength)` tuple with a 5s → 10s → 20s → … → 5min cooldown and skips the preload step entirely while a cooldown is active, letting chat requests proceed directly to the stream (the model is often already loaded via the LM Studio UI). The combined `preload failed` log line now reports consecutive-failure count and remaining cooldown so operators can act on the real issue instead of drowning in repeated warnings. (#67401) Thanks @xantorres.
|
||||
- Agents/replay: re-run tool/result pairing after strict replay tool-call ID sanitization on outbound requests so Anthropic-compatible providers like MiniMax no longer receive malformed orphan tool-result IDs such as `...toolresult1` during compaction and retry flows. (#67620) Thanks @stainlu.
|
||||
- Gateway/startup: fix spurious SIGUSR1 restart loop on Linux/systemd when plugin auto-enable is the only startup config write; the config hash guard was not captured for that write path, causing chokidar to treat each boot write as an external change and trigger a reload → restart cycle that corrupts manifest.db after repeated cycles. Fixes #67436. (#67557) thanks @openperf
|
||||
- Gateway/startup: fix spurious SIGUSR1 restart loop on Linux/systemd when plugin auto-enable is the only startup config write; the config hash guard was not captured for that write path, causing chokidar to treat each boot write as an external change and trigger a reload → restart cycle that corrupts manifest.db after repeated cycles. Fixes #67436. (#67557) thanks @openperf
|
||||
- Codex/harness: auto-enable the Codex plugin when `codex` is selected as an embedded agent harness runtime, including forced default, per-agent, and `OPENCLAW_AGENT_RUNTIME` paths. (#67474) Thanks @duqaXxX.
|
||||
- OpenAI Codex/CLI: keep resumed `codex exec resume` runs on the safe non-interactive path without reintroducing the removed dangerous bypass flag by passing the supported `--skip-git-repo-check` resume arg plus Codex's native `sandbox_mode="workspace-write"` config override. (#67666) Thanks @plgonzalezrx8.
|
||||
- Codex/app-server: parse Desktop-originated app-server user agents such as `Codex Desktop/0.118.0`, keeping the version gate working when the Codex CLI inherits a multi-word originator. (#64666) Thanks @cyrusaf.
|
||||
@@ -4656,9 +5055,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/failover: classify OpenAI-compatible `finish_reason: network_error` stream failures as timeout so model fallback retries continue instead of stopping with an unknown failover reason. (#61784) thanks @lawrence3699.
|
||||
- Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa.
|
||||
- Slack/native commands: fix option menus for slash commands such as `/verbose` when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared `openclaw_cmdarg*` listener. Thanks @Wangmerlyn.
|
||||
- Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing `encryptKey` and blank callback tokens — refuse to start the webhook transport without an `encryptKey`, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.
|
||||
- Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing `encryptKey` and blank callback tokens — refuse to start the webhook transport without an `encryptKey`, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.
|
||||
- Agents/workspace files: route `agents.files.get`, `agents.files.set`, and workspace listing through the shared `fs-safe` helpers (`openFileWithinRoot`/`readFileWithinRoot`/`writeFileWithinRoot`), reject symlink aliases for allowlisted agent files, and have `fs-safe` resolve opened-file real paths from the file descriptor before falling back to path-based `realpath` so a symlink swap between `open` and `realpath` can no longer redirect the validated path off the intended inode. (#66636) Thanks @eleqtrizit.
|
||||
- Gateway/MCP loopback: switch the `/mcp` bearer comparison from plain `!==` to constant-time `safeEqualSecret` (matching the convention every other auth surface in the codebase uses), and reject non-loopback browser-origin requests via `checkBrowserOrigin` before the auth gate runs. Loopback origins (`127.0.0.1:*`, `localhost:*`, same-origin) still go through, including the `localhost`↔`127.0.0.1` host mismatch that browsers flag as `Sec-Fetch-Site: cross-site`. (#66665) Thanks @eleqtrizit.
|
||||
- Gateway/MCP loopback: switch the `/mcp` bearer comparison from plain `!==` to constant-time `safeEqualSecret` (matching the convention every other auth surface in the codebase uses), and reject non-loopback browser-origin requests via `checkBrowserOrigin` before the auth gate runs. Loopback origins (`127.0.0.1:*`, `localhost:*`, same-origin) still go through, including the `localhost`↔`127.0.0.1` host mismatch that browsers flag as `Sec-Fetch-Site: cross-site`. (#66665) Thanks @eleqtrizit.
|
||||
- Auto-reply/billing: classify pure billing cooldown fallback summaries from structured fallback reasons so users see billing guidance instead of the generic failure reply. (#66363) Thanks @Rohan5commit.
|
||||
- Agents/fallback: preserve the original prompt body on model fallback retries with session history so the retrying model keeps the active task instead of only seeing a generic continue message. (#66029) Thanks @WuKongAI-CMU.
|
||||
- Reply/secrets: resolve active reply channel/account SecretRefs before reply-run message-action discovery so channel token SecretRefs (for example Discord) do not degrade into discovery-time unresolved-secret failures. (#66796) Thanks @joshavant.
|
||||
@@ -4724,7 +5123,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser: keep loopback CDP readiness checks reachable under strict SSRF defaults so OpenClaw can reconnect to locally started managed Chrome. (#66354) Thanks @hxy91819.
|
||||
- Agents/context engine: compact engine-owned sessions from the first tool-loop delta and preserve ingest fallback when `afterTurn` is absent, so long-running tool loops can stay bounded without dropping engine state. (#63555) Thanks @Bikkies.
|
||||
- OpenAI Codex/auth: keep malformed Codex CLI auth-file diagnostics on the debug logger instead of stdout so interactive command output stays clean while auth read failures remain traceable. (#66451) Thanks @SimbaKingjoe.
|
||||
- Discord/native commands: return the real status card for native `/status` interactions instead of falling through to the synthetic `✅ Done.` ack when the generic dispatcher produces no visible reply. (#54629) Thanks @tkozzer and @vincentkoc.
|
||||
- Discord/native commands: return the real status card for native `/status` interactions instead of falling through to the synthetic `✅ Done.` ack when the generic dispatcher produces no visible reply. (#54629) Thanks @tkozzer and @vincentkoc.
|
||||
- Hooks/Ollama: let LLM-backed session-memory slug generation honor an explicit `agents.defaults.timeoutSeconds` override instead of always aborting after 15 seconds, so slow local Ollama runs stop silently dropping back to generic filenames. (#66237) Thanks @dmak and @vincentkoc.
|
||||
- Media/transcription: remap `.aac` filenames to `.m4a` for OpenAI-compatible audio uploads so AAC voice notes stop failing MIME-sensitive transcription endpoints. (#66446) Thanks @ben-z.
|
||||
- WhatsApp/Baileys media upload: keep encrypted upload POSTs streaming while still guarding generic-agent dispatcher wiring, so large outbound media sends avoid full-buffer RSS spikes and OOM regressions. (#65966) Thanks @frankekn.
|
||||
@@ -5176,7 +5575,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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).
|
||||
- Providers/Ollama: stop warning that Ollama could not be reached when discovery only sees empty default local stubs, while still keeping real explicit Ollama overrides loud when the endpoint is unreachable.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -5374,7 +5773,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Live model switching: only treat explicit user-driven model changes as pending live switches, so fallback rotation, heartbeat overrides, and compaction no longer trip `LiveSessionModelSwitchError` before making an API call. (#60266) Thanks @kiranvk-2011.
|
||||
- Exec approvals: reuse durable exact-command `allow-always` approvals in allowlist mode so identical reruns stop prompting, and tighten Windows interpreter/path approval handling so wrapper and malformed-path cases fail closed more consistently. (#59880, #59780, #58040, #59182) Thanks @luoyanglang, @SnowSky1, and @pgondhi987.
|
||||
- Node exec approvals: keep node-host `system.run` approvals bound to the prepared execution plan across async forwarding, so mutable script operands still get approval-time binding and drift revalidation instead of dropping back to unbound execution.
|
||||
- Agents/exec approvals: let `exec-approvals.json` agent security override stricter gateway tool defaults so approved subagents can use `security: “full”` without falling back to allowlist enforcement again. (#60310) Thanks @lml2468.
|
||||
- Agents/exec approvals: let `exec-approvals.json` agent security override stricter gateway tool defaults so approved subagents can use `security: “fullâ€` without falling back to allowlist enforcement again. (#60310) Thanks @lml2468.
|
||||
- Agents/exec: restore `host=node` routing for node-pinned and `host=auto` sessions, while still blocking sandboxed `auto` sessions from jumping to gateway. (#60788) Thanks @openperf.
|
||||
- Exec/heartbeat: use the canonical `exec-event` wake reason for `notifyOnExit` so background exec completions still trigger follow-up turns when `HEARTBEAT.md` is empty or comments-only. (#41479) Thanks @rstar327.
|
||||
- Heartbeat: skip wake delivery when the target session lane is already busy so the pending event is retried instead of getting drained too early. (#40526) Thanks @lucky7323.
|
||||
@@ -5390,11 +5789,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/OpenAI: tune the OpenAI prompt overlay for live-chat cadence so GPT replies stay shorter, more human, and less wall-of-text by default. Thanks @vincentkoc.
|
||||
- Providers/compat: stop forcing OpenAI-only defaults on proxy and custom OpenAI-compatible routes, preserve native vendor-specific reasoning/tool/streaming behavior across Anthropic-compatible, Moonshot, Mistral, ModelStudio, OpenRouter, xAI, and Z.ai endpoints, and route GitHub Copilot Claude models through Anthropic Messages instead of OpenAI Responses. Thanks @vincentkoc.
|
||||
- Providers/GitHub Copilot: send IDE identity headers on runtime model requests and GitHub token exchange so IDE-authenticated Copilot runs stop failing with missing `Editor-Version`. (#60641) Thanks @VACInc and @vincentkoc.
|
||||
- Providers/OpenRouter failover: classify `403 “Key limit exceeded”` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.
|
||||
- Providers/OpenRouter failover: classify `403 “Key limit exceededâ€` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.
|
||||
- Providers/Anthropic: keep `claude-cli/*` auth on live Claude CLI credentials at runtime, avoid persisting stale bearer-token profiles, and suppress macOS Keychain prompts during non-interactive Claude CLI setup. (#61234) Thanks @darkamenosa.
|
||||
- Providers/Anthropic: when Claude CLI auth becomes the default, write a real `claude-cli` auth profile so local and gateway agent runs can use Claude CLI immediately without missing-API-key failures. Thanks @vincentkoc.
|
||||
- Memory/dreaming: make Dreams config reads and writes respect the selected memory slot plugin (including `doctor.memory.status` and Control UI fallback state) instead of always targeting `memory-core`. (#62275) Thanks @SnowSky1.
|
||||
- Providers/Anthropic Vertex: honor `cacheRetention: “long”` with the real 1-hour prompt-cache TTL on Vertex AI endpoints, and default `anthropic-vertex` cache retention like direct Anthropic. (#60888) Thanks @affsantos.
|
||||
- Providers/Anthropic Vertex: honor `cacheRetention: “longâ€` with the real 1-hour prompt-cache TTL on Vertex AI endpoints, and default `anthropic-vertex` cache retention like direct Anthropic. (#60888) Thanks @affsantos.
|
||||
- Agents/Anthropic: preserve native `toolu_*` replay ids on direct Anthropic and Anthropic Vertex paths so cache-sensitive history stops rewriting known-valid Anthropic tool-use ids. (#52612) Thanks @vincentkoc.
|
||||
- Providers/Google: add model-level `cacheRetention` support for direct Gemini system prompts by creating, reusing, and refreshing `cachedContents` automatically on Google AI Studio runs. (#51372) Thanks @rafaelmariano-glitch.
|
||||
- Google Gemini CLI auth: detect bundled npm installs by scanning packaged bundle files for the Gemini OAuth client config, so `npm install -g @google/gemini-cli` layouts work again. (#60486) Thanks @wzfmini01.
|
||||
@@ -5408,7 +5807,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Amazon Bedrock/aws-sdk auth: stop injecting the fake `AWS_PROFILE` apiKey marker when no AWS auth env vars exist, so instance-role and other default-chain setups keep working without poisoning provider config. (#61194) Thanks @wirjo.
|
||||
- Agents/Kimi tool-call repair: preserve tool arguments that were already present on streamed tool calls when later malformed deltas fail reevaluation, while still dropping stale repair-only state before `toolcall_end`. Thanks @vincentkoc.
|
||||
- Plugins/Kimi Coding: parse tagged tool calls and keep Anthropic-native tool payloads so Kimi coding endpoints execute tools instead of echoing raw markup. (#60051, #60391) Thanks @obviyus and @Eric-Guo.
|
||||
- Media understanding: auto-register image-capable config providers for vision routing, so custom GLM-style provider ids with image models stop failing with “no media-understanding provider registered”. (#51418) Thanks @xydt-610.
|
||||
- Media understanding: auto-register image-capable config providers for vision routing, so custom GLM-style provider ids with image models stop failing with “no media-understanding provider registeredâ€. (#51418) Thanks @xydt-610.
|
||||
- Plugins/media understanding: enable bundled Groq and Deepgram providers by default so configured transcription models work without extra plugin activation config. (#59982) Thanks @yxjsxy.
|
||||
- MiniMax/pricing: keep bundled MiniMax highspeed pricing distinct in provider catalogs and preserve the lower M2.5 cache-read pricing when onboarding older MiniMax models. (#54214) Thanks @octo-patch.
|
||||
- MiniMax: advertise image input on bundled `MiniMax-M2.7` and `MiniMax-M2.7-highspeed` model definitions so image-capable flows can route through the M2.7 family correctly. (#54843) Thanks @MerlinMiao88888888.
|
||||
@@ -5582,7 +5981,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Matrix/plugin: emit spec-compliant `m.mentions` metadata across text sends, media captions, edits, poll fallback text, and action-driven edits so Matrix mentions notify reliably in clients like Element. (#59323) Thanks @gumadeiras.
|
||||
- Diffs: add plugin-owned `viewerBaseUrl` so viewer links can use a stable proxy/public origin without passing `baseUrl` on every tool call. (#59341) Related #59227. Thanks @gumadeiras.
|
||||
- Agents/compaction: resolve `agents.defaults.compaction.model` consistently for manual `/compact` and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg.
|
||||
- Agents/compaction: add `agents.defaults.compaction.notifyUser` so the `🧹 Compacting context...` start notice is opt-in instead of always being shown. (#54251) Thanks @oguricap0327.
|
||||
- Agents/compaction: add `agents.defaults.compaction.notifyUser` so the `🧹 Compacting context...` start notice is opt-in instead of always being shown. (#54251) Thanks @oguricap0327.
|
||||
- WhatsApp/reactions: add `reactionLevel` guidance for agent reactions. Thanks @mcaxtr.
|
||||
- Exec approvals/channels: auto-enable DM-first native chat approvals when supported channels can infer approvers from existing owner config, while keeping channel fanout explicit and clarifying forwarding versus native approval client config.
|
||||
- Android/assistant: auto-send Google Assistant App Actions prompts once chat is healthy and idle, while keeping bare assistant launches as open-only. (#59721) Thanks @obviyus.
|
||||
@@ -5642,7 +6041,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Exec/env: block additional host environment override pivots for package roots, language runtimes, compiler include paths, and credential/config locations so request-scoped exec cannot redirect trusted toolchains or config lookups. (#59233) Thanks @drobison00.
|
||||
- Dotenv/workspace overrides: block workspace `.env` files from overriding `OPENCLAW_PINNED_PYTHON` and `OPENCLAW_PINNED_WRITE_PYTHON` so trusted helper interpreters cannot be redirected by repo-local env injection. (#58473) Thanks @eleqtrizit.
|
||||
- Plugins/install: accept JSON5 syntax in `openclaw.plugin.json` and bundle `plugin.json` manifests during install/validation, so third-party plugins with trailing commas, comments, or unquoted keys no longer fail to install. (#59084) Thanks @singleGanghood.
|
||||
- Telegram/exec approvals: rewrite shared `/approve … allow-always` callback payloads to `/approve … always` before Telegram button rendering so plugin approval IDs still fit Telegram's `callback_data` limit and keep the Allow Always action visible. (#59217) Thanks @jameslcowan.
|
||||
- Telegram/exec approvals: rewrite shared `/approve … allow-always` callback payloads to `/approve … always` before Telegram button rendering so plugin approval IDs still fit Telegram's `callback_data` limit and keep the Allow Always action visible. (#59217) Thanks @jameslcowan.
|
||||
- Cron/exec timeouts: surface timed-out `exec` and `bash` failures in isolated cron runs even when `verbose: off`, including custom session-target cron jobs, so scheduled runs stop failing silently. (#58247) Thanks @skainguyen1412.
|
||||
- Telegram/exec approvals: fall back to the origin session key for async approval followups and keep resume-failure status delivery sanitized so Telegram followups still land without leaking raw exec metadata. (#59351) Thanks @seonang.
|
||||
- Node-host/exec approvals: bind `pnpm dlx` invocations through the approval planner's mutable-script path so the effective runtime command is resolved for approval instead of being left unbound. (#58374) Thanks @jacobtomlinson.
|
||||
@@ -5735,7 +6134,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Pi/Codex: add native Codex web search support for embedded Pi runs, including config/docs/wizard coverage and managed-tool suppression when native Codex search is active. (#46579) Thanks @Evizero.
|
||||
- Slack/exec approvals: add native Slack approval routing and approver authorization so exec approval prompts can stay in Slack instead of falling back to the Web UI or terminal. Thanks @vincentkoc.
|
||||
- TTS: Add structured provider diagnostics and fallback attempt analytics. (#57954) Thanks @joshavant.
|
||||
- WhatsApp/reactions: agents can now react with emoji on incoming WhatsApp messages, enabling more natural conversational interactions like acknowledging a photo with ❤️ instead of typing a reply. Thanks @mcaxtr.
|
||||
- WhatsApp/reactions: agents can now react with emoji on incoming WhatsApp messages, enabling more natural conversational interactions like acknowledging a photo with â¤ï¸ instead of typing a reply. Thanks @mcaxtr.
|
||||
- Agents/BTW: force `/btw` side questions to disable provider reasoning so Anthropic adaptive-thinking sessions stop failing with `No BTW response generated`. Fixes #55376. Thanks @Catteres and @vincentkoc.
|
||||
- CLI/onboarding: reset the remote gateway URL prompt to the safe loopback default after declining a discovered endpoint, so onboarding does not keep a previously rejected remote URL. (#57828).
|
||||
- Agents/exec defaults: honor per-agent `tools.exec` defaults when no inline directive or session override is present, so configured exec host, security, ask, and node settings actually apply. (#57689).
|
||||
@@ -8193,7 +8592,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle.
|
||||
- Slack/Threading: respect `replyToMode` when Slack auto-populates top-level `thread_ts`, and ignore inline `replyToId` directive tags when `replyToMode` is `off` so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan.
|
||||
- Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan.
|
||||
- Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open`, and replace `files.uploadV2` with Slack's external 3-step upload flow (`files.getUploadURLExternal` → presigned upload POST → `files.completeUploadExternal`) to avoid `missing_scope`/`invalid_arguments` upload failures in DM and threaded media replies (#57018). Thanks @hydro13.
|
||||
- Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open`, and replace `files.uploadV2` with Slack's external 3-step upload flow (`files.getUploadURLExternal` → presigned upload POST → `files.completeUploadExternal`) to avoid `missing_scope`/`invalid_arguments` upload failures in DM and threaded media replies (#57018). Thanks @hydro13.
|
||||
- Webchat/Chat: apply assistant `final` payload messages directly to chat state so sent turns render without waiting for a full history refresh cycle. (#14928) Thanks @BradGroux.
|
||||
- Webchat/Chat: for out-of-band final events (for example tool-call side runs), append provided final assistant payloads directly instead of forcing a transient history reset. (#11139) Thanks @AkshayNavle.
|
||||
- Webchat/Performance: reload `chat.history` after final events only when the final payload lacks a renderable assistant message, avoiding expensive full-history refreshes on normal turns. (#20588) Thanks @amzzzzzzz.
|
||||
@@ -8347,7 +8746,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry.
|
||||
- Agents/Mistral: sanitize tool-call IDs in the embedded agent loop and generate strict provider-safe pending tool-call IDs, preventing Mistral strict9 `HTTP 400` failures on tool continuations. (#23698) Thanks @echoVic.
|
||||
- Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson.
|
||||
- Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) only for direct/private tool-only completions with no final assistant text, while suppressing synthetic acknowledgements for channel/group sessions and runs that already delivered output via messaging tools. (#22834) Thanks @Oldshue.
|
||||
- Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) only for direct/private tool-only completions with no final assistant text, while suppressing synthetic acknowledgements for channel/group sessions and runs that already delivered output via messaging tools. (#22834) Thanks @Oldshue.
|
||||
- Agents/Subagents: honor `tools.subagents.tools.alsoAllow` and explicit subagent `allow` entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example `sessions_send`) are no longer blocked unless re-denied in `tools.subagents.tools.deny`. (#23359) Thanks @goren-beehero.
|
||||
- Agents/Subagents: make announce call timeouts configurable via `agents.defaults.subagents.announceTimeoutMs` and restore a 60s default to prevent false timeout failures on slower announce paths. (#22719) Thanks @Valadon.
|
||||
- Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI "Connection error" runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize.
|
||||
@@ -9191,7 +9590,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra.
|
||||
- Sessions: preserve `verboseLevel`, `thinkingLevel`/`reasoningLevel`, and `ttsAuto` overrides across `/new` and `/reset` session resets. (#10787) Thanks @mcaxtr.
|
||||
- Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini.
|
||||
- Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini.
|
||||
- Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini.
|
||||
- Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.
|
||||
- Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery.
|
||||
- Gateway: prevent `undefined`/missing token in auth config. (#13809) Thanks @asklee-klawd.
|
||||
@@ -10297,7 +10696,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
|
||||
### Changes
|
||||
|
||||
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) - thanks @thewilloftheshadow.
|
||||
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) - thanks @thewilloftheshadow.
|
||||
- CLI: set process titles to `openclaw-<command>` for clearer process listings.
|
||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
|
||||
@@ -10323,7 +10722,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `openclaw models status`, and update docs.
|
||||
- CLI: add `--json` output for `openclaw daemon` lifecycle/install commands.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer "attach a tab" errors (#57018). Thanks @hydro13.
|
||||
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) - thanks @cpojer.
|
||||
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
|
||||
@@ -10360,7 +10759,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904).
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
|
||||
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
|
||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) - thanks @azade-c.
|
||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) - thanks @ThomsenDrake.
|
||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) - thanks @longmaba.
|
||||
@@ -10696,7 +11095,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- WhatsApp: fix group reactions by preserving message IDs and sender JIDs in history; normalize participant phone numbers to JIDs in outbound reactions. (#640) - thanks @mcinteerj.
|
||||
- WhatsApp: expose group participant IDs to the model so reactions can target the right sender.
|
||||
- Cron: `wakeMode: "now"` waits for heartbeat completion (and retries when the main lane is busy). (#666) - thanks @roshanasingh4.
|
||||
- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 "required following item") and replay reasoning items in Responses/Codex Responses history for tool-call-only turns (#57018). Thanks @hydro13.
|
||||
- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 "required following item") and replay reasoning items in Responses/Codex Responses history for tool-call-only turns (#57018). Thanks @hydro13.
|
||||
- Sandbox: add `openclaw sandbox explain` (effective policy inspector + fix-it keys); improve "sandbox jail" tool-policy/elevated errors with actionable config key paths; link to docs (#57018). Thanks @hydro13.
|
||||
- Hooks/Gmail: keep Tailscale serve path at `/` while preserving the public path. (#668) - thanks @antons.
|
||||
- Hooks/Gmail: allow Tailscale target URLs to preserve internal serve paths.
|
||||
|
||||
@@ -293,4 +293,4 @@ USER node
|
||||
HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
ENTRYPOINT ["tini", "-s", "--"]
|
||||
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
|
||||
CMD ["node", "openclaw.mjs", "gateway"]
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026051600
|
||||
versionName = "2026.5.16"
|
||||
versionCode = 2026051700
|
||||
versionName = "2026.5.17"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -12,6 +12,7 @@ import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeResult
|
||||
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
|
||||
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.app.node.A2UIHandler
|
||||
import ai.openclaw.app.node.CalendarHandler
|
||||
@@ -248,6 +249,7 @@ class NodeRuntime(
|
||||
val endpoint: GatewayEndpoint,
|
||||
val fingerprintSha256: String,
|
||||
val auth: GatewayConnectAuth,
|
||||
val previousFingerprintSha256: String? = null,
|
||||
)
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
@@ -260,6 +262,7 @@ class NodeRuntime(
|
||||
|
||||
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
|
||||
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
||||
private val connectAttemptSeq = AtomicLong(0)
|
||||
|
||||
private fun resolveNodeMainSessionKey(agentId: String? = gatewayDefaultAgentId): String {
|
||||
val deviceId = identityStore.loadOrCreate().deviceId
|
||||
@@ -1112,23 +1115,58 @@ class NodeRuntime(
|
||||
endpoint: GatewayEndpoint,
|
||||
auth: GatewayConnectAuth,
|
||||
) {
|
||||
val connectAttemptId = connectAttemptSeq.incrementAndGet()
|
||||
_pendingGatewayTrust.value = null
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) {
|
||||
// First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect.
|
||||
if (tls?.required == true) {
|
||||
val expectedFingerprint =
|
||||
tls.expectedFingerprint
|
||||
?.let(::normalizeGatewayTlsFingerprint)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
_statusText.value = "Verify gateway TLS fingerprint…"
|
||||
scope.launch {
|
||||
val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port)
|
||||
if (!isCurrentConnectAttempt(connectAttemptId)) return@launch
|
||||
val fp =
|
||||
tlsProbe.fingerprintSha256 ?: run {
|
||||
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
|
||||
if (expectedFingerprint == null) {
|
||||
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
|
||||
} else {
|
||||
connectAfterTlsCheck(endpoint = endpoint, auth = auth, connectAttemptId = connectAttemptId)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
_pendingGatewayTrust.value =
|
||||
GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp, auth = auth)
|
||||
val observedFingerprint =
|
||||
normalizeGatewayTlsFingerprint(fp)
|
||||
.takeIf { it.isNotBlank() }
|
||||
?: fp
|
||||
val previousFingerprint = expectedFingerprint?.takeUnless { it == observedFingerprint }
|
||||
if (expectedFingerprint == null || previousFingerprint != null) {
|
||||
_pendingGatewayTrust.value =
|
||||
GatewayTrustPrompt(
|
||||
endpoint = endpoint,
|
||||
fingerprintSha256 = observedFingerprint,
|
||||
auth = auth,
|
||||
previousFingerprintSha256 = previousFingerprint,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
connectAfterTlsCheck(endpoint = endpoint, auth = auth, connectAttemptId = connectAttemptId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
connectAfterTlsCheck(endpoint = endpoint, auth = auth, connectAttemptId = connectAttemptId)
|
||||
}
|
||||
|
||||
private fun isCurrentConnectAttempt(connectAttemptId: Long): Boolean = connectAttemptSeq.get() == connectAttemptId
|
||||
|
||||
private fun connectAfterTlsCheck(
|
||||
endpoint: GatewayEndpoint,
|
||||
auth: GatewayConnectAuth,
|
||||
connectAttemptId: Long,
|
||||
) {
|
||||
if (!isCurrentConnectAttempt(connectAttemptId)) return
|
||||
connectedEndpoint = endpoint
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
@@ -1221,6 +1259,7 @@ class NodeRuntime(
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
connectAttemptSeq.incrementAndGet()
|
||||
stopActiveVoiceSession()
|
||||
connectedEndpoint = null
|
||||
activeGatewayAuth = null
|
||||
|
||||
@@ -53,7 +53,10 @@ fun buildGatewayTlsConfig(
|
||||
onStore: ((String) -> Unit)? = null,
|
||||
): GatewayTlsConfig? {
|
||||
if (params == null) return null
|
||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
||||
val expected =
|
||||
params.expectedFingerprint
|
||||
?.let(::normalizeGatewayTlsFingerprint)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val defaultTrust = defaultTrustManager()
|
||||
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
@@ -200,7 +203,7 @@ private fun sha256Hex(data: ByteArray): String {
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
fun normalizeGatewayTlsFingerprint(raw: String): String {
|
||||
val stripped =
|
||||
raw
|
||||
.trim()
|
||||
|
||||
@@ -100,8 +100,14 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
containerColor = mobileCardSurface,
|
||||
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
|
||||
text = {
|
||||
val message =
|
||||
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}"
|
||||
} else {
|
||||
"The gateway TLS certificate changed. Only continue if you expected this.\n\nOld SHA-256 fingerprint:\n${prompt.previousFingerprintSha256}\n\nNew SHA-256 fingerprint:\n${prompt.fingerprintSha256}"
|
||||
}
|
||||
Text(
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
|
||||
message,
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
)
|
||||
|
||||
@@ -497,8 +497,14 @@ fun OnboardingFlow(
|
||||
containerColor = onboardingSurface,
|
||||
title = { Text("Trust this gateway?", style = onboardingHeadlineStyle, color = onboardingText) },
|
||||
text = {
|
||||
val message =
|
||||
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}"
|
||||
} else {
|
||||
"The gateway TLS certificate changed. Only continue if you expected this.\n\nOld SHA-256 fingerprint:\n${prompt.previousFingerprintSha256}\n\nNew SHA-256 fingerprint:\n${prompt.fingerprintSha256}"
|
||||
}
|
||||
Text(
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
|
||||
message,
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingText,
|
||||
)
|
||||
|
||||
@@ -30,13 +30,16 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -499,19 +502,12 @@ private fun AnnotatedString.Builder.appendInlineNode(
|
||||
}
|
||||
}
|
||||
is Link -> {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
appendInlineNode(
|
||||
current.firstChild,
|
||||
inlineCodeBg = inlineCodeBg,
|
||||
inlineCodeColor = inlineCodeColor,
|
||||
linkColor = linkColor,
|
||||
)
|
||||
}
|
||||
appendLinkNode(
|
||||
link = current,
|
||||
inlineCodeBg = inlineCodeBg,
|
||||
inlineCodeColor = inlineCodeColor,
|
||||
linkColor = linkColor,
|
||||
)
|
||||
}
|
||||
is MarkdownImage -> {
|
||||
val alt = buildPlainText(current.firstChild)
|
||||
@@ -527,13 +523,69 @@ private fun AnnotatedString.Builder.appendInlineNode(
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
appendInlineNode(
|
||||
current.firstChild,
|
||||
inlineCodeBg = inlineCodeBg,
|
||||
inlineCodeColor = inlineCodeColor,
|
||||
linkColor = linkColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
current = current.next
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendLinkNode(
|
||||
link: Link,
|
||||
inlineCodeBg: Color,
|
||||
inlineCodeColor: Color,
|
||||
linkColor: Color,
|
||||
) {
|
||||
val destination = link.destination?.trim().orEmpty()
|
||||
val linkStyle =
|
||||
SpanStyle(
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
if (destination.isEmpty()) {
|
||||
withStyle(linkStyle) {
|
||||
appendInlineNode(
|
||||
link.firstChild,
|
||||
inlineCodeBg = inlineCodeBg,
|
||||
inlineCodeColor = inlineCodeColor,
|
||||
linkColor = linkColor,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
withLink(LinkAnnotation.Url(url = destination, styles = TextLinkStyles(style = linkStyle))) {
|
||||
appendInlineNode(
|
||||
link.firstChild,
|
||||
inlineCodeBg = inlineCodeBg,
|
||||
inlineCodeColor = inlineCodeColor,
|
||||
linkColor = linkColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildChatInlineMarkdown(
|
||||
text: String,
|
||||
linkColor: Color = Color.Blue,
|
||||
): AnnotatedString {
|
||||
val document = markdownParser.parse(text) as Document
|
||||
val paragraph = document.firstChild as? Paragraph ?: return AnnotatedString("")
|
||||
return buildInlineMarkdown(
|
||||
paragraph.firstChild,
|
||||
InlineStyles(
|
||||
inlineCodeBg = Color.Transparent,
|
||||
inlineCodeColor = Color.Unspecified,
|
||||
linkColor = linkColor,
|
||||
baseCallout = TextStyle.Default,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildPlainText(start: Node?): String {
|
||||
val sb = StringBuilder()
|
||||
var node = start
|
||||
|
||||
@@ -10,6 +10,7 @@ import ai.openclaw.app.node.InvokeDispatcher
|
||||
import ai.openclaw.app.protocol.OpenClawTalkCommand
|
||||
import ai.openclaw.app.voice.TalkModeManager
|
||||
import android.Manifest
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -153,7 +154,7 @@ class GatewayBootstrapAuthTest {
|
||||
NodeRuntime(
|
||||
app,
|
||||
prefs,
|
||||
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp-1") },
|
||||
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp:1") },
|
||||
)
|
||||
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
|
||||
val explicitAuth =
|
||||
@@ -169,11 +170,93 @@ class GatewayBootstrapAuthTest {
|
||||
|
||||
runtime.acceptGatewayTrustPrompt()
|
||||
|
||||
assertEquals("fp-1", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
|
||||
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "nodeSession"))
|
||||
assertEquals("f1", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
|
||||
assertEquals("setup-bootstrap-token", waitForDesiredBootstrapToken(runtime, "nodeSession"))
|
||||
assertNull(desiredBootstrapToken(runtime, "operatorSession"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connect_promptsBeforeReplacingChangedTlsFingerprint() =
|
||||
runBlocking {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
android.content.Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
|
||||
prefs.saveGatewayTlsFingerprint(endpoint.stableId, "sha256:aa:aa:aa:aa")
|
||||
val runtime =
|
||||
NodeRuntime(
|
||||
app,
|
||||
prefs,
|
||||
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "sha256:bb:bb:bb:bb") },
|
||||
)
|
||||
|
||||
runtime.connect(
|
||||
endpoint,
|
||||
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null),
|
||||
)
|
||||
|
||||
val prompt = waitForGatewayTrustPrompt(runtime)
|
||||
assertEquals("aaaaaaaa", prompt.previousFingerprintSha256)
|
||||
assertEquals("bbbbbbbb", prompt.fingerprintSha256)
|
||||
assertEquals("sha256:aa:aa:aa:aa", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
|
||||
|
||||
runtime.declineGatewayTrustPrompt()
|
||||
|
||||
assertEquals("sha256:aa:aa:aa:aa", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
|
||||
|
||||
runtime.connect(
|
||||
endpoint,
|
||||
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null),
|
||||
)
|
||||
waitForGatewayTrustPrompt(runtime)
|
||||
runtime.acceptGatewayTrustPrompt()
|
||||
|
||||
assertEquals("bbbbbbbb", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connect_ignoresStaleTlsProbeAfterDisconnect() =
|
||||
runBlocking {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
android.content.Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
|
||||
prefs.saveGatewayTlsFingerprint(endpoint.stableId, "aaaaaaaa")
|
||||
val probeStarted = CompletableDeferred<Unit>()
|
||||
val probeResult = CompletableDeferred<GatewayTlsProbeResult>()
|
||||
val runtime =
|
||||
NodeRuntime(
|
||||
app,
|
||||
prefs,
|
||||
tlsFingerprintProbe = { _, _ ->
|
||||
probeStarted.complete(Unit)
|
||||
probeResult.await()
|
||||
},
|
||||
)
|
||||
|
||||
runtime.connect(
|
||||
endpoint,
|
||||
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null),
|
||||
)
|
||||
probeStarted.await()
|
||||
|
||||
runtime.disconnect()
|
||||
probeResult.complete(GatewayTlsProbeResult(fingerprintSha256 = "aaaaaaaa"))
|
||||
Thread.sleep(100)
|
||||
|
||||
assertNull(runtime.pendingGatewayTrust.value)
|
||||
assertNull(desiredBootstrapToken(runtime, "nodeSession"))
|
||||
assertEquals("aaaaaaaa", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
@@ -269,6 +352,21 @@ class GatewayBootstrapAuthTest {
|
||||
return readField(desired, "bootstrapToken")
|
||||
}
|
||||
|
||||
private fun waitForDesiredBootstrapToken(
|
||||
runtime: NodeRuntime,
|
||||
sessionFieldName: String,
|
||||
): String {
|
||||
var lastObserved: String? = null
|
||||
repeat(50) {
|
||||
desiredBootstrapToken(runtime, sessionFieldName)?.let { token ->
|
||||
lastObserved = token
|
||||
return token
|
||||
}
|
||||
Thread.sleep(10)
|
||||
}
|
||||
error("Expected desired bootstrap token for $sessionFieldName; last observed=$lastObserved")
|
||||
}
|
||||
|
||||
private fun <T> readField(
|
||||
target: Any,
|
||||
name: String,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatMarkdownTest {
|
||||
@Test
|
||||
fun bareUrlsCarryClickableUrlAnnotations() {
|
||||
val url = "https://www.amazon.it/GAZEBO-CANOPY-ACCIAIO-BIANCO-IMPERMEABILE/dp/B01G5R9FCK"
|
||||
|
||||
val annotated = buildChatInlineMarkdown("Open $url")
|
||||
|
||||
assertEquals("Open $url", annotated.text)
|
||||
val links = annotated.getLinkAnnotations(0, annotated.length)
|
||||
assertEquals(1, links.size)
|
||||
assertEquals(5, links.single().start)
|
||||
assertEquals(5 + url.length, links.single().end)
|
||||
assertEquals(url, (links.single().item as LinkAnnotation.Url).url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markdownLinksUseLabelTextAndDestinationUrl() {
|
||||
val annotated = buildChatInlineMarkdown("Open [docs](https://docs.openclaw.ai/help/testing) now")
|
||||
|
||||
assertEquals("Open docs now", annotated.text)
|
||||
val links = annotated.getLinkAnnotations(0, annotated.length)
|
||||
assertEquals(1, links.size)
|
||||
assertEquals(5, links.single().start)
|
||||
assertEquals(9, links.single().end)
|
||||
assertEquals("https://docs.openclaw.ai/help/testing", (links.single().item as LinkAnnotation.Url).url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainTextDoesNotAddLinkAnnotations() {
|
||||
val annotated = buildChatInlineMarkdown("No link here")
|
||||
|
||||
assertEquals("No link here", annotated.text)
|
||||
assertTrue(annotated.getLinkAnnotations(0, annotated.length).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.5.17 - 2026-05-17
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.12 - 2026-05-12
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.5.16
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.16
|
||||
OPENCLAW_IOS_VERSION = 2026.5.17
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.17
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -1 +1 @@
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.5.16"
|
||||
"version": "2026.5.17"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -81,6 +81,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
"OpenClawIPC",
|
||||
"OpenClaw",
|
||||
"OpenClawMacCLI",
|
||||
"OpenClawDiscovery",
|
||||
.product(name: "OpenClawProtocol", package: "OpenClawKit"),
|
||||
.product(name: "SwabbleKit", package: "swabble"),
|
||||
|
||||
@@ -318,7 +318,12 @@ final class AppState {
|
||||
self.iconAnimationsEnabled = true
|
||||
UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey)
|
||||
}
|
||||
self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey)
|
||||
if let storedShowDockIcon = UserDefaults.standard.object(forKey: showDockIconKey) as? Bool {
|
||||
self.showDockIcon = storedShowDockIcon
|
||||
} else {
|
||||
self.showDockIcon = true
|
||||
UserDefaults.standard.set(true, forKey: showDockIconKey)
|
||||
}
|
||||
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
|
||||
self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? ""
|
||||
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
|
||||
@@ -358,19 +363,29 @@ final class AppState {
|
||||
}
|
||||
|
||||
let configRoot = OpenClawConfigFile.loadDict()
|
||||
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
||||
let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot)
|
||||
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
|
||||
let configRemoteResolution = GatewayRemoteConfig.resolveTransportResolution(root: configRoot)
|
||||
let configRemoteTransport = configRemoteResolution.transport
|
||||
let configRemoteUrl = configRemoteResolution.directURL?.absoluteString
|
||||
?? GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||
self.remoteTransport = configRemoteTransport
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let configRemote = (configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]
|
||||
let configRemoteTarget = (configRemote?["sshTarget"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
if resolvedConnectionMode == .remote,
|
||||
configRemoteTransport != .direct,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl),
|
||||
!LoopbackHost.isLoopbackHost(host)
|
||||
!configRemoteTarget.isEmpty,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
self.remoteTarget = configRemoteTarget
|
||||
} else if resolvedConnectionMode == .remote,
|
||||
configRemoteTransport != .direct,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl),
|
||||
!LoopbackHost.isLoopbackHost(host)
|
||||
{
|
||||
self.remoteTarget = "\(NSUserName())@\(host)"
|
||||
} else {
|
||||
@@ -380,9 +395,11 @@ final class AppState {
|
||||
self.remoteToken = configRemoteToken.textFieldValue
|
||||
self.remoteTokenDirty = false
|
||||
self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey)?.nonEmpty
|
||||
?? configRemote?["sshIdentity"] as? String
|
||||
?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
|
||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
let execDefaults = ExecApprovalsStore.resolveDefaults()
|
||||
self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask)
|
||||
@@ -517,7 +534,10 @@ final class AppState {
|
||||
}
|
||||
|
||||
case .ssh:
|
||||
changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed
|
||||
changed = Self.updateGatewayString(
|
||||
&remote,
|
||||
key: "transport",
|
||||
value: RemoteTransport.ssh.rawValue) || changed
|
||||
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(draft.remoteTarget)
|
||||
let expectedRemoteHost = CommandResolver.parseSSHTarget(sanitizedTarget)?.host ?? draft.remoteHost
|
||||
@@ -561,7 +581,8 @@ final class AppState {
|
||||
let hasRemoteUrl = !(remoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
|
||||
let remoteResolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
|
||||
let remoteTransport = remoteResolution.transport
|
||||
|
||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||
case "local":
|
||||
@@ -585,7 +606,7 @@ final class AppState {
|
||||
if remoteTransport != self.remoteTransport {
|
||||
self.remoteTransport = remoteTransport
|
||||
}
|
||||
let remoteUrlText = remoteUrl ?? ""
|
||||
let remoteUrlText = remoteResolution.directURL?.absoluteString ?? remoteUrl ?? ""
|
||||
if remoteUrlText != self.remoteUrl {
|
||||
self.remoteUrl = remoteUrlText
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ final class CLIInstallPrompter {
|
||||
case .alertFirstButtonReturn:
|
||||
Task { await self.installCLI() }
|
||||
case .alertThirdButtonReturn:
|
||||
self.openSettings(tab: .general)
|
||||
self.openSettings(tab: .connection)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
enum ConfigSchemaFormMode {
|
||||
case full
|
||||
case channelQuick
|
||||
}
|
||||
|
||||
struct ConfigSchemaForm: View {
|
||||
@Bindable var store: ChannelsStore
|
||||
let schema: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
let mode: ConfigSchemaFormMode
|
||||
|
||||
init(
|
||||
store: ChannelsStore,
|
||||
schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
mode: ConfigSchemaFormMode = .full)
|
||||
{
|
||||
self.store = store
|
||||
self.schema = schema
|
||||
self.path = path
|
||||
self.mode = mode
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
self.renderNode(self.schema, path: self.path)
|
||||
@@ -12,7 +30,7 @@ struct ConfigSchemaForm: View {
|
||||
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
||||
let storedValue = self.store.configValue(at: path)
|
||||
let value = storedValue ?? schema.explicitDefault
|
||||
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
||||
let label = self.fieldLabel(for: schema, path: path)
|
||||
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
|
||||
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
|
||||
|
||||
@@ -62,18 +80,16 @@ struct ConfigSchemaForm: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
let properties = schema.properties
|
||||
let sortedKeys = properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0
|
||||
let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
let sortedKeys = self.visibleObjectKeys(properties: properties, path: path)
|
||||
ForEach(sortedKeys, id: \ .self) { key in
|
||||
if let child = properties[key] {
|
||||
self.renderNode(child, path: path + [.key(key)])
|
||||
}
|
||||
}
|
||||
if schema.allowsAdditionalProperties {
|
||||
if sortedKeys.isEmpty, self.mode == .channelQuick, self.isChannelRoot(path) {
|
||||
self.renderChannelQuickEmptyState()
|
||||
}
|
||||
if self.shouldRenderAdditionalProperties(schema, path: path, value: value) {
|
||||
self.renderAdditionalProperties(schema, path: path, value: value)
|
||||
}
|
||||
})
|
||||
@@ -100,6 +116,116 @@ struct ConfigSchemaForm: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func fieldLabel(for schema: ConfigSchemaNode, path: ConfigPath) -> String? {
|
||||
hintForPath(path, hints: self.store.configUiHints)?.label
|
||||
?? schema.title
|
||||
?? labelForConfigPath(path)
|
||||
}
|
||||
|
||||
private func visibleObjectKeys(
|
||||
properties: [String: ConfigSchemaNode],
|
||||
path: ConfigPath) -> [String]
|
||||
{
|
||||
let sortedKeys = properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0
|
||||
let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
|
||||
guard self.mode == .channelQuick, self.isChannelRoot(path) else {
|
||||
return sortedKeys
|
||||
}
|
||||
|
||||
return sortedKeys.filter { key in
|
||||
guard let child = properties[key] else { return false }
|
||||
return self.shouldRenderChannelQuickField(key: key, schema: child, path: path + [.key(key)])
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRenderChannelQuickField(
|
||||
key: String,
|
||||
schema: ConfigSchemaNode,
|
||||
path: ConfigPath) -> Bool
|
||||
{
|
||||
if hintForPath(path, hints: self.store.configUiHints)?.advanced == true {
|
||||
return false
|
||||
}
|
||||
if Self.channelQuickKeys.contains(key) {
|
||||
return self.isSimpleField(schema)
|
||||
}
|
||||
return self.store.configValue(at: path) != nil && self.isSimpleField(schema)
|
||||
}
|
||||
|
||||
private func isSimpleField(_ schema: ConfigSchemaNode) -> Bool {
|
||||
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
|
||||
let nonNullVariants = variants.filter { !$0.isNullSchema }
|
||||
if !nonNullVariants.isEmpty {
|
||||
return nonNullVariants.allSatisfy(self.isSimpleField)
|
||||
}
|
||||
if let enumValues = schema.enumValues {
|
||||
return !enumValues.isEmpty
|
||||
}
|
||||
switch schema.schemaType {
|
||||
case "boolean", "integer", "number", "string":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRenderAdditionalProperties(
|
||||
_ schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
value: Any?) -> Bool
|
||||
{
|
||||
guard schema.allowsAdditionalProperties else { return false }
|
||||
if self.mode != .channelQuick { return true }
|
||||
guard let dict = value as? [String: Any] else { return false }
|
||||
let reserved = Set(schema.properties.keys)
|
||||
return dict.keys.contains { !reserved.contains($0) }
|
||||
}
|
||||
|
||||
private func isChannelRoot(_ path: ConfigPath) -> Bool {
|
||||
guard path.count == 2 else { return false }
|
||||
guard case .key("channels") = path[0] else { return false }
|
||||
guard case .key = path[1] else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func renderChannelQuickEmptyState() -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("No quick settings for this channel.")
|
||||
.font(.callout.weight(.semibold))
|
||||
Text("Use Config for account, guild, action, and policy details.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private static let channelQuickKeys: Set<String> = [
|
||||
"apiHash",
|
||||
"apiId",
|
||||
"appToken",
|
||||
"baseUrl",
|
||||
"botToken",
|
||||
"configWrites",
|
||||
"deviceName",
|
||||
"dmPolicy",
|
||||
"enabled",
|
||||
"groupPolicy",
|
||||
"historyLimit",
|
||||
"mode",
|
||||
"nativeCommands",
|
||||
"nativeSkillCommands",
|
||||
"phoneNumber",
|
||||
"signingSecret",
|
||||
"token",
|
||||
"url",
|
||||
"username",
|
||||
"webhookUrl",
|
||||
]
|
||||
|
||||
@ViewBuilder
|
||||
private func renderStringField(
|
||||
_ schema: ConfigSchemaNode,
|
||||
@@ -353,7 +479,11 @@ struct ChannelConfigForm: View {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = store.channelConfigSchema(for: channelId) {
|
||||
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
|
||||
ConfigSchemaForm(
|
||||
store: self.store,
|
||||
schema: schema,
|
||||
path: [.key("channels"), .key(self.channelId)],
|
||||
mode: .channelQuick)
|
||||
} else {
|
||||
Text("Schema unavailable for this channel.")
|
||||
.font(.caption)
|
||||
|
||||
@@ -6,7 +6,7 @@ extension ChannelsSettings {
|
||||
_ id: String,
|
||||
as type: T.Type) -> T?
|
||||
{
|
||||
self.store.snapshot?.decodeChannel(id, as: type)
|
||||
self.store.decodedChannel(id, as: type)
|
||||
}
|
||||
|
||||
private func configuredChannelTint(configured: Bool, running: Bool, hasError: Bool, probeOk: Bool?) -> Color {
|
||||
@@ -358,12 +358,16 @@ extension ChannelsSettings {
|
||||
}
|
||||
|
||||
func ensureSelection() {
|
||||
self.ensureSelection(in: self.orderedChannels)
|
||||
}
|
||||
|
||||
func ensureSelection(in orderedChannels: [ChannelItem]) {
|
||||
guard let selected = self.selectedChannel else {
|
||||
self.selectedChannel = self.orderedChannels.first
|
||||
self.selectedChannel = orderedChannels.first
|
||||
return
|
||||
}
|
||||
if !self.orderedChannels.contains(selected) {
|
||||
self.selectedChannel = self.orderedChannels.first
|
||||
if !orderedChannels.contains(selected) {
|
||||
self.selectedChannel = orderedChannels.first
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,34 +2,49 @@ import SwiftUI
|
||||
|
||||
extension ChannelsSettings {
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
self.sidebar
|
||||
let channels = self.orderedChannels
|
||||
return HStack(spacing: 0) {
|
||||
self.sidebar(channels: channels)
|
||||
self.detail
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear {
|
||||
self.store.start()
|
||||
self.ensureSelection()
|
||||
self.updateActiveWork(active: self.isActive)
|
||||
self.ensureSelection(in: channels)
|
||||
}
|
||||
.onChange(of: self.orderedChannels) { _, _ in
|
||||
self.ensureSelection()
|
||||
.onChange(of: self.isActive) { _, active in
|
||||
self.updateActiveWork(active: active)
|
||||
}
|
||||
.onChange(of: channels) { _, newValue in
|
||||
self.ensureSelection(in: newValue)
|
||||
}
|
||||
.onDisappear { self.store.stop() }
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
SettingsSidebarScroll {
|
||||
private func updateActiveWork(active: Bool) {
|
||||
if active {
|
||||
self.store.start()
|
||||
} else {
|
||||
self.store.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebar(channels: [ChannelItem]) -> some View {
|
||||
let enabled = channels.filter { self.channelEnabled($0) }
|
||||
let available = channels.filter { !self.channelEnabled($0) }
|
||||
|
||||
return SettingsSidebarScroll {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
if !self.enabledChannels.isEmpty {
|
||||
if !enabled.isEmpty {
|
||||
self.sidebarSectionHeader("Configured")
|
||||
ForEach(self.enabledChannels) { channel in
|
||||
ForEach(enabled) { channel in
|
||||
self.sidebarRow(channel)
|
||||
}
|
||||
}
|
||||
|
||||
if !self.availableChannels.isEmpty {
|
||||
if !available.isEmpty {
|
||||
self.sidebarSectionHeader("Available")
|
||||
ForEach(self.availableChannels) { channel in
|
||||
ForEach(available) { channel in
|
||||
self.sidebarRow(channel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ struct ChannelsSettings: View {
|
||||
}
|
||||
|
||||
@Bindable var store: ChannelsStore
|
||||
let isActive: Bool
|
||||
@State var selectedChannel: ChannelItem?
|
||||
|
||||
init(store: ChannelsStore = .shared) {
|
||||
init(store: ChannelsStore = .shared, isActive: Bool = true) {
|
||||
self.store = store
|
||||
self.isActive = isActive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,42 +2,171 @@ import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
extension ChannelsStore {
|
||||
func loadConfigSchema() async {
|
||||
guard !self.configSchemaLoading else { return }
|
||||
func loadConfigSchema(force: Bool = false) async {
|
||||
let sourceKey = self.currentConfigCacheSourceKey()
|
||||
self.resetConfigSchemaCacheIfSourceChanged(sourceKey)
|
||||
if !force, self.configSchema != nil {
|
||||
return
|
||||
}
|
||||
guard !self.queueConfigSchemaReloadIfLoading(sourceKey: sourceKey, force: force) else { return }
|
||||
self.configSchemaLoading = true
|
||||
defer { self.configSchemaLoading = false }
|
||||
self.configSchemaLoadingSourceKey = sourceKey
|
||||
defer {
|
||||
self.configSchemaLoading = false
|
||||
self.configSchemaLoadingSourceKey = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let res: ConfigSchemaResponse = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configSchema,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
let schemaValue = res.schema.foundationValue
|
||||
self.configSchema = ConfigSchemaNode(raw: schemaValue)
|
||||
let hintValues = res.uihints.mapValues { $0.foundationValue }
|
||||
self.configUiHints = decodeUiHints(hintValues)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
var requestSourceKey = sourceKey
|
||||
|
||||
while true {
|
||||
self.configSchemaLoadingSourceKey = requestSourceKey
|
||||
do {
|
||||
let res: ConfigSchemaResponse = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configSchema,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
self.applyConfigSchemaResponse(res, sourceKey: requestSourceKey)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
|
||||
guard self.configSchemaReloadPending else { break }
|
||||
self.configSchemaReloadPending = false
|
||||
requestSourceKey = self.currentConfigCacheSourceKey()
|
||||
self.resetConfigSchemaCacheIfSourceChanged(requestSourceKey)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() async {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = snap.valid == false
|
||||
? "Config invalid; fix it in ~/.openclaw/openclaw.json."
|
||||
: nil
|
||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
self.configDraft = cloneConfigValue(self.configRoot) as? [String: Any] ?? self.configRoot
|
||||
self.configDirty = false
|
||||
self.configLoaded = true
|
||||
@discardableResult
|
||||
func loadConfigSchemaLookup(path: String, force: Bool = false) async -> ConfigSchemaLookupNode? {
|
||||
let sourceKey = self.currentConfigCacheSourceKey()
|
||||
self.resetConfigSchemaCacheIfSourceChanged(sourceKey)
|
||||
let normalizedPath = Self.normalizeConfigLookupPath(path)
|
||||
if !force, let cached = self.configLookupNode(path: normalizedPath) {
|
||||
return cached
|
||||
}
|
||||
if self.configLookupLoadingPaths.contains(normalizedPath) {
|
||||
return self.configLookupNode(path: normalizedPath)
|
||||
}
|
||||
|
||||
self.applyUIConfig(snap)
|
||||
self.configLookupLoadingPaths.insert(normalizedPath)
|
||||
defer { self.configLookupLoadingPaths.remove(normalizedPath) }
|
||||
|
||||
do {
|
||||
let res: ConfigSchemaLookupResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configSchemaLookup,
|
||||
params: ["path": AnyCodable(normalizedPath)],
|
||||
timeoutMs: 5000)
|
||||
guard let node = self.makeConfigLookupNode(res) else {
|
||||
self.configStatus = "Config schema lookup returned an unsupported payload."
|
||||
return nil
|
||||
}
|
||||
self.applyConfigLookupNode(node, sourceKey: sourceKey)
|
||||
return node
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(force: Bool = true) async {
|
||||
let sourceKey = self.currentConfigCacheSourceKey()
|
||||
self.resetConfigCacheIfSourceChanged(sourceKey)
|
||||
if !force, self.configLoaded {
|
||||
return
|
||||
}
|
||||
guard !self.queueConfigReloadIfLoading(sourceKey: sourceKey, force: force) else { return }
|
||||
self.configLoading = true
|
||||
self.configLoadingSourceKey = sourceKey
|
||||
defer {
|
||||
self.configLoading = false
|
||||
self.configLoadingSourceKey = nil
|
||||
}
|
||||
|
||||
var requestForce = force
|
||||
var requestSourceKey = sourceKey
|
||||
|
||||
while true {
|
||||
self.configLoadingSourceKey = requestSourceKey
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 10000)
|
||||
self.applyConfigSnapshot(snap, sourceKey: requestSourceKey, force: requestForce)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
|
||||
guard self.configForceReloadPending else { break }
|
||||
self.configForceReloadPending = false
|
||||
requestForce = true
|
||||
requestSourceKey = self.currentConfigCacheSourceKey()
|
||||
self.resetConfigCacheIfSourceChanged(requestSourceKey)
|
||||
}
|
||||
}
|
||||
|
||||
func applyConfigSnapshot(_ snap: ConfigSnapshot, sourceKey: String, force: Bool) {
|
||||
guard self.configSourceKey == sourceKey else { return }
|
||||
guard force || !self.configDirty else { return }
|
||||
|
||||
self.configStatus = snap.valid == false
|
||||
? "Config invalid; fix it in ~/.openclaw/openclaw.json."
|
||||
: nil
|
||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
self.configDraft = cloneConfigValue(self.configRoot) as? [String: Any] ?? self.configRoot
|
||||
self.configDirty = false
|
||||
self.configLoaded = true
|
||||
self.configSourceKey = sourceKey
|
||||
|
||||
self.applyUIConfig(snap)
|
||||
}
|
||||
|
||||
func applyConfigSchemaResponse(_ res: ConfigSchemaResponse, sourceKey: String) {
|
||||
guard self.configSchemaSourceKey == sourceKey else { return }
|
||||
|
||||
let schemaValue = res.schema.foundationValue
|
||||
self.configSchema = ConfigSchemaNode(raw: schemaValue)
|
||||
let hintValues = res.uihints.mapValues { $0.foundationValue }
|
||||
self.configUiHints = decodeUiHints(hintValues)
|
||||
self.configSchemaSourceKey = sourceKey
|
||||
}
|
||||
|
||||
func configLookupNode(path: String) -> ConfigSchemaLookupNode? {
|
||||
let normalizedPath = Self.normalizeConfigLookupPath(path)
|
||||
if normalizedPath == "." {
|
||||
return self.configLookupRoot
|
||||
}
|
||||
return self.configLookupCache[normalizedPath]
|
||||
}
|
||||
|
||||
func makeConfigLookupNode(_ res: ConfigSchemaLookupResult) -> ConfigSchemaLookupNode? {
|
||||
let schemaValue = res.schema.foundationValue
|
||||
guard let schema = ConfigSchemaNode(raw: schemaValue) else { return nil }
|
||||
let hint = res.hint.map { ConfigUiHint(raw: $0.mapValues(\.foundationValue)) }
|
||||
let children = res.children.compactMap(ConfigSchemaLookupChild.init(raw:))
|
||||
return ConfigSchemaLookupNode(
|
||||
path: Self.normalizeConfigLookupPath(res.path),
|
||||
schema: schema,
|
||||
hint: hint,
|
||||
hintPath: res.hintpath,
|
||||
children: children)
|
||||
}
|
||||
|
||||
func applyConfigLookupNode(_ node: ConfigSchemaLookupNode, sourceKey: String) {
|
||||
guard self.configSchemaSourceKey == sourceKey else { return }
|
||||
if node.path == "." {
|
||||
self.configLookupRoot = node
|
||||
} else {
|
||||
self.configLookupCache[node.path] = node
|
||||
}
|
||||
if let hint = node.hint {
|
||||
self.configUiHints[node.path] = hint
|
||||
}
|
||||
for child in node.children {
|
||||
if let hint = child.hint {
|
||||
self.configUiHints[child.path] = hint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +214,83 @@ extension ChannelsStore {
|
||||
}
|
||||
|
||||
func reloadConfigDraft() async {
|
||||
await self.loadConfig()
|
||||
await self.loadConfig(force: true)
|
||||
}
|
||||
|
||||
func resetConfigSchemaCacheIfSourceChanged(_ sourceKey: String) {
|
||||
guard let cachedSourceKey = self.configSchemaSourceKey else {
|
||||
self.configSchemaSourceKey = sourceKey
|
||||
return
|
||||
}
|
||||
guard cachedSourceKey != sourceKey else { return }
|
||||
self.configSchema = nil
|
||||
self.configLookupRoot = nil
|
||||
self.configLookupCache.removeAll(keepingCapacity: true)
|
||||
self.configLookupLoadingPaths.removeAll(keepingCapacity: true)
|
||||
self.configUiHints = [:]
|
||||
self.configSchemaSourceKey = sourceKey
|
||||
}
|
||||
|
||||
func resetConfigCacheIfSourceChanged(_ sourceKey: String) {
|
||||
guard let cachedSourceKey = self.configSourceKey else {
|
||||
self.configSourceKey = sourceKey
|
||||
return
|
||||
}
|
||||
guard cachedSourceKey != sourceKey else { return }
|
||||
self.configRoot = [:]
|
||||
self.configDraft = [:]
|
||||
self.configDirty = false
|
||||
self.configLoaded = false
|
||||
self.configSourceKey = sourceKey
|
||||
}
|
||||
|
||||
func queueConfigReloadIfLoading(sourceKey: String, force: Bool) -> Bool {
|
||||
guard self.configLoading else { return false }
|
||||
if force || self.configLoadingSourceKey != sourceKey {
|
||||
self.configForceReloadPending = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func queueConfigSchemaReloadIfLoading(sourceKey: String, force: Bool) -> Bool {
|
||||
guard self.configSchemaLoading else { return false }
|
||||
if force || self.configSchemaLoadingSourceKey != sourceKey {
|
||||
self.configSchemaReloadPending = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func currentConfigCacheSourceKey() -> String {
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let settings = CommandResolver.connectionSettings(configRoot: root)
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return [
|
||||
"mode:\(settings.mode.rawValue)",
|
||||
"target:\(settings.target)",
|
||||
"identity:\(settings.identity)",
|
||||
"project:\(settings.projectRoot)",
|
||||
"cli:\(settings.cliPath)",
|
||||
"port:\(GatewayEnvironment.gatewayPort())",
|
||||
"gateway:\(Self.configFingerprint(root["gateway"]))",
|
||||
"token:\(Self.configFingerprint(env["OPENCLAW_GATEWAY_TOKEN"]))",
|
||||
"password:\(Self.configFingerprint(env["OPENCLAW_GATEWAY_PASSWORD"]))",
|
||||
].joined(separator: "|")
|
||||
}
|
||||
|
||||
static func normalizeConfigLookupPath(_ path: String) -> String {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "." : trimmed
|
||||
}
|
||||
|
||||
private static func configFingerprint(_ value: Any?) -> String {
|
||||
guard let value else { return "nil" }
|
||||
if JSONSerialization.isValidJSONObject(value),
|
||||
let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
|
||||
{
|
||||
return "\(data.count):\(data.hashValue)"
|
||||
}
|
||||
let text = String(describing: value)
|
||||
return "\(text.count):\(text.hashValue)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,15 @@ func whatsappLoginWaitRequestTimeoutMs(
|
||||
extension ChannelsStore {
|
||||
func start() {
|
||||
guard !self.isPreview else { return }
|
||||
self.startCount += 1
|
||||
guard self.startCount == 1 else { return }
|
||||
guard self.pollTask == nil else { return }
|
||||
self.pollTask = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh(probe: true)
|
||||
await self.loadConfigSchema()
|
||||
await self.loadConfig()
|
||||
await self.refresh(probe: false)
|
||||
async let schemaLoad: Void = self.loadConfigSchema()
|
||||
async let configLoad: Void = self.loadConfig(force: false)
|
||||
_ = await (schemaLoad, configLoad)
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||
await self.refresh(probe: false)
|
||||
@@ -36,6 +39,10 @@ extension ChannelsStore {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard !self.isPreview else { return }
|
||||
guard self.startCount > 0 else { return }
|
||||
self.startCount -= 1
|
||||
guard self.startCount == 0 else { return }
|
||||
self.pollTask?.cancel()
|
||||
self.pollTask = nil
|
||||
}
|
||||
@@ -46,14 +53,15 @@ extension ChannelsStore {
|
||||
defer { self.isRefreshing = false }
|
||||
|
||||
do {
|
||||
let statusTimeoutMs = probe ? 8000 : 2500
|
||||
let params: [String: AnyCodable] = [
|
||||
"probe": AnyCodable(probe),
|
||||
"timeoutMs": AnyCodable(8000),
|
||||
"timeoutMs": AnyCodable(statusTimeoutMs),
|
||||
]
|
||||
let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .channelsStatus,
|
||||
params: params,
|
||||
timeoutMs: 12000)
|
||||
timeoutMs: probe ? 12000 : 5000)
|
||||
self.snapshot = snap
|
||||
self.lastSuccess = Date()
|
||||
self.lastError = nil
|
||||
|
||||
@@ -219,12 +219,64 @@ struct ConfigSnapshot: Codable {
|
||||
let issues: [Issue]?
|
||||
}
|
||||
|
||||
struct ConfigSchemaLookupChild: Identifiable {
|
||||
let key: String
|
||||
let path: String
|
||||
let typeLabel: String?
|
||||
let required: Bool
|
||||
let hasChildren: Bool
|
||||
let hint: ConfigUiHint?
|
||||
let hintPath: String?
|
||||
|
||||
var id: String {
|
||||
self.path
|
||||
}
|
||||
|
||||
init?(raw: [String: AnyCodable]) {
|
||||
guard let key = raw["key"]?.stringValue,
|
||||
let path = raw["path"]?.stringValue
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
self.key = key
|
||||
self.path = path
|
||||
if let type = raw["type"]?.stringValue {
|
||||
self.typeLabel = type
|
||||
} else if let types = raw["type"]?.arrayValue {
|
||||
self.typeLabel = types.compactMap(\.stringValue).joined(separator: " / ")
|
||||
} else {
|
||||
self.typeLabel = nil
|
||||
}
|
||||
self.required = raw["required"]?.boolValue ?? false
|
||||
self.hasChildren = raw["hasChildren"]?.boolValue ?? false
|
||||
if let hint = raw["hint"]?.dictionaryValue {
|
||||
self.hint = ConfigUiHint(raw: hint.mapValues(\.foundationValue))
|
||||
} else {
|
||||
self.hint = nil
|
||||
}
|
||||
self.hintPath = raw["hintPath"]?.stringValue
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigSchemaLookupNode {
|
||||
let path: String
|
||||
let schema: ConfigSchemaNode
|
||||
let hint: ConfigUiHint?
|
||||
let hintPath: String?
|
||||
let children: [ConfigSchemaLookupChild]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ChannelsStore {
|
||||
static let shared = ChannelsStore()
|
||||
|
||||
var snapshot: ChannelsStatusSnapshot?
|
||||
var snapshot: ChannelsStatusSnapshot? {
|
||||
didSet {
|
||||
self.decodedChannelCache.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
var lastError: String?
|
||||
var lastSuccess: Date?
|
||||
var isRefreshing = false
|
||||
@@ -239,15 +291,27 @@ final class ChannelsStore {
|
||||
var isSavingConfig = false
|
||||
var configSchemaLoading = false
|
||||
var configSchema: ConfigSchemaNode?
|
||||
var configLookupRoot: ConfigSchemaLookupNode?
|
||||
var configLookupCache: [String: ConfigSchemaLookupNode] = [:]
|
||||
var configLookupLoadingPaths: Set<String> = []
|
||||
var configUiHints: [String: ConfigUiHint] = [:]
|
||||
var configSchemaSourceKey: String?
|
||||
var configSchemaLoadingSourceKey: String?
|
||||
var configSchemaReloadPending = false
|
||||
var configLoading = false
|
||||
var configLoadingSourceKey: String?
|
||||
var configForceReloadPending = false
|
||||
var configDraft: [String: Any] = [:]
|
||||
var configDirty = false
|
||||
|
||||
let interval: TimeInterval = 45
|
||||
let isPreview: Bool
|
||||
var startCount = 0
|
||||
var pollTask: Task<Void, Never>?
|
||||
var configRoot: [String: Any] = [:]
|
||||
var configLoaded = false
|
||||
var configSourceKey: String?
|
||||
@ObservationIgnored private var decodedChannelCache: [String: Any] = [:]
|
||||
|
||||
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
|
||||
self.snapshot?.channelMeta?.first(where: { $0.id == id })
|
||||
@@ -290,6 +354,18 @@ final class ChannelsStore {
|
||||
return self.snapshot?.channelOrder ?? []
|
||||
}
|
||||
|
||||
func decodedChannel<T: Decodable>(_ id: String, as type: T.Type) -> T? {
|
||||
let key = "\(id)#\(ObjectIdentifier(type))"
|
||||
if let cached = self.decodedChannelCache[key] as? T {
|
||||
return cached
|
||||
}
|
||||
guard let decoded = self.snapshot?.decodeChannel(id, as: type) else {
|
||||
return nil
|
||||
}
|
||||
self.decodedChannelCache[key] = decoded
|
||||
return decoded
|
||||
}
|
||||
|
||||
func applyWhatsAppLoginWaitResult(_ result: WhatsAppLoginWaitResult) {
|
||||
self.whatsappLoginMessage = result.message
|
||||
self.whatsappLoginConnected = result.connected
|
||||
|
||||
@@ -426,10 +426,15 @@ enum CommandResolver {
|
||||
{
|
||||
let root = configRoot ?? OpenClawConfigFile.loadDict()
|
||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
||||
let cliPath = defaults.string(forKey: remoteCliPathKey) ?? ""
|
||||
let remote = (root["gateway"] as? [String: Any])?["remote"] as? [String: Any]
|
||||
let target = defaults.string(forKey: remoteTargetKey)?.nonEmpty
|
||||
?? remote?["sshTarget"] as? String
|
||||
?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey)?.nonEmpty
|
||||
?? remote?["sshIdentity"] as? String
|
||||
?? ""
|
||||
let projectRoot = defaults.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
|
||||
let cliPath = defaults.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
|
||||
return RemoteSettings(
|
||||
mode: mode,
|
||||
target: self.sanitizedTarget(target),
|
||||
|
||||
@@ -208,6 +208,25 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
|
||||
|| key.hasSuffix("key")
|
||||
}
|
||||
|
||||
func labelForConfigPath(_ path: ConfigPath) -> String? {
|
||||
for segment in path.reversed() {
|
||||
if case let .key(key) = segment {
|
||||
return humanizeConfigKey(key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func humanizeConfigKey(_ key: String) -> String {
|
||||
key.replacingOccurrences(of: "_", with: " ")
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(
|
||||
of: "([a-z0-9])([A-Z])",
|
||||
with: "$1 $2",
|
||||
options: .regularExpression)
|
||||
.capitalized
|
||||
}
|
||||
|
||||
func pathKey(_ path: ConfigPath) -> String {
|
||||
path.compactMap { segment -> String? in
|
||||
switch segment {
|
||||
|
||||
@@ -6,8 +6,8 @@ struct ConfigSettings: View {
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
@Bindable var store: ChannelsStore
|
||||
@State private var hasLoaded = false
|
||||
@State private var activeSectionKey: String?
|
||||
@State private var activeSubsection: SubsectionSelection?
|
||||
@State private var activePath: String?
|
||||
@State private var failedLookupPaths: Set<String> = []
|
||||
|
||||
init(store: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
@@ -23,30 +23,32 @@ struct ConfigSettings: View {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
self.hasLoaded = true
|
||||
await self.store.loadConfigSchema()
|
||||
await self.store.loadConfig()
|
||||
Task { await self.store.loadConfig(force: false) }
|
||||
_ = await self.store.loadConfigSchemaLookup(path: ".")
|
||||
self.ensureSelection()
|
||||
}
|
||||
.task(id: self.activePath) {
|
||||
guard let activePath = self.activePath else { return }
|
||||
await self.loadPath(activePath)
|
||||
}
|
||||
.onAppear { self.ensureSelection() }
|
||||
.onChange(of: self.store.configSchemaLoading) { _, loading in
|
||||
if !loading { self.ensureSelection() }
|
||||
.onChange(of: self.store.configLookupRoot?.path) { _, _ in
|
||||
self.failedLookupPaths.removeAll()
|
||||
self.ensureSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private enum SubsectionSelection: Hashable {
|
||||
case all
|
||||
case key(String)
|
||||
}
|
||||
|
||||
private struct ConfigSection: Identifiable {
|
||||
let key: String
|
||||
let label: String
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
let path: String
|
||||
let hasChildren: Bool
|
||||
|
||||
var id: String {
|
||||
self.key
|
||||
self.path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,21 +56,22 @@ extension ConfigSettings {
|
||||
let key: String
|
||||
let label: String
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
let path: String
|
||||
let hasChildren: Bool
|
||||
|
||||
var id: String {
|
||||
self.key
|
||||
self.path
|
||||
}
|
||||
}
|
||||
|
||||
private var sections: [ConfigSection] {
|
||||
guard let schema = self.store.configSchema else { return [] }
|
||||
return self.resolveSections(schema)
|
||||
guard let root = self.store.configLookupRoot else { return [] }
|
||||
return self.resolveSections(root.children)
|
||||
}
|
||||
|
||||
private var activeSection: ConfigSection? {
|
||||
self.sections.first { $0.key == self.activeSectionKey }
|
||||
guard let activePath = self.activePath else { return nil }
|
||||
return self.sections.first { activePath == $0.path || activePath.hasPrefix("\($0.path).") }
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
@@ -91,16 +94,16 @@ extension ConfigSettings {
|
||||
|
||||
private var detail: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if self.store.configSchemaLoading {
|
||||
if self.store.configLookupRoot == nil,
|
||||
!self.hasLoaded || self.store.configLookupLoadingPaths.contains(".")
|
||||
{
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let section = self.activeSection {
|
||||
self.sectionDetail(section)
|
||||
} else if self.store.configSchema != nil {
|
||||
} else if self.store.configLookupRoot != nil {
|
||||
self.emptyDetail
|
||||
} else {
|
||||
Text("Schema unavailable.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
self.schemaUnavailableDetail
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
@@ -117,6 +120,18 @@ extension ConfigSettings {
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
private var schemaUnavailableDetail: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
self.header
|
||||
Text(self.store.configStatus ?? "Schema unavailable.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
self.actionRow
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
private func sectionDetail(_ section: ConfigSection) -> some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
@@ -176,13 +191,14 @@ extension ConfigSettings {
|
||||
Button(self.store.isSavingConfig ? "Saving…" : "Save") {
|
||||
Task { await self.store.saveConfigDraft() }
|
||||
}
|
||||
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty)
|
||||
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configLoaded || !self.store
|
||||
.configDirty)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private func sidebarSection(_ section: ConfigSection) -> some View {
|
||||
let isExpanded = self.activeSectionKey == section.key
|
||||
let isExpanded = self.activePath == section.path || self.activePath?.hasPrefix("\(section.path).") == true
|
||||
let subsections = isExpanded ? self.resolveSubsections(for: section) : []
|
||||
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -200,7 +216,7 @@ extension ConfigSettings {
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isExpanded && subsections.isEmpty
|
||||
.background(self.activePath == section.path
|
||||
? Color.accentColor.opacity(0.18)
|
||||
: Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
@@ -211,9 +227,8 @@ extension ConfigSettings {
|
||||
|
||||
if isExpanded, !subsections.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
self.sidebarSubRow(title: "All", key: nil, sectionKey: section.key)
|
||||
ForEach(subsections) { sub in
|
||||
self.sidebarSubRow(title: sub.label, key: sub.key, sectionKey: section.key)
|
||||
self.sidebarSubRow(sub)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 20)
|
||||
@@ -223,21 +238,12 @@ extension ConfigSettings {
|
||||
.animation(.easeInOut(duration: 0.18), value: isExpanded)
|
||||
}
|
||||
|
||||
private func sidebarSubRow(title: String, key: String?, sectionKey: String) -> some View {
|
||||
let isSelected: Bool = {
|
||||
guard self.activeSectionKey == sectionKey else { return false }
|
||||
if let key { return self.activeSubsection == .key(key) }
|
||||
return self.activeSubsection == .all
|
||||
}()
|
||||
|
||||
private func sidebarSubRow(_ subsection: ConfigSubsection) -> some View {
|
||||
let isSelected = self.activePath == subsection.path
|
||||
return Button {
|
||||
if let key {
|
||||
self.activeSubsection = .key(key)
|
||||
} else {
|
||||
self.activeSubsection = .all
|
||||
}
|
||||
self.selectPath(subsection.path)
|
||||
} label: {
|
||||
Text(title)
|
||||
Text(subsection.label)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
.padding(.vertical, 4)
|
||||
@@ -252,123 +258,190 @@ extension ConfigSettings {
|
||||
}
|
||||
|
||||
private func sectionForm(_ section: ConfigSection) -> some View {
|
||||
let subsection = self.activeSubsection
|
||||
let defaultPath: ConfigPath = [.key(section.key)]
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
let resolved: (ConfigSchemaNode, ConfigPath) = {
|
||||
if case let .key(key) = subsection,
|
||||
let match = subsections.first(where: { $0.key == key })
|
||||
{
|
||||
return (match.node, match.path)
|
||||
let path = self.activePath ?? section.path
|
||||
if self.store.configLookupLoadingPaths.contains(path) {
|
||||
return AnyView(ProgressView().controlSize(.small))
|
||||
}
|
||||
guard let node = self.store.configLookupNode(path: path) else {
|
||||
if self.failedLookupPaths.contains(path) {
|
||||
return AnyView(self.lookupUnavailable(path: path))
|
||||
}
|
||||
return (self.resolvedSchemaNode(section.node), defaultPath)
|
||||
}()
|
||||
|
||||
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
|
||||
.disabled(self.isNixMode)
|
||||
return AnyView(ProgressView().controlSize(.small))
|
||||
}
|
||||
if !node.children.isEmpty, !Self.shouldRenderFormEditor(for: node.schema) {
|
||||
return AnyView(self.lookupChildrenList(node))
|
||||
}
|
||||
guard self.store.configLoaded else {
|
||||
return AnyView(
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Loading current values…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
})
|
||||
}
|
||||
guard let configPath = Self.configPath(from: node.path) else {
|
||||
return AnyView(
|
||||
Text("Wildcard config entries are edited from their concrete key.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary))
|
||||
}
|
||||
return AnyView(
|
||||
ConfigSchemaForm(store: self.store, schema: node.schema, path: configPath)
|
||||
.disabled(self.isNixMode))
|
||||
}
|
||||
|
||||
private func ensureSelection() {
|
||||
guard let schema = self.store.configSchema else { return }
|
||||
let sections = self.resolveSections(schema)
|
||||
let sections = self.sections
|
||||
guard !sections.isEmpty else { return }
|
||||
|
||||
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
|
||||
if self.activeSectionKey != active.key {
|
||||
self.activeSectionKey = active.key
|
||||
}
|
||||
self.ensureSubsection(for: active)
|
||||
}
|
||||
|
||||
private func ensureSubsection(for section: ConfigSection) {
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
guard !subsections.isEmpty else {
|
||||
self.activeSubsection = nil
|
||||
if let activePath = self.activePath,
|
||||
sections.contains(where: { activePath == $0.path || activePath.hasPrefix("\($0.path).") })
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
switch self.activeSubsection {
|
||||
case .all:
|
||||
return
|
||||
case let .key(key):
|
||||
if subsections.contains(where: { $0.key == key }) { return }
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
if let first = subsections.first {
|
||||
self.activeSubsection = .key(first.key)
|
||||
}
|
||||
self.selectSection(sections[0])
|
||||
}
|
||||
|
||||
private func selectSection(_ section: ConfigSection) {
|
||||
guard self.activeSectionKey != section.key else { return }
|
||||
self.activeSectionKey = section.key
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
if let first = subsections.first {
|
||||
self.activeSubsection = .key(first.key)
|
||||
} else {
|
||||
self.activeSubsection = nil
|
||||
self.activePath = section.path
|
||||
}
|
||||
|
||||
private func selectPath(_ path: String) {
|
||||
self.activePath = path
|
||||
}
|
||||
|
||||
private func lookupUnavailable(path: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(self.store.configStatus ?? "Schema unavailable.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry") {
|
||||
self.failedLookupPaths.remove(path)
|
||||
Task { await self.loadPath(path) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
|
||||
let node = self.resolvedSchemaNode(root)
|
||||
let hints = self.store.configUiHints
|
||||
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
|
||||
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
private func lookupChildrenList(_ node: ConfigSchemaLookupNode) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(node.children) { child in
|
||||
Button {
|
||||
self.selectPath(child.path)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.label(for: child))
|
||||
.font(.callout.weight(.semibold))
|
||||
if let help = child.hint?.help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if let type = child.typeLabel {
|
||||
Text(type)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if child.required {
|
||||
Text("Required")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Image(systemName: child.hasChildren ? "chevron.right" : "slider.horizontal.3")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.primary.opacity(0.04))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys.compactMap { key in
|
||||
guard let child = node.properties[key] else { return nil }
|
||||
let path: ConfigPath = [.key(key)]
|
||||
let hint = hintForPath(path, hints: hints)
|
||||
let label = hint?.label
|
||||
?? child.title
|
||||
?? self.humanize(key)
|
||||
let help = hint?.help ?? child.description
|
||||
return ConfigSection(key: key, label: label, help: help, node: child)
|
||||
}
|
||||
private func resolveSections(_ children: [ConfigSchemaLookupChild]) -> [ConfigSection] {
|
||||
children
|
||||
.sorted(by: self.sortLookupChildren)
|
||||
.map { child in
|
||||
ConfigSection(
|
||||
key: child.key,
|
||||
label: self.label(for: child),
|
||||
help: child.hint?.help,
|
||||
path: child.path,
|
||||
hasChildren: child.hasChildren)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
|
||||
let node = self.resolvedSchemaNode(section.node)
|
||||
guard node.schemaType == "object" else { return [] }
|
||||
let hints = self.store.configUiHints
|
||||
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
|
||||
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
guard let node = self.store.configLookupNode(path: section.path) else {
|
||||
return []
|
||||
}
|
||||
return node.children
|
||||
.sorted(by: self.sortLookupChildren)
|
||||
.map { child in
|
||||
ConfigSubsection(
|
||||
key: child.key,
|
||||
label: self.label(for: child),
|
||||
help: child.hint?.help,
|
||||
path: child.path,
|
||||
hasChildren: child.hasChildren)
|
||||
}
|
||||
}
|
||||
|
||||
return keys.compactMap { key in
|
||||
guard let child = node.properties[key] else { return nil }
|
||||
let path: ConfigPath = [.key(section.key), .key(key)]
|
||||
let hint = hintForPath(path, hints: hints)
|
||||
let label = hint?.label
|
||||
?? child.title
|
||||
?? self.humanize(key)
|
||||
let help = hint?.help ?? child.description
|
||||
return ConfigSubsection(
|
||||
key: key,
|
||||
label: label,
|
||||
help: help,
|
||||
node: child,
|
||||
path: path)
|
||||
private func loadPath(_ path: String) async {
|
||||
guard self.store.configLookupNode(path: path) == nil else {
|
||||
self.failedLookupPaths.remove(path)
|
||||
return
|
||||
}
|
||||
guard !self.store.configLookupLoadingPaths.contains(path) else { return }
|
||||
if await self.store.loadConfigSchemaLookup(path: path) == nil {
|
||||
self.failedLookupPaths.insert(path)
|
||||
} else {
|
||||
self.failedLookupPaths.remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
|
||||
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
|
||||
if !variants.isEmpty {
|
||||
let nonNull = variants.filter { !$0.isNullSchema }
|
||||
if nonNull.count == 1, let only = nonNull.first { return only }
|
||||
private func label(for child: ConfigSchemaLookupChild) -> String {
|
||||
child.hint?.label
|
||||
?? self.humanize(child.key)
|
||||
}
|
||||
|
||||
private func sortLookupChildren(_ lhs: ConfigSchemaLookupChild, _ rhs: ConfigSchemaLookupChild) -> Bool {
|
||||
let orderA = lhs.hint?.order ?? 0
|
||||
let orderB = rhs.hint?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
|
||||
private static func configPath(from lookupPath: String) -> ConfigPath? {
|
||||
guard lookupPath != "." else { return [] }
|
||||
let normalized = lookupPath
|
||||
.replacingOccurrences(of: "[", with: ".")
|
||||
.replacingOccurrences(of: "]", with: "")
|
||||
let parts = normalized
|
||||
.split(separator: ".")
|
||||
.map(String.init)
|
||||
.filter { !$0.isEmpty }
|
||||
guard !parts.contains("*") else { return nil }
|
||||
return parts.map { part in
|
||||
if let index = Int(part) {
|
||||
return .index(index)
|
||||
}
|
||||
return .key(part)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
private static func shouldRenderFormEditor(for schema: ConfigSchemaNode) -> Bool {
|
||||
if schema.schemaType == "array" { return true }
|
||||
return schema.additionalProperties != nil
|
||||
}
|
||||
|
||||
private func humanize(_ key: String) -> String {
|
||||
|
||||
@@ -9,31 +9,51 @@ struct ContextRootMenuLabelView: View {
|
||||
MenuItemHighlightColors.palette(self.isHighlighted)
|
||||
}
|
||||
|
||||
private var usesStackedLayout: Bool {
|
||||
self.subtitle.count > 28 || self.subtitle.contains("\n")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("Context")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(self.palette.primary)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
HStack(alignment: self.usesStackedLayout ? .top : .firstTextBaseline, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Context")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(self.palette.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
if self.usesStackedLayout {
|
||||
self.subtitleText
|
||||
.lineLimit(5)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Text(self.subtitle)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(2)
|
||||
if !self.usesStackedLayout {
|
||||
self.subtitleText
|
||||
.lineLimit(1)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.padding(.leading, 2)
|
||||
.padding(.top, self.usesStackedLayout ? 2 : 0)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, self.usesStackedLayout ? 7 : 8)
|
||||
.padding(.leading, 22)
|
||||
.padding(.trailing, 14)
|
||||
.frame(width: max(1, self.width), alignment: .leading)
|
||||
}
|
||||
|
||||
private var subtitleText: some View {
|
||||
Text(self.subtitle)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,12 @@ final class ControlChannel {
|
||||
case .timedOut:
|
||||
return "Gateway request timed out; check gateway on localhost:\(port)."
|
||||
case .notConnectedToInternet:
|
||||
if Self.isLikelyLocalNetworkPermissionBlock() {
|
||||
return """
|
||||
macOS is blocking OpenClaw Local Network access.
|
||||
Allow OpenClaw in System Settings → Privacy & Security → Local Network, then relaunch the app.
|
||||
"""
|
||||
}
|
||||
return "No network connectivity; cannot reach gateway."
|
||||
default:
|
||||
break
|
||||
@@ -257,6 +263,22 @@ final class ControlChannel {
|
||||
return "Gateway error: \(trimmed)"
|
||||
}
|
||||
|
||||
private static func isLikelyLocalNetworkPermissionBlock() -> Bool {
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
|
||||
guard ConnectionModeResolver.resolve(root: root).mode == .remote,
|
||||
resolution.transport == .direct,
|
||||
let url = resolution.directURL,
|
||||
url.scheme?.lowercased() == "ws",
|
||||
let host = url.host,
|
||||
GatewayRemoteConfig.isTrustedPlaintextRemoteHost(host),
|
||||
!LoopbackHost.isLoopbackHost(host)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func scheduleRecovery(reason: String) {
|
||||
let now = Date()
|
||||
if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return }
|
||||
|
||||
@@ -20,12 +20,11 @@ enum CritterIconRenderer {
|
||||
|
||||
private struct Geometry {
|
||||
let bodyRect: CGRect
|
||||
let bodyCorner: CGFloat
|
||||
let leftArmRect: CGRect
|
||||
let rightArmRect: CGRect
|
||||
let leftEarRect: CGRect
|
||||
let rightEarRect: CGRect
|
||||
let earCorner: CGFloat
|
||||
let earW: CGFloat
|
||||
let earH: CGFloat
|
||||
let antennaLineWidth: CGFloat
|
||||
let legW: CGFloat
|
||||
let legH: CGFloat
|
||||
let legSpacing: CGFloat
|
||||
@@ -33,7 +32,7 @@ enum CritterIconRenderer {
|
||||
let legYBase: CGFloat
|
||||
let legLift: CGFloat
|
||||
let legHeightScale: CGFloat
|
||||
let eyeW: CGFloat
|
||||
let eyeSize: CGSize
|
||||
let eyeY: CGFloat
|
||||
let eyeOffset: CGFloat
|
||||
|
||||
@@ -43,46 +42,60 @@ enum CritterIconRenderer {
|
||||
let snapX = canvas.snapX
|
||||
let snapY = canvas.snapY
|
||||
|
||||
let bodyW = snapX(w * 0.78)
|
||||
let bodyH = snapY(h * 0.58)
|
||||
let bodyW = snapX(w * 0.68)
|
||||
let bodyH = snapY(h * 0.68)
|
||||
let bodyX = snapX((w - bodyW) / 2)
|
||||
let bodyY = snapY(h * 0.36)
|
||||
let bodyCorner = snapX(w * 0.09)
|
||||
let bodyY = snapY(h * 0.24)
|
||||
|
||||
let earW = snapX(w * 0.22)
|
||||
let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle)))
|
||||
let earCorner = snapX(earW * 0.24)
|
||||
let armSize = snapX(w * 0.2)
|
||||
let armY = snapY(bodyY + bodyH * 0.36)
|
||||
let leftArmRect = CGRect(
|
||||
x: snapX(bodyX - armSize * 0.62),
|
||||
y: armY,
|
||||
width: armSize,
|
||||
height: armSize)
|
||||
let rightArmRect = CGRect(
|
||||
x: snapX(bodyX + bodyW - armSize * 0.38),
|
||||
y: armY,
|
||||
width: armSize,
|
||||
height: armSize)
|
||||
|
||||
let antennaW = snapX(w * 0.22)
|
||||
let antennaH = snapY(min(bodyH * 0.24 * earScale, h * 0.19))
|
||||
let antennaLineWidth = max(snapX(w * 0.095), canvas.stepX * 2) * min(1.2, 0.94 + earScale * 0.06)
|
||||
let antennaLift = snapY(earWiggle * 0.35)
|
||||
let leftEarRect = CGRect(
|
||||
x: snapX(bodyX - earW * 0.55 + earWiggle),
|
||||
y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4),
|
||||
width: earW,
|
||||
height: earH)
|
||||
x: snapX(bodyX + bodyW * 0.18 - antennaW * 0.35 - earWiggle * 0.28),
|
||||
y: snapY(bodyY + bodyH * 0.86 + antennaLift),
|
||||
width: antennaW,
|
||||
height: antennaH)
|
||||
let rightEarRect = CGRect(
|
||||
x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle),
|
||||
y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4),
|
||||
width: earW,
|
||||
height: earH)
|
||||
x: snapX(bodyX + bodyW * 0.82 - antennaW * 0.65 + earWiggle * 0.28),
|
||||
y: snapY(bodyY + bodyH * 0.86 - antennaLift),
|
||||
width: antennaW,
|
||||
height: antennaH)
|
||||
|
||||
let legW = snapX(w * 0.11)
|
||||
let legH = snapY(h * 0.26)
|
||||
let legSpacing = snapX(w * 0.085)
|
||||
let legsWidth = snapX(4 * legW + 3 * legSpacing)
|
||||
let legW = snapX(w * 0.15)
|
||||
let legH = snapY(h * 0.25)
|
||||
let legSpacing = snapX(w * 0.16)
|
||||
let legsWidth = snapX(2 * legW + legSpacing)
|
||||
let legStartX = snapX((w - legsWidth) / 2)
|
||||
let legLift = snapY(legH * 0.35 * legWiggle)
|
||||
let legYBase = snapY(bodyY - legH + h * 0.05)
|
||||
let legYBase = snapY(bodyY - legH * 0.58)
|
||||
let legHeightScale = 1 - 0.12 * legWiggle
|
||||
|
||||
let eyeW = snapX(bodyW * 0.2)
|
||||
let eyeY = snapY(bodyY + bodyH * 0.56)
|
||||
let eyeOffset = snapX(bodyW * 0.24)
|
||||
let eyeSize = CGSize(
|
||||
width: snapX(bodyW * 0.15),
|
||||
height: snapY(bodyH * 0.2))
|
||||
let eyeY = snapY(bodyY + bodyH * 0.58)
|
||||
let eyeOffset = snapX(bodyW * 0.22)
|
||||
|
||||
self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH)
|
||||
self.bodyCorner = bodyCorner
|
||||
self.leftArmRect = leftArmRect
|
||||
self.rightArmRect = rightArmRect
|
||||
self.leftEarRect = leftEarRect
|
||||
self.rightEarRect = rightEarRect
|
||||
self.earCorner = earCorner
|
||||
self.earW = earW
|
||||
self.earH = earH
|
||||
self.antennaLineWidth = antennaLineWidth
|
||||
self.legW = legW
|
||||
self.legH = legH
|
||||
self.legSpacing = legSpacing
|
||||
@@ -90,7 +103,7 @@ enum CritterIconRenderer {
|
||||
self.legYBase = legYBase
|
||||
self.legLift = legLift
|
||||
self.legHeightScale = legHeightScale
|
||||
self.eyeW = eyeW
|
||||
self.eyeSize = eyeSize
|
||||
self.eyeY = eyeY
|
||||
self.eyeOffset = eyeOffset
|
||||
}
|
||||
@@ -98,8 +111,6 @@ enum CritterIconRenderer {
|
||||
|
||||
private struct FaceOptions {
|
||||
let blink: CGFloat
|
||||
let earHoles: Bool
|
||||
let earScale: CGFloat
|
||||
let eyesClosedLines: Bool
|
||||
}
|
||||
|
||||
@@ -125,16 +136,18 @@ enum CritterIconRenderer {
|
||||
}
|
||||
NSGraphicsContext.current = context
|
||||
context.imageInterpolation = .none
|
||||
context.cgContext.setShouldAntialias(false)
|
||||
context.cgContext.setShouldAntialias(true)
|
||||
|
||||
let canvas = self.makeCanvas(for: rep, context: context)
|
||||
let geometry = Geometry(canvas: canvas, legWiggle: legWiggle, earWiggle: earWiggle, earScale: earScale)
|
||||
let geometry = Geometry(
|
||||
canvas: canvas,
|
||||
legWiggle: legWiggle,
|
||||
earWiggle: earWiggle,
|
||||
earScale: earHoles ? max(earScale, 1.2) : earScale)
|
||||
|
||||
self.drawBody(in: canvas, geometry: geometry)
|
||||
let face = FaceOptions(
|
||||
blink: blink,
|
||||
earHoles: earHoles,
|
||||
earScale: earScale,
|
||||
eyesClosedLines: eyesClosedLines)
|
||||
self.drawFace(in: canvas, geometry: geometry, options: face)
|
||||
|
||||
@@ -186,25 +199,41 @@ enum CritterIconRenderer {
|
||||
}
|
||||
|
||||
private static func drawBody(in canvas: Canvas, geometry: Geometry) {
|
||||
canvas.context.setStrokeColor(NSColor.labelColor.cgColor)
|
||||
canvas.context.setLineWidth(geometry.antennaLineWidth)
|
||||
canvas.context.setLineCap(.round)
|
||||
canvas.context.setLineJoin(.round)
|
||||
|
||||
let leftStart = CGPoint(
|
||||
x: canvas.snapX(geometry.bodyRect.minX + geometry.bodyRect.width * 0.34),
|
||||
y: canvas.snapY(geometry.bodyRect.maxY - geometry.antennaLineWidth * 0.22))
|
||||
let leftEnd = CGPoint(
|
||||
x: canvas.snapX(geometry.leftEarRect.minX),
|
||||
y: canvas.snapY(geometry.leftEarRect.maxY))
|
||||
let leftControl = CGPoint(
|
||||
x: canvas.snapX(geometry.leftEarRect.midX),
|
||||
y: canvas.snapY(geometry.leftEarRect.minY))
|
||||
let rightStart = CGPoint(
|
||||
x: canvas.snapX(geometry.bodyRect.maxX - geometry.bodyRect.width * 0.34),
|
||||
y: canvas.snapY(geometry.bodyRect.maxY - geometry.antennaLineWidth * 0.22))
|
||||
let rightEnd = CGPoint(
|
||||
x: canvas.snapX(geometry.rightEarRect.maxX),
|
||||
y: canvas.snapY(geometry.rightEarRect.maxY))
|
||||
let rightControl = CGPoint(
|
||||
x: canvas.snapX(geometry.rightEarRect.midX),
|
||||
y: canvas.snapY(geometry.rightEarRect.minY))
|
||||
|
||||
let antennae = CGMutablePath()
|
||||
antennae.move(to: leftStart)
|
||||
antennae.addQuadCurve(to: leftEnd, control: leftControl)
|
||||
antennae.move(to: rightStart)
|
||||
antennae.addQuadCurve(to: rightEnd, control: rightControl)
|
||||
canvas.context.addPath(antennae)
|
||||
canvas.context.strokePath()
|
||||
|
||||
canvas.context.setFillColor(NSColor.labelColor.cgColor)
|
||||
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.bodyRect,
|
||||
cornerWidth: geometry.bodyCorner,
|
||||
cornerHeight: geometry.bodyCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.leftEarRect,
|
||||
cornerWidth: geometry.earCorner,
|
||||
cornerHeight: geometry.earCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.rightEarRect,
|
||||
cornerWidth: geometry.earCorner,
|
||||
cornerHeight: geometry.earCorner,
|
||||
transform: nil))
|
||||
|
||||
for i in 0..<4 {
|
||||
for i in 0..<2 {
|
||||
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
|
||||
let lift = i % 2 == 0 ? geometry.legLift : -geometry.legLift
|
||||
let rect = CGRect(
|
||||
@@ -218,6 +247,10 @@ enum CritterIconRenderer {
|
||||
cornerHeight: geometry.legW * 0.34,
|
||||
transform: nil))
|
||||
}
|
||||
|
||||
canvas.context.addEllipse(in: geometry.leftArmRect)
|
||||
canvas.context.addEllipse(in: geometry.rightArmRect)
|
||||
canvas.context.addEllipse(in: geometry.bodyRect)
|
||||
canvas.context.fillPath()
|
||||
}
|
||||
|
||||
@@ -236,35 +269,8 @@ enum CritterIconRenderer {
|
||||
x: canvas.snapX(canvas.w / 2 + geometry.eyeOffset),
|
||||
y: canvas.snapY(geometry.eyeY))
|
||||
|
||||
if options.earHoles || options.earScale > 1.05 {
|
||||
let holeW = canvas.snapX(geometry.earW * 0.6)
|
||||
let holeH = canvas.snapY(geometry.earH * 0.46)
|
||||
let holeCorner = canvas.snapX(holeW * 0.34)
|
||||
let leftHoleRect = CGRect(
|
||||
x: canvas.snapX(geometry.leftEarRect.midX - holeW / 2),
|
||||
y: canvas.snapY(geometry.leftEarRect.midY - holeH / 2 + geometry.earH * 0.04),
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
let rightHoleRect = CGRect(
|
||||
x: canvas.snapX(geometry.rightEarRect.midX - holeW / 2),
|
||||
y: canvas.snapY(geometry.rightEarRect.midY - holeH / 2 + geometry.earH * 0.04),
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: leftHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: rightHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
}
|
||||
|
||||
if options.eyesClosedLines {
|
||||
let lineW = canvas.snapX(geometry.eyeW * 0.95)
|
||||
let lineW = canvas.snapX(geometry.eyeSize.width * 1.15)
|
||||
let lineH = canvas.snapY(max(canvas.stepY * 2, geometry.bodyRect.height * 0.06))
|
||||
let corner = canvas.snapX(lineH * 0.6)
|
||||
let leftRect = CGRect(
|
||||
@@ -289,34 +295,20 @@ enum CritterIconRenderer {
|
||||
transform: nil))
|
||||
} else {
|
||||
let eyeOpen = max(0.05, 1 - options.blink)
|
||||
let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen)
|
||||
let eyeH = canvas.snapY(geometry.eyeSize.height * eyeOpen)
|
||||
let leftRect = CGRect(
|
||||
x: canvas.snapX(leftCenter.x - geometry.eyeSize.width / 2),
|
||||
y: canvas.snapY(leftCenter.y - eyeH / 2),
|
||||
width: geometry.eyeSize.width,
|
||||
height: eyeH)
|
||||
let rightRect = CGRect(
|
||||
x: canvas.snapX(rightCenter.x - geometry.eyeSize.width / 2),
|
||||
y: canvas.snapY(rightCenter.y - eyeH / 2),
|
||||
width: geometry.eyeSize.width,
|
||||
height: eyeH)
|
||||
|
||||
let left = CGMutablePath()
|
||||
left.move(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y - eyeH)))
|
||||
left.addLine(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y)))
|
||||
left.addLine(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y + eyeH)))
|
||||
left.closeSubpath()
|
||||
|
||||
let right = CGMutablePath()
|
||||
right.move(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y - eyeH)))
|
||||
right.addLine(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y)))
|
||||
right.addLine(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y + eyeH)))
|
||||
right.closeSubpath()
|
||||
|
||||
canvas.context.addPath(left)
|
||||
canvas.context.addPath(right)
|
||||
canvas.context.addEllipse(in: leftRect)
|
||||
canvas.context.addEllipse(in: rightRect)
|
||||
}
|
||||
|
||||
canvas.context.fillPath()
|
||||
|
||||
@@ -2,15 +2,20 @@ import SwiftUI
|
||||
|
||||
extension CronSettings {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
self.schedulerBanner
|
||||
self.content
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.onAppear {
|
||||
self.store.start()
|
||||
self.channelsStore.start()
|
||||
self.updateActiveWork(active: self.isActive)
|
||||
}
|
||||
.onChange(of: self.isActive) { _, active in
|
||||
self.updateActiveWork(active: active)
|
||||
}
|
||||
.onDisappear {
|
||||
self.store.stop()
|
||||
@@ -48,10 +53,16 @@ extension CronSettings {
|
||||
Text(job.displayName)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.store.selectedJobId) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
Task { await self.store.refreshRuns(jobId: newValue) }
|
||||
}
|
||||
}
|
||||
|
||||
private func updateActiveWork(active: Bool) {
|
||||
if active {
|
||||
self.store.start()
|
||||
self.channelsStore.start()
|
||||
} else {
|
||||
self.store.stop()
|
||||
self.channelsStore.stop()
|
||||
}
|
||||
}
|
||||
|
||||
var schedulerBanner: some View {
|
||||
@@ -89,16 +100,18 @@ extension CronSettings {
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Cron")
|
||||
.font(.headline)
|
||||
Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.")
|
||||
.font(.footnote)
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Cron Jobs")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Manage Gateway cron jobs and inspect run history.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await self.store.refreshJobs() }
|
||||
@@ -133,14 +146,34 @@ extension CronSettings {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
List(selection: self.$store.selectedJobId) {
|
||||
ForEach(self.store.jobs) { job in
|
||||
self.jobRow(job)
|
||||
.tag(job.id)
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(self.store.jobs) { job in
|
||||
Button {
|
||||
self.selectJob(job.id)
|
||||
} label: {
|
||||
self.jobRow(job)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.background(
|
||||
self.store.selectedJobId == job.id
|
||||
? Color.accentColor.opacity(0.18) : .clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu { self.jobContextMenu(job) }
|
||||
}
|
||||
|
||||
if self.store.jobs.isEmpty {
|
||||
Text("No cron jobs yet.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
.frame(width: 250)
|
||||
|
||||
@@ -151,6 +184,11 @@ extension CronSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private func selectJob(_ id: String) {
|
||||
self.store.selectedJobId = id
|
||||
Task { await self.store.refreshRuns(jobId: id) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var detail: some View {
|
||||
if let selected = self.selectedJob {
|
||||
|
||||
@@ -104,7 +104,6 @@ extension CronSettings {
|
||||
store.runEntries = [run]
|
||||
|
||||
let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
|
||||
_ = view.body
|
||||
_ = view.jobRow(job)
|
||||
_ = view.jobContextMenu(job)
|
||||
_ = view.detailHeader(job)
|
||||
|
||||
@@ -4,14 +4,16 @@ import SwiftUI
|
||||
struct CronSettings: View {
|
||||
@Bindable var store: CronJobsStore
|
||||
@Bindable var channelsStore: ChannelsStore
|
||||
let isActive: Bool
|
||||
@State var showEditor = false
|
||||
@State var editingJob: CronJob?
|
||||
@State var editorError: String?
|
||||
@State var isSaving = false
|
||||
@State var confirmDelete: CronJob?
|
||||
|
||||
init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) {
|
||||
init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared, isActive: Bool = true) {
|
||||
self.store = store
|
||||
self.channelsStore = channelsStore
|
||||
self.isActive = isActive
|
||||
}
|
||||
}
|
||||
|
||||
135
apps/macos/Sources/OpenClaw/DashboardManager.swift
Normal file
135
apps/macos/Sources/OpenClaw/DashboardManager.swift
Normal file
@@ -0,0 +1,135 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
|
||||
private let dashboardManagerLogger = Logger(subsystem: "ai.openclaw", category: "DashboardManager")
|
||||
|
||||
@MainActor
|
||||
final class DashboardManager {
|
||||
static let shared = DashboardManager()
|
||||
|
||||
private var controller: DashboardWindowController?
|
||||
private static let failureURL = URL(string: "about:blank")!
|
||||
|
||||
private init() {}
|
||||
|
||||
@discardableResult
|
||||
func showConfiguredWindowIfPossible() -> Bool {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
guard let config = self.immediateDashboardConfig(mode: mode),
|
||||
let url = try? GatewayEndpointStore.dashboardURL(
|
||||
for: config,
|
||||
mode: mode,
|
||||
authToken: config.token)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
let auth = DashboardWindowAuth(
|
||||
gatewayUrl: Self.websocketURLString(for: url),
|
||||
token: config.token,
|
||||
password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
||||
guard auth.hasCredential else {
|
||||
return false
|
||||
}
|
||||
if let controller {
|
||||
controller.show(url: url, auth: auth)
|
||||
} else {
|
||||
let controller = DashboardWindowController(url: url, auth: auth)
|
||||
self.controller = controller
|
||||
controller.show(url: url, auth: auth)
|
||||
}
|
||||
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
|
||||
return true
|
||||
}
|
||||
|
||||
func show() async throws {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
dashboardManagerLogger.info("dashboard show requested mode=\(String(describing: mode), privacy: .public)")
|
||||
let config = try await self.dashboardConfig(mode: mode)
|
||||
dashboardManagerLogger.info("dashboard config url=\(config.url.absoluteString, privacy: .public)")
|
||||
let token = await GatewayConnection.shared.controlUiAutoAuthToken(config: config)
|
||||
let url = try GatewayEndpointStore.dashboardURL(for: config, mode: mode, authToken: token)
|
||||
let auth = DashboardWindowAuth(
|
||||
gatewayUrl: Self.websocketURLString(for: url),
|
||||
token: token,
|
||||
password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
||||
|
||||
if let controller {
|
||||
dashboardManagerLogger.info("dashboard reuse window url=\(url.absoluteString, privacy: .public)")
|
||||
controller.show(url: url, auth: auth)
|
||||
return
|
||||
}
|
||||
|
||||
dashboardManagerLogger.info("dashboard create window url=\(url.absoluteString, privacy: .public)")
|
||||
let controller = DashboardWindowController(url: url, auth: auth)
|
||||
self.controller = controller
|
||||
controller.show(url: url, auth: auth)
|
||||
|
||||
// Refresh the cached hello payload without blocking window creation.
|
||||
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
|
||||
}
|
||||
|
||||
func showFailure(_ error: Error) {
|
||||
let message = (error as NSError).localizedDescription
|
||||
dashboardManagerLogger.error("dashboard setup failed error=\(message, privacy: .public)")
|
||||
let controller = self.controller ?? DashboardWindowController(
|
||||
url: Self.failureURL,
|
||||
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
|
||||
self.controller = controller
|
||||
controller.showFailure(
|
||||
title: "Dashboard unavailable",
|
||||
message: message,
|
||||
detail: "Check Settings → Connection or use Debug → Reset Remote Tunnel, then try again.")
|
||||
}
|
||||
|
||||
func close() {
|
||||
self.controller?.closeDashboard()
|
||||
}
|
||||
|
||||
private static func websocketURLString(for dashboardURL: URL) -> String {
|
||||
guard var components = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) else {
|
||||
return dashboardURL.absoluteString
|
||||
}
|
||||
switch components.scheme?.lowercased() {
|
||||
case "https":
|
||||
components.scheme = "wss"
|
||||
default:
|
||||
components.scheme = "ws"
|
||||
}
|
||||
components.queryItems = nil
|
||||
components.fragment = nil
|
||||
return components.url?.absoluteString ?? dashboardURL.absoluteString
|
||||
}
|
||||
|
||||
private func dashboardConfig(mode: AppState.ConnectionMode) async throws -> GatewayConnection.Config {
|
||||
if let config = self.immediateDashboardConfig(mode: mode) {
|
||||
return config
|
||||
}
|
||||
|
||||
return try await Task.detached(priority: .userInitiated) {
|
||||
await GatewayEndpointStore.shared.refresh()
|
||||
return try await GatewayEndpointStore.shared.requireConfig()
|
||||
}.value
|
||||
}
|
||||
|
||||
private func immediateDashboardConfig(mode: AppState.ConnectionMode) -> GatewayConnection.Config? {
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
|
||||
if mode == .remote,
|
||||
resolution.transport == .direct,
|
||||
let url = resolution.directURL
|
||||
{
|
||||
return (
|
||||
url,
|
||||
GatewayRemoteConfig.resolveTokenString(root: root),
|
||||
GatewayRemoteConfig.resolvePasswordString(root: root))
|
||||
}
|
||||
|
||||
if mode == .local {
|
||||
return GatewayEndpointStore.localConfig()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
21
apps/macos/Sources/OpenClaw/DashboardWindow.swift
Normal file
21
apps/macos/Sources/OpenClaw/DashboardWindow.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
let dashboardWindowLogger = Logger(subsystem: "ai.openclaw", category: "DashboardWindow")
|
||||
|
||||
enum DashboardWindowLayout {
|
||||
static let windowSize = NSSize(width: 1240, height: 860)
|
||||
static let windowMinSize = NSSize(width: 900, height: 620)
|
||||
}
|
||||
|
||||
struct DashboardWindowAuth: Equatable {
|
||||
var gatewayUrl: String?
|
||||
var token: String?
|
||||
var password: String?
|
||||
|
||||
var hasCredential: Bool {
|
||||
self.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ||
|
||||
self.password?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
}
|
||||
411
apps/macos/Sources/OpenClaw/DashboardWindowController.swift
Normal file
411
apps/macos/Sources/OpenClaw/DashboardWindowController.swift
Normal file
@@ -0,0 +1,411 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
private final class DashboardWindowContentView: NSView {
|
||||
override var mouseDownCanMoveWindow: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private final class DashboardWindowDragRegionView: NSView {
|
||||
override var mouseDownCanMoveWindow: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
self.window?.performDrag(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
||||
private let webView: WKWebView
|
||||
private var currentURL: URL
|
||||
private var auth: DashboardWindowAuth
|
||||
|
||||
init(url: URL, auth: DashboardWindowAuth) {
|
||||
self.currentURL = url
|
||||
self.auth = auth
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.preferences.isElementFullscreenEnabled = true
|
||||
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
||||
config.userContentController = WKUserContentController()
|
||||
Self.installNativeChromeScript(into: config.userContentController)
|
||||
Self.installNativeAuthScript(into: config.userContentController, url: url, auth: auth)
|
||||
|
||||
self.webView = WKWebView(
|
||||
frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize),
|
||||
configuration: config)
|
||||
self.webView.setValue(true, forKey: "drawsBackground")
|
||||
|
||||
let window = Self.makeWindow(contentView: self.webView)
|
||||
super.init(window: window)
|
||||
|
||||
self.webView.navigationDelegate = self
|
||||
self.window?.delegate = self
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
func show(url: URL, auth: DashboardWindowAuth) {
|
||||
self.currentURL = url
|
||||
self.auth = auth
|
||||
self.refreshNativeAuthScript(url: url, auth: auth)
|
||||
self.load(url)
|
||||
self.show()
|
||||
}
|
||||
|
||||
func show() {
|
||||
if let window {
|
||||
let frame = window.frame
|
||||
if frame.width < DashboardWindowLayout.windowMinSize.width ||
|
||||
frame.height < DashboardWindowLayout.windowMinSize.height
|
||||
{
|
||||
window.setFrame(WindowPlacement.centeredFrame(size: DashboardWindowLayout.windowSize), display: false)
|
||||
}
|
||||
}
|
||||
self.showWindow(nil)
|
||||
self.window?.makeKeyAndOrderFront(nil)
|
||||
self.window?.makeFirstResponder(self.webView)
|
||||
self.window?.orderFrontRegardless()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
func closeDashboard() {
|
||||
self.window?.performClose(nil)
|
||||
}
|
||||
|
||||
func showFailure(title: String, message: String, detail: String? = nil) {
|
||||
self.currentURL = URL(string: "about:blank")!
|
||||
self.auth = DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil)
|
||||
self.refreshNativeAuthScript(url: self.currentURL, auth: self.auth)
|
||||
self.webView.stopLoading()
|
||||
self.webView.loadHTMLString(
|
||||
Self.failureHTML(title: title, message: message, detail: detail, url: nil),
|
||||
baseURL: nil)
|
||||
self.show()
|
||||
}
|
||||
|
||||
private func load(_ url: URL) {
|
||||
dashboardWindowLogger.debug("dashboard load \(url.absoluteString, privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
private func refreshNativeAuthScript(url: URL, auth: DashboardWindowAuth) {
|
||||
let controller = self.webView.configuration.userContentController
|
||||
controller.removeAllUserScripts()
|
||||
Self.installNativeChromeScript(into: controller)
|
||||
Self.installNativeAuthScript(into: controller, url: url, auth: auth)
|
||||
}
|
||||
|
||||
private static func makeWindow(contentView: NSView) -> NSWindow {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
let container = DashboardWindowContentView(frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize))
|
||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(contentView)
|
||||
let topDragRegion = DashboardWindowDragRegionView()
|
||||
topDragRegion.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(topDragRegion)
|
||||
let topRightDragRegion = DashboardWindowDragRegionView()
|
||||
topRightDragRegion.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(topRightDragRegion)
|
||||
let sidebarDragRegion = DashboardWindowDragRegionView()
|
||||
sidebarDragRegion.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(sidebarDragRegion)
|
||||
NSLayoutConstraint.activate([
|
||||
contentView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
contentView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
contentView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
topDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78),
|
||||
topDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -380),
|
||||
topDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
topDragRegion.heightAnchor.constraint(equalToConstant: 28),
|
||||
topRightDragRegion.leadingAnchor.constraint(equalTo: topDragRegion.trailingAnchor),
|
||||
topRightDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8),
|
||||
topRightDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
topRightDragRegion.heightAnchor.constraint(equalToConstant: 6),
|
||||
sidebarDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78),
|
||||
sidebarDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
sidebarDragRegion.widthAnchor.constraint(equalToConstant: 176),
|
||||
sidebarDragRegion.heightAnchor.constraint(equalToConstant: 46),
|
||||
])
|
||||
window.title = "OpenClaw"
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isMovableByWindowBackground = true
|
||||
window.isReleasedWhenClosed = false
|
||||
window.hasShadow = true
|
||||
window.backgroundColor = .windowBackgroundColor
|
||||
window.isOpaque = true
|
||||
let viewController = NSViewController()
|
||||
viewController.view = container
|
||||
window.contentViewController = viewController
|
||||
window.center()
|
||||
window.minSize = DashboardWindowLayout.windowMinSize
|
||||
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
|
||||
return window
|
||||
}
|
||||
|
||||
private static func installNativeChromeScript(into userContentController: WKUserContentController) {
|
||||
let css = """
|
||||
html.openclaw-native-macos {
|
||||
--openclaw-native-titlebar-height: 50px;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
html.openclaw-native-macos .sidebar-shell {
|
||||
padding-top: max(14px, var(--openclaw-native-titlebar-height)) !important;
|
||||
}
|
||||
html.openclaw-native-macos .sidebar-shell__header {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
}
|
||||
"""
|
||||
let script = """
|
||||
(() => {
|
||||
try {
|
||||
if (document.getElementById("openclaw-native-macos-chrome")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "openclaw-native-macos-chrome";
|
||||
style.textContent = \(Self.jsStringLiteral(css));
|
||||
document.documentElement.classList.add("openclaw-native-macos");
|
||||
document.head.appendChild(style);
|
||||
} catch {}
|
||||
})();
|
||||
"""
|
||||
userContentController.addUserScript(
|
||||
WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: true))
|
||||
}
|
||||
|
||||
private static func installNativeAuthScript(
|
||||
into userContentController: WKUserContentController,
|
||||
url: URL,
|
||||
auth: DashboardWindowAuth)
|
||||
{
|
||||
guard auth.hasCredential else { return }
|
||||
let allowedOrigin = self.originString(for: url)
|
||||
let allowedPath = self.allowedPath(for: url)
|
||||
let payload: [String: Any?] = [
|
||||
"gatewayUrl": auth.gatewayUrl,
|
||||
"token": auth.token,
|
||||
"password": auth.password,
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload.compactMapValues { $0 }),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return
|
||||
}
|
||||
let script = """
|
||||
(() => {
|
||||
try {
|
||||
const allowedOrigin = \(Self.jsStringLiteral(allowedOrigin));
|
||||
const allowedPath = \(Self.jsStringLiteral(allowedPath));
|
||||
if (location.origin !== allowedOrigin) return;
|
||||
if (allowedPath !== "/" && !location.pathname.startsWith(allowedPath)) return;
|
||||
Object.defineProperty(window, "__OPENCLAW_NATIVE_CONTROL_AUTH__", {
|
||||
value: \(json),
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
})();
|
||||
"""
|
||||
userContentController.addUserScript(
|
||||
WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
}
|
||||
|
||||
static func originString(for url: URL) -> String {
|
||||
guard let scheme = url.scheme, let host = url.host else { return "" }
|
||||
let hostPart = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host
|
||||
var out = "\(scheme)://\(hostPart)"
|
||||
if let port = url.port {
|
||||
out += ":\(port)"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func allowedPath(for url: URL) -> String {
|
||||
let path = url.path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !path.isEmpty else { return "/" }
|
||||
return path.hasSuffix("/") ? path : path + "/"
|
||||
}
|
||||
|
||||
private static func jsStringLiteral(_ value: String) -> String {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: [value]),
|
||||
let raw = String(data: data, encoding: .utf8),
|
||||
raw.hasPrefix("["),
|
||||
raw.hasSuffix("]")
|
||||
else {
|
||||
return "\"\""
|
||||
}
|
||||
return String(raw.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
func webView(
|
||||
_: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
|
||||
{
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
if Self.shouldAllowNavigation(to: url, dashboardURL: self.currentURL) {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
|
||||
func webView(_: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
self.showLoadFailure(error)
|
||||
}
|
||||
|
||||
func webView(_: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
self.showLoadFailure(error)
|
||||
}
|
||||
|
||||
static func shouldAllowNavigation(to url: URL, dashboardURL: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased() else { return true }
|
||||
if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
|
||||
guard scheme == "http" || scheme == "https" else { return false }
|
||||
return url.scheme?.lowercased() == dashboardURL.scheme?.lowercased() &&
|
||||
url.host?.lowercased() == dashboardURL.host?.lowercased() &&
|
||||
url.port == dashboardURL.port
|
||||
}
|
||||
|
||||
func windowWillClose(_: Notification) {
|
||||
self.webView.stopLoading()
|
||||
}
|
||||
|
||||
private func showLoadFailure(_ error: Error) {
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return }
|
||||
dashboardWindowLogger.error(
|
||||
"dashboard load failed url=\(self.currentURL.absoluteString, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
let html = Self.failureHTML(
|
||||
title: "Dashboard unavailable",
|
||||
message: error.localizedDescription,
|
||||
detail: "The dashboard window is open, but the web UI could not load from this endpoint.",
|
||||
url: self.currentURL)
|
||||
self.webView.loadHTMLString(html, baseURL: nil)
|
||||
}
|
||||
|
||||
private static func failureHTML(title: String, message: String, detail: String?, url: URL?) -> String {
|
||||
let detailHTML = detail.map { "<p class=\"detail\">\(self.htmlEscape($0))</p>" } ?? ""
|
||||
let urlHTML = url.map { "<code>\(self.htmlEscape($0.absoluteString))</code>" } ?? ""
|
||||
return """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #101114;
|
||||
color: rgba(255,255,255,.92);
|
||||
font: 15px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
}
|
||||
main {
|
||||
width: min(540px, calc(100vw - 72px));
|
||||
padding: 34px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
border-radius: 22px;
|
||||
background: rgba(255,255,255,.035);
|
||||
box-shadow: 0 28px 90px rgba(0,0,0,.36);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.badge {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,.07);
|
||||
color: #ff746b;
|
||||
font-size: 24px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 24px;
|
||||
line-height: 1.16;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: rgba(255,255,255,.76);
|
||||
font-size: 16px;
|
||||
}
|
||||
.detail {
|
||||
margin-top: 14px;
|
||||
color: rgba(255,255,255,.56);
|
||||
font-size: 13px;
|
||||
}
|
||||
code {
|
||||
display: block;
|
||||
margin-top: 18px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 10px;
|
||||
background: rgba(0,0,0,.26);
|
||||
color: rgba(255,255,255,.76);
|
||||
overflow-wrap: anywhere;
|
||||
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
body { background: #f5f6f8; color: rgba(0,0,0,.86); }
|
||||
main {
|
||||
background: rgba(255,255,255,.84);
|
||||
border-color: rgba(0,0,0,.1);
|
||||
box-shadow: 0 28px 90px rgba(0,0,0,.12);
|
||||
}
|
||||
.badge { background: rgba(0,0,0,.06); }
|
||||
p { color: rgba(0,0,0,.68); }
|
||||
.detail { color: rgba(0,0,0,.54); }
|
||||
code {
|
||||
background: rgba(0,0,0,.05);
|
||||
border-color: rgba(0,0,0,.08);
|
||||
color: rgba(0,0,0,.68);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="badge">!</div>
|
||||
<h1>\(self.htmlEscape(title))</h1>
|
||||
<p>\(self.htmlEscape(message))</p>
|
||||
\(detailHTML)
|
||||
\(urlHTML)
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
|
||||
private static func htmlEscape(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ struct DebugSettings: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
|
||||
self.overviewSection
|
||||
self.launchdSection
|
||||
self.appInfoSection
|
||||
self.gatewaySection
|
||||
@@ -62,8 +63,8 @@ struct DebugSettings: View {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
.task {
|
||||
@@ -119,6 +120,31 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var overviewSection: some View {
|
||||
HStack(spacing: 12) {
|
||||
DebugMetricCard(
|
||||
title: "App Health",
|
||||
value: self.healthStore.state.debugTitle,
|
||||
icon: "heart.text.square",
|
||||
tint: self.healthStore.state.tint,
|
||||
subtitle: self.healthStore.summaryLine)
|
||||
|
||||
DebugMetricCard(
|
||||
title: "Gateway",
|
||||
value: self.gatewayManager.status.label,
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
tint: self.gatewayManager.status.debugTint,
|
||||
subtitle: self.canRestartGateway ? "Local process" : "Remote connection")
|
||||
|
||||
DebugMetricCard(
|
||||
title: "App PID",
|
||||
value: "\(ProcessInfo.processInfo.processIdentifier)",
|
||||
icon: "number.square",
|
||||
tint: .blue,
|
||||
subtitle: Bundle.main.bundleURL.lastPathComponent)
|
||||
}
|
||||
}
|
||||
|
||||
private func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -216,8 +242,12 @@ struct DebugSettings: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(height: 180)
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2)))
|
||||
.frame(height: 130)
|
||||
.background(.black.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.06))
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if self.canRestartGateway {
|
||||
@@ -929,13 +959,81 @@ extension DebugSettings {
|
||||
|
||||
struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
configuration.label
|
||||
.font(.caption.weight(.semibold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
configuration.content
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.34), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.055))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DebugMetricCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let icon: String
|
||||
let tint: Color
|
||||
let subtitle: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: self.icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(self.tint)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(self.tint.opacity(0.18), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.value)
|
||||
.font(.callout.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Text(self.subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.background(.quaternary.opacity(0.28), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.055))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension HealthState {
|
||||
fileprivate var debugTitle: String {
|
||||
switch self {
|
||||
case .unknown: "Unknown"
|
||||
case .ok: "Healthy"
|
||||
case .linkingNeeded: "Needs Link"
|
||||
case .degraded: "Degraded"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayProcessManager.Status {
|
||||
fileprivate var debugTint: Color {
|
||||
switch self {
|
||||
case .running, .attachedExisting: .green
|
||||
case .starting: .orange
|
||||
case .failed: .red
|
||||
case .stopped: .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,6 +1082,7 @@ extension DebugSettings {
|
||||
|
||||
_ = view.body
|
||||
_ = view.header
|
||||
_ = view.overviewSection
|
||||
_ = view.appInfoSection
|
||||
_ = view.gatewaySection
|
||||
_ = view.logsSection
|
||||
|
||||
@@ -69,6 +69,8 @@ final class DeepLinkHandler {
|
||||
await self.handleAgent(link: link, originalURL: url)
|
||||
case .gateway:
|
||||
break
|
||||
case .dashboard:
|
||||
await self.openDashboard()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +180,14 @@ final class DeepLinkHandler {
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private func openDashboard() async {
|
||||
do {
|
||||
try await DashboardManager.shared.show()
|
||||
} catch {
|
||||
DashboardManager.shared.showFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func confirm(title: String, message: String) -> Bool {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
|
||||
@@ -33,7 +33,7 @@ final class DevicePairingApprovalPrompter {
|
||||
let remoteIp: String?
|
||||
}
|
||||
|
||||
private struct PendingRequest: Codable, Equatable, Identifiable {
|
||||
struct PendingRequest: Codable, Equatable, Identifiable {
|
||||
let requestId: String
|
||||
let deviceId: String
|
||||
let publicKey: String
|
||||
@@ -115,14 +115,16 @@ final class DevicePairingApprovalPrompter {
|
||||
PairingAlertSupport.presentPairingAlert(
|
||||
request: req,
|
||||
requestId: req.requestId,
|
||||
messageText: "Allow device to connect?",
|
||||
informativeText: Self.describe(req),
|
||||
messageText: Self.alertTitle(for: req),
|
||||
informativeText: Self.alertSummary(for: req),
|
||||
buttonTitles: PairingAlertSupport.ButtonTitles(approve: Self.approveButtonTitle(for: req)),
|
||||
accessoryView: Self.buildAccessoryView(for: req),
|
||||
state: self.alertState,
|
||||
onResponse: self.handleAlertResponse)
|
||||
}
|
||||
|
||||
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||
var shouldRemove = response != .alertFirstButtonReturn
|
||||
var shouldRemove = response != .alertSecondButtonReturn
|
||||
defer {
|
||||
if shouldRemove {
|
||||
if self.queue.first == request {
|
||||
@@ -144,14 +146,14 @@ final class DevicePairingApprovalPrompter {
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
_ = await self.approve(requestId: request.requestId)
|
||||
case .alertSecondButtonReturn:
|
||||
shouldRemove = false
|
||||
if let idx = self.queue.firstIndex(of: request) {
|
||||
self.queue.remove(at: idx)
|
||||
}
|
||||
self.queue.append(request)
|
||||
return
|
||||
case .alertSecondButtonReturn:
|
||||
_ = await self.approve(requestId: request.requestId)
|
||||
case .alertThirdButtonReturn:
|
||||
await self.reject(requestId: request.requestId)
|
||||
default:
|
||||
@@ -233,24 +235,166 @@ final class DevicePairingApprovalPrompter {
|
||||
self.updatePendingCounts()
|
||||
}
|
||||
|
||||
private static func describe(_ req: PendingRequest) -> String {
|
||||
var lines: [String] = []
|
||||
lines.append("Device: \(req.displayName ?? req.deviceId)")
|
||||
if let platform = req.platform {
|
||||
lines.append("Platform: \(platform)")
|
||||
static func alertTitle(for req: PendingRequest) -> String {
|
||||
self.isMac(req.platform) ? "New Mac wants to connect" : "New device wants to connect"
|
||||
}
|
||||
|
||||
static func alertSummary(for req: PendingRequest) -> String {
|
||||
let subject = self.isMac(req.platform) ? "this Mac app" : "this device"
|
||||
return "Approve \(subject) to control OpenClaw. Only approve if this is yours; you can remove it later in Settings."
|
||||
}
|
||||
|
||||
static func approveButtonTitle(for req: PendingRequest) -> String {
|
||||
self.isMac(req.platform) ? "Approve Mac" : "Approve Device"
|
||||
}
|
||||
|
||||
static func buildAccessoryView(for req: PendingRequest) -> NSView {
|
||||
let stack = NSStackView()
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 8
|
||||
stack.edgeInsets = NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)
|
||||
|
||||
stack.addArrangedSubview(self.makeValueRow(label: "Device", value: self.deviceName(for: req)))
|
||||
if let platform = self.prettyPlatform(req.platform) {
|
||||
stack.addArrangedSubview(self.makeValueRow(label: "Platform", value: platform))
|
||||
}
|
||||
if let role = req.role {
|
||||
lines.append("Role: \(role)")
|
||||
if let role = self.prettyRole(req.role) {
|
||||
stack.addArrangedSubview(self.makeValueRow(label: "Role", value: role))
|
||||
}
|
||||
if let scopes = req.scopes, !scopes.isEmpty {
|
||||
lines.append("Scopes: \(scopes.joined(separator: ", "))")
|
||||
let accessItems = self.friendlyScopeNames(req.scopes)
|
||||
if !accessItems.isEmpty {
|
||||
stack.addArrangedSubview(self.makeSectionLabel("Access requested"))
|
||||
for item in accessItems {
|
||||
stack.addArrangedSubview(self.makeBullet(item))
|
||||
}
|
||||
}
|
||||
if let remoteIp = req.remoteIp {
|
||||
lines.append("IP: \(remoteIp)")
|
||||
stack.addArrangedSubview(self.makeDetailLine(req))
|
||||
|
||||
let fitting = stack.fittingSize
|
||||
stack.frame = NSRect(x: 0, y: 0, width: 420, height: fitting.height)
|
||||
return stack
|
||||
}
|
||||
|
||||
static func deviceName(for req: PendingRequest) -> String {
|
||||
let trimmedName = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let trimmedName, !trimmedName.isEmpty, trimmedName != req.deviceId {
|
||||
return trimmedName
|
||||
}
|
||||
return self.isMac(req.platform) ? "OpenClaw Mac app" : "New device"
|
||||
}
|
||||
|
||||
static func prettyPlatform(_ raw: String?) -> String? {
|
||||
let platform = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let platform, !platform.isEmpty else { return nil }
|
||||
switch platform.lowercased() {
|
||||
case "macintel", "x86_64-apple-darwin":
|
||||
return "Mac (Intel)"
|
||||
case "macarm", "macarm64", "arm64-apple-darwin", "aarch64-apple-darwin":
|
||||
return "Mac (Apple silicon)"
|
||||
case "darwin":
|
||||
return "Mac"
|
||||
default:
|
||||
if platform.lowercased().contains("mac") {
|
||||
return "Mac"
|
||||
}
|
||||
return platform
|
||||
}
|
||||
}
|
||||
|
||||
static func prettyRole(_ raw: String?) -> String? {
|
||||
let role = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let role, !role.isEmpty else { return nil }
|
||||
return role == "operator" ? "Operator" : role
|
||||
}
|
||||
|
||||
static func friendlyScopeNames(_ scopes: [String]?) -> [String] {
|
||||
guard let scopes else { return [] }
|
||||
var seen = Set<String>()
|
||||
return scopes.compactMap { scope in
|
||||
let normalized = scope.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty, seen.insert(normalized).inserted else { return nil }
|
||||
switch normalized {
|
||||
case "operator.admin":
|
||||
return "Admin access"
|
||||
case "operator.read":
|
||||
return "Read OpenClaw data"
|
||||
case "operator.write":
|
||||
return "Send messages and make changes"
|
||||
case "operator.approvals":
|
||||
return "Manage approvals"
|
||||
case "operator.pairing":
|
||||
return "Pair and repair devices"
|
||||
case "operator.talk.secrets":
|
||||
return "Use Talk credentials"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func shortIdentifier(_ id: String) -> String {
|
||||
let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count > 20 else { return trimmed }
|
||||
return "\(trimmed.prefix(8))...\(trimmed.suffix(7))"
|
||||
}
|
||||
|
||||
private static func isMac(_ platform: String?) -> Bool {
|
||||
guard let platform else { return false }
|
||||
let lower = platform.lowercased()
|
||||
return lower.contains("mac") || lower.contains("darwin")
|
||||
}
|
||||
|
||||
private static func makeValueRow(label: String, value: String) -> NSView {
|
||||
let row = NSStackView()
|
||||
row.orientation = .horizontal
|
||||
row.alignment = .firstBaseline
|
||||
row.spacing = 8
|
||||
|
||||
let labelField = self.makeLabel("\(label):", font: .systemFont(ofSize: 12, weight: .semibold))
|
||||
labelField.textColor = .secondaryLabelColor
|
||||
labelField.setContentHuggingPriority(.required, for: .horizontal)
|
||||
let valueField = self.makeLabel(value, font: .systemFont(ofSize: 12, weight: .regular))
|
||||
valueField.maximumNumberOfLines = 2
|
||||
|
||||
row.addArrangedSubview(labelField)
|
||||
row.addArrangedSubview(valueField)
|
||||
return row
|
||||
}
|
||||
|
||||
private static func makeSectionLabel(_ text: String) -> NSTextField {
|
||||
let label = self.makeLabel(text, font: .systemFont(ofSize: 12, weight: .semibold))
|
||||
label.textColor = .secondaryLabelColor
|
||||
return label
|
||||
}
|
||||
|
||||
private static func makeBullet(_ text: String) -> NSTextField {
|
||||
let label = self.makeLabel("• \(text)", font: .systemFont(ofSize: 12, weight: .regular))
|
||||
label.maximumNumberOfLines = 2
|
||||
return label
|
||||
}
|
||||
|
||||
private static func makeDetailLine(_ req: PendingRequest) -> NSTextField {
|
||||
var parts = ["ID \(self.shortIdentifier(req.deviceId))"]
|
||||
if let remoteIp = req.remoteIp?.trimmingCharacters(in: .whitespacesAndNewlines), !remoteIp.isEmpty {
|
||||
parts.append("IP \(remoteIp.replacingOccurrences(of: "::ffff:", with: ""))")
|
||||
}
|
||||
if req.isRepair == true {
|
||||
lines.append("Repair: yes")
|
||||
parts.append("repair request")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
let label = self.makeLabel(
|
||||
parts.joined(separator: " · "),
|
||||
font: .monospacedSystemFont(ofSize: 11, weight: .regular))
|
||||
label.textColor = .tertiaryLabelColor
|
||||
label.maximumNumberOfLines = 2
|
||||
return label
|
||||
}
|
||||
|
||||
private static func makeLabel(_ text: String, font: NSFont) -> NSTextField {
|
||||
let label = NSTextField(labelWithString: text)
|
||||
label.font = font
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.textColor = .labelColor
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ final class DockIconManager: NSObject, @unchecked Sendable {
|
||||
return
|
||||
}
|
||||
|
||||
let userWantsDockHidden = !UserDefaults.standard.bool(forKey: showDockIconKey)
|
||||
let userWantsDockHidden = (UserDefaults.standard.object(forKey: showDockIconKey) as? Bool) == false
|
||||
let visibleWindows = NSApp?.windows.filter { window in
|
||||
window.isVisible &&
|
||||
window.frame.width > 1 &&
|
||||
|
||||
@@ -64,6 +64,7 @@ actor GatewayConnection {
|
||||
case configSet = "config.set"
|
||||
case configPatch = "config.patch"
|
||||
case configSchema = "config.schema"
|
||||
case configSchemaLookup = "config.schema.lookup"
|
||||
case wizardStart = "wizard.start"
|
||||
case wizardNext = "wizard.next"
|
||||
case wizardCancel = "wizard.cancel"
|
||||
@@ -317,6 +318,28 @@ actor GatewayConnection {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
func controlUiAutoAuthToken(config: Config) async -> String? {
|
||||
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
return token
|
||||
}
|
||||
if let deviceToken = self.lastSnapshot?.auth["deviceToken"]?.value as? String {
|
||||
let trimmed = deviceToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
if let entry = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") {
|
||||
let trimmed = entry.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func sessionDefaultString(_ defaults: [String: OpenClawProtocol.AnyCodable]?, key: String) -> String {
|
||||
let raw = defaults?[key]?.value as? String
|
||||
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
|
||||
@@ -41,21 +41,31 @@ enum GatewayDiscoveryHelpers {
|
||||
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
self.directGatewayUrl(
|
||||
serviceHost: gateway.serviceHost,
|
||||
servicePort: gateway.servicePort)
|
||||
servicePort: gateway.servicePort,
|
||||
gatewayTls: gateway.gatewayTls)
|
||||
}
|
||||
|
||||
static func directGatewayUrl(
|
||||
serviceHost: String?,
|
||||
servicePort: Int?) -> String?
|
||||
servicePort: Int?,
|
||||
gatewayTls: Bool = false) -> String?
|
||||
{
|
||||
// Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort).
|
||||
// Prefer the resolved service endpoint (SRV + A/AAAA).
|
||||
guard let endpoint = self.serviceEndpoint(serviceHost: serviceHost, servicePort: servicePort) else {
|
||||
return nil
|
||||
}
|
||||
// Security: for non-loopback hosts, force TLS to avoid plaintext credential/session leakage.
|
||||
let scheme = self.isLoopbackHost(endpoint.host) ? "ws" : "wss"
|
||||
let portSuffix = endpoint.port == 443 ? "" : ":\(endpoint.port)"
|
||||
let scheme: String
|
||||
if gatewayTls {
|
||||
scheme = "wss"
|
||||
} else if self.isLoopbackHost(endpoint.host)
|
||||
|| GatewayRemoteConfig.isTrustedPlaintextRemoteHost(endpoint.host)
|
||||
{
|
||||
scheme = "ws"
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
let portSuffix = scheme == "wss" && endpoint.port == 443 ? "" : ":\(endpoint.port)"
|
||||
return "\(scheme)://\(endpoint.host)\(portSuffix)"
|
||||
}
|
||||
|
||||
|
||||
@@ -25,14 +25,14 @@ enum GatewayDiscoverySelectionSupport {
|
||||
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
|
||||
|
||||
if preferredTransport == .direct {
|
||||
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port)
|
||||
OpenClawConfigFile.setRemoteGatewayTransport(AppState.RemoteTransport.direct.rawValue)
|
||||
if !state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
OpenClawConfigFile.setRemoteGatewayUrlString(state.remoteUrl)
|
||||
} else {
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
}
|
||||
} else {
|
||||
OpenClawConfigFile.setRemoteGatewayTransport(AppState.RemoteTransport.ssh.rawValue)
|
||||
OpenClawConfigFile.setRemoteGatewayUrlString(state.remoteUrl)
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,10 @@ enum GatewayDiscoverySelectionSupport {
|
||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool
|
||||
{
|
||||
guard GatewayDiscoveryHelpers.directUrl(for: gateway) != nil else { return false }
|
||||
if gateway.stableID.hasPrefix("tailscale-serve|") {
|
||||
if gateway.gatewayTls || gateway.gatewayDirectReachable {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let host = GatewayDiscoveryHelpers.resolvedServiceHost(for: gateway)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
@@ -306,8 +306,9 @@ actor GatewayEndpointStore {
|
||||
password: password))
|
||||
case .remote:
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
|
||||
if resolution.transport == .direct {
|
||||
guard let url = resolution.directURL else {
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.unavailable(
|
||||
mode: .remote,
|
||||
@@ -470,8 +471,9 @@ actor GatewayEndpointStore {
|
||||
|
||||
private func resolveDirectRemoteURL() throws -> URL? {
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
guard GatewayRemoteConfig.resolveTransport(root: root) == .direct else { return nil }
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
|
||||
guard resolution.transport == .direct else { return nil }
|
||||
guard let url = resolution.directURL else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
@@ -667,7 +669,8 @@ extension GatewayEndpointStore {
|
||||
static func dashboardURL(
|
||||
for config: GatewayConnection.Config,
|
||||
mode: AppState.ConnectionMode,
|
||||
localBasePath: String? = nil) throws -> URL
|
||||
localBasePath: String? = nil,
|
||||
authToken: String? = nil) throws -> URL
|
||||
{
|
||||
guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else {
|
||||
throw NSError(domain: "Dashboard", code: 1, userInfo: [
|
||||
@@ -694,7 +697,8 @@ extension GatewayEndpointStore {
|
||||
}
|
||||
|
||||
var fragmentItems: [URLQueryItem] = []
|
||||
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
let tokenCandidate = authToken ?? config.token
|
||||
if let token = tokenCandidate?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
fragmentItems.append(URLQueryItem(name: "token", value: token))
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
enum GatewayRemoteConfig {
|
||||
enum TransportSource: Equatable {
|
||||
case explicit
|
||||
case inferredRemoteURL
|
||||
case legacySSH
|
||||
}
|
||||
|
||||
struct TransportResolution: Equatable {
|
||||
let transport: AppState.RemoteTransport
|
||||
let source: TransportSource
|
||||
let directURL: URL?
|
||||
}
|
||||
|
||||
enum TokenValue: Equatable {
|
||||
case missing
|
||||
case plaintext(String)
|
||||
@@ -25,14 +40,49 @@ enum GatewayRemoteConfig {
|
||||
}
|
||||
|
||||
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
||||
self.resolveTransportResolution(root: root).transport
|
||||
}
|
||||
|
||||
static func resolveTransportResolution(root: [String: Any]) -> TransportResolution {
|
||||
let explicit = self.resolveExplicitTransport(root: root)
|
||||
switch explicit {
|
||||
case .direct:
|
||||
return TransportResolution(
|
||||
transport: .direct,
|
||||
source: .explicit,
|
||||
directURL: self.resolveGatewayUrl(root: root))
|
||||
case .ssh:
|
||||
return TransportResolution(transport: .ssh, source: .explicit, directURL: nil)
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
|
||||
if let url = self.resolveGatewayUrl(root: root),
|
||||
let host = url.host,
|
||||
!LoopbackHost.isLoopbackHost(host)
|
||||
{
|
||||
return TransportResolution(transport: .direct, source: .inferredRemoteURL, directURL: url)
|
||||
}
|
||||
|
||||
return TransportResolution(transport: .ssh, source: .legacySSH, directURL: nil)
|
||||
}
|
||||
|
||||
private static func resolveExplicitTransport(root: [String: Any]) -> AppState.RemoteTransport? {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let raw = remote["transport"] as? String
|
||||
else {
|
||||
return .ssh
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
|
||||
switch trimmed {
|
||||
case AppState.RemoteTransport.direct.rawValue:
|
||||
return .direct
|
||||
case AppState.RemoteTransport.ssh.rawValue:
|
||||
return .ssh
|
||||
default:
|
||||
return .ssh
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveUrlString(root: [String: Any]) -> String? {
|
||||
@@ -69,6 +119,17 @@ enum GatewayRemoteConfig {
|
||||
}
|
||||
}
|
||||
|
||||
static func resolvePasswordString(root: [String: Any]) -> String? {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let raw = remote["password"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func resolveTLSFingerprint(root: [String: Any]) -> String? {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
@@ -85,6 +146,27 @@ enum GatewayRemoteConfig {
|
||||
return self.normalizeGatewayUrl(raw)
|
||||
}
|
||||
|
||||
static func resolveRemotePort(root: [String: Any]) -> Int? {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let value = remote["remotePort"]
|
||||
let port: Int? = switch value {
|
||||
case let raw as Int:
|
||||
raw
|
||||
case let raw as NSNumber:
|
||||
raw.intValue
|
||||
case let raw as String:
|
||||
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
default:
|
||||
nil
|
||||
}
|
||||
guard let port, port > 0, port <= 65535 else { return nil }
|
||||
return port
|
||||
}
|
||||
|
||||
static func normalizeGatewayUrlString(_ raw: String) -> String? {
|
||||
self.normalizeGatewayUrl(raw)?.absoluteString
|
||||
}
|
||||
@@ -96,7 +178,10 @@ enum GatewayRemoteConfig {
|
||||
guard scheme == "ws" || scheme == "wss" else { return nil }
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !host.isEmpty else { return nil }
|
||||
if scheme == "ws", !LoopbackHost.isLoopbackHost(host) {
|
||||
if scheme == "ws",
|
||||
!LoopbackHost.isLoopbackHost(host),
|
||||
!self.isTrustedPlaintextRemoteHost(host)
|
||||
{
|
||||
return nil
|
||||
}
|
||||
if scheme == "ws", url.port == nil {
|
||||
@@ -109,6 +194,59 @@ enum GatewayRemoteConfig {
|
||||
return url
|
||||
}
|
||||
|
||||
static func isTrustedPlaintextRemoteHost(_ host: String) -> Bool {
|
||||
let lower = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !lower.isEmpty else { return false }
|
||||
if lower == "localhost" || lower.hasSuffix(".local") || lower.hasSuffix(".ts.net") {
|
||||
return true
|
||||
}
|
||||
if self.isPrivateIPv6Literal(lower) {
|
||||
return true
|
||||
}
|
||||
guard let parts = self.ipv4Parts(lower) else { return false }
|
||||
switch (parts[0], parts[1]) {
|
||||
case (10, _), (192, 168), (169, 254):
|
||||
return true
|
||||
case (172, 16...31):
|
||||
return true
|
||||
case (100, 64...127):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ipv4Parts(_ value: String) -> [Int]? {
|
||||
let labels = value.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard labels.count == 4 else { return nil }
|
||||
var parts: [Int] = []
|
||||
parts.reserveCapacity(4)
|
||||
for label in labels {
|
||||
guard !label.isEmpty,
|
||||
label.allSatisfy(\.isNumber),
|
||||
let part = Int(label),
|
||||
part >= 0,
|
||||
part <= 255
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
parts.append(part)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
private static func isPrivateIPv6Literal(_ value: String) -> Bool {
|
||||
#if canImport(Darwin)
|
||||
var addr = in6_addr()
|
||||
guard value.withCString({ inet_pton(AF_INET6, $0, &addr) }) == 1 else {
|
||||
return false
|
||||
}
|
||||
return value.hasPrefix("fc") || value.hasPrefix("fd") || value.hasPrefix("fe80:")
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
static func defaultPort(for url: URL) -> Int? {
|
||||
if let port = url.port { return port }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
|
||||
@@ -6,8 +6,15 @@ import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSettings: View {
|
||||
enum Page {
|
||||
case general
|
||||
case connection
|
||||
}
|
||||
|
||||
@Bindable var state: AppState
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
let page: Page
|
||||
let isActive: Bool
|
||||
private let healthStore = HealthStore.shared
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||
@@ -20,79 +27,177 @@ struct GeneralSettings: View {
|
||||
ProcessInfo.processInfo.isNixMode
|
||||
}
|
||||
|
||||
private var remoteLabelWidth: CGFloat {
|
||||
88
|
||||
init(state: AppState, page: Page = .general, isActive: Bool = true) {
|
||||
self.state = state
|
||||
self.page = page
|
||||
self.isActive = isActive
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SettingsToggleRow(
|
||||
title: "OpenClaw active",
|
||||
subtitle: "Pause to stop the OpenClaw gateway; no messages will be processed.",
|
||||
binding: self.activeBinding)
|
||||
|
||||
self.connectionSection
|
||||
|
||||
Divider()
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Launch at login",
|
||||
subtitle: "Automatically start OpenClaw after you sign in.",
|
||||
binding: self.$state.launchAtLogin)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Show Dock icon",
|
||||
subtitle: "Keep OpenClaw visible in the Dock instead of menu-bar-only mode.",
|
||||
binding: self.$state.showDockIcon)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Play menu bar icon animations",
|
||||
subtitle: "Enable idle blinks and wiggles on the status icon.",
|
||||
binding: self.$state.iconAnimationsEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Allow Canvas",
|
||||
subtitle: "Allow the agent to show and control the Canvas panel.",
|
||||
binding: self.$state.canvasEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Allow Camera",
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||
binding: self.$state.peekabooBridgeEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Enable debug tools",
|
||||
subtitle: "Show the Debug tab with development utilities.",
|
||||
binding: self.$state.debugPaneEnabled)
|
||||
}
|
||||
|
||||
Spacer(minLength: 12)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Quit OpenClaw") { NSApp.terminate(nil) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
switch self.page {
|
||||
case .general:
|
||||
self.generalPage
|
||||
case .connection:
|
||||
self.connectionPage
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 22)
|
||||
.frame(maxWidth: 760, alignment: .leading)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
}
|
||||
.onAppear {
|
||||
guard !self.isPreview else { return }
|
||||
self.refreshGatewayStatus()
|
||||
self.updateActiveWork(active: self.isActive)
|
||||
}
|
||||
.onChange(of: self.isActive) { _, active in
|
||||
self.updateActiveWork(active: active)
|
||||
}
|
||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
CanvasManager.shared.hideAll()
|
||||
}
|
||||
}
|
||||
.onDisappear { self.gatewayDiscovery.stop() }
|
||||
}
|
||||
|
||||
private var generalPage: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
SettingsPageHeader(
|
||||
title: "General",
|
||||
subtitle: "Everyday OpenClaw app behavior.")
|
||||
|
||||
self.openClawStatusPanel
|
||||
|
||||
SettingsCardGroup("App") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Launch at login",
|
||||
subtitle: "Automatically start OpenClaw after you sign in.",
|
||||
binding: self.$state.launchAtLogin)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Show Dock icon",
|
||||
subtitle: "Keep OpenClaw visible in the Dock. When off, windows still show the Dock icon while open.",
|
||||
binding: self.$state.showDockIcon)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Play menu bar icon animations",
|
||||
subtitle: "Enable idle blinks and wiggles on the status icon.",
|
||||
binding: self.$state.iconAnimationsEnabled,
|
||||
showsDivider: false)
|
||||
}
|
||||
|
||||
SettingsCardGroup("Capabilities") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Allow Canvas",
|
||||
subtitle: "Allow the agent to show and control the Canvas panel.",
|
||||
binding: self.$state.canvasEnabled)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Allow Camera",
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||
binding: self.$state.peekabooBridgeEnabled,
|
||||
showsDivider: false)
|
||||
}
|
||||
|
||||
SettingsCardGroup("Developer") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Enable debug tools",
|
||||
subtitle: "Show the Debug page with development utilities.",
|
||||
binding: self.$state.debugPaneEnabled,
|
||||
showsDivider: false)
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("App session")
|
||||
.font(.callout.weight(.medium))
|
||||
Text("Quit only when you want to stop the menu bar app completely.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 18)
|
||||
Button("Quit") { NSApp.terminate(nil) }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private var openClawStatusPanel: some View {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(self.state.isPaused ? Color.orange.opacity(0.18) : Color.green.opacity(0.18))
|
||||
Image(systemName: self.state.isPaused ? "pause.fill" : "checkmark")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(self.state.isPaused ? .orange : .green)
|
||||
}
|
||||
.frame(width: 42, height: 42)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.state.isPaused ? "OpenClaw paused" : "OpenClaw active")
|
||||
.font(.headline)
|
||||
Text(self.generalStatusSubtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 20)
|
||||
|
||||
Toggle("OpenClaw active", isOn: self.activeBinding)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.06))
|
||||
}
|
||||
}
|
||||
|
||||
private var generalStatusSubtitle: String {
|
||||
if self.state.isPaused {
|
||||
return "Gateway work is paused; incoming messages will wait."
|
||||
}
|
||||
switch self.state.connectionMode {
|
||||
case .local:
|
||||
return "Processing messages through the local Gateway on this Mac."
|
||||
case .remote:
|
||||
return "Connected to a remote Gateway configuration."
|
||||
case .unconfigured:
|
||||
return "Ready to run after you choose a Gateway connection."
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionPage: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
SettingsPageHeader(
|
||||
title: "Connection",
|
||||
subtitle: "Choose where the Gateway runs and how this Mac app reaches it.")
|
||||
|
||||
self.connectionStatusPanel
|
||||
self.gatewayModeGroup
|
||||
|
||||
switch self.state.connectionMode {
|
||||
case .unconfigured:
|
||||
EmptyView()
|
||||
case .local:
|
||||
self.localGatewayGroup
|
||||
case .remote:
|
||||
self.remoteCard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
@@ -101,56 +206,175 @@ struct GeneralSettings: View {
|
||||
set: { self.state.isPaused = !$0 })
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("OpenClaw runs")
|
||||
.font(.title3.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Picker("Mode", selection: self.$state.connectionMode) {
|
||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
|
||||
private func updateActiveWork(active: Bool) {
|
||||
guard !self.isPreview else { return }
|
||||
if active {
|
||||
self.refreshGatewayStatus()
|
||||
if self.page == .connection {
|
||||
self.gatewayDiscovery.start()
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.frame(width: 260, alignment: .leading)
|
||||
} else {
|
||||
self.gatewayDiscovery.stop()
|
||||
}
|
||||
}
|
||||
|
||||
if self.state.connectionMode == .unconfigured {
|
||||
Text("Pick Local or Remote to start the Gateway.")
|
||||
private var connectionStatusPanel: some View {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(self.connectionStatusTint.opacity(0.18))
|
||||
Image(systemName: self.connectionStatusIcon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(self.connectionStatusTint)
|
||||
}
|
||||
.frame(width: 46, height: 46)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.connectionStatusTitle)
|
||||
.font(.headline)
|
||||
Text(self.connectionStatusSubtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if self.state.connectionMode == .local {
|
||||
// In Nix mode, gateway is managed declaratively - no install buttons.
|
||||
if !self.isNixMode {
|
||||
self.gatewayInstallerCard
|
||||
Spacer(minLength: 18)
|
||||
|
||||
if let ping = ControlChannel.shared.lastPingMs {
|
||||
Text("\(Int(ping)) ms")
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.green.opacity(0.16), in: Capsule())
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.06))
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionStatusIcon: String {
|
||||
switch self.state.connectionMode {
|
||||
case .local: "desktopcomputer"
|
||||
case .remote: self.state.remoteTransport == .ssh ? "point.3.connected.trianglepath.dotted" : "network"
|
||||
case .unconfigured: "questionmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionStatusTint: Color {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: .green
|
||||
case .connecting, .disconnected, .degraded: .orange
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionStatusTitle: String {
|
||||
switch self.state.connectionMode {
|
||||
case .local: "Local Gateway"
|
||||
case .remote: self.state.remoteTransport == .ssh ? "Remote Gateway via SSH" : "Remote Gateway direct"
|
||||
case .unconfigured: "Gateway not configured"
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionStatusSubtitle: String {
|
||||
switch self.state.connectionMode {
|
||||
case .local:
|
||||
return "OpenClaw starts and monitors the Gateway on this Mac."
|
||||
case .remote:
|
||||
let target = self.state.remoteTransport == .ssh ? self.state.remoteTarget : self.state.remoteUrl
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return "Enter a remote endpoint so this Mac app can attach cleanly."
|
||||
}
|
||||
return "\(self.controlStatusLine) · \(trimmed)"
|
||||
case .unconfigured:
|
||||
return "Choose local or remote before the app can attach to a Gateway."
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayModeGroup: some View {
|
||||
SettingsCardGroup("Gateway") {
|
||||
SettingsCardRow(
|
||||
title: "OpenClaw runs",
|
||||
subtitle: "Pick whether this app owns a local Gateway or attaches to another host.",
|
||||
showsDivider: self.state.connectionMode == .unconfigured)
|
||||
{
|
||||
Picker("Gateway location", selection: self.$state.connectionMode) {
|
||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
|
||||
}
|
||||
TailscaleIntegrationSection(
|
||||
connectionMode: self.state.connectionMode,
|
||||
isPaused: self.state.isPaused)
|
||||
self.healthRow
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.frame(width: 260, alignment: .trailing)
|
||||
}
|
||||
|
||||
if self.state.connectionMode == .remote {
|
||||
self.remoteCard
|
||||
if self.state.connectionMode == .unconfigured {
|
||||
SettingsCardRow(
|
||||
title: "Setup needed",
|
||||
subtitle: "Local is best for this Mac. Remote is best when the Gateway already runs on a Mac Studio or server.",
|
||||
showsDivider: false)
|
||||
{
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var localGatewayGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
SettingsCardGroup("Local Gateway") {
|
||||
if !self.isNixMode {
|
||||
self.gatewayInstallerCard
|
||||
}
|
||||
self.healthRow
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
|
||||
TailscaleIntegrationSection(
|
||||
connectionMode: self.state.connectionMode,
|
||||
isPaused: self.state.isPaused)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.remoteTransportRow
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
SettingsCardGroup("Remote Access") {
|
||||
self.remoteTransportRow
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
self.remoteSshRow
|
||||
} else {
|
||||
self.remoteDirectRow
|
||||
}
|
||||
self.remoteTokenRow
|
||||
}
|
||||
|
||||
SettingsCardGroup("Discovery & Status") {
|
||||
self.remoteDiscoveryRow
|
||||
self.remoteStatusRow
|
||||
self.controlChannelRow
|
||||
self.remoteTipRow
|
||||
}
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
self.remoteSshRow
|
||||
} else {
|
||||
self.remoteDirectRow
|
||||
self.remoteAdvancedGroup
|
||||
}
|
||||
self.remoteTokenRow
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
private var remoteDiscoveryRow: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Nearby gateways")
|
||||
.font(.callout.weight(.medium))
|
||||
GatewayDiscoveryInlineList(
|
||||
discovery: self.gatewayDiscovery,
|
||||
currentTarget: self.state.remoteTarget,
|
||||
@@ -159,92 +383,111 @@ struct GeneralSettings: View {
|
||||
{ gateway in
|
||||
self.applyDiscoveredGateway(gateway)
|
||||
}
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
.overlay(alignment: .bottom) {
|
||||
Divider()
|
||||
.padding(.leading, 14)
|
||||
}
|
||||
}
|
||||
|
||||
self.remoteStatusView
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("CLI path") {
|
||||
TextField("/Applications/OpenClaw.app/.../openclaw", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Text("Advanced")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Control channel")
|
||||
.font(.caption.weight(.semibold))
|
||||
if !self.isControlStatusDuplicate || ControlChannel.shared.lastPingMs != nil {
|
||||
let status = self.isControlStatusDuplicate ? nil : self.controlStatusLine
|
||||
let ping = ControlChannel.shared.lastPingMs.map { "Ping \(Int($0)) ms" }
|
||||
let line = [status, ping].compactMap(\.self).joined(separator: " · ")
|
||||
if !line.isEmpty {
|
||||
Text(line)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let hb = HeartbeatStore.shared.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000))
|
||||
Text("Last heartbeat: \(hb.status) · \(ageText)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let authLabel = ControlChannel.shared.authSourceLabel {
|
||||
Text(authLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
@ViewBuilder
|
||||
private var remoteStatusRow: some View {
|
||||
if self.remoteStatus != .idle {
|
||||
SettingsCardRow(title: "Remote test") {
|
||||
self.remoteStatusView
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.onAppear { self.gatewayDiscovery.start() }
|
||||
.onDisappear { self.gatewayDiscovery.stop() }
|
||||
}
|
||||
|
||||
private var controlChannelRow: some View {
|
||||
SettingsCardRow(title: "Control channel", subtitle: self.controlChannelSubtitle) {
|
||||
Text(self.controlStatusLine)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(self.connectionStatusTint.opacity(0.16), in: Capsule())
|
||||
.foregroundStyle(self.connectionStatusTint)
|
||||
}
|
||||
}
|
||||
|
||||
private var controlChannelSubtitle: String? {
|
||||
var parts: [String] = []
|
||||
if let ping = ControlChannel.shared.lastPingMs {
|
||||
parts.append("Ping \(Int(ping)) ms")
|
||||
}
|
||||
if let hb = HeartbeatStore.shared.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000))
|
||||
parts.append("Last heartbeat \(hb.status) · \(ageText)")
|
||||
}
|
||||
if let authLabel = ControlChannel.shared.authSourceLabel {
|
||||
parts.append(authLabel)
|
||||
}
|
||||
return parts.isEmpty ? nil : parts.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private var remoteTipRow: some View {
|
||||
SettingsCardRow(
|
||||
title: "Recommended setup",
|
||||
subtitle: self.state.remoteTransport == .ssh
|
||||
? "Use Tailscale plus an SSH tunnel for stable private access."
|
||||
: "Use Tailscale Serve so the gateway has a valid HTTPS certificate.",
|
||||
showsDivider: false)
|
||||
{
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteAdvancedGroup: some View {
|
||||
SettingsCardGroup("Advanced") {
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.advancedTextField(
|
||||
"Identity file",
|
||||
placeholder: "/Users/you/.ssh/id_ed25519",
|
||||
text: self.$state.remoteIdentity)
|
||||
self.advancedTextField(
|
||||
"Project root",
|
||||
placeholder: "/home/you/Projects/openclaw",
|
||||
text: self.$state.remoteProjectRoot)
|
||||
self.advancedTextField(
|
||||
"CLI path",
|
||||
placeholder: "/Applications/OpenClaw.app/.../openclaw",
|
||||
text: self.$state.remoteCliPath)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
} label: {
|
||||
Text("SSH command details")
|
||||
.font(.callout.weight(.medium))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
}
|
||||
|
||||
private func advancedTextField(_ title: String, placeholder: String, text: Binding<String>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteTransportRow: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Transport")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
SettingsCardRow(
|
||||
title: "Transport",
|
||||
subtitle: "SSH keeps the Gateway private; direct is best for HTTPS or Tailscale Serve.")
|
||||
{
|
||||
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 320)
|
||||
.frame(width: 320)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,59 +496,52 @@ struct GeneralSettings: View {
|
||||
let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget)
|
||||
let canTest = !trimmedTarget.isEmpty && validationMessage == nil
|
||||
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
SettingsCardRow(title: "SSH target", subtitle: "User and host for the remote Gateway machine.") {
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(width: 420)
|
||||
self.remoteTestButton(disabled: !canTest)
|
||||
}
|
||||
if let validationMessage {
|
||||
Text(validationMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteDirectRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Gateway")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
SettingsCardRow(title: "Gateway URL", subtitle: "The WebSocket URL exposed by the remote Gateway.") {
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(width: 420)
|
||||
self.remoteTestButton(
|
||||
disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
Text(
|
||||
"Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.")
|
||||
"Use wss:// for public hosts. ws:// is allowed for localhost, LAN, .local, and Tailnet hosts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteTokenRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Gateway token")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
SettingsCardRow(
|
||||
title: "Gateway token",
|
||||
subtitle: "Used when the remote gateway requires token auth.",
|
||||
showsDivider: false)
|
||||
{
|
||||
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(width: 360)
|
||||
}
|
||||
Text("Used when the remote gateway requires token auth.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
if self.state.remoteTokenUnsupported {
|
||||
Text(
|
||||
"The current gateway.remote.token value is not plain text. "
|
||||
@@ -313,7 +549,8 @@ struct GeneralSettings: View {
|
||||
+ "enter a plaintext token here to replace it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,11 +607,6 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var isControlStatusDuplicate: Bool {
|
||||
guard case let .failed(message) = self.remoteStatus else { return false }
|
||||
return message == self.controlStatusLine
|
||||
}
|
||||
|
||||
private var gatewayInstallerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
|
||||
@@ -3,13 +3,15 @@ import SwiftUI
|
||||
|
||||
struct InstancesSettings: View {
|
||||
var store: InstancesStore
|
||||
let isActive: Bool
|
||||
|
||||
init(store: InstancesStore = .shared) {
|
||||
init(store: InstancesStore = .shared, isActive: Bool = true) {
|
||||
self.store = store
|
||||
self.isActive = isActive
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
if let err = store.lastError {
|
||||
Text("Error: \(err)")
|
||||
@@ -29,20 +31,37 @@ struct InstancesSettings: View {
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.onAppear { self.store.start() }
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.onAppear { self.updateActiveWork(active: self.isActive) }
|
||||
.onChange(of: self.isActive) { _, active in
|
||||
self.updateActiveWork(active: active)
|
||||
}
|
||||
.onDisappear { self.store.stop() }
|
||||
}
|
||||
|
||||
private func updateActiveWork(active: Bool) {
|
||||
if active {
|
||||
self.store.start()
|
||||
} else {
|
||||
self.store.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Connected Instances")
|
||||
.font(.headline)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Latest presence beacons from OpenClaw nodes. Updated periodically.")
|
||||
.font(.footnote)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
SettingsRefreshButton(isLoading: self.store.isLoading) {
|
||||
Task { await self.store.refresh() }
|
||||
}
|
||||
|
||||
@@ -91,8 +91,11 @@ struct OpenClawApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
|
||||
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
||||
private func applyStatusItemAppearance(paused _: Bool, sleeping _: Bool) {
|
||||
// Keep the status item actionable even when the Gateway is paused or disconnected.
|
||||
// The SwiftUI label already renders those states; AppKit's disabled appearance can
|
||||
// leak into menu item validation and grey out app-level commands like Settings.
|
||||
self.statusItem?.button?.appearsDisabled = false
|
||||
}
|
||||
|
||||
private static func applyAttachOnlyOverrideIfNeeded() {
|
||||
@@ -143,7 +146,7 @@ struct OpenClawApp: App {
|
||||
handler.translatesAutoresizingMaskIntoConstraints = false
|
||||
handler.onLeftClick = { [self] in
|
||||
HoverHUDController.shared.dismiss(reason: "statusItemClick")
|
||||
self.toggleWebChatPanel()
|
||||
self.openDashboardWindow()
|
||||
}
|
||||
handler.onRightClick = { [self] in
|
||||
HoverHUDController.shared.dismiss(reason: "statusItemRightClick")
|
||||
@@ -167,14 +170,21 @@ struct OpenClawApp: App {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func toggleWebChatPanel() {
|
||||
private func openDashboardWindow() {
|
||||
HoverHUDController.shared.setSuppressed(true)
|
||||
self.isMenuPresented = false
|
||||
if DashboardManager.shared.showConfiguredWindowIfPossible() {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||
WebChatManager.shared.togglePanel(
|
||||
sessionKey: sessionKey,
|
||||
anchorProvider: { [self] in self.statusButtonScreenFrame() })
|
||||
if DashboardManager.shared.showConfiguredWindowIfPossible() {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await DashboardManager.shared.show()
|
||||
} catch {
|
||||
DashboardManager.shared.showFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +293,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
WebChatManager.shared.show(sessionKey: sessionKey)
|
||||
}
|
||||
}
|
||||
if CommandLine.arguments.contains("--dashboard") {
|
||||
self.webChatAutoLogger.info("Auto-opening dashboard via CLI flag")
|
||||
Task { @MainActor in
|
||||
if DashboardManager.shared.showConfiguredWindowIfPossible() {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await DashboardManager.shared.show()
|
||||
} catch {
|
||||
DashboardManager.shared.showFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
@@ -294,6 +317,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
MacNodeModeCoordinator.shared.stop()
|
||||
TerminationSignalWatcher.shared.stop()
|
||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||
DashboardManager.shared.close()
|
||||
WebChatManager.shared.close()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
Task { await RemoteTunnelManager.shared.stopAll() }
|
||||
@@ -303,6 +327,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
@MainActor
|
||||
private func scheduleFirstRunOnboardingIfNeeded() {
|
||||
if AppStateStore.shared.connectionMode != .unconfigured {
|
||||
OnboardingController.markComplete()
|
||||
return
|
||||
}
|
||||
let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey)
|
||||
let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen
|
||||
guard shouldShow else { return }
|
||||
|
||||
@@ -111,11 +111,7 @@ struct MenuContent: View {
|
||||
self.voiceWakeMicMenu
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
Task { @MainActor in
|
||||
await self.openDashboard()
|
||||
}
|
||||
} label: {
|
||||
Link(destination: URL(string: "openclaw://dashboard")!) {
|
||||
Label("Open Dashboard", systemImage: "gauge")
|
||||
}
|
||||
Button {
|
||||
@@ -342,20 +338,6 @@ struct MenuContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func openDashboard() async {
|
||||
do {
|
||||
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||
let url = try GatewayEndpointStore.dashboardURL(for: config, mode: self.state.connectionMode)
|
||||
NSWorkspace.shared.open(url)
|
||||
} catch {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Dashboard unavailable"
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
|
||||
private var macNodeStatus: (label: String, color: Color)? {
|
||||
guard self.state.connectionMode != .unconfigured else { return nil }
|
||||
guard case .connected = self.controlChannel.state else { return nil }
|
||||
|
||||
@@ -37,8 +37,10 @@ struct MenuHeaderCard<Content: View>: View {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(5)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
self.content
|
||||
}
|
||||
|
||||
@@ -65,28 +65,28 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
|
||||
// Refresh in background for the next open; keep width stable while open.
|
||||
// Refresh in the background for the next open. Rebuilding custom menu
|
||||
// rows while AppKit is tracking the menu causes visible flicker.
|
||||
self.loadTask?.cancel()
|
||||
let forceRefresh = self.cachedSnapshot == nil || self.cachedErrorText != nil
|
||||
self.loadTask = Task { [weak self] in
|
||||
let shouldRepaintAfterRefresh = self.cachedSnapshot == nil || self.cachedErrorText != nil
|
||||
self.loadTask = Task { [weak self, weak menu] in
|
||||
guard let self else { return }
|
||||
let forceRefresh = shouldRepaintAfterRefresh
|
||||
await self.refreshCache(force: forceRefresh)
|
||||
await self.refreshUsageCache(force: forceRefresh)
|
||||
await self.refreshCostUsageCache(force: forceRefresh)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
if shouldRepaintAfterRefresh {
|
||||
await self.repaintOpenMenu(menu)
|
||||
}
|
||||
}
|
||||
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.nodesLoadTask = Task { [weak self] in
|
||||
let shouldRepaintNodesAfterRefresh = self.shouldRepaintNodesAfterRefresh()
|
||||
self.nodesLoadTask = Task { [weak self, weak menu] in
|
||||
guard let self else { return }
|
||||
await self.nodesStore.refresh()
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.injectNodes(into: menu)
|
||||
if !shouldRepaintAfterRefresh, shouldRepaintNodesAfterRefresh {
|
||||
await self.repaintOpenMenuNodes(menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,8 +95,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.originalDelegate?.menuDidClose?(menu)
|
||||
self.isMenuOpen = false
|
||||
self.menuOpenWidth = nil
|
||||
self.loadTask?.cancel()
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.cancelPreviewTasks()
|
||||
}
|
||||
|
||||
@@ -122,25 +120,18 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
guard self.isMenuOpen, let menu = self.statusItem?.menu else { return }
|
||||
self.loadTask?.cancel()
|
||||
self.loadTask = Task { [weak self, weak menu] in
|
||||
guard let self, let menu else { return }
|
||||
guard let self else { return }
|
||||
await self.refreshCache(force: true)
|
||||
await self.refreshUsageCache(force: true)
|
||||
await self.refreshCostUsageCache(force: true)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
await self.repaintOpenMenu(menu)
|
||||
}
|
||||
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.nodesLoadTask = Task { [weak self, weak menu] in
|
||||
guard let self, let menu else { return }
|
||||
guard let self else { return }
|
||||
await self.nodesStore.refresh()
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
await self.repaintOpenMenuNodes(menu)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +269,28 @@ extension MenuSessionsInjector {
|
||||
_ = cursor
|
||||
}
|
||||
|
||||
private func repaintOpenMenu(_ menu: NSMenu?) async {
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen, let menu else { return }
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
}
|
||||
|
||||
private func repaintOpenMenuNodes(_ menu: NSMenu?) async {
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen, let menu else { return }
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRepaintNodesAfterRefresh() -> Bool {
|
||||
guard self.isControlChannelConnected else { return false }
|
||||
return self.sortedNodeEntries().isEmpty
|
||||
|| self.nodesStore.lastError?.nonEmpty != nil
|
||||
|| self.nodesStore.statusMessage?.nonEmpty != nil
|
||||
}
|
||||
|
||||
private func buildContextSubmenu(
|
||||
width: CGFloat,
|
||||
isConnected: Bool,
|
||||
@@ -374,7 +387,7 @@ extension MenuSessionsInjector {
|
||||
return self.cachedErrorText ?? "Loading…"
|
||||
}
|
||||
|
||||
return self.controlChannelStatusText(for: channelState)
|
||||
return Self.menuStatusText(self.controlChannelStatusText(for: channelState))
|
||||
}
|
||||
|
||||
private func activeRows(from snapshot: SessionStoreSnapshot) -> [SessionRow] {
|
||||
@@ -527,7 +540,7 @@ extension MenuSessionsInjector {
|
||||
case .connecting:
|
||||
"Connecting…"
|
||||
case let .degraded(message):
|
||||
message.nonEmpty ?? "Gateway disconnected"
|
||||
Self.menuStatusText(message.nonEmpty ?? "Gateway disconnected")
|
||||
case .disconnected:
|
||||
"Gateway disconnected"
|
||||
}
|
||||
@@ -684,7 +697,7 @@ extension MenuSessionsInjector {
|
||||
self.previewTasks.removeAll()
|
||||
}
|
||||
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 3) -> NSMenuItem {
|
||||
let view = AnyView(
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: symbolName)
|
||||
@@ -810,6 +823,19 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
return "Sessions unavailable"
|
||||
}
|
||||
|
||||
private static func menuStatusText(_ text: String) -> String {
|
||||
let lines = text
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
let singleLine = (lines.isEmpty ? text : lines.joined(separator: " "))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard singleLine.count > 180 else { return singleLine }
|
||||
return "\(singleLine.prefix(177))…"
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuSessionsInjector {
|
||||
@@ -1283,6 +1309,14 @@ extension MenuSessionsInjector {
|
||||
self.inject(into: menu)
|
||||
}
|
||||
|
||||
func testingControlChannelStatusText(for state: ControlChannel.ConnectionState) -> String {
|
||||
self.controlChannelStatusText(for: state)
|
||||
}
|
||||
|
||||
func testingMenuStatusText(_ text: String) -> String {
|
||||
Self.menuStatusText(text)
|
||||
}
|
||||
|
||||
func testingFindInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ final class NodePairingApprovalPrompter {
|
||||
requestId: req.requestId,
|
||||
messageText: "Allow node to connect?",
|
||||
informativeText: Self.describe(req),
|
||||
buttonTitles: PairingAlertSupport.ButtonTitles(approve: "Approve Node"),
|
||||
state: self.alertState,
|
||||
onResponse: self.handleAlertResponse)
|
||||
}
|
||||
@@ -307,11 +308,11 @@ final class NodePairingApprovalPrompter {
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
|
||||
return
|
||||
case .alertSecondButtonReturn:
|
||||
_ = await self.approve(requestId: request.requestId)
|
||||
await self.notify(resolution: .approved, request: request, via: "local")
|
||||
case .alertSecondButtonReturn:
|
||||
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
|
||||
return
|
||||
case .alertThirdButtonReturn:
|
||||
await self.reject(requestId: request.requestId)
|
||||
await self.notify(resolution: .rejected, request: request, via: "local")
|
||||
|
||||
@@ -21,12 +21,16 @@ final class OnboardingController {
|
||||
static let shared = OnboardingController()
|
||||
private var window: NSWindow?
|
||||
|
||||
static func markComplete() {
|
||||
UserDefaults.standard.set(true, forKey: onboardingSeenKey)
|
||||
UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
|
||||
AppStateStore.shared.onboardingSeen = true
|
||||
}
|
||||
|
||||
func show() {
|
||||
if ProcessInfo.processInfo.isNixMode {
|
||||
// Nix mode is fully declarative; onboarding would suggest interactive setup that doesn't apply.
|
||||
UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen")
|
||||
UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
|
||||
AppStateStore.shared.onboardingSeen = true
|
||||
Self.markComplete()
|
||||
return
|
||||
}
|
||||
if let window {
|
||||
|
||||
@@ -54,8 +54,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func finish() {
|
||||
UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen")
|
||||
UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
|
||||
OnboardingController.markComplete()
|
||||
OnboardingController.shared.close()
|
||||
}
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ extension OnboardingView {
|
||||
return "Select a nearby gateway or open Advanced to enter a gateway URL."
|
||||
}
|
||||
if GatewayRemoteConfig.normalizeGatewayUrl(trimmedUrl) == nil {
|
||||
return "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)."
|
||||
return "Gateway URL must use wss:// for public hosts; ws:// is allowed for localhost, LAN, or Tailnet hosts."
|
||||
}
|
||||
return nil
|
||||
case .ssh:
|
||||
|
||||
@@ -301,6 +301,16 @@ enum OpenClawConfigFile {
|
||||
}
|
||||
}
|
||||
|
||||
static func setRemoteGatewayTransport(_ value: String) {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.updateGatewayDict { gateway in
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
remote["transport"] = trimmed
|
||||
gateway["remote"] = remote
|
||||
}
|
||||
}
|
||||
|
||||
static func clearRemoteGatewayUrl() {
|
||||
self.updateGatewayDict { gateway in
|
||||
guard var remote = gateway["remote"] as? [String: Any] else { return }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user