Compare commits
1180 Commits
docs/trust
...
build-runt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3254bae4ca | ||
|
|
77d162fc7f | ||
|
|
dc9808a674 | ||
|
|
60ad2c2e96 | ||
|
|
a7cbce1b3d | ||
|
|
a74251d415 | ||
|
|
cbc3de6c97 | ||
|
|
01b1e350b2 | ||
|
|
3b4096e02e | ||
|
|
adc818db4a | ||
|
|
568fd337be | ||
|
|
d9ca051a1d | ||
|
|
1b6704ef53 | ||
|
|
887b209db4 | ||
|
|
d4bdcda324 | ||
|
|
966957fc66 | ||
|
|
555eb3f62c | ||
|
|
93b9f1ec5f | ||
|
|
6254e96acf | ||
|
|
19f53543d2 | ||
|
|
618008b483 | ||
|
|
31d1ed351f | ||
|
|
22c1210a16 | ||
|
|
273d70741f | ||
|
|
07be14c02d | ||
|
|
5b2cb8ba11 | ||
|
|
1d7b2bc9c8 | ||
|
|
a881bd41eb | ||
|
|
cd44a0d01e | ||
|
|
a73e7786e7 | ||
|
|
2679089e9e | ||
|
|
c95a61aa9d | ||
|
|
73a97ee255 | ||
|
|
b1dca644bc | ||
|
|
b743e652c0 | ||
|
|
71cee673b2 | ||
|
|
dc5d234848 | ||
|
|
a5cbd036de | ||
|
|
260a514467 | ||
|
|
067509fa44 | ||
|
|
e84b20a527 | ||
|
|
4aaafe5322 | ||
|
|
d5ee766afe | ||
|
|
00c91c3678 | ||
|
|
4ab25a2889 | ||
|
|
14fb2c05b1 | ||
|
|
57d5a8df86 | ||
|
|
b6871d9c0f | ||
|
|
f03ea76db3 | ||
|
|
753491ab80 | ||
|
|
d00adfe98c | ||
|
|
2b2c3a071b | ||
|
|
f8fbeb52b0 | ||
|
|
cb46ea037f | ||
|
|
dece9e8b07 | ||
|
|
32221e194a | ||
|
|
5ecc364d55 | ||
|
|
0dbc51aa55 | ||
|
|
58cf37ceeb | ||
|
|
652318e56a | ||
|
|
d8691ff4ec | ||
|
|
8251f7c235 | ||
|
|
ae1880acf6 | ||
|
|
fddf8a6f4a | ||
|
|
412c1d0af1 | ||
|
|
166cf6a3e0 | ||
|
|
fd3d452f1f | ||
|
|
fdd0e78d1b | ||
|
|
60ce38d216 | ||
|
|
acb2a1ce37 | ||
|
|
ba3a0e7adb | ||
|
|
3a7b1b36b6 | ||
|
|
3830a4b58e | ||
|
|
6288c51774 | ||
|
|
a508c34731 | ||
|
|
5baa08ed13 | ||
|
|
55fd88e967 | ||
|
|
725f63f724 | ||
|
|
c82dc02b4d | ||
|
|
2cf060f774 | ||
|
|
5529473af9 | ||
|
|
5e3b211d93 | ||
|
|
3fd40fc5a3 | ||
|
|
f934725ccd | ||
|
|
5709b30700 | ||
|
|
2d5004cee4 | ||
|
|
1287abe0b5 | ||
|
|
a91bcd2cf4 | ||
|
|
67bfe8fb80 | ||
|
|
be4a490c23 | ||
|
|
e9ed5febc5 | ||
|
|
72baa58edd | ||
|
|
76015aab23 | ||
|
|
115cfb4430 | ||
|
|
ab000bc411 | ||
|
|
e3a93d6705 | ||
|
|
7857096d29 | ||
|
|
cedd520f25 | ||
|
|
4bdb857eca | ||
|
|
997b9ad232 | ||
|
|
e075a33ca3 | ||
|
|
c07036e813 | ||
|
|
b562aa6625 | ||
|
|
fe73878dfc | ||
|
|
ee2fa5f411 | ||
|
|
07dea4c6cc | ||
|
|
7bb0b7d1fc | ||
|
|
a90e007d50 | ||
|
|
94e84e6f75 | ||
|
|
e9c8540e21 | ||
|
|
961ca61b0e | ||
|
|
f809ff5e55 | ||
|
|
d27a763eec | ||
|
|
abd009b092 | ||
|
|
f0e373b82e | ||
|
|
35ab521e07 | ||
|
|
d8d9d3724f | ||
|
|
e3445f59c9 | ||
|
|
a68ed3f64c | ||
|
|
31980bcaf1 | ||
|
|
70f86e326d | ||
|
|
bed0e07620 | ||
|
|
632b71c7f8 | ||
|
|
eef13235ad | ||
|
|
89155aa6c6 | ||
|
|
bbcbabab74 | ||
|
|
0e2d8b8a1e | ||
|
|
c5288300a1 | ||
|
|
a7f6c95675 | ||
|
|
74294a4653 | ||
|
|
c59a472ca2 | ||
|
|
722bfaa9c9 | ||
|
|
37086d0c3e | ||
|
|
a1c50b4ee3 | ||
|
|
d75cd40787 | ||
|
|
34b088ede6 | ||
|
|
36b5f0c9a8 | ||
|
|
704c8ed530 | ||
|
|
2158b09b9d | ||
|
|
ed276d3e50 | ||
|
|
53ec78319d | ||
|
|
51709c63fe | ||
|
|
f8925b7588 | ||
|
|
023091ded3 | ||
|
|
ce922915ab | ||
|
|
f749365b1c | ||
|
|
4fc72226fa | ||
|
|
def74465eb | ||
|
|
a91553c7cf | ||
|
|
65ea200c31 | ||
|
|
0b56472cf5 | ||
|
|
8ba16a894f | ||
|
|
99909f7bc7 | ||
|
|
1b455b6d9f | ||
|
|
6b4590be06 | ||
|
|
a948212ca7 | ||
|
|
d491c789a3 | ||
|
|
e58884925a | ||
|
|
a1ff0e4767 | ||
|
|
8e7b7a2b22 | ||
|
|
d9d93485d9 | ||
|
|
5fb4032fb6 | ||
|
|
7c822d039b | ||
|
|
3c6cff5758 | ||
|
|
511719424d | ||
|
|
8cd20e220f | ||
|
|
c92bcf24c4 | ||
|
|
0c77851516 | ||
|
|
50abdaf33b | ||
|
|
012b674f31 | ||
|
|
c9bb6bd0d8 | ||
|
|
ff4f59ec90 | ||
|
|
27deda2221 | ||
|
|
c3812a1ffb | ||
|
|
84601bf96b | ||
|
|
aabe4d9b45 | ||
|
|
856e1a3187 | ||
|
|
5958454710 | ||
|
|
a02e5759cc | ||
|
|
059573a48d | ||
|
|
150c5815eb | ||
|
|
69418cca20 | ||
|
|
5c233f4ded | ||
|
|
c623c51cf4 | ||
|
|
2ac3e780e3 | ||
|
|
4920ca65db | ||
|
|
02ff9f43ea | ||
|
|
b4f14d6f7a | ||
|
|
3cd786cc2d | ||
|
|
778959b3dc | ||
|
|
ef2c66a16b | ||
|
|
b30ed6ca4c | ||
|
|
71009ab1b6 | ||
|
|
c8779ef61d | ||
|
|
218189318d | ||
|
|
f37b1c11e0 | ||
|
|
375e16170d | ||
|
|
3a075f0292 | ||
|
|
c75fe7e3cd | ||
|
|
a3419e48ab | ||
|
|
8ccbd00e1b | ||
|
|
6c33bd9c67 | ||
|
|
75f33e92bf | ||
|
|
59c0b2bb37 | ||
|
|
342e9cac03 | ||
|
|
bdfa2b490b | ||
|
|
00e63da336 | ||
|
|
80eb91d9e7 | ||
|
|
108f0ef8c4 | ||
|
|
92f8c0fac3 | ||
|
|
8fdde0429e | ||
|
|
38f430e133 | ||
|
|
5c5af2b14e | ||
|
|
c25026f2b3 | ||
|
|
a6158873f5 | ||
|
|
a8f3a579d4 | ||
|
|
a4b958efcd | ||
|
|
e3f4cabf49 | ||
|
|
a742d44133 | ||
|
|
b2088d2e1d | ||
|
|
88548784ce | ||
|
|
719280d737 | ||
|
|
de103773c7 | ||
|
|
0653e8d2ec | ||
|
|
699136f89a | ||
|
|
824901083b | ||
|
|
a2ceadcc2a | ||
|
|
5248b759fe | ||
|
|
c682634188 | ||
|
|
c7b6d6a14e | ||
|
|
99fda7b920 | ||
|
|
6a4144f537 | ||
|
|
9a5e617a55 | ||
|
|
6f2f88d3ad | ||
|
|
c118f6c688 | ||
|
|
f92900fc20 | ||
|
|
99caaef6cc | ||
|
|
137079fc21 | ||
|
|
a5b87338e5 | ||
|
|
8678b10aef | ||
|
|
41f546faa5 | ||
|
|
95c986dee1 | ||
|
|
d9c891eb90 | ||
|
|
b2d8b95906 | ||
|
|
a2c695126d | ||
|
|
394e69a2f8 | ||
|
|
7ef956d224 | ||
|
|
7773c5410b | ||
|
|
dce3e4bd94 | ||
|
|
65f8b46c15 | ||
|
|
01ca3da8ee | ||
|
|
2e758d3691 | ||
|
|
be9b5cefbd | ||
|
|
813b96a804 | ||
|
|
1f1e97674f | ||
|
|
04f00f8ef2 | ||
|
|
9084c4e345 | ||
|
|
c6b3736fe7 | ||
|
|
a0e763168f | ||
|
|
5c88d3c9f1 | ||
|
|
b6069fc68c | ||
|
|
b3ef3fca75 | ||
|
|
25be51967a | ||
|
|
63ab5bfddc | ||
|
|
b74c3d80cc | ||
|
|
ac3db098ab | ||
|
|
b2c42697dd | ||
|
|
cbf6ee3a64 | ||
|
|
3ce0e80f57 | ||
|
|
da2fde7b6f | ||
|
|
ca4c2b33d7 | ||
|
|
9f393a045c | ||
|
|
1ab5fcc325 | ||
|
|
c906121ad3 | ||
|
|
fabe4807a6 | ||
|
|
6e36d956d6 | ||
|
|
9143f33a80 | ||
|
|
b567ba5dfc | ||
|
|
6277698f86 | ||
|
|
10feda100e | ||
|
|
2da512e24d | ||
|
|
bf61d94083 | ||
|
|
08f16da8d7 | ||
|
|
fe303fc016 | ||
|
|
aa4d212a09 | ||
|
|
3783cd3850 | ||
|
|
9adcccadb1 | ||
|
|
b4f16001aa | ||
|
|
94eb50658d | ||
|
|
dda3026d13 | ||
|
|
3a3bfa7f13 | ||
|
|
8da99247f1 | ||
|
|
a767777598 | ||
|
|
afa5444242 | ||
|
|
5457f6e7e4 | ||
|
|
d4476c6899 | ||
|
|
d238483337 | ||
|
|
f4782e1e73 | ||
|
|
ac75cc3495 | ||
|
|
c1bf99406f | ||
|
|
910e1e52dd | ||
|
|
d4c7b0505f | ||
|
|
9203a2fdb1 | ||
|
|
95355ba25a | ||
|
|
e89c7b7735 | ||
|
|
6b65a055e6 | ||
|
|
cc0bfa0f39 | ||
|
|
1843bcf1db | ||
|
|
933a9945ae | ||
|
|
234d69f83f | ||
|
|
77db65d669 | ||
|
|
c3340a3894 | ||
|
|
41d053a06f | ||
|
|
47462eed68 | ||
|
|
e7f65b4aac | ||
|
|
7323953ab0 | ||
|
|
cd225c15be | ||
|
|
afc333cc5b | ||
|
|
30eacd36af | ||
|
|
e927fd1e35 | ||
|
|
0c57f5e62e | ||
|
|
c6c6e9f741 | ||
|
|
df95ddc771 | ||
|
|
75d22b2164 | ||
|
|
e687ad15ac | ||
|
|
e683353cab | ||
|
|
2b143de554 | ||
|
|
d979c6c089 | ||
|
|
ee331e8d55 | ||
|
|
ade11ec892 | ||
|
|
7ea14a1c87 | ||
|
|
1911942363 | ||
|
|
6565ec2e53 | ||
|
|
3d38e56401 | ||
|
|
2e64cbd1b8 | ||
|
|
88caa4b50c | ||
|
|
fa4c282f9e | ||
|
|
88cac5985e | ||
|
|
0f4036b0f6 | ||
|
|
c211fd112c | ||
|
|
75f3b5069b | ||
|
|
831fb0aea3 | ||
|
|
7ecc105c3d | ||
|
|
4f8a2ed2ce | ||
|
|
53ffc309f3 | ||
|
|
3e7800befb | ||
|
|
e2f73650d4 | ||
|
|
89dccc79a7 | ||
|
|
3c97ec70d1 | ||
|
|
beffb6fe48 | ||
|
|
71c1d09f22 | ||
|
|
fe27215747 | ||
|
|
86df160617 | ||
|
|
a69e82765f | ||
|
|
8b2a5672be | ||
|
|
d3d82a1c19 | ||
|
|
bd9d35c720 | ||
|
|
723e314e2b | ||
|
|
9a344da298 | ||
|
|
a7b6555195 | ||
|
|
e2c68cb169 | ||
|
|
6ec76af3a6 | ||
|
|
dd11a6bcda | ||
|
|
893d2fb862 | ||
|
|
85b267aae9 | ||
|
|
34b6c743f5 | ||
|
|
33a3a56ee1 | ||
|
|
af34c8fafe | ||
|
|
1a758135d8 | ||
|
|
a58088383b | ||
|
|
b060afd3a5 | ||
|
|
d458131821 | ||
|
|
0f86ee531b | ||
|
|
0c29ffac09 | ||
|
|
ebf44f5096 | ||
|
|
7b39aa3444 | ||
|
|
384a886b70 | ||
|
|
42b0d6f43e | ||
|
|
0931a35709 | ||
|
|
3d0e568007 | ||
|
|
7d0c0bfc7c | ||
|
|
abb4b7c91c | ||
|
|
26a831e2c3 | ||
|
|
9d7113c74c | ||
|
|
df7fff8fd7 | ||
|
|
c1cc28a4e1 | ||
|
|
0d47bea3bf | ||
|
|
18342b0a5b | ||
|
|
80e5aebf6a | ||
|
|
9f9978635c | ||
|
|
abf36ddd5f | ||
|
|
ab6f080d80 | ||
|
|
9e2233da7f | ||
|
|
052d988add | ||
|
|
26b3859b18 | ||
|
|
360b73bbb8 | ||
|
|
2944c7d6af | ||
|
|
d80ccdb9e0 | ||
|
|
d7079b5578 | ||
|
|
428b6e0dee | ||
|
|
8a4f9f168b | ||
|
|
b838429e2f | ||
|
|
b9cbe71faa | ||
|
|
5c7869ae6c | ||
|
|
fa472623f6 | ||
|
|
37aaca0d4e | ||
|
|
fcd2eca9c7 | ||
|
|
108ea4336b | ||
|
|
216f4d4669 | ||
|
|
80abb5ab98 | ||
|
|
2fc479b427 | ||
|
|
b65b3c6ff0 | ||
|
|
229376fbed | ||
|
|
d306d598ce | ||
|
|
cbd9395082 | ||
|
|
dec28e5384 | ||
|
|
46392e033c | ||
|
|
cb2f978ed5 | ||
|
|
913b137090 | ||
|
|
6e1b3ace4d | ||
|
|
2c1a4ddabc | ||
|
|
eb79785b36 | ||
|
|
aa2d74a843 | ||
|
|
ceacc2675d | ||
|
|
a39a5a35b0 | ||
|
|
57d0130336 | ||
|
|
600260ebf8 | ||
|
|
261e2c131e | ||
|
|
ebb54d71ef | ||
|
|
99da4c8d56 | ||
|
|
6c7a7d910a | ||
|
|
4950fcfb33 | ||
|
|
cc2a63cd2d | ||
|
|
6217561931 | ||
|
|
2bd672f3ab | ||
|
|
6491182a79 | ||
|
|
a4bf619522 | ||
|
|
55b7100ab9 | ||
|
|
26bf041add | ||
|
|
bdc3e447e9 | ||
|
|
2c5e24cbb5 | ||
|
|
7793f2efd5 | ||
|
|
ed03b834d5 | ||
|
|
adee048247 | ||
|
|
b8f66c260d | ||
|
|
c46f395bb9 | ||
|
|
628c7b2398 | ||
|
|
806c8b3129 | ||
|
|
485b78bb94 | ||
|
|
2f4b91d738 | ||
|
|
a457782386 | ||
|
|
2fe16af3cd | ||
|
|
45f7ef1bfc | ||
|
|
ef1f98ed6e | ||
|
|
91c041e5da | ||
|
|
21df9ebd92 | ||
|
|
e163883fb3 | ||
|
|
a14d275b2a | ||
|
|
50b7607f77 | ||
|
|
10e6d926bc | ||
|
|
c1ad0e8754 | ||
|
|
12c37a9a3a | ||
|
|
4295ff785f | ||
|
|
ca97c47a02 | ||
|
|
29bec2bfef | ||
|
|
1b8dd2e504 | ||
|
|
47beacec3c | ||
|
|
b93aa7fb66 | ||
|
|
c2deba3b56 | ||
|
|
f41f6d3243 | ||
|
|
164c1a3b5c | ||
|
|
48fd9d7dc7 | ||
|
|
64aff2d0ca | ||
|
|
2b52ded882 | ||
|
|
0954618cfb | ||
|
|
fac040cb10 | ||
|
|
0dec234505 | ||
|
|
bbe3b2b55d | ||
|
|
21dfac972c | ||
|
|
186925fdd9 | ||
|
|
a2fe3b6610 | ||
|
|
457e5308a9 | ||
|
|
3faf5ada2e | ||
|
|
935ca39945 | ||
|
|
5c746d7751 | ||
|
|
356ce7647f | ||
|
|
758fbc2fcc | ||
|
|
8a50936d32 | ||
|
|
21082f7e3a | ||
|
|
e96229e2e5 | ||
|
|
9f2cb3b582 | ||
|
|
b289441e6f | ||
|
|
7d89bebc4f | ||
|
|
cd53387c9e | ||
|
|
d815c7caf8 | ||
|
|
2faceadd0d | ||
|
|
fa1aca83ef | ||
|
|
135899db6b | ||
|
|
277b2de491 | ||
|
|
beee14db14 | ||
|
|
960850445b | ||
|
|
887ca6086e | ||
|
|
3b08f3058b | ||
|
|
cc15b8c6ad | ||
|
|
28014de974 | ||
|
|
909b5411bb | ||
|
|
9020277f09 | ||
|
|
6c38ffc277 | ||
|
|
fa8aa84386 | ||
|
|
7a63b046da | ||
|
|
ae599243fd | ||
|
|
b5c81f732c | ||
|
|
31a16157f3 | ||
|
|
8725c2b19f | ||
|
|
511ba938fb | ||
|
|
ffa27ddcbc | ||
|
|
7ed608c4d6 | ||
|
|
17588f51f0 | ||
|
|
b373461032 | ||
|
|
0241194591 | ||
|
|
e93764350d | ||
|
|
568e7c4f67 | ||
|
|
d6f1e7ae95 | ||
|
|
f3a474af30 | ||
|
|
9606884ca1 | ||
|
|
2bf330777f | ||
|
|
f29567b436 | ||
|
|
cb29346a1b | ||
|
|
482055832d | ||
|
|
12db4ccb31 | ||
|
|
17b6809517 | ||
|
|
93dd9f697e | ||
|
|
d5180b9e88 | ||
|
|
b9f4c124fc | ||
|
|
7a23ac290e | ||
|
|
aa56045b49 | ||
|
|
cbf712b7be | ||
|
|
b6f2c3b746 | ||
|
|
3effffb491 | ||
|
|
ab45b409b8 | ||
|
|
6d66fefbbb | ||
|
|
efdfdd036c | ||
|
|
9255f36654 | ||
|
|
769661a4a2 | ||
|
|
eefb2f8fb3 | ||
|
|
1eb023b26c | ||
|
|
9db2ebed00 | ||
|
|
f1a76e1a36 | ||
|
|
f33031bc9e | ||
|
|
4ce9b35f75 | ||
|
|
bce02d7a9e | ||
|
|
c8733822c5 | ||
|
|
379b445582 | ||
|
|
a47b08d551 | ||
|
|
cb54a532f0 | ||
|
|
58b1d7643e | ||
|
|
4c4d2558e3 | ||
|
|
1712a71a39 | ||
|
|
d31e0dee55 | ||
|
|
7a8bbefbb3 | ||
|
|
a378fac081 | ||
|
|
d355fecd4d | ||
|
|
bcadef2e20 | ||
|
|
d08ff2c2c9 | ||
|
|
fef86e475b | ||
|
|
fe90e14239 | ||
|
|
b6d6cfd8d9 | ||
|
|
8ec0ef5866 | ||
|
|
a3e2d0563e | ||
|
|
2c8b921054 | ||
|
|
8189949549 | ||
|
|
f832f3dcc3 | ||
|
|
8f6d87f1b6 | ||
|
|
24e9dccea6 | ||
|
|
86a156db26 | ||
|
|
3182a117c1 | ||
|
|
870b1d50df | ||
|
|
14b1bcd2e1 | ||
|
|
c48b4471aa | ||
|
|
616658d4b0 | ||
|
|
cdeedd8093 | ||
|
|
a324031801 | ||
|
|
b5ab92eef4 | ||
|
|
53e4d37cf1 | ||
|
|
032842a74c | ||
|
|
7dea9a131b | ||
|
|
c14eb2b60e | ||
|
|
2c962ef8fc | ||
|
|
70cf0e4d42 | ||
|
|
bb67585674 | ||
|
|
7b89e68d18 | ||
|
|
58548c729f | ||
|
|
c3e87da2df | ||
|
|
66414b28ba | ||
|
|
7b4984e73d | ||
|
|
202b06b279 | ||
|
|
46a3c16066 | ||
|
|
04a88a6ee6 | ||
|
|
cab25b5837 | ||
|
|
4587175fb1 | ||
|
|
8647a1ebed | ||
|
|
2690dfa77b | ||
|
|
36b80c4f31 | ||
|
|
69a1ab2319 | ||
|
|
f032ade9c8 | ||
|
|
c1feda14fd | ||
|
|
f202629996 | ||
|
|
eb846c95bf | ||
|
|
79aaab403c | ||
|
|
6c0dca30b8 | ||
|
|
981d572132 | ||
|
|
107cc03140 | ||
|
|
e720e022e3 | ||
|
|
ddfdd20d79 | ||
|
|
d7c0bbd7cd | ||
|
|
44bbb4ddf4 | ||
|
|
7addb519da | ||
|
|
cf04208cb9 | ||
|
|
3c3695d7c2 | ||
|
|
7572070f4e | ||
|
|
de02b07209 | ||
|
|
f9bb748a6c | ||
|
|
014b42dd45 | ||
|
|
f7121677f3 | ||
|
|
7d7ab8a09a | ||
|
|
725741486f | ||
|
|
a6f3048e44 | ||
|
|
424c718bc5 | ||
|
|
44570d9deb | ||
|
|
d6a635ed48 | ||
|
|
750a7146e4 | ||
|
|
914b9d1e79 | ||
|
|
683aa09b55 | ||
|
|
b607c41a52 | ||
|
|
61228639c2 | ||
|
|
a7eb0dd9a5 | ||
|
|
7b697d6128 | ||
|
|
b2f66b1797 | ||
|
|
ed7d83bcfc | ||
|
|
3ca74f8e6d | ||
|
|
61725fb37e | ||
|
|
444a910d9e | ||
|
|
4a44da7d91 | ||
|
|
68c78c4b43 | ||
|
|
906c32da12 | ||
|
|
19238f098b | ||
|
|
bbbec7a5c1 | ||
|
|
2a83609287 | ||
|
|
28ff755623 | ||
|
|
df820f0315 | ||
|
|
2dfbb407ba | ||
|
|
b79e7fdb7a | ||
|
|
69dd1a31bf | ||
|
|
dabfcbe941 | ||
|
|
6f6954fb34 | ||
|
|
48fef27862 | ||
|
|
c6bac6703e | ||
|
|
6d0cd54ac1 | ||
|
|
1ff15e60d3 | ||
|
|
bb53d984d3 | ||
|
|
414b7db8af | ||
|
|
377bb9073e | ||
|
|
fc8f59261a | ||
|
|
451deb066f | ||
|
|
65aac6494a | ||
|
|
84ffb05886 | ||
|
|
0024ea49d3 | ||
|
|
bcecf05292 | ||
|
|
d0ff8c341e | ||
|
|
075b335ba6 | ||
|
|
6ec1f10df0 | ||
|
|
01247723c7 | ||
|
|
9f368ac9e6 | ||
|
|
edb06170f5 | ||
|
|
ceae46ce33 | ||
|
|
513576b487 | ||
|
|
56708b636e | ||
|
|
cee9f64f1b | ||
|
|
0f53a3b9f3 | ||
|
|
e3d5fff264 | ||
|
|
b78bfd5213 | ||
|
|
2ba918ac71 | ||
|
|
d70cc39544 | ||
|
|
decf2b518a | ||
|
|
41d7d0e2e6 | ||
|
|
d171686f77 | ||
|
|
161222f6fe | ||
|
|
2ff5c17917 | ||
|
|
10a52ac294 | ||
|
|
811e0c5797 | ||
|
|
5e205030ed | ||
|
|
4104229996 | ||
|
|
f58d4cad8e | ||
|
|
461ead8ceb | ||
|
|
f835eb32f3 | ||
|
|
eccd4d8c39 | ||
|
|
56bc9b5058 | ||
|
|
0d0ebd0e20 | ||
|
|
f50db0e835 | ||
|
|
aa09be168d | ||
|
|
6863b9dbe1 | ||
|
|
289272f16a | ||
|
|
4ae7287151 | ||
|
|
21ee5c0aaf | ||
|
|
7e065d90f0 | ||
|
|
726ff36fd5 | ||
|
|
eafda6f526 | ||
|
|
e211b75475 | ||
|
|
b229a3de0c | ||
|
|
9c3bc4939c | ||
|
|
8c3a12e011 | ||
|
|
d75bcc27f9 | ||
|
|
8181f51dbd | ||
|
|
97cde14819 | ||
|
|
f18e3fba79 | ||
|
|
56b38d2fbe | ||
|
|
b08146fad6 | ||
|
|
301b3ff912 | ||
|
|
be57344b99 | ||
|
|
54060104a7 | ||
|
|
c872a43146 | ||
|
|
a8e4ab3ebe | ||
|
|
52bfe5060c | ||
|
|
4de879a6c5 | ||
|
|
a11aecc3c1 | ||
|
|
516cbf4366 | ||
|
|
772c03d41a | ||
|
|
20abab7c4f | ||
|
|
7b3e5ce0d1 | ||
|
|
0e6aefde33 | ||
|
|
1f18592754 | ||
|
|
8a32936855 | ||
|
|
1eeffd7c09 | ||
|
|
9eb749b0a6 | ||
|
|
a6fda4ae8e | ||
|
|
ef91cd8479 | ||
|
|
ae1214140e | ||
|
|
ed2ae5886d | ||
|
|
a0b9ce31bd | ||
|
|
bfbe12d9f5 | ||
|
|
a8c30634ac | ||
|
|
28b78b25b7 | ||
|
|
ea0ef18704 | ||
|
|
27eef96380 | ||
|
|
fc6d821611 | ||
|
|
203fca001c | ||
|
|
07fbf46091 | ||
|
|
3f69607d8c | ||
|
|
8cb0373bc1 | ||
|
|
3e00460cdc | ||
|
|
b84cd25537 | ||
|
|
82c1d9d3ef | ||
|
|
2493455f08 | ||
|
|
c8c8fc4530 | ||
|
|
852c897956 | ||
|
|
096a7a571d | ||
|
|
20dea3cdb1 | ||
|
|
3ec275f0b5 | ||
|
|
5a6fc20bd7 | ||
|
|
c1d2f74bc5 | ||
|
|
371446456c | ||
|
|
98f2ad56a6 | ||
|
|
b7ef0a5d05 | ||
|
|
de34a809f4 | ||
|
|
165dbc232f | ||
|
|
61371a712b | ||
|
|
25b048dc43 | ||
|
|
86e4cc56b9 | ||
|
|
aaf03c60c9 | ||
|
|
c000847dc0 | ||
|
|
a6cd7ef49c | ||
|
|
384a2f6a19 | ||
|
|
a54707b866 | ||
|
|
e0d7f97c55 | ||
|
|
5f4dda6c7c | ||
|
|
615f6e1e40 | ||
|
|
cf26c409c6 | ||
|
|
b744ba3410 | ||
|
|
53a8f474ee | ||
|
|
c4dbcc3444 | ||
|
|
ceb934299b | ||
|
|
5b23999404 | ||
|
|
dec6859702 | ||
|
|
2547514b47 | ||
|
|
abf42abd41 | ||
|
|
4f2f641950 | ||
|
|
3189430ad0 | ||
|
|
d6641ed306 | ||
|
|
a2b6a064f7 | ||
|
|
fb1d8f8361 | ||
|
|
5fd1822c7c | ||
|
|
6bc5987d6c | ||
|
|
110cc5d791 | ||
|
|
a0ff9d9bbb | ||
|
|
97b566b8b3 | ||
|
|
221fe499db | ||
|
|
b3c3ec4231 | ||
|
|
9860d6fcc2 | ||
|
|
add170add0 | ||
|
|
66951e52e6 | ||
|
|
13cb1bb020 | ||
|
|
53cc623481 | ||
|
|
57b91b6b81 | ||
|
|
526c71a655 | ||
|
|
90117a3849 | ||
|
|
9e2e57458e | ||
|
|
c0bf6bc24f | ||
|
|
0fdcb3be43 | ||
|
|
83e08b3bd5 | ||
|
|
62aae7f69d | ||
|
|
19df928e7f | ||
|
|
6bf333bf31 | ||
|
|
f9f816d139 | ||
|
|
9b9dc65a22 | ||
|
|
6da69255fa | ||
|
|
9a26a735e4 | ||
|
|
c0cd3c3c08 | ||
|
|
542271e305 | ||
|
|
a5ca0df4f4 | ||
|
|
1bd9a12276 | ||
|
|
7d658410e5 | ||
|
|
db60b424a2 | ||
|
|
5e7c3250cb | ||
|
|
55a25f9875 | ||
|
|
3efb752124 | ||
|
|
d8da642611 | ||
|
|
adc4e0940c | ||
|
|
0465d314b0 | ||
|
|
426484ba2c | ||
|
|
c0aa83a998 | ||
|
|
a99ad11a41 | ||
|
|
937e1c21f2 | ||
|
|
8217d77ece | ||
|
|
3821d74019 | ||
|
|
14e70543a2 | ||
|
|
d02202e765 | ||
|
|
cf471051c7 | ||
|
|
fa32820410 | ||
|
|
de43e884e7 | ||
|
|
200aa441df | ||
|
|
a429380e33 | ||
|
|
43f75e53b8 | ||
|
|
b8f70ffcab | ||
|
|
fc8ccf80a6 | ||
|
|
dd1fb7ff78 | ||
|
|
aae290eed3 | ||
|
|
8f535285d2 | ||
|
|
808ec68e41 | ||
|
|
e63dcc320b | ||
|
|
5bead2de85 | ||
|
|
dbdcbe03e7 | ||
|
|
bc299ae17e | ||
|
|
c0c0e0f9ae | ||
|
|
2954cdabf9 | ||
|
|
153601f98b | ||
|
|
9e7aab9baf | ||
|
|
8927c69b3f | ||
|
|
cd747dc582 | ||
|
|
f94c06c53f | ||
|
|
123ae82fca | ||
|
|
182afe9f59 | ||
|
|
809f87c41c | ||
|
|
6b400eca5c | ||
|
|
6aab89939f | ||
|
|
576f7072a7 | ||
|
|
5db579f2e0 | ||
|
|
29e84dc130 | ||
|
|
aeb953bdf4 | ||
|
|
4136cdac63 | ||
|
|
775a6c6620 | ||
|
|
28adddd760 | ||
|
|
48b3d7096c | ||
|
|
b97191b81a | ||
|
|
0b59c48087 | ||
|
|
3c043f5d2d | ||
|
|
96f80d6d82 | ||
|
|
5f55a53f0e | ||
|
|
09fa33f7e2 | ||
|
|
20cefd78cb | ||
|
|
696a358215 | ||
|
|
badde6e29f | ||
|
|
50900721c3 | ||
|
|
ced4ac4902 | ||
|
|
6a361685ab | ||
|
|
ac3f834cee | ||
|
|
03ea99ec65 | ||
|
|
7f660d59da | ||
|
|
32aea365ed | ||
|
|
d5142f312a | ||
|
|
64f7182180 | ||
|
|
42ab5dd2d1 | ||
|
|
0b20ee2722 | ||
|
|
6a1ad2b499 | ||
|
|
cc35c66ff0 | ||
|
|
938b1dd1e7 | ||
|
|
3513ff09de | ||
|
|
633fe8b9c1 | ||
|
|
f8c404a485 | ||
|
|
d73f3336de | ||
|
|
043ae00446 | ||
|
|
bf2dc0d9c2 | ||
|
|
5c6318b583 | ||
|
|
c9f02da89f | ||
|
|
00b7ab7db7 | ||
|
|
d8a2c80cd7 | ||
|
|
c06a962bb6 | ||
|
|
2a3da21333 | ||
|
|
ee8d8be2e3 | ||
|
|
c5406e1d24 | ||
|
|
e95ce05c1e | ||
|
|
2d5647a804 | ||
|
|
07850e8a93 | ||
|
|
1bde33c0bc | ||
|
|
b2a4283c36 | ||
|
|
0e8ec83742 | ||
|
|
9be114738f | ||
|
|
0ab4ac6468 | ||
|
|
05e2957edc | ||
|
|
2b5ad475ad | ||
|
|
63aa155ade | ||
|
|
d9d321f94b | ||
|
|
348bbdeee1 | ||
|
|
2f67564c93 | ||
|
|
8188fcb90d | ||
|
|
9521fe977a | ||
|
|
d2857fbea9 | ||
|
|
5a261322fb | ||
|
|
e9294ff925 | ||
|
|
5daaab3692 | ||
|
|
e1220c48f5 | ||
|
|
9762e48134 | ||
|
|
cfc2604d3e | ||
|
|
82f0388951 | ||
|
|
91c30f46f7 | ||
|
|
31295c8341 | ||
|
|
ad5e7b9688 | ||
|
|
3f5351529f | ||
|
|
7418400c47 | ||
|
|
387fb40745 | ||
|
|
857db619e1 | ||
|
|
5e496a1519 | ||
|
|
e6f75e526d | ||
|
|
9365cd424f | ||
|
|
dff3f8c271 | ||
|
|
615c9c3c9c | ||
|
|
185792b6cd | ||
|
|
7db6eade05 | ||
|
|
2a1ed0ed41 | ||
|
|
af784b9a8c | ||
|
|
ee29703368 | ||
|
|
519ffd59d4 | ||
|
|
82576aa684 | ||
|
|
ffcf37f8c1 | ||
|
|
52ad64f8f9 | ||
|
|
9abf86f7e0 | ||
|
|
bf76452b43 | ||
|
|
9d0a1e32bb | ||
|
|
21f0e3fa0c | ||
|
|
47b6cde8ca | ||
|
|
b9d14855d0 | ||
|
|
9c5404d95e | ||
|
|
fe5cc8f3ba | ||
|
|
2b9a501b77 | ||
|
|
994bcbf670 | ||
|
|
e03dc987e3 | ||
|
|
5b7a33272a | ||
|
|
e4d63818f5 | ||
|
|
2fa78c17d1 | ||
|
|
b8b7a6e0fa | ||
|
|
c60844931b | ||
|
|
64b7f3455e | ||
|
|
90d1e9cd71 | ||
|
|
872079d42f | ||
|
|
e6f67d5f31 | ||
|
|
f537bd1796 | ||
|
|
65eefd65e1 | ||
|
|
5544646a09 | ||
|
|
4734f99108 | ||
|
|
01ec81dae4 | ||
|
|
222b2d7c3c | ||
|
|
eb594a090d | ||
|
|
b4e406b6c4 | ||
|
|
5faba6a48c | ||
|
|
0e824a178a | ||
|
|
4d8a4fbb48 | ||
|
|
95b077ad2a | ||
|
|
186ecd2161 | ||
|
|
03ff4960b3 | ||
|
|
24d2c6292e | ||
|
|
eed6113359 | ||
|
|
013e8f6b3b | ||
|
|
53af46ffb8 | ||
|
|
8719f381d1 | ||
|
|
743f4b2849 | ||
|
|
b1dd23f61d | ||
|
|
9a01d2bba7 | ||
|
|
4d4296cae5 | ||
|
|
12565661a3 | ||
|
|
d5a724fbee | ||
|
|
77e8a80908 | ||
|
|
a3c695faae | ||
|
|
a042b32d2f | ||
|
|
cab0abf52a | ||
|
|
77b89719d5 | ||
|
|
a73ccf2b53 | ||
|
|
d14be8472e | ||
|
|
80407cbc6a | ||
|
|
9409942de4 | ||
|
|
0e046f61ab | ||
|
|
01b3226ecb | ||
|
|
d0f64c955e | ||
|
|
df7464ddf6 | ||
|
|
3369ef5aef | ||
|
|
4133f4bd37 | ||
|
|
f19eabee54 | ||
|
|
7d4078c704 | ||
|
|
5ba72bd9bf | ||
|
|
4b9cb46c6e | ||
|
|
f47584fec8 | ||
|
|
8852250192 | ||
|
|
f5a4a202e5 | ||
|
|
240cdd3749 | ||
|
|
d3483590fb | ||
|
|
7582e93a8e | ||
|
|
7cc6add9b8 | ||
|
|
cb3290fca3 | ||
|
|
71f357d949 | ||
|
|
bfa7d21e99 | ||
|
|
5a313c83b7 | ||
|
|
8e5689a84d | ||
|
|
fc5d147d1b | ||
|
|
baa3bf270b | ||
|
|
68b00a5388 | ||
|
|
09e2160080 | ||
|
|
29b587e73c | ||
|
|
ff11d8793b | ||
|
|
571c195c54 | ||
|
|
dee3abfcd5 | ||
|
|
60898821f7 | ||
|
|
ae97f8f798 | ||
|
|
69ba9a0562 | ||
|
|
a3c9bc792e | ||
|
|
709c225b2b | ||
|
|
c16bc71279 | ||
|
|
054366dea4 | ||
|
|
d714ac7797 | ||
|
|
3e6d1e9cf8 | ||
|
|
478af81706 | ||
|
|
ff32f43459 | ||
|
|
81b5e2766b | ||
|
|
078642b308 | ||
|
|
69f809dca3 | ||
|
|
9236a27456 | ||
|
|
fe2d883cf7 | ||
|
|
5349a0f7c2 | ||
|
|
8ff2787981 | ||
|
|
94ff44f112 | ||
|
|
ebcc6480c2 | ||
|
|
f2c56de955 | ||
|
|
a7142c6218 | ||
|
|
ee82c173ae | ||
|
|
2b5e0a6075 | ||
|
|
76e4e9d176 | ||
|
|
684c18458a | ||
|
|
9fb48f4dff | ||
|
|
ebc68861a6 | ||
|
|
d3428053d9 | ||
|
|
188c4cd076 | ||
|
|
b908388245 | ||
|
|
66d7178f2d | ||
|
|
d583782ee3 | ||
|
|
61d59a8028 | ||
|
|
9dce3d8bf8 | ||
|
|
1d6abddb9f | ||
|
|
226bf74634 | ||
|
|
3e0e78f82a | ||
|
|
eb60e2e1b2 | ||
|
|
9e147f00b4 | ||
|
|
50645b905b | ||
|
|
6084d13b95 | ||
|
|
5b4121d601 | ||
|
|
d82c5ea9d1 | ||
|
|
8d1a1d9e86 | ||
|
|
64df787448 | ||
|
|
cc233da373 | ||
|
|
e9de242159 | ||
|
|
bc4881ed0c | ||
|
|
cdc31903c2 | ||
|
|
d1f36bfd84 | ||
|
|
4caeb203a6 | ||
|
|
e1e05e57cb | ||
|
|
8218a94a31 | ||
|
|
e401e2584d | ||
|
|
0dbe087ef8 | ||
|
|
4734c985c8 | ||
|
|
270779b2cd | ||
|
|
7bd073340a | ||
|
|
4f61a3f527 | ||
|
|
3e2f0ca077 | ||
|
|
747b11c83e | ||
|
|
268c14f021 | ||
|
|
1a4fb35030 | ||
|
|
2004ce919a | ||
|
|
3150ece95a | ||
|
|
f97ad8f288 | ||
|
|
4c74a2f06e | ||
|
|
9f84afc992 | ||
|
|
a1fc6a6ea6 | ||
|
|
1b9c1c648d | ||
|
|
ece55b4682 | ||
|
|
1b03eb71aa | ||
|
|
bc0160d0f2 | ||
|
|
06bc9f368b | ||
|
|
81361755b7 | ||
|
|
b769b65b48 | ||
|
|
d71f6afb7f | ||
|
|
25ecd4216c | ||
|
|
b3882eccef | ||
|
|
7fc1026746 | ||
|
|
e707a7bd36 | ||
|
|
60a7625f2a | ||
|
|
fdc3a6a809 | ||
|
|
50a6e0e69e | ||
|
|
aa1dbd34a1 | ||
|
|
3881af5b37 | ||
|
|
e3b432e481 | ||
|
|
09e1cbc35d | ||
|
|
497b060e49 | ||
|
|
a6fbd0393d | ||
|
|
abf6b4997e | ||
|
|
b87b16e2b6 | ||
|
|
b566b09f81 | ||
|
|
1f1fc095a0 | ||
|
|
31791233d6 | ||
|
|
4f043991e0 | ||
|
|
4c7838e3cf | ||
|
|
5f4b29145c | ||
|
|
d3ee5deb87 | ||
|
|
c8424bf29a | ||
|
|
3967ece625 | ||
|
|
cb9a5e1cb9 | ||
|
|
302dafbe1a | ||
|
|
493f6f458b | ||
|
|
57f40a5da6 | ||
|
|
788ea6e9d1 | ||
|
|
1a7e180e68 | ||
|
|
00a0890889 | ||
|
|
4b1cadaecb | ||
|
|
e53a221e5c | ||
|
|
28d9dd7a77 | ||
|
|
644bef157a | ||
|
|
35c0e66ed0 | ||
|
|
3d0a41b584 | ||
|
|
3a67721dae | ||
|
|
6a386a7886 | ||
|
|
8025e7c6c2 | ||
|
|
842499d6c5 | ||
|
|
3aa94afcfd | ||
|
|
9a134c8a10 | ||
|
|
ce0eddd384 | ||
|
|
7d3e5788e8 | ||
|
|
74193ff754 | ||
|
|
c76288bdf1 | ||
|
|
ef70a55b7a | ||
|
|
6f7d31c426 | ||
|
|
d69b32a073 | ||
|
|
d73b48b32c | ||
|
|
ec399aaddf | ||
|
|
18e8bd68c5 | ||
|
|
3bbd29bef9 | ||
|
|
a0361b8ba9 | ||
|
|
6543ce717c | ||
|
|
1ba266a8e8 | ||
|
|
bf080c2338 | ||
|
|
274da72c38 | ||
|
|
83248f7603 | ||
|
|
af50b914a4 | ||
|
|
cd84885a4a | ||
|
|
6dd6bce997 | ||
|
|
4711a943e3 |
@@ -1,18 +1,22 @@
|
||||
# PR Review Instructions
|
||||
# PR Workflow for Maintainers
|
||||
|
||||
Please read this in full and do not skip sections.
|
||||
This is the single source of truth for the maintainer PR workflow.
|
||||
|
||||
## Triage order
|
||||
|
||||
Process PRs **oldest to newest**. Older PRs are more likely to have merge conflicts and stale dependencies; resolving them first keeps the queue healthy and avoids snowballing rebase pain.
|
||||
|
||||
## Working rule
|
||||
|
||||
Skills execute workflow, maintainers provide judgment.
|
||||
Skills execute workflow. Maintainers provide judgment.
|
||||
Always pause between skills to evaluate technical direction, not just command success.
|
||||
Default mode is local-first, do not write to GitHub until maintainer explicitly says go.
|
||||
|
||||
These three skills must be used in order:
|
||||
|
||||
1. `review-pr`
|
||||
2. `prepare-pr`
|
||||
3. `merge-pr`
|
||||
1. `review-pr` — review only, produce findings
|
||||
2. `prepare-pr` — rebase, fix, gate, push to PR head branch
|
||||
3. `merge-pr` — squash-merge, verify MERGED state, clean up
|
||||
|
||||
They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward.
|
||||
|
||||
@@ -21,26 +25,64 @@ If submitted code is low quality, ignore it and implement the best solution for
|
||||
|
||||
Do not continue if you cannot verify the problem is real or test the fix.
|
||||
|
||||
## Remote write policy
|
||||
## Script-first contract
|
||||
|
||||
Until the maintainer explicitly approves remote actions, stay local-only.
|
||||
Skill runs should invoke these wrappers automatically. You only need to run them manually when debugging or doing an explicit script-only run:
|
||||
|
||||
Remote actions include:
|
||||
- `scripts/pr-review <PR>`
|
||||
- `scripts/pr review-checkout-main <PR>` or `scripts/pr review-checkout-pr <PR>` while reviewing
|
||||
- `scripts/pr review-guard <PR>` before writing review outputs
|
||||
- `scripts/pr review-validate-artifacts <PR>` after writing outputs
|
||||
- `scripts/pr-prepare init <PR>`
|
||||
- `scripts/pr-prepare validate-commit <PR>`
|
||||
- `scripts/pr-prepare gates <PR>`
|
||||
- `scripts/pr-prepare push <PR>`
|
||||
- Optional one-shot prepare: `scripts/pr-prepare run <PR>`
|
||||
- `scripts/pr-merge <PR>` (verify-only; short form remains backward compatible)
|
||||
- `scripts/pr-merge verify <PR>` (verify-only)
|
||||
- Optional one-shot merge: `scripts/pr-merge run <PR>`
|
||||
|
||||
- Pushing branches.
|
||||
- Posting PR comments.
|
||||
- Editing PR metadata (labels, assignees, state).
|
||||
- Merging PRs.
|
||||
- Editing advisory state or publishing advisories.
|
||||
These wrappers run shared preflight checks and generate deterministic artifacts. They are designed to work from repo root or PR worktree cwd.
|
||||
|
||||
Allowed before approval:
|
||||
## Required artifacts
|
||||
|
||||
- Local code changes.
|
||||
- Local tests and validation.
|
||||
- Drafting copy for PR/advisory comments.
|
||||
- Read-only `gh` commands.
|
||||
- `.local/pr-meta.json` and `.local/pr-meta.env` from review init.
|
||||
- `.local/review.md` and `.local/review.json` from review output.
|
||||
- `.local/prep-context.env` and `.local/prep.md` from prepare.
|
||||
- `.local/prep.env` from prepare completion.
|
||||
|
||||
When approved, perform only the approved remote action, then pause for next instruction.
|
||||
## Structured review handoff
|
||||
|
||||
`review-pr` must write `.local/review.json`.
|
||||
In normal skill runs this is handled automatically. Use `scripts/pr review-artifacts-init <PR>` and `scripts/pr review-tests <PR> ...` manually only for debugging or explicit script-only runs.
|
||||
|
||||
Minimum schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"recommendation": "READY FOR /prepare-pr",
|
||||
"findings": [
|
||||
{
|
||||
"id": "F1",
|
||||
"severity": "IMPORTANT",
|
||||
"title": "Missing changelog entry",
|
||||
"area": "CHANGELOG.md",
|
||||
"fix": "Add a Fixes entry for PR #<PR>"
|
||||
}
|
||||
],
|
||||
"tests": {
|
||||
"ran": ["pnpm test -- ..."],
|
||||
"gaps": ["..."],
|
||||
"result": "pass"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`prepare-pr` resolves all `BLOCKER` and `IMPORTANT` findings from this file.
|
||||
|
||||
## Coding Agent
|
||||
|
||||
Use ChatGPT 5.3 Codex High. Fall back to 5.2 Codex High or 5.3 Codex Medium if necessary.
|
||||
|
||||
## PR quality bar
|
||||
|
||||
@@ -53,6 +95,60 @@ When approved, perform only the approved remote action, then pause for next inst
|
||||
- Harden changes. Always evaluate security impact and abuse paths.
|
||||
- Understand the system before changing it. Never make the codebase messier just to clear a PR queue.
|
||||
|
||||
## Rebase and conflict resolution
|
||||
|
||||
Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness.
|
||||
|
||||
- During `prepare-pr`: rebase onto `main` as the first step, before fixing findings or running gates.
|
||||
- If conflicts are complex or touch areas you do not understand, stop and escalate.
|
||||
- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful.
|
||||
|
||||
## Commit and changelog rules
|
||||
|
||||
- In normal `prepare-pr` runs, commits are created via `scripts/committer "<msg>" <file...>`. Use it manually only when operating outside the skill flow; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- During `prepare-pr`, use concise, action-oriented subjects **without** PR numbers or thanks; reserve `(#<PR>) thanks @<pr-author>` for the final merge/squash commit.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor (mandatory in this workflow).
|
||||
- When working on an issue: reference the issue in the changelog entry.
|
||||
- In this workflow, changelog is always required even for internal/test-only changes.
|
||||
|
||||
## Gate policy
|
||||
|
||||
In fresh worktrees, dependency bootstrap is handled by wrappers before local gates. Manual equivalent:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Gate set:
|
||||
|
||||
- Always: `pnpm build`, `pnpm check`
|
||||
- `pnpm test` required unless high-confidence docs-only criteria pass.
|
||||
|
||||
## Co-contributor and clawtributors
|
||||
|
||||
- If we squash, add the PR author as a co-contributor in the commit body using a `Co-authored-by:` trailer.
|
||||
- When maintainer prepares and merges the PR, add the maintainer as an additional `Co-authored-by:` trailer too.
|
||||
- Avoid `--auto` merges for maintainer landings. Merge only after checks are green so the maintainer account is the actor and attribution is deterministic.
|
||||
- For squash merges, set `--author-email` to a reviewer-owned email with fallback candidates; if merge fails due to author-email validation, retry once with the next candidate.
|
||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||
- When merging a PR: leave a PR comment that explains exactly what we did, include the SHA hashes, and record the comment URL in the final report.
|
||||
- Manual post-merge step for new contributors: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README.
|
||||
|
||||
## Review mode vs landing mode
|
||||
|
||||
- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode (exception path):** use only when normal `review-pr -> prepare-pr -> merge-pr` flow cannot safely preserve attribution or cannot satisfy branch protection. Create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: the contributor needs to be in the git graph after this!
|
||||
|
||||
## Pre-review safety checks
|
||||
|
||||
- Before starting a review when a GH Issue/PR is pasted: `review-pr`/`scripts/pr-review` should create and use an isolated `.worktrees/pr-<PR>` checkout from `origin/main` automatically. Do not require a clean main checkout, and do not run `git pull` in a dirty main checkout.
|
||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors.
|
||||
|
||||
## Unified workflow
|
||||
|
||||
Entry criteria:
|
||||
@@ -78,7 +174,6 @@ Maintainer checkpoint before `prepare-pr`:
|
||||
```
|
||||
What problem are they trying to solve?
|
||||
What is the most optimal implementation?
|
||||
Is the code properly scoped?
|
||||
Can we fix up everything?
|
||||
Do we have any questions?
|
||||
```
|
||||
@@ -94,27 +189,30 @@ Stop and escalate instead of continuing if:
|
||||
Purpose:
|
||||
|
||||
- Make the PR merge-ready on its head branch.
|
||||
- Rebase onto current `main`, fix blocker/important findings, and run gates.
|
||||
- Rebase onto current `main` first, then fix blocker/important findings, then run gates.
|
||||
- In fresh worktrees, bootstrap dependencies before local gates (`pnpm install --frozen-lockfile`).
|
||||
|
||||
Expected output:
|
||||
|
||||
- Updated code and tests on the PR head branch.
|
||||
- `.local/prep.md` with changes, verification, and current HEAD SHA.
|
||||
- Final status: `PR is ready for /mergepr`.
|
||||
- Final status: `PR is ready for /merge-pr`.
|
||||
|
||||
Maintainer checkpoint before `merge-pr`:
|
||||
|
||||
```
|
||||
Is this the most optimal implementation?
|
||||
Is the code properly scoped?
|
||||
Is the code properly reusing existing logic in the codebase?
|
||||
Is the code properly typed?
|
||||
Is the code hardened?
|
||||
Do we have enough tests?
|
||||
Are tests using fake timers where relevant? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
|
||||
Do we need regression tests?
|
||||
Are tests using fake timers where appropriate? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
|
||||
Do not add performative tests, ensure tests are real and there are no regressions.
|
||||
Take your time, fix it properly, refactor if necessary.
|
||||
Do you see any follow-up refactors we should do?
|
||||
Did any changes introduce any potential security vulnerabilities?
|
||||
Take your time, fix it properly, refactor if necessary.
|
||||
```
|
||||
|
||||
Stop and escalate instead of continuing if:
|
||||
@@ -123,59 +221,29 @@ Stop and escalate instead of continuing if:
|
||||
- Fixing findings requires broad architecture changes outside safe PR scope.
|
||||
- Security hardening requirements remain unresolved.
|
||||
|
||||
### Security advisory companion flow
|
||||
|
||||
Use this for GHSA-linked fixes and private reports.
|
||||
|
||||
1. Implement and test the fix locally first, do not edit advisory content yet.
|
||||
2. Land the code fix PR through normal flow, including attribution and changelog where needed.
|
||||
3. Prepare public-safe advisory text:
|
||||
- No internal workflow chatter.
|
||||
- No unnecessary exploit detail.
|
||||
- Clear impact, affected range, fixed range, remediation, credits.
|
||||
4. In GitHub advisory UI, set package ranges in the structured fields:
|
||||
- `Affected versions`: `< fixed_version`
|
||||
- `Patched versions`: `>= fixed_version`
|
||||
Do not rely on description text alone.
|
||||
5. If collaborator can edit text but cannot change advisory state, hand off to a Publisher to move triage -> accepted draft -> publish.
|
||||
6. Advisory comments are posted manually in UI when required by policy. Do not rely on `gh api` automation for advisory comments.
|
||||
|
||||
Maintainer checkpoint for security advisories:
|
||||
|
||||
- Is the rewrite public-safe and free of internal/process notes?
|
||||
- Are affected and patched ranges correctly set in the advisory form fields?
|
||||
- Are credits present and accurate?
|
||||
- Do we have Publisher action if state controls are unavailable?
|
||||
|
||||
### 3) `merge-pr`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Merge only after review and prep artifacts are present and checks are green.
|
||||
- Use squash merge flow and verify the PR ends in `MERGED` state.
|
||||
- Use deterministic squash merge flow (`--match-head-commit` + explicit subject/body with co-author trailer), then verify the PR ends in `MERGED` state.
|
||||
- If no required checks are configured on the PR, treat that as acceptable and continue after branch-up-to-date validation.
|
||||
|
||||
Go or no-go checklist before merge:
|
||||
|
||||
- All BLOCKER and IMPORTANT findings are resolved.
|
||||
- Verification is meaningful and regression risk is acceptably low.
|
||||
- Docs and changelog are updated when required.
|
||||
- Changelog is updated (mandatory) and docs are updated when required.
|
||||
- Required CI checks are green and the branch is not behind `main`.
|
||||
|
||||
Expected output:
|
||||
|
||||
- Successful merge commit and recorded merge SHA.
|
||||
- Worktree cleanup after successful merge.
|
||||
- Comment on PR indicating merge was successful.
|
||||
|
||||
Maintainer checkpoint after merge:
|
||||
|
||||
- Were any refactors intentionally deferred and now need follow-up issue(s)?
|
||||
- Did this reveal broader architecture or test gaps we should address?
|
||||
|
||||
## Chasing main mitigation
|
||||
|
||||
To reduce repeated "branch behind main" loops:
|
||||
|
||||
1. Keep prep and merge windows short.
|
||||
2. Rebase/update once, as late as possible, right before final checks.
|
||||
3. Avoid non-essential commits on the PR branch after checks start.
|
||||
4. Prefer merge queue or auto-merge when available.
|
||||
- Run `bun scripts/update-clawtributors.ts` if the contributor is new.
|
||||
|
||||
@@ -1,182 +1,99 @@
|
||||
---
|
||||
name: merge-pr
|
||||
description: Merge a GitHub PR via squash after /preparepr. Use when asked to merge a ready PR. Do not push to main or modify code. Ensure the PR ends in MERGED state and clean up worktrees after success.
|
||||
description: Script-first deterministic squash merge with strict required-check gating, head-SHA pinning, and reliable attribution/commenting.
|
||||
---
|
||||
|
||||
# Merge PR
|
||||
|
||||
## Overview
|
||||
|
||||
Merge a prepared PR via `gh pr merge --squash` and clean up the worktree after success.
|
||||
Merge a prepared PR only after deterministic validation.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
- If missing, use `.local/prep.env` from the PR worktree.
|
||||
|
||||
## Safety
|
||||
|
||||
- Use `gh pr merge --squash` as the only path to `main`.
|
||||
- Do not run `git push` at all during merge.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
- Do not execute merge or PR-comment GitHub write actions until maintainer explicitly approves.
|
||||
- Never use `gh pr merge --auto` in this flow.
|
||||
- Never run `git push` directly.
|
||||
- Require `--match-head-commit` during merge.
|
||||
- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
|
||||
|
||||
## Execution Rule
|
||||
## Execution Contract
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Read `.local/review.md` and `.local/prep.md` in the worktree. Do not skip.
|
||||
- Clean up the real worktree directory `.worktrees/pr-<PR>` only after a successful merge.
|
||||
- Expect cleanup to remove `.local/` artifacts.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Ensure `gh pr merge` succeeds.
|
||||
- Ensure PR state is `MERGED`, never `CLOSED`.
|
||||
- Record the merge SHA.
|
||||
- Run cleanup only after merge success.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all merge steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all merge work.
|
||||
1. Validate merge readiness:
|
||||
|
||||
```sh
|
||||
cd ~/dev/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
scripts/pr-merge verify <PR>
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
|
||||
## Load Local Artifacts (Mandatory)
|
||||
|
||||
Expect these files from earlier steps:
|
||||
|
||||
- `.local/review.md` from `/reviewpr`
|
||||
- `.local/prep.md` from `/preparepr`
|
||||
Backward-compatible verify form also works:
|
||||
|
||||
```sh
|
||||
ls -la .local || true
|
||||
|
||||
if [ -f .local/review.md ]; then
|
||||
echo "Found .local/review.md"
|
||||
sed -n '1,120p' .local/review.md
|
||||
else
|
||||
echo "Missing .local/review.md. Stop and run /reviewpr, then /preparepr."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f .local/prep.md ]; then
|
||||
echo "Found .local/prep.md"
|
||||
sed -n '1,120p' .local/prep.md
|
||||
else
|
||||
echo "Missing .local/prep.md. Stop and run /preparepr first."
|
||||
exit 1
|
||||
fi
|
||||
scripts/pr-merge <PR>
|
||||
```
|
||||
|
||||
2. Run one-shot deterministic merge:
|
||||
|
||||
```sh
|
||||
scripts/pr-merge run <PR>
|
||||
```
|
||||
|
||||
3. Ensure output reports:
|
||||
|
||||
- `merge_sha=<sha>`
|
||||
- `merge_author_email=<email>`
|
||||
- `comment_url=<url>`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta
|
||||
1. Validate artifacts
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,headRefName,baseRefName,headRepository,body --jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
require=(.local/review.md .local/review.json .local/prep.md .local/prep.env)
|
||||
for f in "${require[@]}"; do
|
||||
[ -s "$f" ] || { echo "Missing artifact: $f"; exit 1; }
|
||||
done
|
||||
```
|
||||
|
||||
2. Run sanity checks
|
||||
|
||||
Stop if any are true:
|
||||
|
||||
- PR is a draft.
|
||||
- Required checks are failing.
|
||||
- Branch is behind main.
|
||||
2. Validate checks and branch status
|
||||
|
||||
```sh
|
||||
# Checks
|
||||
gh pr checks <PR>
|
||||
|
||||
# Check behind main
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
git merge-base --is-ancestor origin/main pr-<PR> || echo "PR branch is behind main, run /preparepr"
|
||||
scripts/pr-merge verify <PR>
|
||||
source .local/prep.env
|
||||
```
|
||||
|
||||
If anything is failing or behind, stop and say to run `/preparepr`.
|
||||
`scripts/pr-merge` treats “no required checks configured” as acceptable (`[]`), but fails on any required `fail` or `pending`.
|
||||
|
||||
3. Merge PR and delete branch
|
||||
|
||||
If checks are still running, use `--auto` to queue the merge.
|
||||
3. Merge deterministically (wrapper-managed)
|
||||
|
||||
```sh
|
||||
# Check status first
|
||||
check_status=$(gh pr checks <PR> 2>&1)
|
||||
if echo "$check_status" | grep -q "pending\|queued"; then
|
||||
echo "Checks still running, using --auto to queue merge"
|
||||
gh pr merge <PR> --squash --delete-branch --auto
|
||||
echo "Merge queued. Monitor with: gh pr checks <PR> --watch"
|
||||
else
|
||||
gh pr merge <PR> --squash --delete-branch
|
||||
fi
|
||||
scripts/pr-merge run <PR>
|
||||
```
|
||||
|
||||
Before running merge command, pause and ask for explicit maintainer go-ahead.
|
||||
`scripts/pr-merge run` performs:
|
||||
|
||||
If merge fails, report the error and stop. Do not retry in a loop.
|
||||
If the PR needs changes beyond what `/preparepr` already did, stop and say to run `/preparepr` again.
|
||||
- deterministic squash merge pinned to `PREP_HEAD_SHA`
|
||||
- reviewer merge author email selection with fallback candidates
|
||||
- one retry only when merge fails due to author-email validation
|
||||
- co-author trailers for PR author and reviewer
|
||||
- post-merge verification of both co-author trailers on commit message
|
||||
- PR comment retry (3 attempts), then comment URL extraction
|
||||
- cleanup after confirmed `MERGED`
|
||||
|
||||
4. Get merge SHA
|
||||
4. Manual fallback (only if wrapper is unavailable)
|
||||
|
||||
```sh
|
||||
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
|
||||
echo "merge_sha=$merge_sha"
|
||||
scripts/pr merge-run <PR>
|
||||
```
|
||||
|
||||
5. Optional comment
|
||||
5. Cleanup
|
||||
|
||||
Use a literal multiline string or heredoc for newlines.
|
||||
|
||||
```sh
|
||||
gh pr comment <PR> --body "$(printf 'Merged via squash.\n\n- Merge commit: %s\n\nThanks @%s!\n' \"$merge_sha\" \"$contrib\")"
|
||||
```
|
||||
|
||||
6. Verify PR state is MERGED
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json state --jq .state
|
||||
```
|
||||
|
||||
7. Clean up worktree only on success
|
||||
|
||||
Run cleanup only if step 6 returned `MERGED`.
|
||||
|
||||
```sh
|
||||
cd ~/dev/openclaw
|
||||
|
||||
git worktree remove ".worktrees/pr-<PR>" --force
|
||||
|
||||
git branch -D temp/pr-<PR> 2>/dev/null || true
|
||||
git branch -D pr-<PR> 2>/dev/null || true
|
||||
```
|
||||
Cleanup is handled by `run` after merge success.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not close PRs.
|
||||
- End in MERGED state.
|
||||
- Clean up only after merge success.
|
||||
- Never push to main. Use `gh pr merge --squash` only.
|
||||
- Do not run `git push` at all in this command.
|
||||
- End in `MERGED`, never `CLOSED`.
|
||||
- Cleanup only after confirmed merge.
|
||||
|
||||
@@ -1,251 +1,122 @@
|
||||
---
|
||||
name: prepare-pr
|
||||
description: Prepare a GitHub PR for merge by rebasing onto main, fixing review findings, running gates, committing fixes, and pushing to the PR head branch. Use after /reviewpr. Never merge or push to main.
|
||||
description: Script-first PR preparation with structured findings resolution, deterministic push safety, and explicit gate execution.
|
||||
---
|
||||
|
||||
# Prepare PR
|
||||
|
||||
## Overview
|
||||
|
||||
Prepare a PR branch for merge with review fixes, green gates, and an updated head branch.
|
||||
Prepare the PR head branch for merge after `/review-pr`.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
- If missing, use `.local/pr-meta.env` if present in the PR worktree.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`. Push only to the PR head branch.
|
||||
- Never run `git push` without specifying remote and branch explicitly. Do not run bare `git push`.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
- Never push to `main`.
|
||||
- Only push to PR head with explicit `--force-with-lease` against known head SHA.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`. Stage only specific files changed.
|
||||
- Do not push to GitHub until the maintainer explicitly approves the push step.
|
||||
- Wrappers are cwd-agnostic; run from repo root or PR worktree.
|
||||
|
||||
## Execution Rule
|
||||
## Execution Contract
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Rebase PR commits onto `origin/main`.
|
||||
- Fix all BLOCKER and IMPORTANT items from `.local/review.md`.
|
||||
- Run gates and pass.
|
||||
- Commit prep changes.
|
||||
- Push the updated HEAD back to the PR head branch.
|
||||
- Write `.local/prep.md` with a prep summary.
|
||||
- Output exactly: `PR is ready for /mergepr`.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all prep steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all prep work.
|
||||
1. Run setup:
|
||||
|
||||
```sh
|
||||
cd ~/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
scripts/pr-prepare init <PR>
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
2. Resolve findings from structured review:
|
||||
|
||||
## Load Review Findings (Mandatory)
|
||||
- `.local/review.json` is mandatory.
|
||||
- Resolve all `BLOCKER` and `IMPORTANT` items.
|
||||
|
||||
3. Commit scoped changes with concise subjects (no PR number/thanks; those belong on the final merge/squash commit).
|
||||
|
||||
4. Run gates via wrapper.
|
||||
|
||||
5. Push via wrapper (includes pre-push remote verification, one automatic lease-retry path, and post-push API propagation retry).
|
||||
|
||||
Optional one-shot path:
|
||||
|
||||
```sh
|
||||
if [ -f .local/review.md ]; then
|
||||
echo "Found review findings from /reviewpr"
|
||||
else
|
||||
echo "Missing .local/review.md. Run /reviewpr first and save findings."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read it
|
||||
sed -n '1,200p' .local/review.md
|
||||
scripts/pr-prepare run <PR>
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta (author, head branch, head repo URL)
|
||||
1. Setup and artifacts
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository,body --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
scripts/pr-prepare init <PR>
|
||||
|
||||
ls -la .local/review.md .local/review.json .local/pr-meta.env .local/prep-context.env
|
||||
jq . .local/review.json >/dev/null
|
||||
```
|
||||
|
||||
2. Fetch the PR branch tip into a local ref
|
||||
2. Resolve required findings
|
||||
|
||||
List required items:
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
jq -r '.findings[] | select(.severity=="BLOCKER" or .severity=="IMPORTANT") | "- [\(.severity)] \(.id): \(.title) => \(.fix)"' .local/review.json
|
||||
```
|
||||
|
||||
3. Rebase PR commits onto latest main
|
||||
Fix all required findings. Keep scope tight.
|
||||
|
||||
3. Update changelog/docs (changelog is mandatory in this workflow)
|
||||
|
||||
```sh
|
||||
# Move worktree to the PR tip first
|
||||
git reset --hard pr-<PR>
|
||||
|
||||
# Rebase onto current main
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
jq -r '.changelog' .local/review.json
|
||||
jq -r '.docs' .local/review.json
|
||||
```
|
||||
|
||||
If conflicts happen:
|
||||
4. Commit scoped changes
|
||||
|
||||
- Resolve each conflicted file.
|
||||
- Run `git add <resolved_file>` for each file.
|
||||
- Run `git rebase --continue`.
|
||||
Use concise, action-oriented subject lines without PR numbers/thanks. The final merge/squash commit is the only place we include PR numbers and contributor thanks.
|
||||
|
||||
If the rebase gets confusing or you resolve conflicts 3 or more times, stop and report.
|
||||
|
||||
4. Fix issues from `.local/review.md`
|
||||
|
||||
- Fix all BLOCKER and IMPORTANT items.
|
||||
- NITs are optional.
|
||||
- Keep scope tight.
|
||||
|
||||
Keep a running log in `.local/prep.md`:
|
||||
|
||||
- List which review items you fixed.
|
||||
- List which files you touched.
|
||||
- Note behavior changes.
|
||||
|
||||
5. Update `CHANGELOG.md` if flagged in review
|
||||
|
||||
Check `.local/review.md` section H for guidance.
|
||||
If flagged and user-facing:
|
||||
|
||||
- Check if `CHANGELOG.md` exists.
|
||||
Use explicit file list:
|
||||
|
||||
```sh
|
||||
ls CHANGELOG.md 2>/dev/null
|
||||
scripts/committer "fix: <summary>" <file1> <file2> ...
|
||||
```
|
||||
|
||||
- Follow existing format.
|
||||
- Add a concise entry with PR number and contributor.
|
||||
|
||||
6. Update docs if flagged in review
|
||||
|
||||
Check `.local/review.md` section G for guidance.
|
||||
If flagged, update only docs related to the PR changes.
|
||||
|
||||
7. Commit prep fixes
|
||||
|
||||
Stage only specific files:
|
||||
5. Run gates
|
||||
|
||||
```sh
|
||||
git add <file1> <file2> ...
|
||||
scripts/pr-prepare gates <PR>
|
||||
```
|
||||
|
||||
Preferred commit tool:
|
||||
6. Push safely to PR head
|
||||
|
||||
```sh
|
||||
committer "fix: <summary> (#<PR>) (thanks @$contrib)" <changed files>
|
||||
scripts/pr-prepare push <PR>
|
||||
```
|
||||
|
||||
If `committer` is not found:
|
||||
This push step includes:
|
||||
|
||||
- robust fork remote resolution from owner/name,
|
||||
- pre-push remote SHA verification,
|
||||
- one automatic rebase + gate rerun + retry if lease push fails,
|
||||
- post-push PR-head propagation retry,
|
||||
- idempotent behavior when local prep HEAD is already on the PR head,
|
||||
- post-push SHA verification and `.local/prep.env` generation.
|
||||
|
||||
7. Verify handoff artifacts
|
||||
|
||||
```sh
|
||||
git commit -m "fix: <summary> (#<PR>) (thanks @$contrib)"
|
||||
ls -la .local/prep.md .local/prep.env
|
||||
```
|
||||
|
||||
8. Run full gates before pushing
|
||||
8. Output
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
pnpm check
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Require all to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix and rerun cycles. If gates still fail after 3 attempts, stop and report the failures. Do not loop indefinitely.
|
||||
|
||||
9. Push updates back to the PR head branch
|
||||
|
||||
```sh
|
||||
# Ensure remote for PR head exists
|
||||
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
|
||||
|
||||
# Use force with lease after rebase
|
||||
# Double check: $head must NOT be "main" or "master"
|
||||
echo "Pushing to branch: $head"
|
||||
if [ "$head" = "main" ] || [ "$head" = "master" ]; then
|
||||
echo "ERROR: head branch is main/master. This is wrong. Stopping."
|
||||
exit 1
|
||||
fi
|
||||
git push --force-with-lease prhead HEAD:$head
|
||||
```
|
||||
|
||||
Before running the command above, pause and ask for explicit maintainer go-ahead to perform the push.
|
||||
|
||||
10. Verify PR is not behind main (Mandatory)
|
||||
|
||||
```sh
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR>-verify --force
|
||||
git merge-base --is-ancestor origin/main pr-<PR>-verify && echo "PR is up to date with main" || echo "ERROR: PR is still behind main, rebase again"
|
||||
git branch -D pr-<PR>-verify 2>/dev/null || true
|
||||
```
|
||||
|
||||
If still behind main, repeat steps 2 through 9.
|
||||
|
||||
11. Write prep summary artifacts (Mandatory)
|
||||
|
||||
Update `.local/prep.md` with:
|
||||
|
||||
- Current HEAD sha from `git rev-parse HEAD`.
|
||||
- Short bullet list of changes.
|
||||
- Gate results.
|
||||
- Push confirmation.
|
||||
- Rebase verification result.
|
||||
|
||||
Create or overwrite `.local/prep.md` and verify it exists and is non-empty:
|
||||
|
||||
```sh
|
||||
git rev-parse HEAD
|
||||
ls -la .local/prep.md
|
||||
wc -l .local/prep.md
|
||||
```
|
||||
|
||||
12. Output
|
||||
|
||||
Include a diff stat summary:
|
||||
|
||||
```sh
|
||||
git diff --stat origin/main..HEAD
|
||||
git diff --shortstat origin/main..HEAD
|
||||
```
|
||||
|
||||
Report totals: X files changed, Y insertions(+), Z deletions(-).
|
||||
|
||||
If gates passed and push succeeded, print exactly:
|
||||
|
||||
```
|
||||
PR is ready for /mergepr
|
||||
```
|
||||
|
||||
Otherwise, list remaining failures and stop.
|
||||
- Summarize resolved findings and gate results.
|
||||
- Print exactly: `PR is ready for /merge-pr`.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree on success. `/mergepr` may reuse it.
|
||||
- Do not run `gh pr merge`.
|
||||
- Never push to main. Only push to the PR head branch.
|
||||
- Run and pass all gates before pushing.
|
||||
- Do not run `gh pr merge` in this skill.
|
||||
- Do not delete worktree.
|
||||
|
||||
@@ -1,229 +1,142 @@
|
||||
---
|
||||
name: review-pr
|
||||
description: Review-only GitHub pull request analysis with the gh CLI. Use when asked to review a PR, provide structured feedback, or assess readiness to land. Do not merge, push, or make code changes you intend to keep.
|
||||
description: Script-first review-only GitHub pull request analysis. Use for deterministic PR review with structured findings handoff to /prepare-pr.
|
||||
---
|
||||
|
||||
# Review PR
|
||||
|
||||
## Overview
|
||||
|
||||
Perform a thorough review-only PR assessment and return a structured recommendation on readiness for /preparepr.
|
||||
Perform a read-only review and produce both human and machine-readable outputs.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, always ask. Never auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
- If missing, always ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`, not during review, not ever.
|
||||
- Do not run `git push` at all during review. Treat review as read only.
|
||||
- Do not stop or kill the gateway. Do not run gateway stop commands. Do not kill processes on port 18792.
|
||||
- Do not perform any GitHub write action (comments, assignees, labels, state changes) unless maintainer explicitly approves it.
|
||||
- Never push, merge, or modify code intended to keep.
|
||||
- Work only in `.worktrees/pr-<PR>`.
|
||||
- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
|
||||
|
||||
## Execution Rule
|
||||
## Execution Contract
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs, not a plan.
|
||||
|
||||
## Known Failure Modes
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Do not stop after printing the checklist. That is not completion.
|
||||
|
||||
## Writing Style for Output
|
||||
|
||||
- Write casual and direct.
|
||||
- Avoid em dashes and en dashes. Use commas or separate sentences.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Run the commands in the worktree and inspect the PR directly.
|
||||
- Produce the structured review sections A through J.
|
||||
- Save the full review to `.local/review.md` inside the worktree.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all review steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all review work.
|
||||
1. Run wrapper setup:
|
||||
|
||||
```sh
|
||||
cd ~/dev/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
git fetch origin main
|
||||
|
||||
# Reuse existing worktree if it exists, otherwise create new
|
||||
if [ -d "$WORKTREE_DIR" ]; then
|
||||
cd "$WORKTREE_DIR"
|
||||
git checkout temp/pr-<PR> 2>/dev/null || git checkout -b temp/pr-<PR>
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
else
|
||||
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
|
||||
cd "$WORKTREE_DIR"
|
||||
fi
|
||||
|
||||
# Create local scratch space that persists across /reviewpr to /preparepr to /mergepr
|
||||
mkdir -p .local
|
||||
scripts/pr-review <PR>
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
Start on `origin/main` so you can check for existing implementations before looking at PR code.
|
||||
2. Use explicit branch mode switches:
|
||||
|
||||
- Main baseline mode: `scripts/pr review-checkout-main <PR>`
|
||||
- PR-head mode: `scripts/pr review-checkout-pr <PR>`
|
||||
|
||||
3. Before writing review outputs, run branch guard:
|
||||
|
||||
```sh
|
||||
scripts/pr review-guard <PR>
|
||||
```
|
||||
|
||||
4. Write both outputs:
|
||||
|
||||
- `.local/review.md` with sections A through J.
|
||||
- `.local/review.json` with structured findings.
|
||||
|
||||
5. Validate artifacts semantically:
|
||||
|
||||
```sh
|
||||
scripts/pr review-validate-artifacts <PR>
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta and context
|
||||
1. Setup and metadata
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length,body}'
|
||||
scripts/pr-review <PR>
|
||||
ls -la .local/pr-meta.json .local/pr-meta.env .local/review-context.env .local/review-mode.env
|
||||
```
|
||||
|
||||
2. Check if this already exists in main before looking at the PR branch
|
||||
|
||||
- Identify the core feature or fix from the PR title and description.
|
||||
- Search for existing implementations using keywords from the PR title, changed file paths, and function or component names from the diff.
|
||||
2. Existing implementation check on main
|
||||
|
||||
```sh
|
||||
# Use keywords from the PR title and changed files
|
||||
rg -n "<keyword_from_pr_title>" -S src packages apps ui || true
|
||||
rg -n "<function_or_component_name>" -S src packages apps ui || true
|
||||
|
||||
git log --oneline --all --grep="<keyword_from_pr_title>" | head -20
|
||||
scripts/pr review-checkout-main <PR>
|
||||
rg -n "<keyword>" -S src extensions apps || true
|
||||
git log --oneline --all --grep "<keyword>" | head -20
|
||||
```
|
||||
|
||||
If it already exists, call it out as a BLOCKER or at least IMPORTANT.
|
||||
|
||||
3. Optional claim step, only with explicit approval
|
||||
|
||||
If the maintainer asks to claim the PR, assign yourself. Otherwise skip this.
|
||||
3. Claim PR
|
||||
|
||||
```sh
|
||||
gh_user=$(gh api user --jq .login)
|
||||
gh pr edit <PR> --add-assignee "$gh_user"
|
||||
gh pr edit <PR> --add-assignee "$gh_user" || echo "Could not assign reviewer, continuing"
|
||||
```
|
||||
|
||||
4. Read the PR description carefully
|
||||
|
||||
Use the body from step 1. Summarize goal, scope, and missing context.
|
||||
|
||||
5. Read the diff thoroughly
|
||||
|
||||
Minimum:
|
||||
4. Read PR description and diff
|
||||
|
||||
```sh
|
||||
scripts/pr review-checkout-pr <PR>
|
||||
gh pr diff <PR>
|
||||
|
||||
source .local/review-context.env
|
||||
git diff --stat "$MERGE_BASE"..pr-<PR>
|
||||
git diff "$MERGE_BASE"..pr-<PR>
|
||||
```
|
||||
|
||||
If you need full code context locally, fetch the PR head to a local ref and diff it. Do not create a merge commit.
|
||||
5. Optional local tests
|
||||
|
||||
Use the wrapper for target validation and executed-test verification:
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
# Show changes without modifying the working tree
|
||||
|
||||
git diff --stat origin/main..pr-<PR>
|
||||
git diff origin/main..pr-<PR>
|
||||
scripts/pr review-tests <PR> <test-file> [<test-file> ...]
|
||||
```
|
||||
|
||||
If you want to browse the PR version of files directly, temporarily check out `pr-<PR>` in the worktree. Do not commit or push. Return to `temp/pr-<PR>` and reset to `origin/main` afterward.
|
||||
6. Initialize review artifact templates
|
||||
|
||||
```sh
|
||||
# Use only if needed
|
||||
# git checkout pr-<PR>
|
||||
# ...inspect files...
|
||||
|
||||
git checkout temp/pr-<PR>
|
||||
git reset --hard origin/main
|
||||
scripts/pr review-artifacts-init <PR>
|
||||
```
|
||||
|
||||
6. Validate the change is needed and valuable
|
||||
7. Produce review outputs
|
||||
|
||||
Be honest. Call out low value AI slop.
|
||||
- Fill `.local/review.md` sections A through J.
|
||||
- Fill `.local/review.json`.
|
||||
|
||||
7. Evaluate implementation quality
|
||||
Minimum JSON shape:
|
||||
|
||||
Review correctness, design, performance, and ergonomics.
|
||||
```json
|
||||
{
|
||||
"recommendation": "READY FOR /prepare-pr",
|
||||
"findings": [
|
||||
{
|
||||
"id": "F1",
|
||||
"severity": "IMPORTANT",
|
||||
"title": "...",
|
||||
"area": "path/or/component",
|
||||
"fix": "Actionable fix"
|
||||
}
|
||||
],
|
||||
"tests": {
|
||||
"ran": [],
|
||||
"gaps": [],
|
||||
"result": "pass"
|
||||
},
|
||||
"docs": "up_to_date|missing|not_applicable",
|
||||
"changelog": "required"
|
||||
}
|
||||
```
|
||||
|
||||
8. Perform a security review
|
||||
|
||||
Assume OpenClaw subagents run with full disk access, including git, gh, and shell. Check auth, input validation, secrets, dependencies, tool safety, and privacy.
|
||||
|
||||
9. Review tests and verification
|
||||
|
||||
Identify what exists, what is missing, and what would be a minimal regression test.
|
||||
|
||||
10. Check docs
|
||||
|
||||
Check if the PR touches code with related documentation such as README, docs, inline API docs, or config examples.
|
||||
|
||||
- If docs exist for the changed area and the PR does not update them, flag as IMPORTANT.
|
||||
- If the PR adds a new feature or config option with no docs, flag as IMPORTANT.
|
||||
- If the change is purely internal with no user-facing impact, skip this.
|
||||
|
||||
11. Check changelog
|
||||
|
||||
Check if `CHANGELOG.md` exists and whether the PR warrants an entry.
|
||||
|
||||
- If the project has a changelog and the PR is user-facing, flag missing entry as IMPORTANT.
|
||||
- Leave the change for /preparepr, only flag it here.
|
||||
|
||||
12. Answer the key question
|
||||
|
||||
Decide if /preparepr can fix issues or the contributor must update the PR.
|
||||
|
||||
13. Save findings to the worktree
|
||||
|
||||
Write the full structured review sections A through J to `.local/review.md`.
|
||||
Create or overwrite the file and verify it exists and is non-empty.
|
||||
8. Guard + validate before final output
|
||||
|
||||
```sh
|
||||
ls -la .local/review.md
|
||||
wc -l .local/review.md
|
||||
scripts/pr review-guard <PR>
|
||||
scripts/pr review-validate-artifacts <PR>
|
||||
```
|
||||
|
||||
14. Output the structured review
|
||||
|
||||
Produce a review that matches what you saved to `.local/review.md`.
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /preparepr | NEEDS WORK | NEEDS DISCUSSION | NOT USEFUL (CLOSE)
|
||||
- 1 to 3 sentences.
|
||||
|
||||
B) What changed
|
||||
|
||||
C) What is good
|
||||
|
||||
D) Security findings
|
||||
|
||||
E) Concerns or questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as BLOCKER, IMPORTANT, or NIT.
|
||||
- For each, point to file or area and propose a concrete fix.
|
||||
|
||||
F) Tests
|
||||
|
||||
G) Docs status
|
||||
|
||||
- State if related docs are up to date, missing, or not applicable.
|
||||
|
||||
H) Changelog
|
||||
|
||||
- State if `CHANGELOG.md` needs an entry and which category.
|
||||
|
||||
I) Follow ups (optional)
|
||||
|
||||
J) Suggested PR comment (optional)
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree after review.
|
||||
- Review only, do not merge, do not push.
|
||||
- Keep review read-only.
|
||||
- Do not delete worktree.
|
||||
- Use merge-base scoped diff for local context to avoid stale branch drift.
|
||||
|
||||
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a problem or unexpected behavior in Clawdbot.
|
||||
title: "[Bug]: "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
What went wrong?
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
What did you expect to happen?
|
||||
|
||||
## Actual behavior
|
||||
|
||||
What actually happened?
|
||||
|
||||
## Environment
|
||||
|
||||
- Clawdbot version:
|
||||
- OS:
|
||||
- Install method (pnpm/npx/docker/etc):
|
||||
|
||||
## Logs or screenshots
|
||||
|
||||
Paste relevant logs or add screenshots (redact secrets).
|
||||
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: Bug report
|
||||
description: Report a defect or unexpected behavior in OpenClaw.
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-sentence statement of what is broken.
|
||||
placeholder: After upgrading to 2026.2.13, Telegram thread replies fail with "reply target not found".
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide the shortest deterministic repro path.
|
||||
placeholder: |
|
||||
1. Configure channel X.
|
||||
2. Send message Y.
|
||||
3. Run command Z.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What should happen if the bug does not exist.
|
||||
placeholder: Agent posts a reply in the same thread.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What happened instead, including user-visible errors.
|
||||
placeholder: No reply is posted; gateway logs "reply target not found".
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpenClaw version
|
||||
description: Exact version/build tested.
|
||||
placeholder: 2026.2.13
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: OS and version where this occurs.
|
||||
placeholder: macOS 15.4 / Ubuntu 24.04 / Windows 11
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: install_method
|
||||
attributes:
|
||||
label: Install method
|
||||
description: How OpenClaw was installed or launched.
|
||||
placeholder: npm global / pnpm dev / docker / mac app
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, and evidence
|
||||
description: Include redacted logs/screenshots/recordings that prove the behavior.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact and severity
|
||||
description: |
|
||||
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
|
||||
Include:
|
||||
- Affected users/systems/channels
|
||||
- Severity (annoying, blocks workflow, data risk, etc.)
|
||||
- Frequency (always/intermittent/edge case)
|
||||
- Consequence (missed messages, failed onboarding, extra cost, etc.)
|
||||
placeholder: |
|
||||
Affected: Telegram group users on 2026.2.13
|
||||
Severity: High (blocks replies)
|
||||
Frequency: 100% repro
|
||||
Consequence: Agents cannot respond in threads
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any context that helps triage but does not fit above.
|
||||
placeholder: Regression started after upgrade from 2026.2.12; temporary workaround is restarting gateway every 30m.
|
||||
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea or improvement for Clawdbot.
|
||||
title: "[Feature]: "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Describe the problem you are trying to solve or the opportunity you see.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
What would you like Clawdbot to do?
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Any other approaches you have considered?
|
||||
|
||||
## Additional context
|
||||
|
||||
Links, screenshots, or related issues.
|
||||
70
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
70
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Feature request
|
||||
description: Propose a new capability or product improvement.
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Help us evaluate this request with concrete use cases and tradeoffs.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-line statement of the requested capability.
|
||||
placeholder: Add per-channel default response prefix.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem to solve
|
||||
description: What user pain this solves and why current behavior is insufficient.
|
||||
placeholder: Teams cannot distinguish agent personas in mixed channels, causing misrouted follow-ups.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposed_solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Desired behavior/API/UX with as much specificity as possible.
|
||||
placeholder: Support channels.<channel>.responsePrefix with default fallback and account-level override.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other approaches considered and why they are weaker.
|
||||
placeholder: Manual prefixing in prompts is inconsistent and hard to enforce.
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact
|
||||
description: |
|
||||
Explain who is affected, severity/urgency, how often this pain occurs, and practical consequences.
|
||||
Include:
|
||||
- Affected users/systems/channels
|
||||
- Severity (annoying, blocks workflow, etc.)
|
||||
- Frequency (always/intermittent/edge case)
|
||||
- Consequence (delays, errors, extra manual work, etc.)
|
||||
placeholder: |
|
||||
Affected: Multi-team shared channels
|
||||
Severity: Medium
|
||||
Frequency: Daily
|
||||
Consequence: +20 minutes/day/operator and delayed alerts
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence/examples
|
||||
description: Prior art, links, screenshots, logs, or metrics.
|
||||
placeholder: Comparable behavior in X, sample config, and screenshot of current limitation.
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Extra context, constraints, or references not covered above.
|
||||
placeholder: Must remain backward-compatible with existing config keys.
|
||||
108
.github/pull_request_template.md
vendored
Normal file
108
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
## Summary
|
||||
|
||||
Describe the problem and fix in 2–5 bullets:
|
||||
|
||||
- Problem:
|
||||
- Why it matters:
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
|
||||
## Change Type (select all)
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactor
|
||||
- [ ] Docs
|
||||
- [ ] Security hardening
|
||||
- [ ] Chore/infra
|
||||
|
||||
## Scope (select all touched areas)
|
||||
|
||||
- [ ] Gateway / orchestration
|
||||
- [ ] Skills / tool execution
|
||||
- [ ] Auth / tokens
|
||||
- [ ] Memory / storage
|
||||
- [ ] Integrations
|
||||
- [ ] API / contracts
|
||||
- [ ] UI / DX
|
||||
- [ ] CI/CD / infra
|
||||
|
||||
## Linked Issue/PR
|
||||
|
||||
- Closes #
|
||||
- Related #
|
||||
|
||||
## User-visible / Behavior Changes
|
||||
|
||||
List user-visible changes (including defaults/config).
|
||||
If none, write `None`.
|
||||
|
||||
## Security Impact (required)
|
||||
|
||||
- New permissions/capabilities? (`Yes/No`)
|
||||
- Secrets/tokens handling changed? (`Yes/No`)
|
||||
- New/changed network calls? (`Yes/No`)
|
||||
- Command/tool execution surface changed? (`Yes/No`)
|
||||
- Data access scope changed? (`Yes/No`)
|
||||
- If any `Yes`, explain risk + mitigation:
|
||||
|
||||
## Repro + Verification
|
||||
|
||||
### Environment
|
||||
|
||||
- OS:
|
||||
- Runtime/container:
|
||||
- Model/provider:
|
||||
- Integration/channel (if any):
|
||||
- Relevant config (redacted):
|
||||
|
||||
### Steps
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Expected
|
||||
|
||||
-
|
||||
|
||||
### Actual
|
||||
|
||||
-
|
||||
|
||||
## Evidence
|
||||
|
||||
Attach at least one:
|
||||
|
||||
- [ ] Failing test/log before + passing after
|
||||
- [ ] Trace/log snippets
|
||||
- [ ] Screenshot/recording
|
||||
- [ ] Perf numbers (if relevant)
|
||||
|
||||
## Human Verification (required)
|
||||
|
||||
What you personally verified (not just CI), and how:
|
||||
|
||||
- Verified scenarios:
|
||||
- Edge cases checked:
|
||||
- What you did **not** verify:
|
||||
|
||||
## Compatibility / Migration
|
||||
|
||||
- Backward compatible? (`Yes/No`)
|
||||
- Config/env changes? (`Yes/No`)
|
||||
- Migration needed? (`Yes/No`)
|
||||
- If yes, exact upgrade steps:
|
||||
|
||||
## Failure Recovery (if this breaks)
|
||||
|
||||
- How to disable/revert this change quickly:
|
||||
- Files/config to restore:
|
||||
- Known bad symptoms reviewers should watch for:
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
List only real risks for this PR. Add/remove entries as needed. If none, write `None`.
|
||||
|
||||
- Risk:
|
||||
- Mitigation:
|
||||
20
.github/workflows/auto-response.yml
vendored
20
.github/workflows/auto-response.yml
vendored
@@ -132,16 +132,34 @@ jobs:
|
||||
}
|
||||
|
||||
const invalidLabel = "invalid";
|
||||
const dirtyLabel = "dirty";
|
||||
const noisyPrMessage =
|
||||
"Closing this PR because it looks dirty (too many unrelated commits). Please recreate the PR from a clean branch.";
|
||||
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (pullRequest) {
|
||||
if (labelSet.has(dirtyLabel)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: noisyPrMessage,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const labelCount = labelSet.size;
|
||||
if (labelCount > 20) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: "Closing this PR because it has more than 20 labels, which usually means the branch is too noisy. Please recreate the PR from a clean branch.",
|
||||
body: noisyPrMessage,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -204,6 +204,14 @@ jobs:
|
||||
if: matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure Node test resources
|
||||
if: matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: |
|
||||
# `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
|
||||
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
|
||||
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
|
||||
1
.github/workflows/formal-conformance.yml
vendored
1
.github/workflows/formal-conformance.yml
vendored
@@ -108,6 +108,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR (informational)
|
||||
if: steps.drift.outputs.drift == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
|
||||
24
.github/workflows/install-smoke.yml
vendored
24
.github/workflows/install-smoke.yml
vendored
@@ -33,19 +33,17 @@ jobs:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
56
.github/workflows/sandbox-common-smoke.yml
vendored
Normal file
56
.github/workflows/sandbox-common-smoke.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Sandbox Common Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- Dockerfile.sandbox
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
pull_request:
|
||||
paths:
|
||||
- Dockerfile.sandbox
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
|
||||
concurrency:
|
||||
group: sandbox-common-smoke-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sandbox-common-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Build minimal sandbox base (USER sandbox)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker build -t openclaw-sandbox-smoke-base:bookworm-slim - <<'EOF'
|
||||
FROM debian:bookworm-slim
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
EOF
|
||||
|
||||
- name: Build sandbox-common image (root for installs, sandbox at runtime)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \
|
||||
TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \
|
||||
PACKAGES="ca-certificates" \
|
||||
INSTALL_PNPM=0 \
|
||||
INSTALL_BUN=0 \
|
||||
INSTALL_BREW=0 \
|
||||
FINAL_USER=sandbox \
|
||||
scripts/sandbox-common-setup.sh
|
||||
|
||||
u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
|
||||
test "$u" = "sandbox"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,6 +27,8 @@ apps/android/.cxx/
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
apps/shared/MoltbotKit/.build/
|
||||
apps/shared/OpenClawKit/.build/
|
||||
apps/shared/OpenClawKit/Package.resolved
|
||||
**/ModuleCache/
|
||||
bin/
|
||||
bin/clawdbot-mac
|
||||
|
||||
17
AGENTS.md
17
AGENTS.md
@@ -100,8 +100,8 @@
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Read this when submitting a PR: `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr))
|
||||
- Read this when submitting an issue: `docs/help/submitting-an-issue.md` ([Submitting an Issue](https://docs.openclaw.ai/help/submitting-an-issue))
|
||||
- PR submission template (canonical): `.github/pull_request_template.md`
|
||||
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
|
||||
|
||||
## Shorthand Commands
|
||||
|
||||
@@ -119,6 +119,19 @@
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
||||
|
||||
## GHSA (Repo Advisory) Patch/Publish
|
||||
|
||||
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
|
||||
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
|
||||
- Private fork PRs must be closed:
|
||||
`fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)`
|
||||
`gh pr list -R "$fork" --state open` (must be empty)
|
||||
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
|
||||
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
|
||||
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
|
||||
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
|
||||
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
211
CHANGELOG.md
211
CHANGELOG.md
@@ -2,10 +2,199 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.15 (Unreleased)
|
||||
|
||||
### Changes
|
||||
|
||||
- Build: add `pnpm build:runtime` for packagers/runtime builds to skip plugin-sdk declaration generation when types are not needed. (#17636) Thanks @joshp123.
|
||||
- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
|
||||
- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
|
||||
- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
|
||||
- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
|
||||
- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
|
||||
- Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
|
||||
- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code.
|
||||
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
|
||||
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
|
||||
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
|
||||
- Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez.
|
||||
- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.
|
||||
- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
|
||||
- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
|
||||
- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
|
||||
- TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.
|
||||
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
|
||||
- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
|
||||
- Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz.
|
||||
- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
|
||||
- Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n.
|
||||
- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
|
||||
- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
|
||||
- Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
|
||||
- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
|
||||
- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
|
||||
- CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.
|
||||
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
|
||||
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
|
||||
- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
|
||||
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
|
||||
- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
|
||||
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
|
||||
|
||||
## 2026.2.14
|
||||
|
||||
### Changes
|
||||
|
||||
- Telegram: add poll sending via `openclaw message poll` (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.
|
||||
- Slack/Discord: add `dmPolicy` + `allowFrom` config aliases for DM access control; legacy `dm.policy` + `dm.allowFrom` keys remain supported and `openclaw doctor --fix` can migrate them.
|
||||
- Discord: allow exec approval prompts to target channels or both DM+channel via `channels.discord.execApprovals.target`. (#16051) Thanks @leonnardo.
|
||||
- Sandbox: add `sandbox.browser.binds` to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.
|
||||
- Discord: add debug logging for message routing decisions to improve `--debug` tracing. (#16202) Thanks @jayleekr.
|
||||
- Agents: add optional `messages.suppressToolErrors` config to hide non-mutating tool-failure warnings from user-facing chat while still surfacing mutating failures. (#16620) Thanks @vai-oro.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/Plugins: ensure `openclaw message send` exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.
|
||||
- CLI/Plugins: run registered plugin `gateway_stop` hooks before `openclaw message` exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.
|
||||
- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
|
||||
- Telegram: when `channels.telegram.commands.native` is `false`, exclude plugin commands from `setMyCommands` menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.
|
||||
- LINE: return 200 OK for Developers Console "Verify" requests (`{"events":[]}`) without `X-Line-Signature`, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.
|
||||
- Cron: deliver text-only output directly when `delivery.to` is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow.
|
||||
- Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
|
||||
- Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks @mcaxtr.
|
||||
- Media understanding: treat binary `application/vnd.*`/zip/octet-stream attachments as non-text (while keeping vendor `+json`/`+xml` text-eligible) so Office/ZIP files are not inlined into prompt body text. (#16513) Thanks @rmramsey32.
|
||||
- Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.
|
||||
- Auto-reply/Block streaming: strip leading whitespace from streamed block replies so messages starting with blank lines no longer deliver visible leading empty lines. (#16422) Thanks @mcinteerj.
|
||||
- Auto-reply/Queue: keep queued followups and overflow summaries when drain attempts fail, then retry delivery instead of dropping messages on transient errors. (#16771) Thanks @mmhzlrj.
|
||||
- Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)
|
||||
- Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit `workspaceDir`. (#16722)
|
||||
- BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.
|
||||
- CLI: fix lazy core command registration so top-level maintenance commands (`doctor`, `dashboard`, `reset`, `uninstall`) resolve correctly instead of exposing a non-functional `maintenance` placeholder command.
|
||||
- CLI/Dashboard: when `gateway.bind=lan`, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev.
|
||||
- TUI/Gateway: resolve local gateway target URL from `gateway.bind` mode (tailnet/lan) instead of hardcoded localhost so `openclaw tui` connects when gateway is non-loopback. (#16299) Thanks @cortexuvula.
|
||||
- TUI: honor explicit `--session <key>` in `openclaw tui` even when `session.scope` is `global`, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu.
|
||||
- TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla.
|
||||
- TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds.
|
||||
- TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73.
|
||||
- TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75.
|
||||
- TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe.
|
||||
- TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren.
|
||||
- TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog.
|
||||
- TUI/Hooks: pass explicit reset reason (`new` vs `reset`) through `sessions.reset` and emit internal command hooks for gateway-triggered resets so `/new` hook workflows fire in TUI/webchat.
|
||||
- Gateway/Agent: route bare `/new` and `/reset` through `sessions.reset` before running the fresh-session greeting prompt, so reset commands clear the current session in-place instead of falling through to normal agent runs. (#16732) Thanks @kdotndot and @vignesh07.
|
||||
- Cron: prevent `cron list`/`cron status` from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.
|
||||
- Cron: repair missing/corrupt `nextRunAtMs` for the updated job without globally recomputing unrelated due jobs during `cron update`. (#15750)
|
||||
- Cron: treat persisted jobs with missing `enabled` as enabled by default across update/list/timer due-path checks, and add regression coverage for missing-`enabled` store records. (#15433) Thanks @eternauta1337.
|
||||
- Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale `runningAtMs` markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn.
|
||||
- Heartbeat/Cron: treat cron-tagged queued system events as cron reminders even on interval wakes, so isolated cron announce summaries no longer run under the default heartbeat prompt. (#14947) Thanks @archedark-ada and @vignesh07.
|
||||
- Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as `guild=dm`. Thanks @thewilloftheshadow.
|
||||
- Discord: treat empty per-guild `channels: {}` config maps as no channel allowlist (not deny-all), so `groupPolicy: "open"` guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu.
|
||||
- Models/CLI: guard `models status` string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.
|
||||
- Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.
|
||||
- Gateway/Config: make `config.patch` merge object arrays by `id` (for example `agents.list`) instead of replacing the whole array, so partial agent updates do not silently delete unrelated agents. (#6766) Thanks @lightclient.
|
||||
- Webchat/Prompts: stop injecting direct-chat `conversation_label` into inbound untrusted metadata context blocks, preventing internal label noise from leaking into visible chat replies. (#16556) Thanks @nberardi.
|
||||
- Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.
|
||||
- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
|
||||
- Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.
|
||||
- Agents/Tools: make required-parameter validation errors list missing fields and instruct: "Supply correct parameters before retrying," reducing repeated invalid tool-call loops (for example `read({})`). (#14729)
|
||||
- Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including `session_status` model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.
|
||||
- Agents/Process/Bootstrap: preserve unbounded `process log` offset-only pagination (default tail applies only when both `offset` and `limit` are omitted) and enforce strict `bootstrapTotalMaxChars` budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.
|
||||
- Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing `BOOTSTRAP.md` once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.
|
||||
- Agents/Workspace: create `BOOTSTRAP.md` when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.
|
||||
- Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.
|
||||
- Agents: treat empty-stream provider failures (`request ended without sending any chunks`) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive.
|
||||
- Agents: treat `read` tool `file_path` arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.
|
||||
- Agents/Transcript: drop malformed tool-call blocks with blank required fields (`id`/`name` or missing `input`/`arguments`) during session transcript repair to prevent persistent tool-call corruption on future turns. (#15485) Thanks @mike-zachariades.
|
||||
- Tools/Write/Edit: normalize structured text-block arguments for `content`/`oldText`/`newText` before filesystem edits, preventing JSON-like file corruption and false “exact text not found” misses from block-form params. (#16778) Thanks @danielpipernz.
|
||||
- Ollama/Agents: avoid forcing `<final>` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg.
|
||||
- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
|
||||
- Agents/Process: supervise PTY/child process lifecycles with explicit ownership, cancellation, timeouts, and deterministic cleanup, preventing Codex/Pi PTY sessions from dying or stalling on resume. (#14257) Thanks @onutc.
|
||||
- Skills: watch `SKILL.md` only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.
|
||||
- Memory/QMD: make `memory status` read-only by skipping QMD boot update/embed side effects for status-only manager checks.
|
||||
- Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.
|
||||
- Memory/Builtin: keep `memory status` dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi.
|
||||
- Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological `qmd` command output.
|
||||
- Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks.
|
||||
- Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.
|
||||
- Memory/QMD: pass result limits to `search`/`vsearch` commands so QMD can cap results earlier.
|
||||
- Memory/QMD: avoid reading full markdown files when a `from/lines` window is requested in QMD reads.
|
||||
- Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.
|
||||
- Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`.
|
||||
- Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.
|
||||
- Memory/QMD: avoid multi-collection `query` ranking corruption by running one `qmd query -c <collection>` per managed collection and merging by best score (also used for `search`/`vsearch` fallback-to-query). (#16740) Thanks @volarian-vai.
|
||||
- Memory/QMD: make `openclaw memory index` verify and print the active QMD index file path/size, and fail when QMD leaves a missing or zero-byte index artifact after an update. (#16775) Thanks @Shunamxiao.
|
||||
- Memory/QMD: detect null-byte `ENOTDIR` update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.
|
||||
- Memory/QMD/Security: add `rawKeyPrefix` support for QMD scope rules and preserve legacy `keyPrefix: "agent:..."` matching, preventing scoped deny bypass when operators match agent-prefixed session keys.
|
||||
- Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai.
|
||||
- Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24.
|
||||
- Security/Memory-LanceDB: require explicit `autoCapture: true` opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n.
|
||||
- Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07.
|
||||
- Gateway/Memory: clean up `agentRunSeq` tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07.
|
||||
- Auto-reply/Memory: bound `ABORT_MEMORY` growth by evicting oldest entries and deleting reset (`false`) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07.
|
||||
- Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07.
|
||||
- Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07.
|
||||
- Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek.
|
||||
- Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris.
|
||||
- Sandbox/Prompts: show the sandbox container workdir as the prompt working directory and clarify host-path usage for file tools, preventing host-path `exec` failures in sandbox sessions. (#16790) Thanks @carrotRakko.
|
||||
- Media/Security: allow local media reads from OpenClaw state `workspace/` and `sandboxes/` roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.
|
||||
- Media/Security: harden local media allowlist bypasses by requiring an explicit `readFile` override when callers mark paths as validated, and reject filesystem-root `localRoots` entries. (#16739)
|
||||
- Media/Security: allow outbound local media reads from the active agent workspace (including `workspace-<agentId>`) via agent-scoped local roots, avoiding broad global allowlisting of all per-agent workspaces. (#17136) Thanks @MisterGuy420.
|
||||
- Outbound/Media: thread explicit `agentId` through core `sendMessage` direct-delivery path so agent-scoped local media roots apply even when mirror metadata is absent. (#17268) Thanks @gumadeiras.
|
||||
- Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.
|
||||
- Security/BlueBubbles: require explicit `mediaLocalRoots` allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.
|
||||
- Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.
|
||||
- Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.
|
||||
- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
|
||||
- Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.
|
||||
- Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.
|
||||
- Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.
|
||||
- Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config). Config note: `hooks.transformsDir` must now be within that directory. Thanks @akhmittra.
|
||||
- Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).
|
||||
- Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.
|
||||
- Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.
|
||||
- Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.
|
||||
- Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.
|
||||
- Security/Slack: compute command authorization for DM slash commands even when `dmPolicy=open`, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.
|
||||
- Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.
|
||||
- Security/Google Chat: deprecate `users/<email>` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.
|
||||
- Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.
|
||||
- Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject `@username` principals), auto-resolve `@username` to IDs in `openclaw doctor --fix` (when possible), and warn in `openclaw security audit` when legacy configs contain usernames. Thanks @vincentkoc.
|
||||
- Telegram/Security: reject Telegram webhook startup when `webhookSecret` is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL.
|
||||
- Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).
|
||||
- Telegram: set webhook callback timeout handling to `onTimeout: "return"` (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington.
|
||||
- Signal: preserve case-sensitive `group:` target IDs during normalization so mixed-case group IDs no longer fail with `Group not found`. (#16748) Thanks @repfigit.
|
||||
- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
|
||||
- Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.
|
||||
- Security/Agents: enforce workspace-root path bounds for `apply_patch` in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec.
|
||||
- Security/Agents: enforce symlink-escape checks for `apply_patch` delete hunks under `workspaceOnly`, while still allowing deleting the symlink itself. Thanks @p80n-sec.
|
||||
- Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.
|
||||
- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.
|
||||
- Scripts/Security: validate GitHub logins and avoid shell invocation in `scripts/update-clawtributors.ts` to prevent command injection via malicious commit records. Thanks @scanleale.
|
||||
- Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent).
|
||||
- Security/Gateway: harden tool-supplied `gatewayUrl` overrides by restricting them to loopback or the configured `gateway.remote.url`. Thanks @p80n-sec.
|
||||
- Security/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth.
|
||||
- Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.
|
||||
- Security/Gateway: stop returning raw resolved config values in `skills.status` requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.
|
||||
- Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.
|
||||
- Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal.
|
||||
- Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.
|
||||
- Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.
|
||||
- Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.
|
||||
- Security/Exec: harden PATH handling by disabling project-local `node_modules/.bin` bootstrapping by default, disallowing node-host `PATH` overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra.
|
||||
- Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: `channels.tlon.allowPrivateNetwork`). Thanks @p80n-sec.
|
||||
- Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without `telnyx.publicKey` are now rejected unless `skipSignatureVerification` is enabled. Thanks @p80n-sec.
|
||||
- Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec.
|
||||
- Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.
|
||||
|
||||
## 2026.2.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Install: add optional Podman-based setup: `setup-podman.sh` for one-time host setup (openclaw user, image, launch script, systemd quadlet), `run-openclaw-podman.sh launch` / `launch setup`; systemd Quadlet unit for openclaw user service; docs for rootless container, openclaw user (subuid/subgid), and quadlet (troubleshooting). (#16273) Thanks @DarwinsBuddy.
|
||||
- Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.
|
||||
- Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.
|
||||
- Slack/Plugins: add thread-ownership outbound gating via `message_sending` hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.
|
||||
@@ -24,11 +213,15 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
|
||||
- Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh.
|
||||
- Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable.
|
||||
- Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories.
|
||||
- Security/Sessions: preserve inter-session input provenance for routed prompts so delegated/internal sessions are not treated as direct external user instructions. Thanks @anbecker.
|
||||
- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale.
|
||||
- Agents: strip leading empty lines from `sanitizeUserFacingText` output and normalize whitespace-only outputs to empty text. (#16158) Thanks @mcinteerj.
|
||||
- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y.
|
||||
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
|
||||
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
|
||||
- Auto-reply/Threading: honor explicit `[[reply_to_*]]` tags even when `replyToMode` is `off`. (#16174) Thanks @aldoeliacim.
|
||||
- Plugins/Threading: rename `allowTagsWhenOff` to `allowExplicitReplyTagsWhenOff` and keep the old key as a deprecated alias for compatibility. (#16189)
|
||||
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
|
||||
- Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.
|
||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||
@@ -39,6 +232,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: scope skill commands to the resolved agent for default accounts so `setMyCommands` no longer triggers `BOT_COMMANDS_TOO_MUCH` when multiple agents are configured. (#15599)
|
||||
- Discord: avoid misrouting numeric guild allowlist entries to `/channels/<guildId>` by prefixing guild-only inputs with `guild:` during resolution. (#12326) Thanks @headswim.
|
||||
- Memory/QMD: default `memory.qmd.searchMode` to `search` for faster CPU-only recall and always scope `search`/`vsearch` requests to managed collections (auto-falling back to `query` when required). (#16047) Thanks @togotago.
|
||||
- Memory/LanceDB: add configurable `captureMaxChars` for auto-capture while keeping the legacy 500-char default. (#16641) Thanks @ciberponk.
|
||||
- MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
|
||||
- Media: classify `text/*` MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.
|
||||
- Inbound/Web UI: preserve literal `\n` sequences when normalizing inbound text so Windows paths like `C:\\Work\\nxxx\\README.md` are not corrupted. (#11547) Thanks @mcaxtr.
|
||||
@@ -51,7 +245,7 @@ Docs: https://docs.openclaw.ai
|
||||
- OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into `pi` `auth.json` so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.
|
||||
- Auth/OpenAI Codex: share OAuth login handling across onboarding and `models auth login --provider openai-codex`, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20.
|
||||
- Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.
|
||||
- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.
|
||||
- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly (including Docker TTY installs that would otherwise hang). (#12972) Thanks @vincentkoc.
|
||||
- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
|
||||
- macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.
|
||||
- Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr.
|
||||
@@ -61,12 +255,12 @@ Docs: https://docs.openclaw.ai
|
||||
- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
|
||||
- Heartbeat: allow explicit wake (`wake`) and hook wake (`hook:*`) reasons to run even when `HEARTBEAT.md` is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.
|
||||
- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
|
||||
- Agents/Heartbeat: stop auto-creating `HEARTBEAT.md` during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.
|
||||
- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew.
|
||||
- Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.
|
||||
- Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.
|
||||
- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
|
||||
- Gateway/Routing: speed up hot paths for session listing (derived titles + previews), WS broadcast, and binding resolution.
|
||||
- Gateway/Sessions: cache derived title + last-message transcript reads to speed up repeated sessions list refreshes.
|
||||
- CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale.
|
||||
- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
|
||||
- CLI: speed up startup by lazily registering core commands (keeps rich `--help` while reducing cold-start overhead).
|
||||
@@ -75,6 +269,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
|
||||
- Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.
|
||||
- Security/Browser: constrain `POST /trace/stop`, `POST /wait/download`, and `POST /download` output paths to OpenClaw temp roots and reject traversal/escape paths.
|
||||
- Security/Browser: sanitize download `suggestedFilename` to keep implicit `wait/download` paths within the downloads root. Thanks @1seal.
|
||||
- Security/Browser: confine `POST /hooks/file-chooser` upload paths to an OpenClaw temp uploads root and reject traversal/escape paths. Thanks @1seal.
|
||||
- Security/Browser: require auth for the sandbox browser bridge server (protects `/profiles`, `/tabs`, CDP URLs, and other control endpoints). Thanks @jackhax.
|
||||
- Security: bind local helper servers to loopback and fail closed on non-loopback OAuth callback hosts (reduces localhost/LAN attack surface).
|
||||
- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
|
||||
- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
|
||||
- Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.
|
||||
@@ -109,6 +307,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.
|
||||
- Memory: switch default local embedding model to the QAT `embeddinggemma-300m-qat-Q8_0` variant for better quality at the same footprint. (#15429) Thanks @azade-c.
|
||||
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
|
||||
- Security/Pairing: generate 256-bit base64url device and node pairing tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. (#16535) Thanks @FaizanKolega, @gumadeiras.
|
||||
|
||||
## 2026.2.12
|
||||
|
||||
@@ -128,6 +327,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates.
|
||||
- Sessions: guard `withSessionStoreLock` against undefined `storePath` to prevent `path.dirname` crash. (#14717)
|
||||
- Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek.
|
||||
- Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc.
|
||||
- Security/Audit: add hook session-routing hardening checks (`hooks.defaultSessionKey`, `hooks.allowRequestSessionKey`, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing.
|
||||
@@ -145,6 +345,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Configure/Gateway: reject literal `"undefined"`/`"null"` token input and validate gateway password prompt values to avoid invalid password-mode configs. (#13767) Thanks @omair445.
|
||||
- Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55.
|
||||
- Gateway/Control UI: resolve missing dashboard assets when `openclaw` is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.
|
||||
- Gateway/Control UI: keep partial assistant output visible when runs are aborted, and persist aborted partials to session transcripts for follow-up context.
|
||||
- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini.
|
||||
- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon.
|
||||
- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro.
|
||||
@@ -440,8 +641,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
|
||||
- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.
|
||||
- Security: enforce access-group gating for Slack slash commands when channel type lookup fails.
|
||||
- Security: require validated shared-secret auth before skipping device identity on gateway connect.
|
||||
- Security: require validated shared-secret auth before skipping device identity on gateway connect. Thanks @simecek.
|
||||
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
|
||||
- Security/Gateway: require `operator.approvals` for in-chat `/approve` when invoked from gateway clients. Thanks @yueyueL.
|
||||
- Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek.
|
||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
|
||||
@@ -481,7 +683,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning).
|
||||
- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures.
|
||||
- Plugins: validate plugin/hook install paths and reject traversal-like names.
|
||||
- Security/Plugins/Hooks: validate install paths and reject traversal-like names (prevents path traversal outside the state dir). Thanks @logicx24.
|
||||
- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.
|
||||
- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.
|
||||
- Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014)
|
||||
@@ -1741,6 +1943,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults.
|
||||
- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski.
|
||||
- Auth: read Codex CLI keychain tokens on macOS before falling back to `~/.codex/auth.json`, preventing stale refresh tokens from breaking gateway live tests.
|
||||
- Security/Exec approvals: reject shell command substitution (`$()` and backticks) inside double quotes to prevent exec allowlist bypass when exec allowlist mode is explicitly enabled (the default configuration does not use this mode). Thanks @simecek.
|
||||
- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks).
|
||||
- Telegram: serialize media-group processing to avoid missed albums under load.
|
||||
- Signal: handle `dataMessage.reaction` events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist.
|
||||
|
||||
45
Dockerfile.sandbox-common
Normal file
45
Dockerfile.sandbox-common
Normal file
@@ -0,0 +1,45 @@
|
||||
ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
USER root
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ARG PACKAGES="curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file"
|
||||
ARG INSTALL_PNPM=1
|
||||
ARG INSTALL_BUN=1
|
||||
ARG BUN_INSTALL_DIR=/opt/bun
|
||||
ARG INSTALL_BREW=1
|
||||
ARG BREW_INSTALL_DIR=/home/linuxbrew/.linuxbrew
|
||||
ARG FINAL_USER=sandbox
|
||||
|
||||
ENV BUN_INSTALL=${BUN_INSTALL_DIR}
|
||||
ENV HOMEBREW_PREFIX=${BREW_INSTALL_DIR}
|
||||
ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar
|
||||
ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew
|
||||
ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH}
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ${PACKAGES} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
|
||||
|
||||
RUN if [ "${INSTALL_BUN}" = "1" ]; then \
|
||||
curl -fsSL https://bun.sh/install | bash; \
|
||||
ln -sf "${BUN_INSTALL_DIR}/bin/bun" /usr/local/bin/bun; \
|
||||
fi
|
||||
|
||||
RUN if [ "${INSTALL_BREW}" = "1" ]; then \
|
||||
if ! id -u linuxbrew >/dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \
|
||||
mkdir -p "${BREW_INSTALL_DIR}"; \
|
||||
chown -R linuxbrew:linuxbrew "$(dirname "${BREW_INSTALL_DIR}")"; \
|
||||
su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \
|
||||
if [ ! -e "${BREW_INSTALL_DIR}/Library" ]; then ln -s "${BREW_INSTALL_DIR}/Homebrew/Library" "${BREW_INSTALL_DIR}/Library"; fi; \
|
||||
if [ ! -x "${BREW_INSTALL_DIR}/bin/brew" ]; then echo \"brew install failed\"; exit 1; fi; \
|
||||
ln -sf "${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \
|
||||
fi
|
||||
|
||||
# Default is sandbox, but allow BASE_IMAGE overrides to select another final user.
|
||||
USER ${FINAL_USER}
|
||||
|
||||
@@ -112,9 +112,9 @@ Full security guide: [Security](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
|
||||
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dmPolicy="pairing"` / `channels.slack.dmPolicy="pairing"`; legacy: `channels.discord.dm.policy`, `channels.slack.dm.policy`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `openclaw pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`).
|
||||
|
||||
Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
@@ -360,7 +360,7 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
11
SECURITY.md
11
SECURITY.md
@@ -39,6 +39,10 @@ Reports without reproduction steps, demonstrated impact, and remediation advice
|
||||
OpenClaw is a labor of love. There is no bug bounty program and no budget for paid reports. Please still disclose responsibly so we can fix issues quickly.
|
||||
The best way to help the project right now is by sending PRs.
|
||||
|
||||
## Maintainers: GHSA Updates via CLI
|
||||
|
||||
When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Public Internet Exposure
|
||||
@@ -51,6 +55,12 @@ For threat model + hardening guidance (including `openclaw security audit --deep
|
||||
|
||||
- `https://docs.openclaw.ai/gateway/security`
|
||||
|
||||
### Tool filesystem hardening
|
||||
|
||||
- `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory.
|
||||
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory.
|
||||
- Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution.
|
||||
|
||||
### Web Interface Safety
|
||||
|
||||
OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for **local use only**.
|
||||
@@ -60,6 +70,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
|
||||
- CLI: `openclaw gateway run --bind loopback`.
|
||||
- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure.
|
||||
- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth.
|
||||
- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk.
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
|
||||
194
appcast.xml
194
appcast.xml
@@ -2,6 +2,144 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.2.14</title>
|
||||
<pubDate>Sun, 15 Feb 2026 04:24:34 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>202602140</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.14</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.14</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Telegram: add poll sending via <code>openclaw message poll</code> (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.</li>
|
||||
<li>Slack/Discord: add <code>dmPolicy</code> + <code>allowFrom</code> config aliases for DM access control; legacy <code>dm.policy</code> + <code>dm.allowFrom</code> keys remain supported and <code>openclaw doctor --fix</code> can migrate them.</li>
|
||||
<li>Discord: allow exec approval prompts to target channels or both DM+channel via <code>channels.discord.execApprovals.target</code>. (#16051) Thanks @leonnardo.</li>
|
||||
<li>Sandbox: add <code>sandbox.browser.binds</code> to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.</li>
|
||||
<li>Discord: add debug logging for message routing decisions to improve <code>--debug</code> tracing. (#16202) Thanks @jayleekr.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>CLI/Plugins: ensure <code>openclaw message send</code> exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.</li>
|
||||
<li>CLI/Plugins: run registered plugin <code>gateway_stop</code> hooks before <code>openclaw message</code> exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.</li>
|
||||
<li>WhatsApp: honor per-account <code>dmPolicy</code> overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.</li>
|
||||
<li>Telegram: when <code>channels.telegram.commands.native</code> is <code>false</code>, exclude plugin commands from <code>setMyCommands</code> menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.</li>
|
||||
<li>LINE: return 200 OK for Developers Console "Verify" requests (<code>{"events":[]}</code>) without <code>X-Line-Signature</code>, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.</li>
|
||||
<li>Cron: deliver text-only output directly when <code>delivery.to</code> is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow.</li>
|
||||
<li>Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.</li>
|
||||
<li>Media: accept <code>MEDIA:</code>-prefixed paths (lenient whitespace) when loading outbound media to prevent <code>ENOENT</code> for tool-returned local media paths. (#13107) Thanks @mcaxtr.</li>
|
||||
<li>Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.</li>
|
||||
<li>Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)</li>
|
||||
<li>Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit <code>workspaceDir</code>. (#16722)</li>
|
||||
<li>BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.</li>
|
||||
<li>CLI: fix lazy core command registration so top-level maintenance commands (<code>doctor</code>, <code>dashboard</code>, <code>reset</code>, <code>uninstall</code>) resolve correctly instead of exposing a non-functional <code>maintenance</code> placeholder command.</li>
|
||||
<li>CLI/Dashboard: when <code>gateway.bind=lan</code>, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev.</li>
|
||||
<li>TUI/Gateway: resolve local gateway target URL from <code>gateway.bind</code> mode (tailnet/lan) instead of hardcoded localhost so <code>openclaw tui</code> connects when gateway is non-loopback. (#16299) Thanks @cortexuvula.</li>
|
||||
<li>TUI: honor explicit <code>--session <key></code> in <code>openclaw tui</code> even when <code>session.scope</code> is <code>global</code>, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu.</li>
|
||||
<li>TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla.</li>
|
||||
<li>TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds.</li>
|
||||
<li>TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73.</li>
|
||||
<li>TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75.</li>
|
||||
<li>TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe.</li>
|
||||
<li>TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren.</li>
|
||||
<li>TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog.</li>
|
||||
<li>TUI/Hooks: pass explicit reset reason (<code>new</code> vs <code>reset</code>) through <code>sessions.reset</code> and emit internal command hooks for gateway-triggered resets so <code>/new</code> hook workflows fire in TUI/webchat.</li>
|
||||
<li>Cron: prevent <code>cron list</code>/<code>cron status</code> from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.</li>
|
||||
<li>Cron: repair missing/corrupt <code>nextRunAtMs</code> for the updated job without globally recomputing unrelated due jobs during <code>cron update</code>. (#15750)</li>
|
||||
<li>Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale <code>runningAtMs</code> markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn.</li>
|
||||
<li>Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as <code>guild=dm</code>. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: treat empty per-guild <code>channels: {}</code> config maps as no channel allowlist (not deny-all), so <code>groupPolicy: "open"</code> guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu.</li>
|
||||
<li>Models/CLI: guard <code>models status</code> string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.</li>
|
||||
<li>Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.</li>
|
||||
<li>Gateway/Sessions: abort active embedded runs and clear queued session work before <code>sessions.reset</code>, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.</li>
|
||||
<li>Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.</li>
|
||||
<li>Agents: add a safety timeout around embedded <code>session.compact()</code> to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.</li>
|
||||
<li>Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including <code>session_status</code> model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.</li>
|
||||
<li>Agents/Process/Bootstrap: preserve unbounded <code>process log</code> offset-only pagination (default tail applies only when both <code>offset</code> and <code>limit</code> are omitted) and enforce strict <code>bootstrapTotalMaxChars</code> budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.</li>
|
||||
<li>Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing <code>BOOTSTRAP.md</code> once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.</li>
|
||||
<li>Agents/Workspace: create <code>BOOTSTRAP.md</code> when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.</li>
|
||||
<li>Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.</li>
|
||||
<li>Agents: treat empty-stream provider failures (<code>request ended without sending any chunks</code>) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive.</li>
|
||||
<li>Agents: treat <code>read</code> tool <code>file_path</code> arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.</li>
|
||||
<li>Ollama/Agents: avoid forcing <code><final></code> tag enforcement for Ollama models, which could suppress all output as <code>(no output)</code>. (#16191) Thanks @Glucksberg.</li>
|
||||
<li>Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.</li>
|
||||
<li>Skills: watch <code>SKILL.md</code> only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.</li>
|
||||
<li>Memory/QMD: make <code>memory status</code> read-only by skipping QMD boot update/embed side effects for status-only manager checks.</li>
|
||||
<li>Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.</li>
|
||||
<li>Memory/Builtin: keep <code>memory status</code> dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi.</li>
|
||||
<li>Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological <code>qmd</code> command output.</li>
|
||||
<li>Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks.</li>
|
||||
<li>Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.</li>
|
||||
<li>Memory/QMD: pass result limits to <code>search</code>/<code>vsearch</code> commands so QMD can cap results earlier.</li>
|
||||
<li>Memory/QMD: avoid reading full markdown files when a <code>from/lines</code> window is requested in QMD reads.</li>
|
||||
<li>Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.</li>
|
||||
<li>Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy <code>stdout</code>.</li>
|
||||
<li>Memory/QMD: treat prefixed <code>no results found</code> marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.</li>
|
||||
<li>Memory/QMD: avoid multi-collection <code>query</code> ranking corruption by running one <code>qmd query -c <collection></code> per managed collection and merging by best score (also used for <code>search</code>/<code>vsearch</code> fallback-to-query). (#16740) Thanks @volarian-vai.</li>
|
||||
<li>Memory/QMD: detect null-byte <code>ENOTDIR</code> update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.</li>
|
||||
<li>Memory/QMD/Security: add <code>rawKeyPrefix</code> support for QMD scope rules and preserve legacy <code>keyPrefix: "agent:..."</code> matching, preventing scoped deny bypass when operators match agent-prefixed session keys.</li>
|
||||
<li>Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai.</li>
|
||||
<li>Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24.</li>
|
||||
<li>Security/Memory-LanceDB: require explicit <code>autoCapture: true</code> opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n.</li>
|
||||
<li>Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07.</li>
|
||||
<li>Gateway/Memory: clean up <code>agentRunSeq</code> tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07.</li>
|
||||
<li>Auto-reply/Memory: bound <code>ABORT_MEMORY</code> growth by evicting oldest entries and deleting reset (<code>false</code>) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07.</li>
|
||||
<li>Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07.</li>
|
||||
<li>Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07.</li>
|
||||
<li>Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek.</li>
|
||||
<li>Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris.</li>
|
||||
<li>Media/Security: allow local media reads from OpenClaw state <code>workspace/</code> and <code>sandboxes/</code> roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.</li>
|
||||
<li>Media/Security: harden local media allowlist bypasses by requiring an explicit <code>readFile</code> override when callers mark paths as validated, and reject filesystem-root <code>localRoots</code> entries. (#16739)</li>
|
||||
<li>Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.</li>
|
||||
<li>Security/BlueBubbles: require explicit <code>mediaLocalRoots</code> allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.</li>
|
||||
<li>Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.</li>
|
||||
<li>Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.</li>
|
||||
<li>Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.</li>
|
||||
<li>Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.</li>
|
||||
<li>Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.</li>
|
||||
<li>Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.</li>
|
||||
<li>Security/Hooks: restrict hook transform modules to <code>~/.openclaw/hooks/transforms</code> (prevents path traversal/escape module loads via config). Config note: <code>hooks.transformsDir</code> must now be within that directory. Thanks @akhmittra.</li>
|
||||
<li>Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).</li>
|
||||
<li>Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.</li>
|
||||
<li>Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.</li>
|
||||
<li>Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.</li>
|
||||
<li>Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.</li>
|
||||
<li>Security/Slack: compute command authorization for DM slash commands even when <code>dmPolicy=open</code>, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.</li>
|
||||
<li>Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.</li>
|
||||
<li>Security/Google Chat: deprecate <code>users/<email></code> allowlists (treat <code>users/...</code> as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.</li>
|
||||
<li>Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.</li>
|
||||
<li>Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject <code>@username</code> principals), auto-resolve <code>@username</code> to IDs in <code>openclaw doctor --fix</code> (when possible), and warn in <code>openclaw security audit</code> when legacy configs contain usernames. Thanks @vincentkoc.</li>
|
||||
<li>Telegram/Security: reject Telegram webhook startup when <code>webhookSecret</code> is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL.</li>
|
||||
<li>Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).</li>
|
||||
<li>Telegram: set webhook callback timeout handling to <code>onTimeout: "return"</code> (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington.</li>
|
||||
<li>Signal: preserve case-sensitive <code>group:</code> target IDs during normalization so mixed-case group IDs no longer fail with <code>Group not found</code>. (#16748) Thanks @repfigit.</li>
|
||||
<li>Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.</li>
|
||||
<li>Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.</li>
|
||||
<li>Security/Agents: enforce workspace-root path bounds for <code>apply_patch</code> in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec.</li>
|
||||
<li>Security/Agents: enforce symlink-escape checks for <code>apply_patch</code> delete hunks under <code>workspaceOnly</code>, while still allowing deleting the symlink itself. Thanks @p80n-sec.</li>
|
||||
<li>Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.</li>
|
||||
<li>macOS: hard-limit unkeyed <code>openclaw://agent</code> deep links and ignore <code>deliver</code> / <code>to</code> / <code>channel</code> unless a valid unattended key is provided. Thanks @Cillian-Collins.</li>
|
||||
<li>Scripts/Security: validate GitHub logins and avoid shell invocation in <code>scripts/update-clawtributors.ts</code> to prevent command injection via malicious commit records. Thanks @scanleale.</li>
|
||||
<li>Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent).</li>
|
||||
<li>Security/Gateway: harden tool-supplied <code>gatewayUrl</code> overrides by restricting them to loopback or the configured <code>gateway.remote.url</code>. Thanks @p80n-sec.</li>
|
||||
<li>Security/Gateway: block <code>system.execApprovals.*</code> via <code>node.invoke</code> (use <code>exec.approvals.node.*</code> instead). Thanks @christos-eth.</li>
|
||||
<li>Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.</li>
|
||||
<li>Security/Gateway: stop returning raw resolved config values in <code>skills.status</code> requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.</li>
|
||||
<li>Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.</li>
|
||||
<li>Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal.</li>
|
||||
<li>Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.</li>
|
||||
<li>Security/Node Host: enforce <code>system.run</code> rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.</li>
|
||||
<li>Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.</li>
|
||||
<li>Security/Exec: harden PATH handling by disabling project-local <code>node_modules/.bin</code> bootstrapping by default, disallowing node-host <code>PATH</code> overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra.</li>
|
||||
<li>Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: <code>channels.tlon.allowPrivateNetwork</code>). Thanks @p80n-sec.</li>
|
||||
<li>Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without <code>telnyx.publicKey</code> are now rejected unless <code>skipSignatureVerification</code> is enabled. Thanks @p80n-sec.</li>
|
||||
<li>Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec.</li>
|
||||
<li>Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.14/OpenClaw-2026.2.14.zip" length="22914034" type="application/octet-stream" sparkle:edSignature="lR3nuq46/akMIN8RFDpMkTE0VOVoDVG53Xts589LryMGEtUvJxRQDtHBXfx7ZvToTq6CFKG+L5Kq/4rUspMoAQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.13</title>
|
||||
<pubDate>Sat, 14 Feb 2026 04:30:23 +0100</pubDate>
|
||||
@@ -199,61 +337,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.12/OpenClaw-2026.2.12.zip" length="22877692" type="application/octet-stream" sparkle:edSignature="TGylTM4/7Lab+qp1nuPeOAmEVV1WkafXUPub8ws0z/0mYfbVygRuiev+u3zdPjQWhLnGYTgRgKVyW+kB2+Q2BQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.9</title>
|
||||
<pubDate>Mon, 09 Feb 2026 13:23:25 -0600</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>9194</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.9</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.9</h2>
|
||||
<h3>Added</h3>
|
||||
<ul>
|
||||
<li>iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.</li>
|
||||
<li>Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.</li>
|
||||
<li>Plugins: device pairing + phone control plugins (Telegram <code>/pair</code>, iOS/Android node controls). (#11755) Thanks @mbelinky.</li>
|
||||
<li>Tools: add Grok (xAI) as a <code>web_search</code> provider. (#12419) Thanks @tmchow.</li>
|
||||
<li>Gateway: add agent management RPC methods for the web UI (<code>agents.create</code>, <code>agents.update</code>, <code>agents.delete</code>). (#11045) Thanks @advaitpaliwal.</li>
|
||||
<li>Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman.</li>
|
||||
<li>Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.</li>
|
||||
<li>Paths: add <code>OPENCLAW_HOME</code> for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.</li>
|
||||
<li>Telegram: recover proactive sends when stale topic thread IDs are used by retrying without <code>message_thread_id</code>. (#11620)</li>
|
||||
<li>Telegram: render markdown spoilers with <code><tg-spoiler></code> HTML tags. (#11543) Thanks @ezhikkk.</li>
|
||||
<li>Telegram: truncate command registration to 100 entries to avoid <code>BOT_COMMANDS_TOO_MUCH</code> failures on startup. (#12356) Thanks @arosstale.</li>
|
||||
<li>Telegram: match DM <code>allowFrom</code> against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).</li>
|
||||
<li>Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.</li>
|
||||
<li>Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.</li>
|
||||
<li>Tools/web_search: include provider-specific settings in the web search cache key, and pass <code>inlineCitations</code> for Grok. (#12419) Thanks @tmchow.</li>
|
||||
<li>Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.</li>
|
||||
<li>Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov.</li>
|
||||
<li>Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking.</li>
|
||||
<li>Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session <code>parentId</code> chain so agents can remember again. (#12283) Thanks @Takhoffman.</li>
|
||||
<li>Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman.</li>
|
||||
<li>Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.</li>
|
||||
<li>Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204.</li>
|
||||
<li>Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204.</li>
|
||||
<li>Cron tool: recover flat params when LLM omits the <code>job</code> wrapper for add requests. (#12124) Thanks @tyler6204.</li>
|
||||
<li>Gateway/CLI: when <code>gateway.bind=lan</code>, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.</li>
|
||||
<li>Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao.</li>
|
||||
<li>Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc.</li>
|
||||
<li>Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.</li>
|
||||
<li>Config: clamp <code>maxTokens</code> to <code>contextWindow</code> to prevent invalid model configs. (#5516) Thanks @lailoo.</li>
|
||||
<li>Thinking: allow xhigh for <code>github-copilot/gpt-5.2-codex</code> and <code>github-copilot/gpt-5.2</code>. (#11646) Thanks @LatencyTDH.</li>
|
||||
<li>Discord: support forum/media thread-create starter messages, wire <code>message thread create --message</code>, and harden routing. (#10062) Thanks @jarvis89757.</li>
|
||||
<li>Paths: structurally resolve <code>OPENCLAW_HOME</code>-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.</li>
|
||||
<li>Memory: set Voyage embeddings <code>input_type</code> for improved retrieval. (#10818) Thanks @mcinteerj.</li>
|
||||
<li>Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204.</li>
|
||||
<li>Media understanding: recognize <code>.caf</code> audio attachments for transcription. (#10982) Thanks @succ985.</li>
|
||||
<li>State dir: honor <code>OPENCLAW_STATE_DIR</code> for default device identity and canvas storage paths. (#4824) Thanks @kossoy.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.9/OpenClaw-2026.2.9.zip" length="22872529" type="application/octet-stream" sparkle:edSignature="zvgwqlgqI7J5Gsi9VSULIQTMKqLiGE5ulC6NnRLKtOPphQsHZVdYSWm0E90+Yq8mG4lpsvbxQOSSPxpl43QTAw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602130
|
||||
versionName = "2026.2.13"
|
||||
versionCode = 202602150
|
||||
versionName = "2026.2.15"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -63,7 +63,11 @@ android {
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
disable += setOf(
|
||||
"GradleDependency",
|
||||
"IconLauncherShape",
|
||||
"NewerVersionAvailable",
|
||||
)
|
||||
warningsAsErrors = true
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val statusText: StateFlow<String> = runtime.statusText
|
||||
val serverName: StateFlow<String?> = runtime.serverName
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtime.pendingGatewayTrust
|
||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
|
||||
@@ -145,6 +146,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.disconnect()
|
||||
}
|
||||
|
||||
fun acceptGatewayTrustPrompt() {
|
||||
runtime.acceptGatewayTrustPrompt()
|
||||
}
|
||||
|
||||
fun declineGatewayTrustPrompt() {
|
||||
runtime.declineGatewayTrustPrompt()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import ai.openclaw.android.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.android.gateway.GatewayDiscovery
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import ai.openclaw.android.gateway.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.android.node.*
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction
|
||||
import ai.openclaw.android.voice.TalkModeManager
|
||||
@@ -166,12 +167,20 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
private lateinit var gatewayEventHandler: GatewayEventHandler
|
||||
|
||||
data class GatewayTrustPrompt(
|
||||
val endpoint: GatewayEndpoint,
|
||||
val fingerprintSha256: String,
|
||||
)
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
|
||||
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
||||
|
||||
private val _mainSessionKey = MutableStateFlow("main")
|
||||
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
|
||||
|
||||
@@ -405,8 +414,11 @@ class NodeRuntime(context: Context) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
gateways.collect { list ->
|
||||
if (list.isNotEmpty()) {
|
||||
// Persist the last discovered gateway (best-effort UX parity with iOS).
|
||||
prefs.setLastDiscoveredStableId(list.last().stableId)
|
||||
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
|
||||
// UX parity with iOS: only set once when unset.
|
||||
if (lastDiscoveredStableId.value.trim().isEmpty()) {
|
||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||
}
|
||||
}
|
||||
|
||||
if (didAutoConnect) return@collect
|
||||
@@ -416,6 +428,12 @@ class NodeRuntime(context: Context) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isNotEmpty() && port in 1..65535) {
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
if (!manualTls.value) return@collect
|
||||
val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
@@ -425,6 +443,11 @@ class NodeRuntime(context: Context) {
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return@collect
|
||||
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
||||
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(target)
|
||||
}
|
||||
@@ -520,17 +543,42 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
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.
|
||||
_statusText.value = "Verify gateway TLS fingerprint…"
|
||||
scope.launch {
|
||||
val fp = probeGatewayTlsFingerprint(endpoint.host, endpoint.port) ?: run {
|
||||
_statusText.value = "Failed: can't read TLS fingerprint"
|
||||
return@launch
|
||||
}
|
||||
_pendingGatewayTrust.value = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
connectedEndpoint = endpoint
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
val token = prefs.loadGatewayToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
|
||||
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
|
||||
}
|
||||
|
||||
fun acceptGatewayTrustPrompt() {
|
||||
val prompt = _pendingGatewayTrust.value ?: return
|
||||
_pendingGatewayTrust.value = null
|
||||
prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, prompt.fingerprintSha256)
|
||||
connect(prompt.endpoint)
|
||||
}
|
||||
|
||||
fun declineGatewayTrustPrompt() {
|
||||
_pendingGatewayTrust.value = null
|
||||
_statusText.value = "Offline"
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
|
||||
@@ -550,6 +598,7 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
fun disconnect() {
|
||||
connectedEndpoint = null
|
||||
_pendingGatewayTrust.value = null
|
||||
operatorSession.disconnect()
|
||||
nodeSession.disconnect()
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.InetSocketAddress
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Locale
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLParameters
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.SNIHostName
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
@@ -59,13 +67,74 @@ fun buildGatewayTlsConfig(
|
||||
|
||||
val context = SSLContext.getInstance("TLS")
|
||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
||||
val verifier =
|
||||
if (expected != null || params.allowTOFU) {
|
||||
// When pinning, we intentionally ignore hostname mismatch (service discovery often yields IPs).
|
||||
HostnameVerifier { _, _ -> true }
|
||||
} else {
|
||||
HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
}
|
||||
return GatewayTlsConfig(
|
||||
sslSocketFactory = context.socketFactory,
|
||||
trustManager = trustManager,
|
||||
hostnameVerifier = HostnameVerifier { _, _ -> true },
|
||||
hostnameVerifier = verifier,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun probeGatewayTlsFingerprint(
|
||||
host: String,
|
||||
port: Int,
|
||||
timeoutMs: Int = 3_000,
|
||||
): String? {
|
||||
val trimmedHost = host.trim()
|
||||
if (trimmedHost.isEmpty()) return null
|
||||
if (port !in 1..65535) return null
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val trustAll =
|
||||
@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager")
|
||||
object : X509TrustManager {
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
}
|
||||
|
||||
val context = SSLContext.getInstance("TLS")
|
||||
context.init(null, arrayOf(trustAll), SecureRandom())
|
||||
|
||||
val socket = (context.socketFactory.createSocket() as SSLSocket)
|
||||
try {
|
||||
socket.soTimeout = timeoutMs
|
||||
socket.connect(InetSocketAddress(trimmedHost, port), timeoutMs)
|
||||
|
||||
// Best-effort SNI for hostnames (avoid crashing on IP literals).
|
||||
try {
|
||||
if (trimmedHost.any { it.isLetter() }) {
|
||||
val params = SSLParameters()
|
||||
params.serverNames = listOf(SNIHostName(trimmedHost))
|
||||
socket.sslParameters = params
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
socket.startHandshake()
|
||||
val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null
|
||||
sha256Hex(cert.encoded)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as java.security.KeyStore?)
|
||||
@@ -78,7 +147,7 @@ private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = StringBuilder(digest.size * 2)
|
||||
for (byte in digest) {
|
||||
out.append(String.format("%02x", byte))
|
||||
out.append(String.format(Locale.US, "%02x", byte))
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
@@ -86,5 +155,5 @@ private fun sha256Hex(data: ByteArray): String {
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
val stripped = raw.trim()
|
||||
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
|
||||
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
return stripped.lowercase(Locale.US).filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
}
|
||||
|
||||
@@ -187,11 +187,11 @@ class AppUpdateHandler(
|
||||
lastNotifUpdate = now
|
||||
if (contentLength > 0) {
|
||||
val pct = ((totalBytes * 100) / contentLength).toInt()
|
||||
val mb = String.format("%.1f", totalBytes / 1048576.0)
|
||||
val totalMb = String.format("%.1f", contentLength / 1048576.0)
|
||||
val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
|
||||
val totalMb = String.format(Locale.US, "%.1f", contentLength / 1048576.0)
|
||||
notifManager.notify(notifId, buildProgressNotif(pct, 100, "$mb / $totalMb MB ($pct%)"))
|
||||
} else {
|
||||
val mb = String.format("%.1f", totalBytes / 1048576.0)
|
||||
val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
|
||||
notifManager.notify(notifId, buildProgressNotif(0, 0, "${mb} MB downloaded"))
|
||||
}
|
||||
}
|
||||
@@ -239,13 +239,15 @@ class AppUpdateHandler(
|
||||
// Use PackageInstaller session API — works from background on API 34+
|
||||
// The system handles showing the install confirmation dialog
|
||||
notifManager.cancel(notifId)
|
||||
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle("Installing Update...")
|
||||
|
||||
notifManager.notify(
|
||||
notifId,
|
||||
android.app.Notification.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle("Installing Update...")
|
||||
.setContentIntent(launchPi)
|
||||
.setContentText("${String.format("%.1f", totalBytes / 1048576.0)} MB downloaded")
|
||||
.build())
|
||||
.setContentText("${String.format(Locale.US, "%.1f", totalBytes / 1048576.0)} MB downloaded")
|
||||
.build(),
|
||||
)
|
||||
|
||||
val installer = appContext.packageManager.packageInstaller
|
||||
val params = android.content.pm.PackageInstaller.SessionParams(
|
||||
|
||||
@@ -26,6 +26,59 @@ class ConnectionManager(
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
companion object {
|
||||
internal fun resolveTlsParamsForEndpoint(
|
||||
endpoint: GatewayEndpoint,
|
||||
storedFingerprint: String?,
|
||||
manualTlsEnabled: Boolean,
|
||||
): GatewayTlsParams? {
|
||||
val stableId = endpoint.stableId
|
||||
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
val isManual = stableId.startsWith("manual|")
|
||||
|
||||
if (isManual) {
|
||||
if (!manualTlsEnabled) return null
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = null,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
|
||||
// Prefer stored pins. Never let discovery-provided TXT override a stored fingerprint.
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
if (hinted) {
|
||||
// TXT is unauthenticated. Do not treat the advertised fingerprint as authoritative.
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = null,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun buildInvokeCommands(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCanvasCommand.Present.rawValue)
|
||||
@@ -130,37 +183,6 @@ class ConnectionManager(
|
||||
|
||||
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
|
||||
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
val manual = endpoint.stableId.startsWith("manual|")
|
||||
|
||||
if (manual) {
|
||||
if (!manualTls()) return null
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (hinted) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
@@ -42,6 +43,7 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -89,6 +91,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val gateways by viewModel.gateways.collectAsState()
|
||||
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
|
||||
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||
@@ -112,6 +115,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingTrust != null) {
|
||||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
title = { Text("Trust this gateway?") },
|
||||
text = {
|
||||
Text(
|
||||
"First-time TLS connection.\n\n" +
|
||||
"Verify this SHA-256 fingerprint out-of-band before trusting:\n" +
|
||||
prompt.fingerprintSha256,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
|
||||
Text("Trust and connect")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||
val commitWakeWords = {
|
||||
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectionManagerTest {
|
||||
@Test
|
||||
fun resolveTlsParamsForEndpoint_prefersStoredPinOverAdvertisedFingerprint() {
|
||||
val endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "_openclaw-gw._tcp.|local.|Test",
|
||||
name = "Test",
|
||||
host = "10.0.0.2",
|
||||
port = 18789,
|
||||
tlsEnabled = true,
|
||||
tlsFingerprintSha256 = "attacker",
|
||||
)
|
||||
|
||||
val params =
|
||||
ConnectionManager.resolveTlsParamsForEndpoint(
|
||||
endpoint,
|
||||
storedFingerprint = "legit",
|
||||
manualTlsEnabled = false,
|
||||
)
|
||||
|
||||
assertEquals("legit", params?.expectedFingerprint)
|
||||
assertEquals(false, params?.allowTOFU)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveTlsParamsForEndpoint_doesNotTrustAdvertisedFingerprintWhenNoStoredPin() {
|
||||
val endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "_openclaw-gw._tcp.|local.|Test",
|
||||
name = "Test",
|
||||
host = "10.0.0.2",
|
||||
port = 18789,
|
||||
tlsEnabled = true,
|
||||
tlsFingerprintSha256 = "attacker",
|
||||
)
|
||||
|
||||
val params =
|
||||
ConnectionManager.resolveTlsParamsForEndpoint(
|
||||
endpoint,
|
||||
storedFingerprint = null,
|
||||
manualTlsEnabled = false,
|
||||
)
|
||||
|
||||
assertNull(params?.expectedFingerprint)
|
||||
assertEquals(false, params?.allowTOFU)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveTlsParamsForEndpoint_manualRespectsManualTlsToggle() {
|
||||
val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443)
|
||||
|
||||
val off =
|
||||
ConnectionManager.resolveTlsParamsForEndpoint(
|
||||
endpoint,
|
||||
storedFingerprint = null,
|
||||
manualTlsEnabled = false,
|
||||
)
|
||||
assertNull(off)
|
||||
|
||||
val on =
|
||||
ConnectionManager.resolveTlsParamsForEndpoint(
|
||||
endpoint,
|
||||
storedFingerprint = null,
|
||||
manualTlsEnabled = true,
|
||||
)
|
||||
assertNull(on?.expectedFingerprint)
|
||||
assertEquals(false, on?.allowTOFU)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ final class CalendarService: CalendarServicing {
|
||||
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
let authorized = EventKitAuthorization.allowsRead(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
@@ -39,7 +39,7 @@ final class CalendarService: CalendarServicing {
|
||||
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||
let authorized = EventKitAuthorization.allowsWrite(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
@@ -95,38 +95,6 @@ final class CalendarService: CalendarServicing {
|
||||
return OpenClawCalendarAddPayload(event: payload)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
case .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveCalendar(
|
||||
store: EKEventStore,
|
||||
calendarId: String?,
|
||||
|
||||
@@ -93,14 +93,10 @@ actor CameraController {
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
let maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||
imageData: rawData,
|
||||
let res = try PhotoCapture.transcodeJPEGForGateway(
|
||||
rawData: rawData,
|
||||
maxWidthPx: maxWidth,
|
||||
quality: quality,
|
||||
maxBytes: maxEncodedBytes)
|
||||
quality: quality)
|
||||
|
||||
return (
|
||||
format: format.rawValue,
|
||||
@@ -335,8 +331,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?)
|
||||
{
|
||||
error: Error?
|
||||
) {
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
|
||||
@@ -364,8 +360,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||
error: Error?)
|
||||
{
|
||||
error: Error?
|
||||
) {
|
||||
guard let error else { return }
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
|
||||
34
apps/ios/Sources/EventKit/EventKitAuthorization.swift
Normal file
34
apps/ios/Sources/EventKit/EventKitAuthorization.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import EventKit
|
||||
|
||||
enum EventKitAuthorization {
|
||||
static func allowsRead(status: EKAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func allowsWrite(status: EKAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import AVFoundation
|
||||
import Contacts
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import CryptoKit
|
||||
import EventKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
@@ -9,6 +10,7 @@ import Network
|
||||
import Observation
|
||||
import Photos
|
||||
import ReplayKit
|
||||
import Security
|
||||
import Speech
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
@@ -16,13 +18,27 @@ import UIKit
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayConnectionController {
|
||||
struct TrustPrompt: Identifiable, Equatable {
|
||||
let stableID: String
|
||||
let gatewayName: String
|
||||
let host: String
|
||||
let port: Int
|
||||
let fingerprintSha256: String
|
||||
let isManual: Bool
|
||||
|
||||
var id: String { self.stableID }
|
||||
}
|
||||
|
||||
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
|
||||
private(set) var discoveryStatusText: String = "Idle"
|
||||
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
|
||||
private(set) var pendingTrustPrompt: TrustPrompt?
|
||||
|
||||
private let discovery = GatewayDiscoveryModel()
|
||||
private weak var appModel: NodeAppModel?
|
||||
private var didAutoConnect = false
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
private var pendingTrustConnect: (url: URL, stableID: String, isManual: Bool)?
|
||||
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
self.appModel = appModel
|
||||
@@ -57,27 +73,57 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
|
||||
private func connectDiscoveredGateway(
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async
|
||||
{
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
guard let host = self.resolveGatewayHost(gateway) else { return }
|
||||
let port = gateway.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||
|
||||
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
|
||||
guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { return }
|
||||
|
||||
let stableID = gateway.stableID
|
||||
// Discovery is a LAN operation; refuse unauthenticated plaintext connects.
|
||||
let tlsRequired = true
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
guard gateway.tlsEnabled || stored != nil else { return }
|
||||
|
||||
if tlsRequired, stored == nil {
|
||||
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
|
||||
else { return }
|
||||
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
|
||||
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false)
|
||||
self.pendingTrustPrompt = TrustPrompt(
|
||||
stableID: stableID,
|
||||
gatewayName: gateway.name,
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
fingerprintSha256: fp,
|
||||
isManual: false)
|
||||
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
|
||||
return
|
||||
}
|
||||
|
||||
let tlsParams = stored.map { fp in
|
||||
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
||||
}
|
||||
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: tlsParams?.required == true,
|
||||
stableID: gateway.stableID)
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: gateway.stableID,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
@@ -92,19 +138,34 @@ final class GatewayConnectionController {
|
||||
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
|
||||
else { return }
|
||||
let stableID = self.manualStableID(host: host, port: resolvedPort)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: host))
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
if resolvedUseTLS, stored == nil {
|
||||
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
|
||||
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
|
||||
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
|
||||
self.pendingTrustPrompt = TrustPrompt(
|
||||
stableID: stableID,
|
||||
gatewayName: "\(host):\(resolvedPort)",
|
||||
host: host,
|
||||
port: resolvedPort,
|
||||
fingerprintSha256: fp,
|
||||
isManual: true)
|
||||
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
|
||||
return
|
||||
}
|
||||
|
||||
let tlsParams = stored.map { fp in
|
||||
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
||||
}
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: resolvedPort,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: host,
|
||||
port: resolvedPort,
|
||||
useTLS: tlsParams?.required == true,
|
||||
useTLS: resolvedUseTLS && tlsParams != nil,
|
||||
stableID: stableID)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
@@ -117,36 +178,63 @@ final class GatewayConnectionController {
|
||||
|
||||
func connectLastKnown() async {
|
||||
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
|
||||
switch last {
|
||||
case let .manual(host, port, useTLS, _):
|
||||
await self.connectManual(host: host, port: port, useTLS: useTLS)
|
||||
case let .discovered(stableID, _):
|
||||
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return }
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
}
|
||||
|
||||
func clearPendingTrustPrompt() {
|
||||
self.pendingTrustPrompt = nil
|
||||
self.pendingTrustConnect = nil
|
||||
}
|
||||
|
||||
func acceptPendingTrustPrompt() async {
|
||||
guard let pending = self.pendingTrustConnect,
|
||||
let prompt = self.pendingTrustPrompt,
|
||||
pending.stableID == prompt.stableID
|
||||
else { return }
|
||||
|
||||
GatewayTLSStore.saveFingerprint(prompt.fingerprintSha256, stableID: pending.stableID)
|
||||
self.clearPendingTrustPrompt()
|
||||
|
||||
if pending.isManual {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: prompt.host,
|
||||
port: prompt.port,
|
||||
useTLS: true,
|
||||
stableID: pending.stableID)
|
||||
} else {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: pending.stableID, useTLS: true)
|
||||
}
|
||||
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
let resolvedUseTLS = last.useTLS
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: last.stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: last.host))
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: last.host,
|
||||
port: last.port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
if resolvedUseTLS != last.useTLS {
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
host: last.host,
|
||||
port: last.port,
|
||||
useTLS: resolvedUseTLS,
|
||||
stableID: last.stableID)
|
||||
}
|
||||
let tlsParams = GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: prompt.fingerprintSha256,
|
||||
allowTOFU: false,
|
||||
storeKey: pending.stableID)
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: last.stableID,
|
||||
url: pending.url,
|
||||
gatewayStableID: pending.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
func declinePendingTrustPrompt() {
|
||||
self.clearPendingTrustPrompt()
|
||||
self.appModel?.gatewayStatusText = "Offline"
|
||||
}
|
||||
|
||||
private func updateFromDiscovery() {
|
||||
let newGateways = self.discovery.gateways
|
||||
self.gateways = newGateways
|
||||
@@ -223,25 +311,30 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: lastKnown.stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: lastKnown.host))
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: lastKnown.host,
|
||||
port: lastKnown.port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
if case let .manual(host, port, useTLS, stableID) = lastKnown {
|
||||
let resolvedUseTLS = useTLS || self.shouldForceTLS(host: host)
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let tlsParams = stored.map { fp in
|
||||
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
||||
}
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: resolvedUseTLS && tlsParams != nil)
|
||||
else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: lastKnown.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
guard tlsParams != nil else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
@@ -254,36 +347,26 @@ final class GatewayConnectionController {
|
||||
self.gateways.contains(where: { $0.stableID == id })
|
||||
}) {
|
||||
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
guard let host = self.resolveGatewayHost(target) else { return }
|
||||
let port = target.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
guard GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: target.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.connectDiscoveredGateway(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if self.gateways.count == 1, let gateway = self.gateways.first {
|
||||
guard let host = self.resolveGatewayHost(gateway) else { return }
|
||||
let port = gateway.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
guard GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: gateway.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -339,15 +422,27 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? {
|
||||
private func resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
allowTOFU: Bool) -> GatewayTLSParams?
|
||||
{
|
||||
let stableID = gateway.stableID
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil {
|
||||
// Never let unauthenticated discovery (TXT) override a stored pin.
|
||||
if let stored {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
@@ -364,21 +459,35 @@ final class GatewayConnectionController {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: stored == nil || allowTOFUReset,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
||||
return tailnet
|
||||
private func probeTLSFingerprint(url: URL) async -> String? {
|
||||
await withCheckedContinuation { continuation in
|
||||
let probe = GatewayTLSFingerprintProbe(url: url, timeoutSeconds: 3) { fp in
|
||||
continuation.resume(returning: fp)
|
||||
}
|
||||
probe.start()
|
||||
}
|
||||
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
||||
return lanHost
|
||||
}
|
||||
|
||||
private func resolveServiceEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
||||
guard case let .service(name, type, domain, _) = endpoint else { return nil }
|
||||
let key = "\(domain)|\(type)|\(name)"
|
||||
return await withCheckedContinuation { continuation in
|
||||
let resolver = GatewayServiceResolver(name: name, type: type, domain: domain) { [weak self] result in
|
||||
Task { @MainActor in
|
||||
self?.pendingServiceResolvers[key] = nil
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
self.pendingServiceResolvers[key] = resolver
|
||||
resolver.start()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
||||
@@ -662,5 +771,84 @@ extension GatewayConnectionController {
|
||||
func _test_triggerAutoConnect() {
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
func _test_didAutoConnect() -> Bool {
|
||||
self.didAutoConnect
|
||||
}
|
||||
|
||||
func _test_resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
allowTOFU: Bool) -> GatewayTLSParams?
|
||||
{
|
||||
self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate {
|
||||
private let url: URL
|
||||
private let timeoutSeconds: Double
|
||||
private let onComplete: (String?) -> Void
|
||||
private var didFinish = false
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionWebSocketTask?
|
||||
|
||||
init(url: URL, timeoutSeconds: Double, onComplete: @escaping (String?) -> Void) {
|
||||
self.url = url
|
||||
self.timeoutSeconds = timeoutSeconds
|
||||
self.onComplete = onComplete
|
||||
}
|
||||
|
||||
func start() {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = self.timeoutSeconds
|
||||
config.timeoutIntervalForResource = self.timeoutSeconds
|
||||
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
self.session = session
|
||||
let task = session.webSocketTask(with: self.url)
|
||||
self.task = task
|
||||
task.resume()
|
||||
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in
|
||||
self?.finish(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust
|
||||
else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let fp = GatewayTLSFingerprintProbe.certificateFingerprint(trust)
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
self.finish(fp)
|
||||
}
|
||||
|
||||
private func finish(_ fingerprint: String?) {
|
||||
objc_sync_enter(self)
|
||||
defer { objc_sync_exit(self) }
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.session?.invalidateAndCancel()
|
||||
self.onComplete(fingerprint)
|
||||
}
|
||||
|
||||
private static func certificateFingerprint(_ trust: SecTrust) -> String? {
|
||||
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
|
||||
let cert = chain.first
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let data = SecCertificateCopyData(cert) as Data
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,43 +136,9 @@ final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
private func updateStatusText() {
|
||||
let states = Array(self.statesByDomain.values)
|
||||
if states.isEmpty {
|
||||
self.statusText = self.browsers.isEmpty ? "Idle" : "Setup"
|
||||
return
|
||||
}
|
||||
|
||||
if let failed = states.first(where: { state in
|
||||
if case .failed = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .failed(err) = failed {
|
||||
self.statusText = "Failed: \(err)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let waiting = states.first(where: { state in
|
||||
if case .waiting = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .waiting(err) = waiting {
|
||||
self.statusText = "Waiting: \(err)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .ready = $0 { true } else { false } }) {
|
||||
self.statusText = "Searching…"
|
||||
return
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .setup = $0 { true } else { false } }) {
|
||||
self.statusText = "Setup"
|
||||
return
|
||||
}
|
||||
|
||||
self.statusText = "Searching…"
|
||||
self.statusText = GatewayDiscoveryStatusText.make(
|
||||
states: Array(self.statesByDomain.values),
|
||||
hasBrowsers: !self.browsers.isEmpty)
|
||||
}
|
||||
|
||||
private static func prettyState(_ state: NWBrowser.State) -> String {
|
||||
|
||||
55
apps/ios/Sources/Gateway/GatewayServiceResolver.swift
Normal file
55
apps/ios/Sources/Gateway/GatewayServiceResolver.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
// NetService-based resolver for Bonjour services.
|
||||
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
|
||||
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
private let service: NetService
|
||||
private let completion: ((host: String, port: Int)?) -> Void
|
||||
private var didFinish = false
|
||||
|
||||
init(
|
||||
name: String,
|
||||
type: String,
|
||||
domain: String,
|
||||
completion: @escaping ((host: String, port: Int)?) -> Void)
|
||||
{
|
||||
self.service = NetService(domain: domain, type: type, name: name)
|
||||
self.completion = completion
|
||||
super.init()
|
||||
self.service.delegate = self
|
||||
}
|
||||
|
||||
func start(timeout: TimeInterval = 2.0) {
|
||||
self.service.schedule(in: .main, forMode: .common)
|
||||
self.service.resolve(withTimeout: timeout)
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
let host = Self.normalizeHost(sender.hostName)
|
||||
let port = sender.port
|
||||
guard let host, !host.isEmpty, port > 0 else {
|
||||
self.finish(result: nil)
|
||||
return
|
||||
}
|
||||
self.finish(result: (host: host, port: port))
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
self.finish(result: nil)
|
||||
}
|
||||
|
||||
private func finish(result: ((host: String, port: Int))?) {
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
self.service.stop()
|
||||
self.service.remove(from: .main, forMode: .common)
|
||||
self.completion(result)
|
||||
}
|
||||
|
||||
private static func normalizeHost(_ raw: String?) -> String? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty { return nil }
|
||||
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ enum GatewaySettingsStore {
|
||||
private static let manualPortDefaultsKey = "gateway.manual.port"
|
||||
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
||||
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
||||
private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
|
||||
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
|
||||
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
|
||||
private static let lastGatewayTlsDefaultsKey = "gateway.last.tls"
|
||||
@@ -114,25 +115,73 @@ enum GatewaySettingsStore {
|
||||
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
enum LastGatewayConnection: Equatable {
|
||||
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
|
||||
case discovered(stableID: String, useTLS: Bool)
|
||||
|
||||
var stableID: String {
|
||||
switch self {
|
||||
case let .manual(_, _, _, stableID):
|
||||
return stableID
|
||||
case let .discovered(stableID, _):
|
||||
return stableID
|
||||
}
|
||||
}
|
||||
|
||||
var useTLS: Bool {
|
||||
switch self {
|
||||
case let .manual(_, _, useTLS, _):
|
||||
return useTLS
|
||||
case let .discovered(_, useTLS):
|
||||
return useTLS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum LastGatewayKind: String {
|
||||
case manual
|
||||
case discovered
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? {
|
||||
static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(LastGatewayKind.discovered.rawValue, forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
static func loadLastGatewayConnection() -> LastGatewayConnection? {
|
||||
let defaults = UserDefaults.standard
|
||||
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !stableID.isEmpty else { return nil }
|
||||
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual
|
||||
|
||||
if kind == .discovered {
|
||||
return .discovered(stableID: stableID, useTLS: useTLS)
|
||||
}
|
||||
|
||||
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
|
||||
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil }
|
||||
return (host: host, port: port, useTLS: useTLS, stableID: stableID)
|
||||
// Back-compat: older builds persisted manual-style host/port without a kind marker.
|
||||
guard !host.isEmpty, port > 0, port <= 65535 else { return nil }
|
||||
return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID)
|
||||
}
|
||||
|
||||
static func loadGatewayClientIdOverride(stableID: String) -> String? {
|
||||
|
||||
42
apps/ios/Sources/Gateway/GatewaySetupCode.swift
Normal file
42
apps/ios/Sources/Gateway/GatewaySetupCode.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
struct GatewaySetupPayload: Codable {
|
||||
var url: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var tls: Bool?
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
enum GatewaySetupCode {
|
||||
static func decode(raw: String) -> GatewaySetupPayload? {
|
||||
if let payload = decodeFromJSON(raw) {
|
||||
return payload
|
||||
}
|
||||
if let decoded = decodeBase64Payload(raw),
|
||||
let payload = decodeFromJSON(decoded)
|
||||
{
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeFromJSON(_ json: String) -> GatewaySetupPayload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(GatewaySetupPayload.self, from: data)
|
||||
}
|
||||
|
||||
private static func decodeBase64Payload(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padding = normalized.count % 4
|
||||
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
42
apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift
Normal file
42
apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayTrustPromptAlert: ViewModifier {
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
|
||||
private var promptBinding: Binding<GatewayConnectionController.TrustPrompt?> {
|
||||
Binding(
|
||||
get: { self.gatewayController.pendingTrustPrompt },
|
||||
set: { newValue in
|
||||
if newValue == nil {
|
||||
self.gatewayController.clearPendingTrustPrompt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.alert(item: self.promptBinding) { prompt in
|
||||
Alert(
|
||||
title: Text("Trust this gateway?"),
|
||||
message: Text(
|
||||
"""
|
||||
First-time TLS connection.
|
||||
|
||||
Verify this SHA-256 fingerprint out-of-band before trusting:
|
||||
\(prompt.fingerprintSha256)
|
||||
"""),
|
||||
primaryButton: .cancel(Text("Cancel")) {
|
||||
self.gatewayController.declinePendingTrustPrompt()
|
||||
},
|
||||
secondaryButton: .default(Text("Trust and connect")) {
|
||||
Task { await self.gatewayController.acceptPendingTrustPrompt() }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func gatewayTrustPromptAlert() -> some View {
|
||||
self.modifier(GatewayTrustPromptAlert())
|
||||
}
|
||||
}
|
||||
|
||||
43
apps/ios/Sources/Gateway/TCPProbe.swift
Normal file
43
apps/ios/Sources/Gateway/TCPProbe.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import os
|
||||
|
||||
enum TCPProbe {
|
||||
static func probe(host: String, port: Int, timeoutSeconds: Double, queueLabel: String) async -> Bool {
|
||||
guard port >= 1, port <= 65535 else { return false }
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
|
||||
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
let queue = DispatchQueue(label: queueLabel)
|
||||
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||
let finish: @Sendable (Bool) -> Void = { ok in
|
||||
let shouldResume = finished.withLock { flag -> Bool in
|
||||
if flag { return false }
|
||||
flag = true
|
||||
return true
|
||||
}
|
||||
guard shouldResume else { return }
|
||||
connection.cancel()
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260213</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.15</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260215</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
|
||||
@@ -61,37 +61,10 @@ extension NodeAppModel {
|
||||
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
|
||||
guard let host = url.host, !host.isEmpty else { return false }
|
||||
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)
|
||||
guard portInt >= 1, portInt <= 65535 else { return false }
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(portInt)) else { return false }
|
||||
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||
return await withCheckedContinuation { cont in
|
||||
let queue = DispatchQueue(label: "a2ui.preflight")
|
||||
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||
let finish: @Sendable (Bool) -> Void = { ok in
|
||||
let shouldResume = finished.withLock { flag -> Bool in
|
||||
if flag { return false }
|
||||
flag = true
|
||||
return true
|
||||
}
|
||||
guard shouldResume else { return }
|
||||
connection.cancel()
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||
}
|
||||
return await TCPProbe.probe(
|
||||
host: host,
|
||||
port: portInt,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
queueLabel: "a2ui.preflight")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ struct GatewayOnboardingView: View {
|
||||
}
|
||||
.navigationTitle("Connect Gateway")
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,15 +257,6 @@ private struct ManualEntryStep: View {
|
||||
self.manualPassword = ""
|
||||
}
|
||||
|
||||
private struct SetupPayload: Codable {
|
||||
var url: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var tls: Bool?
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
private func applySetupCode() {
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else {
|
||||
@@ -272,7 +264,7 @@ private struct ManualEntryStep: View {
|
||||
return
|
||||
}
|
||||
|
||||
guard let payload = self.decodeSetupPayload(raw: raw) else {
|
||||
guard let payload = GatewaySetupCode.decode(raw: raw) else {
|
||||
self.setupStatusText = "Setup code not recognized."
|
||||
return
|
||||
}
|
||||
@@ -322,34 +314,7 @@ private struct ManualEntryStep: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeSetupPayload(raw: String) -> SetupPayload? {
|
||||
if let payload = decodeSetupPayloadFromJSON(raw) {
|
||||
return payload
|
||||
}
|
||||
if let decoded = decodeBase64Payload(raw),
|
||||
let payload = decodeSetupPayloadFromJSON(decoded)
|
||||
{
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(SetupPayload.self, from: data)
|
||||
}
|
||||
|
||||
private func decodeBase64Payload(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padding = normalized.count % 4
|
||||
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
// (GatewaySetupCode) decode raw setup codes.
|
||||
}
|
||||
|
||||
private struct ConnectionStatusBox: View {
|
||||
|
||||
@@ -6,7 +6,7 @@ final class RemindersService: RemindersServicing {
|
||||
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
let authorized = EventKitAuthorization.allowsRead(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
@@ -50,7 +50,7 @@ final class RemindersService: RemindersServicing {
|
||||
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||
let authorized = EventKitAuthorization.allowsWrite(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
@@ -100,38 +100,6 @@ final class RemindersService: RemindersServicing {
|
||||
return OpenClawRemindersAddPayload(reminder: payload)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
case .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveList(
|
||||
store: EKEventStore,
|
||||
listId: String?,
|
||||
|
||||
@@ -52,6 +52,7 @@ struct RootCanvas: View {
|
||||
CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.sheet(item: self.$presentedSheet) { sheet in
|
||||
switch sheet {
|
||||
case .settings:
|
||||
@@ -255,64 +256,11 @@ private struct CanvasContent: View {
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
|
||||
if self.appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
}
|
||||
|
||||
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
switch cameraHUDKind {
|
||||
case .photo:
|
||||
systemImage = "camera.fill"
|
||||
tint = nil
|
||||
case .recording:
|
||||
systemImage = "video.fill"
|
||||
tint = .red
|
||||
case .success:
|
||||
systemImage = "checkmark.circle.fill"
|
||||
tint = .green
|
||||
case .error:
|
||||
systemImage = "exclamationmark.triangle.fill"
|
||||
tint = .red
|
||||
}
|
||||
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||
}
|
||||
|
||||
if self.voiceWakeEnabled {
|
||||
let voiceStatus = self.appModel.voiceWake.statusText
|
||||
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if self.appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
StatusActivityBuilder.build(
|
||||
appModel: self.appModel,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
cameraHUDText: self.cameraHUDText,
|
||||
cameraHUDKind: self.cameraHUDKind)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,66 +104,10 @@ struct RootTabs: View {
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
||||
if self.appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
}
|
||||
|
||||
if let cameraHUDText = self.appModel.cameraHUDText,
|
||||
let cameraHUDKind = self.appModel.cameraHUDKind,
|
||||
!cameraHUDText.isEmpty
|
||||
{
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
switch cameraHUDKind {
|
||||
case .photo:
|
||||
systemImage = "camera.fill"
|
||||
tint = nil
|
||||
case .recording:
|
||||
systemImage = "video.fill"
|
||||
tint = .red
|
||||
case .success:
|
||||
systemImage = "checkmark.circle.fill"
|
||||
tint = .green
|
||||
case .error:
|
||||
systemImage = "exclamationmark.triangle.fill"
|
||||
tint = .red
|
||||
}
|
||||
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||
}
|
||||
|
||||
if self.voiceWakeEnabled {
|
||||
let voiceStatus = self.appModel.voiceWake.statusText
|
||||
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if self.appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
StatusActivityBuilder.build(
|
||||
appModel: self.appModel,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
cameraHUDText: self.appModel.cameraHUDText,
|
||||
cameraHUDKind: self.appModel.cameraHUDKind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.localIPAddress = Self.primaryIPv4Address()
|
||||
self.localIPAddress = NetworkInterfaces.primaryIPv4Address()
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
self.syncManualPortText()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -376,6 +376,7 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -388,11 +389,13 @@ struct SettingsTab: View {
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(),
|
||||
case let .manual(host, port, _, _) = lastKnown
|
||||
{
|
||||
Button {
|
||||
Task { await self.connectLastKnown() }
|
||||
} label: {
|
||||
self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
|
||||
self.lastKnownButtonLabel(host: host, port: port)
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -587,15 +590,6 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SetupPayload: Codable {
|
||||
var url: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var tls: Bool?
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
private func applySetupCodeAndConnect() async {
|
||||
self.setupStatusText = nil
|
||||
guard self.applySetupCode() else { return }
|
||||
@@ -623,7 +617,7 @@ struct SettingsTab: View {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let payload = self.decodeSetupPayload(raw: raw) else {
|
||||
guard let payload = GatewaySetupCode.decode(raw: raw) else {
|
||||
self.setupStatusText = "Setup code not recognized."
|
||||
return false
|
||||
}
|
||||
@@ -724,67 +718,14 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
private static func probeTCP(host: String, port: Int, timeoutSeconds: Double) async -> Bool {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||
return await withCheckedContinuation { cont in
|
||||
let queue = DispatchQueue(label: "gateway.preflight")
|
||||
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||
let finish: @Sendable (Bool) -> Void = { ok in
|
||||
let shouldResume = finished.withLock { flag -> Bool in
|
||||
if flag { return false }
|
||||
flag = true
|
||||
return true
|
||||
}
|
||||
guard shouldResume else { return }
|
||||
connection.cancel()
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) {
|
||||
finish(false)
|
||||
}
|
||||
}
|
||||
await TCPProbe.probe(
|
||||
host: host,
|
||||
port: port,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
queueLabel: "gateway.preflight")
|
||||
}
|
||||
|
||||
private func decodeSetupPayload(raw: String) -> SetupPayload? {
|
||||
if let payload = decodeSetupPayloadFromJSON(raw) {
|
||||
return payload
|
||||
}
|
||||
if let decoded = decodeBase64Payload(raw),
|
||||
let payload = decodeSetupPayloadFromJSON(decoded)
|
||||
{
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(SetupPayload.self, from: data)
|
||||
}
|
||||
|
||||
private func decodeBase64Payload(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padding = normalized.count % 4
|
||||
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
// (GatewaySetupCode) decode raw setup codes.
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -849,44 +790,6 @@ struct SettingsTab: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
var fallback: String?
|
||||
var en0: String?
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let name = String(cString: ptr.pointee.ifa_name)
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
|
||||
if name == "en0" { en0 = ip; break }
|
||||
if fallback == nil { fallback = ip }
|
||||
}
|
||||
|
||||
return en0 ?? fallback
|
||||
}
|
||||
|
||||
private static func hasTailnetIPv4() -> Bool {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
|
||||
|
||||
70
apps/ios/Sources/Status/StatusActivityBuilder.swift
Normal file
70
apps/ios/Sources/Status/StatusActivityBuilder.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import SwiftUI
|
||||
|
||||
enum StatusActivityBuilder {
|
||||
static func build(
|
||||
appModel: NodeAppModel,
|
||||
voiceWakeEnabled: Bool,
|
||||
cameraHUDText: String?,
|
||||
cameraHUDKind: NodeAppModel.CameraHUDKind?
|
||||
) -> StatusPill.Activity? {
|
||||
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
||||
if appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
}
|
||||
|
||||
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
switch cameraHUDKind {
|
||||
case .photo:
|
||||
systemImage = "camera.fill"
|
||||
tint = nil
|
||||
case .recording:
|
||||
systemImage = "video.fill"
|
||||
tint = .red
|
||||
case .success:
|
||||
systemImage = "checkmark.circle.fill"
|
||||
tint = .green
|
||||
case .error:
|
||||
systemImage = "exclamationmark.triangle.fill"
|
||||
tint = .red
|
||||
}
|
||||
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||
}
|
||||
|
||||
if voiceWakeEnabled {
|
||||
let voiceStatus = appModel.voiceWake.statusText
|
||||
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
105
apps/ios/Tests/GatewayConnectionSecurityTests.swift
Normal file
105
apps/ios/Tests/GatewayConnectionSecurityTests.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionSecurityTests {
|
||||
private func clearTLSFingerprint(stableID: String) {
|
||||
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
|
||||
suite.removeObject(forKey: "gateway.tls.\(stableID)")
|
||||
}
|
||||
|
||||
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
|
||||
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: "evil.example.com",
|
||||
tailnetDns: "evil.example.com",
|
||||
gatewayPort: 12345,
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: "22",
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
#expect(params?.expectedFingerprint == "11")
|
||||
#expect(params?.allowTOFU == false)
|
||||
}
|
||||
|
||||
@Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
gatewayPort: nil,
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: "22",
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
#expect(params?.expectedFingerprint == nil)
|
||||
#expect(params?.allowTOFU == false)
|
||||
}
|
||||
|
||||
@Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(true, forKey: "gateway.autoconnect")
|
||||
defaults.set(false, forKey: "gateway.manual.enabled")
|
||||
defaults.removeObject(forKey: "gateway.last.host")
|
||||
defaults.removeObject(forKey: "gateway.last.port")
|
||||
defaults.removeObject(forKey: "gateway.last.tls")
|
||||
defaults.removeObject(forKey: "gateway.last.stableID")
|
||||
defaults.removeObject(forKey: "gateway.last.kind")
|
||||
defaults.removeObject(forKey: "gateway.preferredStableID")
|
||||
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
|
||||
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: "test.local",
|
||||
tailnetDns: nil,
|
||||
gatewayPort: 18789,
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
controller._test_setGateways([gateway])
|
||||
controller._test_triggerAutoConnect()
|
||||
|
||||
#expect(controller._test_didAutoConnect() == false)
|
||||
}
|
||||
}
|
||||
@@ -124,4 +124,76 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
|
||||
}
|
||||
|
||||
@Test func lastGateway_manualRoundTrip() {
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|example.com|443")
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
|
||||
}
|
||||
|
||||
@Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() {
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
|
||||
// Simulate a prior manual record that included host/port.
|
||||
applyDefaults([
|
||||
"gateway.last.host": "10.0.0.99",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": true,
|
||||
"gateway.last.stableID": "manual|10.0.0.99|18789",
|
||||
"gateway.last.kind": "manual",
|
||||
])
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
#expect(defaults.object(forKey: "gateway.last.port") == nil)
|
||||
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
|
||||
}
|
||||
|
||||
@Test func lastGateway_backCompat_manualLoadsWhenKindMissing() {
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
|
||||
applyDefaults([
|
||||
"gateway.last.kind": nil,
|
||||
"gateway.last.host": "example.org",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": false,
|
||||
"gateway.last.stableID": "manual|example.org|18789",
|
||||
])
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260213</string>
|
||||
</dict>
|
||||
</plist>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.15</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260215</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -81,8 +81,8 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.2.13"
|
||||
CFBundleVersion: "20260213"
|
||||
CFBundleShortVersionString: "2026.2.15"
|
||||
CFBundleVersion: "20260215"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.13"
|
||||
CFBundleVersion: "20260213"
|
||||
CFBundleShortVersionString: "2026.2.15"
|
||||
CFBundleVersion: "20260215"
|
||||
|
||||
@@ -110,8 +110,8 @@ struct AboutSettings: View {
|
||||
private var buildTimestamp: String? {
|
||||
guard
|
||||
let raw =
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) ??
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String)
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) ??
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String)
|
||||
else { return nil }
|
||||
let parser = ISO8601DateFormatter()
|
||||
parser.formatOptions = [.withInternetDateTime]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
// Human-friendly age string (e.g., "2m ago").
|
||||
/// Human-friendly age string (e.g., "2m ago").
|
||||
func age(from date: Date, now: Date = .init()) -> String {
|
||||
let seconds = max(0, Int(now.timeIntervalSince(date)))
|
||||
let minutes = seconds / 60
|
||||
|
||||
@@ -19,7 +19,7 @@ enum AgentWorkspace {
|
||||
]
|
||||
enum BootstrapSafety: Equatable {
|
||||
case safe
|
||||
case unsafe(reason: String)
|
||||
case unsafe (reason: String)
|
||||
}
|
||||
|
||||
static func displayPath(for url: URL) -> String {
|
||||
@@ -72,7 +72,7 @@ enum AgentWorkspace {
|
||||
return .safe
|
||||
}
|
||||
if !isDir.boolValue {
|
||||
return .unsafe(reason: "Workspace path points to a file.")
|
||||
return .unsafe (reason: "Workspace path points to a file.")
|
||||
}
|
||||
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
||||
if fm.fileExists(atPath: agentsURL.path) {
|
||||
@@ -82,9 +82,9 @@ enum AgentWorkspace {
|
||||
let entries = try self.workspaceEntries(workspaceURL: workspaceURL)
|
||||
return entries.isEmpty
|
||||
? .safe
|
||||
: .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.")
|
||||
: .unsafe (reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.")
|
||||
} catch {
|
||||
return .unsafe(reason: "Couldn't inspect the workspace folder.")
|
||||
return .unsafe (reason: "Couldn't inspect the workspace folder.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -234,9 +234,8 @@ enum OpenClawOAuthStore {
|
||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||
}
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
let preferred = home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
return home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
.appendingPathComponent("credentials", isDirectory: true)
|
||||
return preferred
|
||||
}
|
||||
|
||||
static func oauthURL() -> URL {
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
// Prefer the OpenClawKit wrapper to keep gateway request payloads consistent.
|
||||
typealias AnyCodable = OpenClawKit.AnyCodable
|
||||
typealias InstanceIdentity = OpenClawKit.InstanceIdentity
|
||||
|
||||
extension AnyCodable {
|
||||
var stringValue: String? { self.value as? String }
|
||||
var boolValue: Bool? { self.value as? Bool }
|
||||
var intValue: Int? { self.value as? Int }
|
||||
var doubleValue: Double? { self.value as? Double }
|
||||
var dictionaryValue: [String: AnyCodable]? { self.value as? [String: AnyCodable] }
|
||||
var arrayValue: [AnyCodable]? { self.value as? [AnyCodable] }
|
||||
var stringValue: String? {
|
||||
self.value as? String
|
||||
}
|
||||
|
||||
var boolValue: Bool? {
|
||||
self.value as? Bool
|
||||
}
|
||||
|
||||
var intValue: Int? {
|
||||
self.value as? Int
|
||||
}
|
||||
|
||||
var doubleValue: Double? {
|
||||
self.value as? Double
|
||||
}
|
||||
|
||||
var dictionaryValue: [String: AnyCodable]? {
|
||||
self.value as? [String: AnyCodable]
|
||||
}
|
||||
|
||||
var arrayValue: [AnyCodable]? {
|
||||
self.value as? [AnyCodable]
|
||||
}
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
@@ -25,23 +41,3 @@ extension AnyCodable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenClawProtocol.AnyCodable {
|
||||
var stringValue: String? { self.value as? String }
|
||||
var boolValue: Bool? { self.value as? Bool }
|
||||
var intValue: Int? { self.value as? Int }
|
||||
var doubleValue: Double? { self.value as? Double }
|
||||
var dictionaryValue: [String: OpenClawProtocol.AnyCodable]? { self.value as? [String: OpenClawProtocol.AnyCodable] }
|
||||
var arrayValue: [OpenClawProtocol.AnyCodable]? { self.value as? [OpenClawProtocol.AnyCodable] }
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
case let dict as [String: OpenClawProtocol.AnyCodable]:
|
||||
dict.mapValues { $0.foundationValue }
|
||||
case let array as [OpenClawProtocol.AnyCodable]:
|
||||
array.map(\.foundationValue)
|
||||
default:
|
||||
self.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,11 +422,10 @@ final class AppState {
|
||||
let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser
|
||||
let port = parsed.port
|
||||
let assembled: String
|
||||
if let user {
|
||||
assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
|
||||
let assembled: String = if let user {
|
||||
port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
|
||||
} else {
|
||||
assembled = port == 22 ? host : "\(host):\(port)"
|
||||
port == 22 ? host : "\(host):\(port)"
|
||||
}
|
||||
if assembled != self.remoteTarget {
|
||||
self.remoteTarget = assembled
|
||||
@@ -698,7 +697,9 @@ extension AppState {
|
||||
@MainActor
|
||||
enum AppStateStore {
|
||||
static let shared = AppState()
|
||||
static var isPausedFlag: Bool { UserDefaults.standard.bool(forKey: pauseDefaultsKey) }
|
||||
static var isPausedFlag: Bool {
|
||||
UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
}
|
||||
|
||||
static func updateLaunchAtLogin(enabled: Bool) {
|
||||
Task.detached(priority: .utility) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import AVFoundation
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
|
||||
actor CameraCaptureService {
|
||||
@@ -106,14 +106,16 @@ actor CameraCaptureService {
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
let maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||
imageData: rawData,
|
||||
maxWidthPx: maxWidth,
|
||||
quality: quality,
|
||||
maxBytes: maxEncodedBytes)
|
||||
let res: (data: Data, widthPx: Int, heightPx: Int)
|
||||
do {
|
||||
res = try PhotoCapture.transcodeJPEGForGateway(
|
||||
rawData: rawData,
|
||||
maxWidthPx: maxWidth,
|
||||
quality: quality)
|
||||
} catch {
|
||||
throw CameraError.captureFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
|
||||
}
|
||||
|
||||
@@ -355,8 +357,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?)
|
||||
{
|
||||
error: Error?
|
||||
) {
|
||||
guard !self.didResume, let cont else { return }
|
||||
self.didResume = true
|
||||
self.cont = nil
|
||||
@@ -378,8 +380,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||
error: Error?)
|
||||
{
|
||||
error: Error?
|
||||
) {
|
||||
guard let error else { return }
|
||||
guard !self.didResume, let cont else { return }
|
||||
self.didResume = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
|
||||
@@ -39,7 +39,9 @@ final class HoverChromeContainerView: NSView {
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
@@ -60,14 +62,18 @@ final class HoverChromeContainerView: NSView {
|
||||
self.window?.performDrag(with: event)
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private final class CanvasResizeHandleView: NSView {
|
||||
private var startPoint: NSPoint = .zero
|
||||
private var startFrame: NSRect = .zero
|
||||
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard let window else { return }
|
||||
@@ -102,7 +108,9 @@ final class HoverChromeContainerView: NSView {
|
||||
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
|
||||
|
||||
private final class PassthroughVisualEffectView: NSVisualEffectView {
|
||||
override func hitTest(_: NSPoint) -> NSView? { nil }
|
||||
override func hitTest(_: NSPoint) -> NSView? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private let closeBackground: NSVisualEffectView = {
|
||||
@@ -190,7 +198,9 @@ final class HoverChromeContainerView: NSView {
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class CanvasFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
private let onChange: () -> Void
|
||||
private let watcher: CoalescingFSEventsWatcher
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.url = url
|
||||
self.queue = DispatchQueue(label: "ai.openclaw.canvaswatcher")
|
||||
self.onChange = onChange
|
||||
self.watcher = CoalescingFSEventsWatcher(
|
||||
paths: [url.path],
|
||||
queueLabel: "ai.openclaw.canvaswatcher",
|
||||
onChange: onChange)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -19,76 +15,10 @@ final class CanvasFileWatcher: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.stream == nil else { return }
|
||||
|
||||
let retainedSelf = Unmanaged.passRetained(self)
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: retainedSelf.toOpaque(),
|
||||
retain: nil,
|
||||
release: { pointer in
|
||||
guard let pointer else { return }
|
||||
Unmanaged<CanvasFileWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = [self.url.path] as CFArray
|
||||
let flags = FSEventStreamCreateFlags(
|
||||
kFSEventStreamCreateFlagFileEvents |
|
||||
kFSEventStreamCreateFlagUseCFTypes |
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||
if FSEventStreamStart(stream) == false {
|
||||
self.stream = nil
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
self.watcher.start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let stream = self.stream else { return }
|
||||
self.stream = nil
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasFileWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<CanvasFileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(numEvents: Int, eventFlags: UnsafePointer<FSEventStreamEventFlags>?) {
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
|
||||
// Coalesce rapid changes (common during builds/atomic saves).
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
self.watcher.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
import WebKit
|
||||
|
||||
|
||||
@@ -11,8 +11,13 @@ enum CanvasLayout {
|
||||
}
|
||||
|
||||
final class CanvasPanel: NSPanel {
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { true }
|
||||
override var canBecomeKey: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var canBecomeMain: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
enum CanvasPresentation {
|
||||
|
||||
@@ -19,7 +19,8 @@ extension CanvasWindowController {
|
||||
// Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace.
|
||||
if scheme == "openclaw" {
|
||||
if let currentScheme = self.webView.url?.scheme,
|
||||
CanvasScheme.allSchemes.contains(currentScheme) {
|
||||
CanvasScheme.allSchemes.contains(currentScheme)
|
||||
{
|
||||
Task { await DeepLinkHandler.shared.handle(url: url) }
|
||||
} else {
|
||||
canvasWindowLogger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
@@ -183,7 +183,9 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
@MainActor deinit {
|
||||
for name in CanvasA2UIActionMessageHandler.allMessageNames {
|
||||
|
||||
@@ -10,7 +10,6 @@ extension ChannelsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func channelHeaderActions(_ channel: ChannelItem) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if channel.id == "whatsapp" {
|
||||
@@ -88,7 +87,6 @@ extension ChannelsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func genericChannelSection(_ channel: ChannelItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.configEditorSection(channelId: channel.id)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
extension ChannelsStore {
|
||||
func loadConfigSchema() async {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
extension ChannelsStore {
|
||||
func start() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawProtocol
|
||||
|
||||
struct ChannelsStatusSnapshot: Codable {
|
||||
struct WhatsAppSelf: Codable {
|
||||
|
||||
111
apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift
Normal file
111
apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class CoalescingFSEventsWatcher: @unchecked Sendable {
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
|
||||
private let paths: [String]
|
||||
private let shouldNotify: (Int, UnsafeMutableRawPointer?) -> Bool
|
||||
private let onChange: () -> Void
|
||||
private let coalesceDelay: TimeInterval
|
||||
|
||||
init(
|
||||
paths: [String],
|
||||
queueLabel: String,
|
||||
coalesceDelay: TimeInterval = 0.12,
|
||||
shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true },
|
||||
onChange: @escaping () -> Void
|
||||
) {
|
||||
self.paths = paths
|
||||
self.queue = DispatchQueue(label: queueLabel)
|
||||
self.coalesceDelay = coalesceDelay
|
||||
self.shouldNotify = shouldNotify
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stop()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.stream == nil else { return }
|
||||
|
||||
let retainedSelf = Unmanaged.passRetained(self)
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: retainedSelf.toOpaque(),
|
||||
retain: nil,
|
||||
release: { pointer in
|
||||
guard let pointer else { return }
|
||||
Unmanaged<CoalescingFSEventsWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = self.paths as CFArray
|
||||
let flags = FSEventStreamCreateFlags(
|
||||
kFSEventStreamCreateFlagFileEvents |
|
||||
kFSEventStreamCreateFlagUseCFTypes |
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||
if FSEventStreamStart(stream) == false {
|
||||
self.stream = nil
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let stream = self.stream else { return }
|
||||
self.stream = nil
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
extension CoalescingFSEventsWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<CoalescingFSEventsWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(
|
||||
numEvents: Int,
|
||||
eventPaths: UnsafeMutableRawPointer?,
|
||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?
|
||||
) {
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
guard self.shouldNotify(numEvents, eventPaths) else { return }
|
||||
|
||||
// Coalesce rapid changes (common during builds/atomic saves).
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + self.coalesceDelay) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class ConfigFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
private let onChange: () -> Void
|
||||
private let watchedDir: URL
|
||||
private let targetPath: String
|
||||
private let targetName: String
|
||||
private let watcher: CoalescingFSEventsWatcher
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.url = url
|
||||
self.queue = DispatchQueue(label: "ai.openclaw.configwatcher")
|
||||
self.onChange = onChange
|
||||
self.watchedDir = url.deletingLastPathComponent()
|
||||
self.targetPath = url.path
|
||||
self.targetName = url.lastPathComponent
|
||||
let watchedDirPath = self.watchedDir.path
|
||||
let targetPath = self.targetPath
|
||||
let targetName = self.targetName
|
||||
self.watcher = CoalescingFSEventsWatcher(
|
||||
paths: [watchedDirPath],
|
||||
queueLabel: "ai.openclaw.configwatcher",
|
||||
shouldNotify: { _, eventPaths in
|
||||
guard let eventPaths else { return true }
|
||||
let paths = unsafeBitCast(eventPaths, to: NSArray.self)
|
||||
for case let path as String in paths {
|
||||
if path == targetPath { return true }
|
||||
if path.hasSuffix("/\(targetName)") { return true }
|
||||
if path == watchedDirPath { return true }
|
||||
}
|
||||
return false
|
||||
},
|
||||
onChange: onChange)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -25,94 +36,10 @@ final class ConfigFileWatcher: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.stream == nil else { return }
|
||||
|
||||
let retainedSelf = Unmanaged.passRetained(self)
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: retainedSelf.toOpaque(),
|
||||
retain: nil,
|
||||
release: { pointer in
|
||||
guard let pointer else { return }
|
||||
Unmanaged<ConfigFileWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = [self.watchedDir.path] as CFArray
|
||||
let flags = FSEventStreamCreateFlags(
|
||||
kFSEventStreamCreateFlagFileEvents |
|
||||
kFSEventStreamCreateFlagUseCFTypes |
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||
if FSEventStreamStart(stream) == false {
|
||||
self.stream = nil
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
self.watcher.start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let stream = self.stream else { return }
|
||||
self.stream = nil
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigFileWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<ConfigFileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(
|
||||
numEvents: numEvents,
|
||||
eventPaths: eventPaths,
|
||||
eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(
|
||||
numEvents: Int,
|
||||
eventPaths: UnsafeMutableRawPointer?,
|
||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
|
||||
{
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
guard self.matchesTarget(eventPaths: eventPaths) else { return }
|
||||
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool {
|
||||
guard let eventPaths else { return true }
|
||||
let paths = unsafeBitCast(eventPaths, to: NSArray.self)
|
||||
for case let path as String in paths {
|
||||
if path == self.targetPath { return true }
|
||||
if path.hasSuffix("/\(self.targetName)") { return true }
|
||||
if path == self.watchedDir.path { return true }
|
||||
}
|
||||
return false
|
||||
self.watcher.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +39,26 @@ struct ConfigSchemaNode {
|
||||
self.raw = dict
|
||||
}
|
||||
|
||||
var title: String? { self.raw["title"] as? String }
|
||||
var description: String? { self.raw["description"] as? String }
|
||||
var enumValues: [Any]? { self.raw["enum"] as? [Any] }
|
||||
var constValue: Any? { self.raw["const"] }
|
||||
var explicitDefault: Any? { self.raw["default"] }
|
||||
var title: String? {
|
||||
self.raw["title"] as? String
|
||||
}
|
||||
|
||||
var description: String? {
|
||||
self.raw["description"] as? String
|
||||
}
|
||||
|
||||
var enumValues: [Any]? {
|
||||
self.raw["enum"] as? [Any]
|
||||
}
|
||||
|
||||
var constValue: Any? {
|
||||
self.raw["const"]
|
||||
}
|
||||
|
||||
var explicitDefault: Any? {
|
||||
self.raw["default"]
|
||||
}
|
||||
|
||||
var requiredKeys: Set<String> {
|
||||
Set((self.raw["required"] as? [String]) ?? [])
|
||||
}
|
||||
|
||||
@@ -45,7 +45,9 @@ extension ConfigSettings {
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
|
||||
var id: String { self.key }
|
||||
var id: String {
|
||||
self.key
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConfigSubsection: Identifiable {
|
||||
@@ -55,7 +57,9 @@ extension ConfigSettings {
|
||||
let node: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
|
||||
var id: String { self.key }
|
||||
var id: String {
|
||||
self.key
|
||||
}
|
||||
}
|
||||
|
||||
private var sections: [ConfigSection] {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
enum ConfigStore {
|
||||
struct Overrides: Sendable {
|
||||
|
||||
@@ -70,7 +70,6 @@ struct ContextMenuCardView: View {
|
||||
return "\(count) sessions · 24h"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionRow(_ row: SessionRow) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
ContextUsageBar(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct ControlHeartbeatEvent: Codable {
|
||||
@@ -15,7 +15,10 @@ struct ControlHeartbeatEvent: Codable {
|
||||
}
|
||||
|
||||
struct ControlAgentEvent: Codable, Sendable, Identifiable {
|
||||
var id: String { "\(self.runId)-\(self.seq)" }
|
||||
var id: String {
|
||||
"\(self.runId)-\(self.seq)"
|
||||
}
|
||||
|
||||
let runId: String
|
||||
let seq: Int
|
||||
let stream: String
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension CronJobEditor {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawProtocol
|
||||
import Observation
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct CronJobEditor: View {
|
||||
@@ -32,18 +32,24 @@ struct CronJobEditor: View {
|
||||
@State var wakeMode: CronWakeMode = .now
|
||||
@State var deleteAfterRun: Bool = false
|
||||
|
||||
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
||||
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String {
|
||||
rawValue
|
||||
} }
|
||||
@State var scheduleKind: ScheduleKind = .every
|
||||
@State var atDate: Date = .init().addingTimeInterval(60 * 5)
|
||||
@State var everyText: String = "1h"
|
||||
@State var cronExpr: String = "0 9 * * 3"
|
||||
@State var cronTz: String = ""
|
||||
|
||||
enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { rawValue } }
|
||||
enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String {
|
||||
rawValue
|
||||
} }
|
||||
@State var payloadKind: PayloadKind = .systemEvent
|
||||
@State var systemEventText: String = ""
|
||||
@State var agentMessage: String = ""
|
||||
enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String { rawValue } }
|
||||
enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String {
|
||||
rawValue
|
||||
} }
|
||||
@State var deliveryMode: DeliveryChoice = .announce
|
||||
@State var channel: String = "last"
|
||||
@State var to: String = ""
|
||||
@@ -244,7 +250,6 @@ struct CronJobEditor: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 2)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -4,21 +4,27 @@ enum CronSessionTarget: String, CaseIterable, Identifiable, Codable {
|
||||
case main
|
||||
case isolated
|
||||
|
||||
var id: String { self.rawValue }
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
|
||||
case now
|
||||
case nextHeartbeat = "next-heartbeat"
|
||||
|
||||
var id: String { self.rawValue }
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable {
|
||||
case none
|
||||
case announce
|
||||
|
||||
var id: String { self.rawValue }
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
struct CronDelivery: Codable, Equatable {
|
||||
@@ -98,11 +104,11 @@ enum CronSchedule: Codable, Equatable {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if let date = makeIsoFormatter(withFractional: true).date(from: trimmed) { return date }
|
||||
return makeIsoFormatter(withFractional: false).date(from: trimmed)
|
||||
return self.makeIsoFormatter(withFractional: false).date(from: trimmed)
|
||||
}
|
||||
|
||||
static func formatIsoDate(_ date: Date) -> String {
|
||||
makeIsoFormatter(withFractional: false).string(from: date)
|
||||
self.makeIsoFormatter(withFractional: false).string(from: date)
|
||||
}
|
||||
|
||||
private static func makeIsoFormatter(withFractional: Bool) -> ISO8601DateFormatter {
|
||||
@@ -231,7 +237,9 @@ struct CronEvent: Codable, Sendable {
|
||||
}
|
||||
|
||||
struct CronRunLogEntry: Codable, Identifiable, Sendable {
|
||||
var id: String { "\(self.jobId)-\(self.ts)" }
|
||||
var id: String {
|
||||
"\(self.jobId)-\(self.ts)"
|
||||
}
|
||||
|
||||
let ts: Int
|
||||
let jobId: String
|
||||
@@ -243,7 +251,10 @@ struct CronRunLogEntry: Codable, Identifiable, Sendable {
|
||||
let durationMs: Int?
|
||||
let nextRunAtMs: Int?
|
||||
|
||||
var date: Date { Date(timeIntervalSince1970: TimeInterval(self.ts) / 1000) }
|
||||
var date: Date {
|
||||
Date(timeIntervalSince1970: TimeInterval(self.ts) / 1000)
|
||||
}
|
||||
|
||||
var runDate: Date? {
|
||||
guard let runAtMs else { return nil }
|
||||
return Date(timeIntervalSince1970: TimeInterval(runAtMs) / 1000)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
extension CronSettings {
|
||||
func save(payload: [String: AnyCodable]) async {
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
import AppKit
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw", category: "DeepLink")
|
||||
|
||||
enum DeepLinkAgentPolicy {
|
||||
static let maxMessageChars = 20000
|
||||
static let maxUnkeyedConfirmChars = 240
|
||||
|
||||
enum ValidationError: Error, Equatable, LocalizedError {
|
||||
case messageTooLongForConfirmation(max: Int, actual: Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .messageTooLongForConfirmation(max, actual):
|
||||
"Message is too long to confirm safely (\(actual) chars; max \(max) without key)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func validateMessageForHandle(message: String, allowUnattended: Bool) -> Result<Void, ValidationError> {
|
||||
if !allowUnattended, message.count > self.maxUnkeyedConfirmChars {
|
||||
return .failure(.messageTooLongForConfirmation(max: self.maxUnkeyedConfirmChars, actual: message.count))
|
||||
}
|
||||
return .success(())
|
||||
}
|
||||
|
||||
static func effectiveDelivery(
|
||||
link: AgentDeepLink,
|
||||
allowUnattended: Bool) -> (deliver: Bool, to: String?, channel: GatewayAgentChannel)
|
||||
{
|
||||
if !allowUnattended {
|
||||
// Without the unattended key, ignore delivery/routing knobs to reduce exfiltration risk.
|
||||
return (deliver: false, to: nil, channel: .last)
|
||||
}
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
let deliver = channel.shouldDeliver(link.deliver)
|
||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
return (deliver: deliver, to: to, channel: channel)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DeepLinkHandler {
|
||||
static let shared = DeepLinkHandler()
|
||||
|
||||
private var lastPromptAt: Date = .distantPast
|
||||
|
||||
// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
|
||||
// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
|
||||
// outside callers can't know this randomly generated key.
|
||||
/// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
|
||||
/// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
|
||||
/// outside callers can't know this randomly generated key.
|
||||
private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey()
|
||||
|
||||
func handle(url: URL) async {
|
||||
@@ -35,7 +72,7 @@ final class DeepLinkHandler {
|
||||
|
||||
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
|
||||
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if messagePreview.count > 20000 {
|
||||
if messagePreview.count > DeepLinkAgentPolicy.maxMessageChars {
|
||||
self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.")
|
||||
return
|
||||
}
|
||||
@@ -48,9 +85,18 @@ final class DeepLinkHandler {
|
||||
}
|
||||
self.lastPromptAt = Date()
|
||||
|
||||
let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))…" : messagePreview
|
||||
if case let .failure(error) = DeepLinkAgentPolicy.validateMessageForHandle(
|
||||
message: messagePreview,
|
||||
allowUnattended: allowUnattended)
|
||||
{
|
||||
self.presentAlert(title: "Deep link blocked", message: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
let urlText = originalURL.absoluteString
|
||||
let urlPreview = urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText
|
||||
let body =
|
||||
"Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)"
|
||||
"Run the agent with this message?\n\n\(messagePreview)\n\nURL:\n\(urlPreview)"
|
||||
guard self.confirm(title: "Run OpenClaw agent?", message: body) else { return }
|
||||
}
|
||||
|
||||
@@ -59,7 +105,7 @@ final class DeepLinkHandler {
|
||||
}
|
||||
|
||||
do {
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
let effectiveDelivery = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: allowUnattended)
|
||||
let explicitSessionKey = link.sessionKey?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.nonEmpty
|
||||
@@ -72,9 +118,9 @@ final class DeepLinkHandler {
|
||||
message: messagePreview,
|
||||
sessionKey: resolvedSessionKey,
|
||||
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||
deliver: channel.shouldDeliver(link.deliver),
|
||||
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||
channel: channel,
|
||||
deliver: effectiveDelivery.deliver,
|
||||
to: effectiveDelivery.to,
|
||||
channel: effectiveDelivery.channel,
|
||||
timeoutSeconds: link.timeoutSeconds,
|
||||
idempotencyKey: UUID().uuidString)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import AppKit
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@@ -22,11 +22,6 @@ final class DevicePairingApprovalPrompter {
|
||||
private var alertHostWindow: NSWindow?
|
||||
private var resolvedByRequestId: Set<String> = []
|
||||
|
||||
private final class AlertHostWindow: NSWindow {
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { true }
|
||||
}
|
||||
|
||||
private struct PairingList: Codable {
|
||||
let pending: [PendingRequest]
|
||||
let paired: [PairedDevice]?
|
||||
@@ -55,7 +50,9 @@ final class DevicePairingApprovalPrompter {
|
||||
let isRepair: Bool?
|
||||
let ts: Double
|
||||
|
||||
var id: String { self.requestId }
|
||||
var id: String {
|
||||
self.requestId
|
||||
}
|
||||
}
|
||||
|
||||
private struct PairingResolvedEvent: Codable {
|
||||
@@ -231,35 +228,11 @@ final class DevicePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
private func endActiveAlert() {
|
||||
guard let alert = self.activeAlert else { return }
|
||||
if let parent = alert.window.sheetParent {
|
||||
parent.endSheet(alert.window, returnCode: .abort)
|
||||
}
|
||||
self.activeAlert = nil
|
||||
self.activeRequestId = nil
|
||||
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
|
||||
}
|
||||
|
||||
private func requireAlertHostWindow() -> NSWindow {
|
||||
if let alertHostWindow {
|
||||
return alertHostWindow
|
||||
}
|
||||
|
||||
let window = AlertHostWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = ""
|
||||
window.isReleasedWhenClosed = false
|
||||
window.level = .floating
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.isOpaque = false
|
||||
window.hasShadow = false
|
||||
window.backgroundColor = .clear
|
||||
window.ignoresMouseEvents = true
|
||||
|
||||
self.alertHostWindow = window
|
||||
return window
|
||||
PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
|
||||
@@ -8,7 +8,9 @@ enum ExecSecurity: String, CaseIterable, Codable, Identifiable {
|
||||
case allowlist
|
||||
case full
|
||||
|
||||
var id: String { self.rawValue }
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@@ -24,7 +26,9 @@ enum ExecApprovalQuickMode: String, CaseIterable, Identifiable {
|
||||
case ask
|
||||
case allow
|
||||
|
||||
var id: String { self.rawValue }
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@@ -67,7 +71,9 @@ enum ExecAsk: String, CaseIterable, Codable, Identifiable {
|
||||
case onMiss = "on-miss"
|
||||
case always
|
||||
|
||||
var id: String { self.rawValue }
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import AppKit
|
||||
import OpenClawKit
|
||||
import CryptoKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
|
||||
struct ExecApprovalPromptRequest: Codable, Sendable {
|
||||
@@ -76,7 +76,9 @@ private struct ExecHostResponse: Codable {
|
||||
enum ExecApprovalsSocketClient {
|
||||
private struct TimeoutError: LocalizedError {
|
||||
var message: String
|
||||
var errorDescription: String? { self.message }
|
||||
var errorDescription: String? {
|
||||
self.message
|
||||
}
|
||||
}
|
||||
|
||||
static func requestDecision(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
private let gatewayConnectionLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection")
|
||||
@@ -24,9 +24,13 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
self = GatewayAgentChannel(rawValue: normalized) ?? .last
|
||||
}
|
||||
|
||||
var isDeliverable: Bool { self != .webchat }
|
||||
var isDeliverable: Bool {
|
||||
self != .webchat
|
||||
}
|
||||
|
||||
func shouldDeliver(_ deliver: Bool) -> Bool { deliver && self.isDeliverable }
|
||||
func shouldDeliver(_ deliver: Bool) -> Bool {
|
||||
deliver && self.isDeliverable
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayAgentInvocation: Sendable {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawDiscovery
|
||||
import Foundation
|
||||
import OpenClawDiscovery
|
||||
|
||||
enum GatewayDiscoveryHelpers {
|
||||
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
@@ -15,19 +15,29 @@ enum GatewayDiscoveryHelpers {
|
||||
|
||||
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
self.directGatewayUrl(
|
||||
tailnetDns: gateway.tailnetDns,
|
||||
serviceHost: gateway.serviceHost,
|
||||
servicePort: gateway.servicePort,
|
||||
lanHost: gateway.lanHost,
|
||||
gatewayPort: gateway.gatewayPort)
|
||||
}
|
||||
|
||||
static func directGatewayUrl(
|
||||
tailnetDns: String?,
|
||||
serviceHost: String?,
|
||||
servicePort: Int?,
|
||||
lanHost: String?,
|
||||
gatewayPort: Int?) -> String?
|
||||
{
|
||||
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
|
||||
return "wss://\(tailnetDns)"
|
||||
// Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort).
|
||||
// Prefer the resolved service endpoint (SRV + A/AAAA).
|
||||
if let host = self.trimmed(serviceHost), !host.isEmpty,
|
||||
let port = servicePort, port > 0
|
||||
{
|
||||
let scheme = port == 443 ? "wss" : "ws"
|
||||
let portSuffix = port == 443 ? "" : ":\(port)"
|
||||
return "\(scheme)://\(host)\(portSuffix)"
|
||||
}
|
||||
|
||||
// Legacy fallback (best-effort): keep existing behavior when we couldn't resolve SRV.
|
||||
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
|
||||
let port = gatewayPort ?? 18789
|
||||
return "ws://\(lanHost):\(port)"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import OpenClawIPC
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import OSLog
|
||||
|
||||
// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks.
|
||||
/// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks.
|
||||
struct Semver: Comparable, CustomStringConvertible, Sendable {
|
||||
let major: Int
|
||||
let minor: Int
|
||||
let patch: Int
|
||||
|
||||
var description: String { "\(self.major).\(self.minor).\(self.patch)" }
|
||||
var description: String {
|
||||
"\(self.major).\(self.minor).\(self.patch)"
|
||||
}
|
||||
|
||||
static func < (lhs: Semver, rhs: Semver) -> Bool {
|
||||
if lhs.major != rhs.major { return lhs.major < rhs.major }
|
||||
@@ -93,7 +95,7 @@ enum GatewayEnvironment {
|
||||
return (trimmed?.isEmpty == false) ? trimmed : nil
|
||||
}
|
||||
|
||||
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
||||
/// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
||||
static func expectedGatewayVersion(from versionString: String?) -> Semver? {
|
||||
Semver.parse(versionString)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import OpenClawDiscovery
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSettings: View {
|
||||
@@ -16,8 +16,13 @@ struct GeneralSettings: View {
|
||||
@State private var remoteStatus: RemoteStatus = .idle
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
private var remoteLabelWidth: CGFloat { 88 }
|
||||
private var isNixMode: Bool {
|
||||
ProcessInfo.processInfo.isNixMode
|
||||
}
|
||||
|
||||
private var remoteLabelWidth: CGFloat {
|
||||
88
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
@@ -683,7 +688,9 @@ extension GeneralSettings {
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: gateway.serviceHost ?? host,
|
||||
port: gateway.servicePort ?? gateway.gatewayPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ final class HealthStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only escape hatch: the HealthStore is a process-wide singleton but
|
||||
// state derivation is pure from `snapshot` + `lastError`.
|
||||
/// Test-only escape hatch: the HealthStore is a process-wide singleton but
|
||||
/// state derivation is pure from `snapshot` + `lastError`.
|
||||
func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) {
|
||||
self.snapshot = snapshot
|
||||
self.lastError = lastError
|
||||
|
||||
@@ -72,7 +72,9 @@ enum IconOverrideSelection: String, CaseIterable, Identifiable {
|
||||
case mainBash, mainRead, mainWrite, mainEdit, mainOther
|
||||
case otherBash, otherRead, otherWrite, otherEdit, otherOther
|
||||
|
||||
var id: String { self.rawValue }
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Cocoa
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
struct InstanceInfo: Identifiable, Codable {
|
||||
@@ -158,7 +158,7 @@ final class InstancesStore {
|
||||
|
||||
private func localFallbackInstance(reason: String) -> InstanceInfo {
|
||||
let host = Host.current().localizedName ?? "this-mac"
|
||||
let ip = Self.primaryIPv4Address()
|
||||
let ip = SystemPresenceInfo.primaryIPv4Address()
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
@@ -172,58 +172,13 @@ final class InstancesStore {
|
||||
platform: platform,
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||
lastInputSeconds: Self.lastInputSeconds(),
|
||||
lastInputSeconds: SystemPresenceInfo.lastInputSeconds(),
|
||||
mode: "local",
|
||||
reason: reason,
|
||||
text: text,
|
||||
ts: ts)
|
||||
}
|
||||
|
||||
private static func lastInputSeconds() -> Int? {
|
||||
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
|
||||
return Int(seconds.rounded())
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
var fallback: String?
|
||||
var en0: String?
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let name = String(cString: ptr.pointee.ifa_name)
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
|
||||
if name == "en0" { en0 = ip; break }
|
||||
if fallback == nil { fallback = ip }
|
||||
}
|
||||
|
||||
return en0 ?? fallback
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Keep the last raw payload for logging.
|
||||
|
||||
@@ -7,8 +7,7 @@ enum LogLocator {
|
||||
{
|
||||
return URL(fileURLWithPath: override)
|
||||
}
|
||||
let preferred = URL(fileURLWithPath: "/tmp/openclaw")
|
||||
return preferred
|
||||
return URL(fileURLWithPath: "/tmp/openclaw")
|
||||
}
|
||||
|
||||
private static var stdoutLog: URL {
|
||||
|
||||
@@ -37,7 +37,9 @@ enum AppLogLevel: String, CaseIterable, Identifiable {
|
||||
|
||||
static let `default`: AppLogLevel = .info
|
||||
|
||||
var id: String { self.rawValue }
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
|
||||
@@ -345,7 +345,7 @@ protocol UpdaterProviding: AnyObject {
|
||||
func checkForUpdates(_ sender: Any?)
|
||||
}
|
||||
|
||||
// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
|
||||
/// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
|
||||
final class DisabledUpdaterController: UpdaterProviding {
|
||||
var automaticallyChecksForUpdates: Bool = false
|
||||
var automaticallyDownloadsUpdates: Bool = false
|
||||
@@ -394,7 +394,9 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding {
|
||||
set { self.controller.updater.automaticallyDownloadsUpdates = newValue }
|
||||
}
|
||||
|
||||
var isAvailable: Bool { true }
|
||||
var isAvailable: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func checkForUpdates(_ sender: Any?) {
|
||||
self.controller.checkForUpdates(sender)
|
||||
|
||||
@@ -400,7 +400,6 @@ struct MenuContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusLine(label: String, color: Color) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
@@ -590,6 +589,8 @@ struct MenuContent: View {
|
||||
private struct AudioInputDevice: Identifiable, Equatable {
|
||||
let uid: String
|
||||
let name: String
|
||||
var id: String { self.uid }
|
||||
var id: String {
|
||||
self.uid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ final class HighlightedMenuItemHostView: NSView {
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
let size = self.hosting.fittingSize
|
||||
|
||||
@@ -159,7 +159,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
extension MenuSessionsInjector {
|
||||
// MARK: - Injection
|
||||
|
||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||
private var mainSessionKey: String {
|
||||
WorkActivityStore.shared.mainSessionKey
|
||||
}
|
||||
|
||||
private func inject(into menu: NSMenu) {
|
||||
self.cancelPreviewTasks()
|
||||
@@ -1175,8 +1177,7 @@ extension MenuSessionsInjector {
|
||||
|
||||
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
||||
if highlighted {
|
||||
let container = HighlightedMenuItemHostView(rootView: rootView, width: width)
|
||||
return container
|
||||
return HighlightedMenuItemHostView(rootView: rootView, width: width)
|
||||
}
|
||||
|
||||
let hosting = NSHostingView(rootView: rootView)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user