mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-21 22:43:15 +08:00
Compare commits
552 Commits
v2026.2.21
...
client-sid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c61fb69c1 | ||
|
|
602a1ebd55 | ||
|
|
1051f42f96 | ||
|
|
99a2f5379e | ||
|
|
9f0b6a8c92 | ||
|
|
7499e0f619 | ||
|
|
59807efa31 | ||
|
|
edaa5ef7a5 | ||
|
|
bd4f670544 | ||
|
|
9b9cc44a4e | ||
|
|
6dad6a8cd0 | ||
|
|
d79f10297f | ||
|
|
0d93c9f759 | ||
|
|
9325418098 | ||
|
|
dd07c06d00 | ||
|
|
26acb77450 | ||
|
|
9c30243c8f | ||
|
|
01bd83d644 | ||
|
|
6eaf2baa57 | ||
|
|
85a3c0c818 | ||
|
|
35d5bd4e07 | ||
|
|
267d2193bf | ||
|
|
694a9eb6d3 | ||
|
|
c0995103a5 | ||
|
|
703f7213b6 | ||
|
|
4520fdda69 | ||
|
|
b4cdffc7a4 | ||
|
|
a96d89f343 | ||
|
|
f4dd0577b0 | ||
|
|
2c6dd84718 | ||
|
|
6c2e999776 | ||
|
|
ae8d4a8eec | ||
|
|
c3e13175d2 | ||
|
|
f101d59d57 | ||
|
|
de2e5c7b74 | ||
|
|
b9e9fbc97c | ||
|
|
aa2b16abe8 | ||
|
|
833d7574e7 | ||
|
|
27bd6f4c54 | ||
|
|
4985fb7f05 | ||
|
|
d9a7b447f5 | ||
|
|
ee3abb2278 | ||
|
|
15657dd48d | ||
|
|
53a7afe238 | ||
|
|
d625f888a9 | ||
|
|
a4c107ee11 | ||
|
|
cf570d3b44 | ||
|
|
2b63592be5 | ||
|
|
48c0acc26f | ||
|
|
409b6a3321 | ||
|
|
8e7d8c3d8e | ||
|
|
a1c8525766 | ||
|
|
cfb3cee7aa | ||
|
|
c2c7114ed3 | ||
|
|
ccc00d874c | ||
|
|
2a66c8d676 | ||
|
|
2d2e1c2403 | ||
|
|
902544cf2d | ||
|
|
c99e7696e6 | ||
|
|
1e76ca593e | ||
|
|
1ba1c3f306 | ||
|
|
ce09fe2bb7 | ||
|
|
e67f813b0e | ||
|
|
7cac6bd85d | ||
|
|
c7606e7064 | ||
|
|
8887f41d7d | ||
|
|
ed38b50fa5 | ||
|
|
b014c70292 | ||
|
|
6ceadaa41f | ||
|
|
8a0a28763e | ||
|
|
d06ad6bc55 | ||
|
|
b967687e55 | ||
|
|
45d1096951 | ||
|
|
5e9cbdc1a1 | ||
|
|
b10b8dc8f8 | ||
|
|
991e3184b7 | ||
|
|
089a78c061 | ||
|
|
6f3fed0470 | ||
|
|
d6d73d0ed9 | ||
|
|
e893157600 | ||
|
|
2557945a8d | ||
|
|
dd5774a300 | ||
|
|
6e253096ed | ||
|
|
96674ca301 | ||
|
|
008a8c9dc6 | ||
|
|
0194d50339 | ||
|
|
0c1a52307c | ||
|
|
0ae7f962f9 | ||
|
|
d559f226b3 | ||
|
|
9a0830bc7c | ||
|
|
88c564f050 | ||
|
|
24f477625a | ||
|
|
50c0616278 | ||
|
|
e16e7be85b | ||
|
|
ccd96873b5 | ||
|
|
f144a39bb7 | ||
|
|
089270e769 | ||
|
|
ad400afb24 | ||
|
|
1f0695ba47 | ||
|
|
be5921e8fe | ||
|
|
682e42b0a1 | ||
|
|
d624aa5ab2 | ||
|
|
b601f474f0 | ||
|
|
0511e28a27 | ||
|
|
9daab2abb3 | ||
|
|
4ddaafee68 | ||
|
|
9df896e5b9 | ||
|
|
751ca08728 | ||
|
|
b25b1812e8 | ||
|
|
56c57048cb | ||
|
|
4cc975fec1 | ||
|
|
d9085a7704 | ||
|
|
c358ada510 | ||
|
|
7adcf5a49e | ||
|
|
0889ea221d | ||
|
|
2b24a44cd9 | ||
|
|
d7f01c2c55 | ||
|
|
6d74704d7a | ||
|
|
babe1b0f26 | ||
|
|
8acf5ffca7 | ||
|
|
b56c07e991 | ||
|
|
ba2790222d | ||
|
|
9f97555b5e | ||
|
|
7cf280805c | ||
|
|
3d03375043 | ||
|
|
94e5a46187 | ||
|
|
cd7faea93b | ||
|
|
6bf5e76be6 | ||
|
|
bdbbcbcc11 | ||
|
|
265da4dd2a | ||
|
|
121d027229 | ||
|
|
185fba1d22 | ||
|
|
75c1bfbae8 | ||
|
|
b109fa53ea | ||
|
|
ad1c07e7c0 | ||
|
|
abf3dfc375 | ||
|
|
794c902e50 | ||
|
|
86907aa500 | ||
|
|
4a1b6e42fd | ||
|
|
ea91933e2c | ||
|
|
639b2f5f5b | ||
|
|
6bc753624f | ||
|
|
4f7032fbd9 | ||
|
|
23e07bc49c | ||
|
|
9ec440d1f4 | ||
|
|
d325c01503 | ||
|
|
6471ff02dc | ||
|
|
64b9ae8fb1 | ||
|
|
271999d42a | ||
|
|
71c17da2ba | ||
|
|
c4aac407dc | ||
|
|
b0f6f18569 | ||
|
|
7778eee5e3 | ||
|
|
4c8545ad53 | ||
|
|
16f6b55cd4 | ||
|
|
44a272ef67 | ||
|
|
0e68789ebf | ||
|
|
f41be7159c | ||
|
|
2cf9c3abe4 | ||
|
|
b791ac2167 | ||
|
|
b25fd03b8c | ||
|
|
a32edf423b | ||
|
|
a2a19cdad2 | ||
|
|
b03656a771 | ||
|
|
fd8b7b5c4a | ||
|
|
b6ce5e06cd | ||
|
|
b257ba9e30 | ||
|
|
d069f8b23a | ||
|
|
d476994fb9 | ||
|
|
07d09c881d | ||
|
|
3d718b5c37 | ||
|
|
df35829810 | ||
|
|
be0e0ebf89 | ||
|
|
8613b6c6ee | ||
|
|
cca4dba53b | ||
|
|
77a8a253a9 | ||
|
|
6fe4bbc24f | ||
|
|
3664d51b6f | ||
|
|
a9fa434191 | ||
|
|
a4b3aeeefa | ||
|
|
244ccc801e | ||
|
|
474ba45a2f | ||
|
|
9d17a30643 | ||
|
|
2d4e4e2288 | ||
|
|
d6ad647f56 | ||
|
|
fb73c0034e | ||
|
|
fc54e3eabd | ||
|
|
ae07d3fa0f | ||
|
|
266b3a356d | ||
|
|
7c9e1bada0 | ||
|
|
c21792f5a0 | ||
|
|
3284d2eb22 | ||
|
|
aab20e58d7 | ||
|
|
76828e8dc8 | ||
|
|
649e910465 | ||
|
|
e729c992a7 | ||
|
|
2fd57cec0b | ||
|
|
076c5ebaef | ||
|
|
856b5aca2c | ||
|
|
d4b0397378 | ||
|
|
b55979844b | ||
|
|
fad2c0c8a1 | ||
|
|
f37a09a9e6 | ||
|
|
a9b14df1e3 | ||
|
|
14d6b3741c | ||
|
|
f28fcf243a | ||
|
|
735fc23faf | ||
|
|
c2600c5d75 | ||
|
|
856b8e28a6 | ||
|
|
42f27ca39d | ||
|
|
391d32d461 | ||
|
|
cea5bcc4ac | ||
|
|
0858512abd | ||
|
|
ab159a68c9 | ||
|
|
a038ad29f9 | ||
|
|
f4afa12054 | ||
|
|
7ed3ee0a26 | ||
|
|
e36f857e46 | ||
|
|
706837f6a3 | ||
|
|
1e1851a991 | ||
|
|
e2603aecf5 | ||
|
|
10328892fa | ||
|
|
a3936264ea | ||
|
|
142e8cb383 | ||
|
|
67aef31187 | ||
|
|
3a80934aaa | ||
|
|
342cd19e91 | ||
|
|
4a42bc64af | ||
|
|
b3c5b532ad | ||
|
|
91dd21b6b6 | ||
|
|
397d48c0a4 | ||
|
|
fcb191c5cb | ||
|
|
e14af1a346 | ||
|
|
c42a7aff37 | ||
|
|
e0db04a50d | ||
|
|
049b8b14bc | ||
|
|
17c9d550e9 | ||
|
|
4508b818a1 | ||
|
|
55e38d3b44 | ||
|
|
8202582f4b | ||
|
|
cdfe45eeb8 | ||
|
|
29a782b9cd | ||
|
|
7f611f0e13 | ||
|
|
542fc169d2 | ||
|
|
96c985400d | ||
|
|
4f700e96af | ||
|
|
54e5f80424 | ||
|
|
98b2b16ac3 | ||
|
|
2f023a4775 | ||
|
|
73b4330d4c | ||
|
|
daf036a4f6 | ||
|
|
6d11b46994 | ||
|
|
63b4c500d9 | ||
|
|
413f81b856 | ||
|
|
961bde27fe | ||
|
|
eea0a68199 | ||
|
|
2b5952f8c3 | ||
|
|
c51c2a2dca | ||
|
|
2e9ee22a9c | ||
|
|
8920e281cc | ||
|
|
483c464b62 | ||
|
|
55d492b4cd | ||
|
|
68cb4fc8a1 | ||
|
|
68b92e80f7 | ||
|
|
35fe33aa90 | ||
|
|
a10d689860 | ||
|
|
f2d664e24f | ||
|
|
2830dafbe9 | ||
|
|
c45a5c551f | ||
|
|
4550a52007 | ||
|
|
853ae626fa | ||
|
|
5b4409d5d0 | ||
|
|
426d97797d | ||
|
|
a37e12eabc | ||
|
|
7a6ff4c55a | ||
|
|
75a9ea004b | ||
|
|
3317b49d3b | ||
|
|
2e8e357bf7 | ||
|
|
057233953e | ||
|
|
1381c4c64a | ||
|
|
5af39b051d | ||
|
|
dfe0483d80 | ||
|
|
8083cb8e0b | ||
|
|
a97992fcf2 | ||
|
|
ba23d2b1fe | ||
|
|
8cc3a5e460 | ||
|
|
012654c7c5 | ||
|
|
a353dae14f | ||
|
|
150c048b0a | ||
|
|
f589295a0a | ||
|
|
0afd5d38c5 | ||
|
|
2595690a4d | ||
|
|
7707e3406c | ||
|
|
8922cb4085 | ||
|
|
548c227411 | ||
|
|
6ea47c3f02 | ||
|
|
8af676edb3 | ||
|
|
204f379f6b | ||
|
|
9aa5b5d157 | ||
|
|
ffd9b86ca4 | ||
|
|
e84d89ab06 | ||
|
|
d3991d6aa9 | ||
|
|
2958a8414d | ||
|
|
8934da785b | ||
|
|
0bb81f7294 | ||
|
|
4cf5c3e109 | ||
|
|
59563847e4 | ||
|
|
d748657265 | ||
|
|
4ab85cee0b | ||
|
|
fc2ed0b843 | ||
|
|
bcfae0434b | ||
|
|
833144fd72 | ||
|
|
dd4e8f8098 | ||
|
|
c9593c4c87 | ||
|
|
7c248cca4a | ||
|
|
98790339ef | ||
|
|
01ec832f78 | ||
|
|
884c6afc26 | ||
|
|
b97691f3a7 | ||
|
|
c78ea8ec3f | ||
|
|
8cdb184f10 | ||
|
|
95dab6e019 | ||
|
|
e23c08b5f4 | ||
|
|
780bbbd062 | ||
|
|
1ef30b82b2 | ||
|
|
843a037532 | ||
|
|
8394f0e30e | ||
|
|
8752203f59 | ||
|
|
03586e3d00 | ||
|
|
fbf0c99d7c | ||
|
|
d5cc357737 | ||
|
|
b1c50cc5c0 | ||
|
|
1534248169 | ||
|
|
fa4e4efd92 | ||
|
|
bfe016fa29 | ||
|
|
37d5320f6b | ||
|
|
5164822cd5 | ||
|
|
389630fc64 | ||
|
|
4a2ff03f49 | ||
|
|
c708a18b0f | ||
|
|
1b0e021e91 | ||
|
|
f3d4045c03 | ||
|
|
0e39371dc4 | ||
|
|
b2de8719ad | ||
|
|
7731f28a24 | ||
|
|
5fd1d2cadc | ||
|
|
81a85c19ff | ||
|
|
1baac3e31d | ||
|
|
0bd9f0d4ac | ||
|
|
617e38cec0 | ||
|
|
8942ac04a8 | ||
|
|
21087c5c70 | ||
|
|
1357e02cff | ||
|
|
69cedc7a15 | ||
|
|
6c813bd32b | ||
|
|
4414af977a | ||
|
|
a186036814 | ||
|
|
d12817994f | ||
|
|
60c735dd98 | ||
|
|
828f4e18e0 | ||
|
|
c7c047287e | ||
|
|
0e1aa77928 | ||
|
|
6ac89757ba | ||
|
|
71bd15bb42 | ||
|
|
2f46308d5a | ||
|
|
4ef4aa3c10 | ||
|
|
0608587bc3 | ||
|
|
a9227f571b | ||
|
|
21b0eac917 | ||
|
|
738e2c21dd | ||
|
|
dea154ccae | ||
|
|
b34097f62d | ||
|
|
1bc5c2a7e9 | ||
|
|
ffa63173e0 | ||
|
|
1257aee6e1 | ||
|
|
7c500ff623 | ||
|
|
2028ca4428 | ||
|
|
61dc7ac679 | ||
|
|
73d93dee64 | ||
|
|
dd41fadcaf | ||
|
|
2712883d16 | ||
|
|
90a378ca3a | ||
|
|
861718e4dc | ||
|
|
5c8f0b5a77 | ||
|
|
cc2ff68947 | ||
|
|
58254b3b57 | ||
|
|
52ddb6ae18 | ||
|
|
5d9e7c942c | ||
|
|
a1ccd03da0 | ||
|
|
84686db850 | ||
|
|
a04cdc0390 | ||
|
|
944913fc98 | ||
|
|
bb490a4b51 | ||
|
|
b5a66e7b7e | ||
|
|
fecc29d2c8 | ||
|
|
3d2f4aea63 | ||
|
|
bd8b3cd15e | ||
|
|
580417685b | ||
|
|
1c78ade1a1 | ||
|
|
ceaa43df7a | ||
|
|
d5bfbc36d8 | ||
|
|
0f36cbe677 | ||
|
|
ab3fa83f17 | ||
|
|
5de9419748 | ||
|
|
938fb652b5 | ||
|
|
6de7f9d9b0 | ||
|
|
4503bd0591 | ||
|
|
037da5d8a8 | ||
|
|
cdb92494d1 | ||
|
|
81ddc98e12 | ||
|
|
8581e6b52d | ||
|
|
adedacbfe1 | ||
|
|
04a23f45b7 | ||
|
|
42e181dd4b | ||
|
|
2d62685ff0 | ||
|
|
e46634db9a | ||
|
|
dc7ec65c8f | ||
|
|
e2a50228a1 | ||
|
|
00ab894feb | ||
|
|
7bfbbd6309 | ||
|
|
bd74d49169 | ||
|
|
59189750e4 | ||
|
|
0f9ea0229a | ||
|
|
f9e21d5720 | ||
|
|
b01335830d | ||
|
|
c45ef5f8b5 | ||
|
|
1794f42ac0 | ||
|
|
d35a8b48f5 | ||
|
|
544a1142b0 | ||
|
|
822688dc13 | ||
|
|
a418c6db06 | ||
|
|
6fd31fc0b0 | ||
|
|
2000dcdcd0 | ||
|
|
6051dc10ff | ||
|
|
d6c2fd5453 | ||
|
|
bdfb979940 | ||
|
|
31a0449f69 | ||
|
|
c93fc3786c | ||
|
|
2042a69211 | ||
|
|
c394c5fa99 | ||
|
|
d015dc9216 | ||
|
|
7036352d94 | ||
|
|
5d61afb362 | ||
|
|
3274a1b804 | ||
|
|
8f1b467646 | ||
|
|
8f11868cc2 | ||
|
|
0e49eec056 | ||
|
|
e978297c28 | ||
|
|
c481b22245 | ||
|
|
1bbeedfab2 | ||
|
|
ac6c344d9b | ||
|
|
626d8e9f62 | ||
|
|
b703ea3675 | ||
|
|
ac633366ce | ||
|
|
518dbbf4c6 | ||
|
|
302fa03f41 | ||
|
|
48ddb1cc81 | ||
|
|
549549f6a0 | ||
|
|
a20c773251 | ||
|
|
b889a5d516 | ||
|
|
0ecb07e6d1 | ||
|
|
4f835c4c0d | ||
|
|
9ebfc99c1b | ||
|
|
0a207b9860 | ||
|
|
324922f804 | ||
|
|
b3c7fd6c69 | ||
|
|
85c768d3d2 | ||
|
|
0401762144 | ||
|
|
9ead79937e | ||
|
|
70fdab6e95 | ||
|
|
0876fbde19 | ||
|
|
f086245afe | ||
|
|
96ef00ec38 | ||
|
|
603e28648b | ||
|
|
61817c90e7 | ||
|
|
a814cce359 | ||
|
|
c240104dc3 | ||
|
|
e5aa04d432 | ||
|
|
3fd7dc5046 | ||
|
|
272bf2d8bc | ||
|
|
d982893490 | ||
|
|
7ba09e414f | ||
|
|
c3e1c82871 | ||
|
|
5e607ae1eb | ||
|
|
5dc1b5a8db | ||
|
|
c0706b7799 | ||
|
|
cf371fde6d | ||
|
|
8745964142 | ||
|
|
af66e3103a | ||
|
|
ae06dbb794 | ||
|
|
b44aa5b1f7 | ||
|
|
884166c7af | ||
|
|
1fd88af219 | ||
|
|
1b585b2959 | ||
|
|
2a0ea7cb97 | ||
|
|
ec8288e9b8 | ||
|
|
807968e4df | ||
|
|
01f42a0372 | ||
|
|
194ebd9e30 | ||
|
|
50489fb2d4 | ||
|
|
fc43a16d43 | ||
|
|
63488eb981 | ||
|
|
bfa59bd22e | ||
|
|
dda9e9f094 | ||
|
|
bd9d3e2f87 | ||
|
|
b2ed54f600 | ||
|
|
2d7d00ef8e | ||
|
|
a410dad602 | ||
|
|
8fd8988ff7 | ||
|
|
bc037dfe01 | ||
|
|
c41d1070b7 | ||
|
|
e588e3cc20 | ||
|
|
ae70bf4dca | ||
|
|
aff272ec35 | ||
|
|
992b7e5577 | ||
|
|
7724abeee0 | ||
|
|
f903603722 | ||
|
|
00b98a368a | ||
|
|
f9108120c2 | ||
|
|
4540790cb6 | ||
|
|
c3af00bddb | ||
|
|
22940b7b98 | ||
|
|
25e89cc863 | ||
|
|
817905f3a0 | ||
|
|
51c0893673 | ||
|
|
2ba6de7eaa | ||
|
|
ed960ba4eb | ||
|
|
6ffca36284 | ||
|
|
2c14b0cf4c | ||
|
|
747bb581b3 | ||
|
|
3ed71d6f76 | ||
|
|
d6353cc54b | ||
|
|
8a661e30c9 | ||
|
|
9632b9bcf0 | ||
|
|
09d5f508b1 | ||
|
|
51149fcaf1 | ||
|
|
f97c45c5b5 | ||
|
|
4b226b74f5 | ||
|
|
ddcb2d79b1 | ||
|
|
764b1f2932 | ||
|
|
e371da38aa | ||
|
|
9fc6c8b713 | ||
|
|
afa22acc4a | ||
|
|
89aad7b922 | ||
|
|
c730d4dd72 | ||
|
|
4c1dd9d068 | ||
|
|
5e423b596c | ||
|
|
57fbbaebca | ||
|
|
bdfb97afad | ||
|
|
efdec39254 | ||
|
|
35a57bc940 | ||
|
|
905e355f65 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -99,3 +99,5 @@ package-lock.json
|
||||
|
||||
# Local iOS signing overrides
|
||||
apps/ios/LocalSigning.xcconfig
|
||||
# Generated protocol schema (produced via pnpm protocol:gen)
|
||||
dist/protocol.schema.json
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
`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`
|
||||
- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls.
|
||||
- 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
|
||||
|
||||
99
CHANGELOG.md
99
CHANGELOG.md
@@ -2,7 +2,98 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.21 (Unreleased)
|
||||
## 2026.2.22 (Unreleased)
|
||||
|
||||
### Changes
|
||||
|
||||
- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path.
|
||||
- Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior.
|
||||
- Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang.
|
||||
- iOS/Talk: prefetch TTS segments and suppress expected speech-cancellation errors for smoother talk playback. (#22833) Thanks @ngutman.
|
||||
- Skills/Security: defense-in-depth security hardening for community skills (ClawHub installs). Adds capability declarations (`shell`, `filesystem`, `network`, `browser`, `sessions`), trust tier classification (builtin/verified/community/local), SKILL.md content scanning (blocks prompt injection, capability inflation, boundary spoofing), skill-aware tool policy enforcement (denies undeclared dangerous tools for community skills), command-dispatch gating, and before-tool-call audit monitoring with session context. Community skills that fail critical scanning are blocked from loading. `openclaw skills list/info/check` now show capabilities, trust tiers, scan results, and runtime policy.
|
||||
- Skills/Logging: all security-related log entries tagged with `category: "security"` for filtering. Skills CLI commands output structured JSON to the file logger (no more ASCII tables in logs). Web UI Logs tab adds a "Security" filter chip for security-only event views.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected.
|
||||
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli.
|
||||
- Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started `signal-cli` is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn.
|
||||
- Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths.
|
||||
- ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen.
|
||||
- Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`).
|
||||
- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
- Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including `gateway.controlUi.dangerouslyDisableDeviceAuth=true`) and point operators to `openclaw security audit`.
|
||||
- Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
|
||||
- Security/Exec approvals: treat `env` and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
- Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp `trigger_id` fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime `Date.now()+Math.random()` token/id patterns.
|
||||
- Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including `hooks.transformsDir` and `hooks.mappings[].transform.module`) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
|
||||
- Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine.
|
||||
- Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus.
|
||||
- Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia.
|
||||
- Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13.
|
||||
- Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester.
|
||||
- Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr.
|
||||
- Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged `memory.qmd.paths` and `memory.qmd.scope.rules` no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai.
|
||||
- Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear `invalid cron schedule: expr is required` error instead of crashing with `undefined.trim` failures and auto-disable churn. (#23223) Thanks @asimons81.
|
||||
- Memory/QMD: migrate legacy unscoped collection bindings (for example `memory-root`) to per-agent scoped names (for example `memory-root-main`) during startup when safe, so QMD-backed `memory_search` no longer fails with `Collection not found` after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby.
|
||||
- Memory/QMD: normalize Han-script BM25 search queries before invoking `qmd search` so mixed CJK+Latin prompts no longer return empty results due to tokenizer mismatch. (#23426) Thanks @LunaLee0130.
|
||||
- TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends.
|
||||
- TUI/RTL: isolate right-to-left script lines (Arabic/Hebrew ranges) with Unicode bidi isolation marks in TUI text sanitization so RTL assistant output no longer renders in reversed visual order in terminal chat panes. (#21936) Thanks @Asm3r96.
|
||||
- TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness.
|
||||
- TUI/Input: arm Ctrl+C exit timing when clearing non-empty composer text and add a SIGINT fallback path so double Ctrl+C exits remain responsive during active runs instead of requiring an extra press or appearing stuck. (#23407) Thanks @tinybluedev.
|
||||
- Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane.
|
||||
- Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry.
|
||||
- Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson.
|
||||
- Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) when runs execute tools successfully but return no final assistant text, preventing silent no-reply turns after tool-only completions. (#22834) Thanks @Oldshue.
|
||||
- Agents/Subagents: honor `tools.subagents.tools.alsoAllow` and explicit subagent `allow` entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example `sessions_send`) are no longer blocked unless re-denied in `tools.subagents.tools.deny`. (#23359) Thanks @goren-beehero.
|
||||
- Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize.
|
||||
- Plugins/Hooks: run legacy `before_agent_start` once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710.
|
||||
- Models/Config: default missing Anthropic provider/model `api` fields to `anthropic-messages` during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123.
|
||||
- Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07.
|
||||
- Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt.
|
||||
- Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit `scopes`, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81.
|
||||
- Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl.
|
||||
- Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07.
|
||||
- Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces.
|
||||
- BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines.
|
||||
- BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits `handle` but provides DM `chatGuid`, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31.
|
||||
- Security/Audit: add `openclaw security audit` finding `gateway.nodes.allow_commands_dangerous` for risky `gateway.nodes.allowCommands` overrides, with severity upgraded to critical on remote gateway exposure.
|
||||
- Gateway/Control plane: reduce cross-client write limiter contention by adding `connId` fallback keying when device ID and client IP are both unavailable.
|
||||
- Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (`__proto__`, `constructor`, `prototype`) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn.
|
||||
- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes) and block `SHELL` in dangerous env override policy paths so untrusted shell-path injection falls back safely to `/bin/sh`. Thanks @athuljayaram for reporting.
|
||||
- Security/Config: make parsed chat allowlist checks fail closed when `allowFrom` is empty, restoring expected DM/pairing gating.
|
||||
- Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: when users choose `allow-always` for shell-wrapper commands (for example `/bin/zsh -lc ...`), persist allowlist patterns for the inner executable(s) instead of the wrapper shell binary, preventing accidental broad shell allowlisting in moderate mode. (#23276) Thanks @xrom2863.
|
||||
- Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
- Security/Agents: auto-generate and persist a dedicated `commands.ownerDisplaySecret` when `commands.ownerDisplay=hash`, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
|
||||
- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), and centralize range checks into a single CIDR policy table to reduce classifier drift.
|
||||
- Security/Archive: block zip symlink escapes during archive extraction.
|
||||
- Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative `../` sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed.
|
||||
- Browser/Upload: accept canonical in-root upload paths when the configured uploads directory is a symlink alias (for example `/tmp` -> `/private/tmp` on macOS), so browser upload validation no longer rejects valid files during client->server revalidation. (#23300, #23222, #22848) Thanks @bgaither4, @parkerati, and @Nabsku.
|
||||
- Security/Discord: add `openclaw security audit` warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel `users`, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting.
|
||||
- Security/Gateway: block node-role connections when device identity metadata is missing.
|
||||
- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
- Media/Understanding: preserve `application/pdf` MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte.
|
||||
- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
- Security/Gateway avatars: block symlink traversal during local avatar `data:` URL resolution by enforcing realpath containment and file-identity checks before reads. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
|
||||
- Security/Control UI: centralize avatar URL/path validation across gateway/config helpers and enforce a 2 MB max size for local agent avatar files before `/avatar` resolution, reducing oversized-avatar memory risk without changing supported avatar formats.
|
||||
- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.
|
||||
- Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
|
||||
- CI/Tests: fix TypeScript case-table typing and lint assertion regressions so `pnpm check` passes again after Synology Chat landing. (#23012) Thanks @druide67.
|
||||
- Security/Browser relay: harden extension relay auth token handling for `/extension` and `/cdp` pathways.
|
||||
- Cron: persist `delivered` state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario.
|
||||
- Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise.
|
||||
- Config/Channels: whitelist `channels.modelByChannel` in config validation and exclude it from plugin auto-enable channel detection so model overrides no longer trigger `unknown channel id` validation errors or bogus `modelByChannel` plugin enables. (#23412) Thanks @ProspectOre.
|
||||
- Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia.
|
||||
- Gateway/Daemon: verify gateway health after daemon restart.
|
||||
- Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam.
|
||||
|
||||
## 2026.2.21
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -45,6 +136,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/Copilot: add `claude-sonnet-4.6` and `claude-sonnet-4.5` to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn.
|
||||
- Auto-reply/WebChat: avoid defaulting inbound runtime channel labels to unrelated providers (for example `whatsapp`) for webchat sessions so channel-specific formatting guidance stays accurate. (#21534) Thanks @lbo728.
|
||||
- Status: include persisted `cacheRead`/`cacheWrite` in session summaries so compact `/status` output consistently shows cache hit percentages from real session data.
|
||||
- Sessions/Usage: persist `totalTokens` from `promptTokens` snapshots even when providers omit structured usage payloads, so session history/status no longer regress to `unknown` token utilization for otherwise successful runs. (#21819) Thanks @zymclaw.
|
||||
- Heartbeat/Cron: restore interval heartbeat behavior so missing `HEARTBEAT.md` no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths.
|
||||
- WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured `allowFrom` recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats.
|
||||
- Heartbeat/Active hours: constrain active-hours `24` sentinel parsing to `24:00` in time validation so invalid values like `24:30` are rejected early. (#21410) thanks @adhitShet.
|
||||
@@ -52,6 +144,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/Pairing: default `pairing list` and `pairing approve` to the sole available pairing channel when omitted, so TUI-only setups can recover from `pairing required` without guessing channel arguments. (#21527) Thanks @losts1.
|
||||
- TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux.
|
||||
- TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer.
|
||||
- TUI/Models: scope `models.list` to the configured model allowlist (`agents.defaults.models`) so `/model` picker no longer floods with unrelated catalog entries by default. (#18816) Thanks @fwends.
|
||||
- TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when `showOk` is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton.
|
||||
- TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with `RangeError: Maximum call stack size exceeded`. (#18068) Thanks @JaniJegoroff.
|
||||
- Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr.
|
||||
@@ -61,6 +154,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0.
|
||||
- Slack: pass `recipient_team_id` / `recipient_user_id` through Slack native streaming calls so `chat.startStream`/`appendStream`/`stopStream` work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012.
|
||||
- CLI/Config: add canonical `--strict-json` parsing for `config set` and keep `--json` as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet.
|
||||
- CLI/Config: preserve explicitly unset config paths in persisted JSON after writes so `openclaw config unset <path>` no longer re-introduces defaulted keys (for example `commands.ownerDisplay`) through schema normalization. (#22984) Thanks @aronchick.
|
||||
- CLI: keep `openclaw -v` as a root-only version alias so subcommand `-v, --verbose` flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet.
|
||||
- Memory: return empty snippets when `memory_get`/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo.
|
||||
- Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii.
|
||||
@@ -68,6 +162,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/Streaming: restore 30-char first-preview debounce and scope `NO_REPLY` prefix suppression to partial sentinel fragments so normal `No...` text is not filtered. (#22613) thanks @obviyus.
|
||||
- Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow.
|
||||
- Telegram/Status reactions: keep lifecycle reactions active when available-reactions lookup fails by falling back to unrestricted variant selection instead of suppressing reaction updates. (#22380) thanks @obviyus.
|
||||
- Discord/Events: await `DiscordMessageListener` message handlers so regular `MESSAGE_CREATE` traffic is processed through queue ordering/timeout flow instead of fire-and-forget drops. (#22396) Thanks @sIlENtbuffER.
|
||||
- Discord/Streaming: apply `replyToMode: first` only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report.
|
||||
- Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow.
|
||||
- Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang.
|
||||
@@ -95,6 +190,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.
|
||||
- Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.
|
||||
- Agents/Tool display: fix exec cwd suffix inference so `pushd ... && popd ... && <command>` does not keep stale `(in <dir>)` context in summaries. (#21925) Thanks @Lukavyi.
|
||||
- Agents/Google: flatten residual nested `anyOf`/`oneOf` unions in Gemini tool-schema cleanup so Cloud Code Assist no longer rejects unsupported union keywords that survive earlier simplification. (#22825) Thanks @Oceanswave.
|
||||
- Tools/web_search: handle xAI Responses API payloads that emit top-level `output_text` blocks (without a `message` wrapper) so Grok web_search no longer returns `No response` for those results. (#20508) Thanks @echoVic.
|
||||
- Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg.
|
||||
- Docker/Build: include `ownerDisplay` in `CommandsSchema` object-level defaults so Docker `pnpm build` no longer fails with `TS2769` during plugin SDK d.ts generation. (#22558) Thanks @obviyus.
|
||||
@@ -169,6 +265,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus.
|
||||
- Telegram/Agents: gate exec/bash tool-failure warnings behind verbose mode so default Telegram replies stay clean while verbose sessions still surface diagnostics. (#20560) Thanks @obviyus.
|
||||
- Telegram/Cron/Heartbeat: honor explicit Telegram topic targets in cron and heartbeat delivery (`<chatId>:topic:<threadId>`) so scheduled sends land in the configured topic instead of the last active thread. (#19367) Thanks @Lukavyi.
|
||||
- Telegram/DM routing: prevent DM inbound origin metadata from leaking into main-session `lastRoute` updates and normalize DM `lastRoute.to` to provider-prefixed `telegram:<chatId>`. (#19491) thanks @guirguispierre.
|
||||
- Gateway/Daemon: forward `TMPDIR` into installed service environments so macOS LaunchAgent gateway runs can open SQLite temp/journal files reliably instead of failing with `SQLITE_CANTOPEN`. (#20512) Thanks @Clawborn.
|
||||
- Agents/Billing: include the active model that produced a billing error in user-facing billing messages (for example, `OpenAI (gpt-5.3)`) across payload, failover, and lifecycle error paths, so users can identify exactly which key needs credits. (#20510) Thanks @echoVic.
|
||||
- Gateway/TUI: honor `agents.defaults.blockStreamingDefault` for `chat.send` by removing the hardcoded block-streaming disable override, so replies can use configured block-mode delivery. (#19693) Thanks @neipor.
|
||||
|
||||
@@ -44,6 +44,9 @@ Welcome to the lobster tank! 🦞
|
||||
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
|
||||
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
|
||||
|
||||
- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams
|
||||
- GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
|
||||
@@ -57,6 +57,7 @@ OpenClaw security guidance assumes:
|
||||
- The host where OpenClaw runs is within a trusted OS/admin boundary.
|
||||
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
|
||||
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
|
||||
- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries.
|
||||
|
||||
## Plugin Trust Boundary
|
||||
|
||||
@@ -85,6 +86,10 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
|
||||
- Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`).
|
||||
- Config: `gateway.bind="loopback"` (default).
|
||||
- CLI: `openclaw gateway run --bind loopback`.
|
||||
- `gateway.controlUi.dangerouslyDisableDeviceAuth` is intended for localhost-only break-glass use.
|
||||
- OpenClaw keeps deployment flexibility by design and does not hard-forbid non-local setups.
|
||||
- Non-local and other risky configurations are surfaced by `openclaw security audit` as dangerous findings.
|
||||
- This operator-selected tradeoff is by design and not, by itself, a security vulnerability.
|
||||
- Canvas host note: network-visible canvas is **intentional** for trusted node scenarios (LAN/tailnet).
|
||||
- Expected setup: non-loopback bind + Gateway auth (token/password/trusted-proxy) + firewall/tailnet controls.
|
||||
- Expected routes: `/__openclaw__/canvas/`, `/__openclaw__/a2ui/`.
|
||||
|
||||
@@ -178,7 +178,7 @@ class GatewaySession(
|
||||
private val connectDeferred = CompletableDeferred<Unit>()
|
||||
private val closedDeferred = CompletableDeferred<Unit>()
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private val connectNonceDeferred = CompletableDeferred<String?>()
|
||||
private val connectNonceDeferred = CompletableDeferred<String>()
|
||||
private val client: OkHttpClient = buildClient()
|
||||
private var socket: WebSocket? = null
|
||||
private val loggerTag = "OpenClawGateway"
|
||||
@@ -296,7 +296,7 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendConnect(connectNonce: String?) {
|
||||
private suspend fun sendConnect(connectNonce: String) {
|
||||
val identity = identityStore.loadOrCreate()
|
||||
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
|
||||
val trimmedToken = token?.trim().orEmpty()
|
||||
@@ -332,7 +332,7 @@ class GatewaySession(
|
||||
|
||||
private fun buildConnectParams(
|
||||
identity: DeviceIdentity,
|
||||
connectNonce: String?,
|
||||
connectNonce: String,
|
||||
authToken: String,
|
||||
authPassword: String?,
|
||||
): JsonObject {
|
||||
@@ -385,9 +385,7 @@ class GatewaySession(
|
||||
put("publicKey", JsonPrimitive(publicKey))
|
||||
put("signature", JsonPrimitive(signature))
|
||||
put("signedAt", JsonPrimitive(signedAtMs))
|
||||
if (!connectNonce.isNullOrBlank()) {
|
||||
put("nonce", JsonPrimitive(connectNonce))
|
||||
}
|
||||
put("nonce", JsonPrimitive(connectNonce))
|
||||
}
|
||||
} else {
|
||||
null
|
||||
@@ -447,8 +445,8 @@ class GatewaySession(
|
||||
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
|
||||
if (event == "connect.challenge") {
|
||||
val nonce = extractConnectNonce(payloadJson)
|
||||
if (!connectNonceDeferred.isCompleted) {
|
||||
connectNonceDeferred.complete(nonce)
|
||||
if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) {
|
||||
connectNonceDeferred.complete(nonce.trim())
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -459,12 +457,11 @@ class GatewaySession(
|
||||
onEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private suspend fun awaitConnectNonce(): String? {
|
||||
if (isLoopbackHost(endpoint.host)) return null
|
||||
private suspend fun awaitConnectNonce(): String {
|
||||
return try {
|
||||
withTimeout(2_000) { connectNonceDeferred.await() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} catch (err: Throwable) {
|
||||
throw IllegalStateException("connect challenge timeout", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,14 +592,13 @@ class GatewaySession(
|
||||
scopes: List<String>,
|
||||
signedAtMs: Long,
|
||||
token: String?,
|
||||
nonce: String?,
|
||||
nonce: String,
|
||||
): String {
|
||||
val scopeString = scopes.joinToString(",")
|
||||
val authToken = token.orEmpty()
|
||||
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
|
||||
val parts =
|
||||
mutableListOf(
|
||||
version,
|
||||
"v2",
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
@@ -610,10 +606,8 @@ class GatewaySession(
|
||||
scopeString,
|
||||
signedAtMs.toString(),
|
||||
authToken,
|
||||
nonce,
|
||||
)
|
||||
if (!nonce.isNullOrBlank()) {
|
||||
parts.add(nonce)
|
||||
}
|
||||
return parts.joinToString("|")
|
||||
}
|
||||
|
||||
|
||||
@@ -704,7 +704,7 @@ final class GatewayConnectionController {
|
||||
var addr = in_addr()
|
||||
let parsed = host.withCString { inet_pton(AF_INET, $0, &addr) == 1 }
|
||||
guard parsed else { return false }
|
||||
let value = ntohl(addr.s_addr)
|
||||
let value = UInt32(bigEndian: addr.s_addr)
|
||||
let firstOctet = UInt8((value >> 24) & 0xFF)
|
||||
return firstOctet == 127
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ final class TalkModeManager: NSObject {
|
||||
private var incrementalSpeechBuffer = IncrementalSpeechBuffer()
|
||||
private var incrementalSpeechContext: IncrementalSpeechContext?
|
||||
private var incrementalSpeechDirective: TalkDirective?
|
||||
private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState?
|
||||
private var incrementalSpeechPrefetchMonitorTask: Task<Void, Never>?
|
||||
|
||||
private let logger = Logger(subsystem: "bot.molt", category: "TalkMode")
|
||||
|
||||
@@ -551,6 +553,16 @@ final class TalkModeManager: NSObject {
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
let msg = error.localizedDescription
|
||||
let lowered = msg.lowercased()
|
||||
let isCancellation = lowered.contains("cancelled") || lowered.contains("canceled")
|
||||
if isCancellation {
|
||||
GatewayDiagnostics.log("talk speech: cancelled")
|
||||
if self.captureMode == .continuous, self.isEnabled, !self.isSpeaking {
|
||||
self.statusText = "Listening"
|
||||
}
|
||||
self.logger.debug("speech recognition cancelled")
|
||||
return
|
||||
}
|
||||
GatewayDiagnostics.log("talk speech: error=\(msg)")
|
||||
if !self.isSpeaking {
|
||||
if msg.localizedCaseInsensitiveContains("no speech detected") {
|
||||
@@ -1177,6 +1189,7 @@ final class TalkModeManager: NSObject {
|
||||
self.incrementalSpeechQueue.removeAll()
|
||||
self.incrementalSpeechTask?.cancel()
|
||||
self.incrementalSpeechTask = nil
|
||||
self.cancelIncrementalPrefetch()
|
||||
self.incrementalSpeechActive = true
|
||||
self.incrementalSpeechUsed = false
|
||||
self.incrementalSpeechLanguage = nil
|
||||
@@ -1189,6 +1202,7 @@ final class TalkModeManager: NSObject {
|
||||
self.incrementalSpeechQueue.removeAll()
|
||||
self.incrementalSpeechTask?.cancel()
|
||||
self.incrementalSpeechTask = nil
|
||||
self.cancelIncrementalPrefetch()
|
||||
self.incrementalSpeechActive = false
|
||||
self.incrementalSpeechContext = nil
|
||||
self.incrementalSpeechDirective = nil
|
||||
@@ -1216,20 +1230,168 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
self.incrementalSpeechTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
defer {
|
||||
self.cancelIncrementalPrefetch()
|
||||
self.isSpeaking = false
|
||||
self.stopRecognition()
|
||||
self.incrementalSpeechTask = nil
|
||||
}
|
||||
while !Task.isCancelled {
|
||||
guard !self.incrementalSpeechQueue.isEmpty else { break }
|
||||
let segment = self.incrementalSpeechQueue.removeFirst()
|
||||
self.statusText = "Speaking…"
|
||||
self.isSpeaking = true
|
||||
self.lastSpokenText = segment
|
||||
await self.speakIncrementalSegment(segment)
|
||||
await self.updateIncrementalContextIfNeeded()
|
||||
let context = self.incrementalSpeechContext
|
||||
let prefetchedAudio = await self.consumeIncrementalPrefetchedAudioIfAvailable(
|
||||
for: segment,
|
||||
context: context)
|
||||
if let context {
|
||||
self.startIncrementalPrefetchMonitor(context: context)
|
||||
}
|
||||
await self.speakIncrementalSegment(
|
||||
segment,
|
||||
context: context,
|
||||
prefetchedAudio: prefetchedAudio)
|
||||
self.cancelIncrementalPrefetchMonitor()
|
||||
}
|
||||
self.isSpeaking = false
|
||||
self.stopRecognition()
|
||||
self.incrementalSpeechTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelIncrementalPrefetch() {
|
||||
self.cancelIncrementalPrefetchMonitor()
|
||||
self.incrementalSpeechPrefetch?.task.cancel()
|
||||
self.incrementalSpeechPrefetch = nil
|
||||
}
|
||||
|
||||
private func cancelIncrementalPrefetchMonitor() {
|
||||
self.incrementalSpeechPrefetchMonitorTask?.cancel()
|
||||
self.incrementalSpeechPrefetchMonitorTask = nil
|
||||
}
|
||||
|
||||
private func startIncrementalPrefetchMonitor(context: IncrementalSpeechContext) {
|
||||
self.cancelIncrementalPrefetchMonitor()
|
||||
self.incrementalSpeechPrefetchMonitorTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
while !Task.isCancelled {
|
||||
if self.ensureIncrementalPrefetchForUpcomingSegment(context: context) {
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 40_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureIncrementalPrefetchForUpcomingSegment(context: IncrementalSpeechContext) -> Bool {
|
||||
guard context.canUseElevenLabs else {
|
||||
self.cancelIncrementalPrefetch()
|
||||
return false
|
||||
}
|
||||
guard let nextSegment = self.incrementalSpeechQueue.first else { return false }
|
||||
if let existing = self.incrementalSpeechPrefetch {
|
||||
if existing.segment == nextSegment, existing.context == context {
|
||||
return true
|
||||
}
|
||||
existing.task.cancel()
|
||||
self.incrementalSpeechPrefetch = nil
|
||||
}
|
||||
self.startIncrementalPrefetch(segment: nextSegment, context: context)
|
||||
return self.incrementalSpeechPrefetch != nil
|
||||
}
|
||||
|
||||
private func startIncrementalPrefetch(segment: String, context: IncrementalSpeechContext) {
|
||||
guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else { return }
|
||||
let prefetchOutputFormat = self.resolveIncrementalPrefetchOutputFormat(context: context)
|
||||
let request = self.makeIncrementalTTSRequest(
|
||||
text: segment,
|
||||
context: context,
|
||||
outputFormat: prefetchOutputFormat)
|
||||
let id = UUID()
|
||||
let task = Task { [weak self] in
|
||||
let stream = ElevenLabsTTSClient(apiKey: apiKey).streamSynthesize(voiceId: voiceId, request: request)
|
||||
var chunks: [Data] = []
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
try Task.checkCancellation()
|
||||
chunks.append(chunk)
|
||||
}
|
||||
await self?.completeIncrementalPrefetch(id: id, chunks: chunks)
|
||||
} catch is CancellationError {
|
||||
await self?.clearIncrementalPrefetch(id: id)
|
||||
} catch {
|
||||
await self?.failIncrementalPrefetch(id: id, error: error)
|
||||
}
|
||||
}
|
||||
self.incrementalSpeechPrefetch = IncrementalSpeechPrefetchState(
|
||||
id: id,
|
||||
segment: segment,
|
||||
context: context,
|
||||
outputFormat: prefetchOutputFormat,
|
||||
chunks: nil,
|
||||
task: task)
|
||||
}
|
||||
|
||||
private func completeIncrementalPrefetch(id: UUID, chunks: [Data]) {
|
||||
guard var prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return }
|
||||
prefetch.chunks = chunks
|
||||
self.incrementalSpeechPrefetch = prefetch
|
||||
}
|
||||
|
||||
private func clearIncrementalPrefetch(id: UUID) {
|
||||
guard let prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return }
|
||||
prefetch.task.cancel()
|
||||
self.incrementalSpeechPrefetch = nil
|
||||
}
|
||||
|
||||
private func failIncrementalPrefetch(id: UUID, error: any Error) {
|
||||
guard let prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return }
|
||||
self.logger.debug("incremental prefetch failed: \(error.localizedDescription, privacy: .public)")
|
||||
prefetch.task.cancel()
|
||||
self.incrementalSpeechPrefetch = nil
|
||||
}
|
||||
|
||||
private func consumeIncrementalPrefetchedAudioIfAvailable(
|
||||
for segment: String,
|
||||
context: IncrementalSpeechContext?
|
||||
) async -> IncrementalPrefetchedAudio?
|
||||
{
|
||||
guard let context else {
|
||||
self.cancelIncrementalPrefetch()
|
||||
return nil
|
||||
}
|
||||
guard let prefetch = self.incrementalSpeechPrefetch else {
|
||||
return nil
|
||||
}
|
||||
guard prefetch.context == context else {
|
||||
prefetch.task.cancel()
|
||||
self.incrementalSpeechPrefetch = nil
|
||||
return nil
|
||||
}
|
||||
guard prefetch.segment == segment else {
|
||||
return nil
|
||||
}
|
||||
if let chunks = prefetch.chunks, !chunks.isEmpty {
|
||||
let prefetched = IncrementalPrefetchedAudio(chunks: chunks, outputFormat: prefetch.outputFormat)
|
||||
self.incrementalSpeechPrefetch = nil
|
||||
return prefetched
|
||||
}
|
||||
await prefetch.task.value
|
||||
guard let completed = self.incrementalSpeechPrefetch else { return nil }
|
||||
guard completed.context == context, completed.segment == segment else { return nil }
|
||||
guard let chunks = completed.chunks, !chunks.isEmpty else { return nil }
|
||||
let prefetched = IncrementalPrefetchedAudio(chunks: chunks, outputFormat: completed.outputFormat)
|
||||
self.incrementalSpeechPrefetch = nil
|
||||
return prefetched
|
||||
}
|
||||
|
||||
private func resolveIncrementalPrefetchOutputFormat(context: IncrementalSpeechContext) -> String? {
|
||||
if TalkTTSValidation.pcmSampleRate(from: context.outputFormat) != nil {
|
||||
return ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
|
||||
}
|
||||
return context.outputFormat
|
||||
}
|
||||
|
||||
private func finishIncrementalSpeech() async {
|
||||
guard self.incrementalSpeechActive else { return }
|
||||
let leftover = self.incrementalSpeechBuffer.flush()
|
||||
@@ -1337,77 +1499,103 @@ final class TalkModeManager: NSObject {
|
||||
canUseElevenLabs: canUseElevenLabs)
|
||||
}
|
||||
|
||||
private func speakIncrementalSegment(_ text: String) async {
|
||||
await self.updateIncrementalContextIfNeeded()
|
||||
guard let context = self.incrementalSpeechContext else {
|
||||
private func makeIncrementalTTSRequest(
|
||||
text: String,
|
||||
context: IncrementalSpeechContext,
|
||||
outputFormat: String?
|
||||
) -> ElevenLabsTTSRequest
|
||||
{
|
||||
ElevenLabsTTSRequest(
|
||||
text: text,
|
||||
modelId: context.modelId,
|
||||
outputFormat: outputFormat,
|
||||
speed: TalkTTSValidation.resolveSpeed(
|
||||
speed: context.directive?.speed,
|
||||
rateWPM: context.directive?.rateWPM),
|
||||
stability: TalkTTSValidation.validatedStability(
|
||||
context.directive?.stability,
|
||||
modelId: context.modelId),
|
||||
similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity),
|
||||
style: TalkTTSValidation.validatedUnit(context.directive?.style),
|
||||
speakerBoost: context.directive?.speakerBoost,
|
||||
seed: TalkTTSValidation.validatedSeed(context.directive?.seed),
|
||||
normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize),
|
||||
language: context.language,
|
||||
latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier))
|
||||
}
|
||||
|
||||
private static func makeBufferedAudioStream(chunks: [Data]) -> AsyncThrowingStream<Data, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
for chunk in chunks {
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private func speakIncrementalSegment(
|
||||
_ text: String,
|
||||
context preferredContext: IncrementalSpeechContext? = nil,
|
||||
prefetchedAudio: IncrementalPrefetchedAudio? = nil
|
||||
) async
|
||||
{
|
||||
let context: IncrementalSpeechContext
|
||||
if let preferredContext {
|
||||
context = preferredContext
|
||||
} else {
|
||||
await self.updateIncrementalContextIfNeeded()
|
||||
guard let resolvedContext = self.incrementalSpeechContext else {
|
||||
try? await TalkSystemSpeechSynthesizer.shared.speak(
|
||||
text: text,
|
||||
language: self.incrementalSpeechLanguage)
|
||||
return
|
||||
}
|
||||
context = resolvedContext
|
||||
}
|
||||
|
||||
guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else {
|
||||
try? await TalkSystemSpeechSynthesizer.shared.speak(
|
||||
text: text,
|
||||
language: self.incrementalSpeechLanguage)
|
||||
return
|
||||
}
|
||||
|
||||
if context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId {
|
||||
let request = ElevenLabsTTSRequest(
|
||||
text: text,
|
||||
modelId: context.modelId,
|
||||
outputFormat: context.outputFormat,
|
||||
speed: TalkTTSValidation.resolveSpeed(
|
||||
speed: context.directive?.speed,
|
||||
rateWPM: context.directive?.rateWPM),
|
||||
stability: TalkTTSValidation.validatedStability(
|
||||
context.directive?.stability,
|
||||
modelId: context.modelId),
|
||||
similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity),
|
||||
style: TalkTTSValidation.validatedUnit(context.directive?.style),
|
||||
speakerBoost: context.directive?.speakerBoost,
|
||||
seed: TalkTTSValidation.validatedSeed(context.directive?.seed),
|
||||
normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize),
|
||||
language: context.language,
|
||||
latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier))
|
||||
let client = ElevenLabsTTSClient(apiKey: apiKey)
|
||||
let stream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
let sampleRate = TalkTTSValidation.pcmSampleRate(from: context.outputFormat)
|
||||
let result: StreamingPlaybackResult
|
||||
if let sampleRate {
|
||||
self.lastPlaybackWasPCM = true
|
||||
var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate)
|
||||
if !playback.finished, playback.interruptedAt == nil {
|
||||
self.logger.warning("pcm playback failed; retrying mp3")
|
||||
self.lastPlaybackWasPCM = false
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
|
||||
let mp3Stream = client.streamSynthesize(
|
||||
voiceId: voiceId,
|
||||
request: ElevenLabsTTSRequest(
|
||||
text: text,
|
||||
modelId: context.modelId,
|
||||
outputFormat: mp3Format,
|
||||
speed: TalkTTSValidation.resolveSpeed(
|
||||
speed: context.directive?.speed,
|
||||
rateWPM: context.directive?.rateWPM),
|
||||
stability: TalkTTSValidation.validatedStability(
|
||||
context.directive?.stability,
|
||||
modelId: context.modelId),
|
||||
similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity),
|
||||
style: TalkTTSValidation.validatedUnit(context.directive?.style),
|
||||
speakerBoost: context.directive?.speakerBoost,
|
||||
seed: TalkTTSValidation.validatedSeed(context.directive?.seed),
|
||||
normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize),
|
||||
language: context.language,
|
||||
latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier)))
|
||||
playback = await self.mp3Player.play(stream: mp3Stream)
|
||||
}
|
||||
result = playback
|
||||
} else {
|
||||
self.lastPlaybackWasPCM = false
|
||||
result = await self.mp3Player.play(stream: stream)
|
||||
}
|
||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
}
|
||||
let client = ElevenLabsTTSClient(apiKey: apiKey)
|
||||
let request = self.makeIncrementalTTSRequest(
|
||||
text: text,
|
||||
context: context,
|
||||
outputFormat: context.outputFormat)
|
||||
let stream: AsyncThrowingStream<Data, Error>
|
||||
if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty {
|
||||
stream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
|
||||
} else {
|
||||
try? await TalkSystemSpeechSynthesizer.shared.speak(
|
||||
text: text,
|
||||
language: self.incrementalSpeechLanguage)
|
||||
stream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
}
|
||||
let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat
|
||||
let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat)
|
||||
let result: StreamingPlaybackResult
|
||||
if let sampleRate {
|
||||
self.lastPlaybackWasPCM = true
|
||||
var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate)
|
||||
if !playback.finished, playback.interruptedAt == nil {
|
||||
self.logger.warning("pcm playback failed; retrying mp3")
|
||||
self.lastPlaybackWasPCM = false
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
|
||||
let mp3Stream = client.streamSynthesize(
|
||||
voiceId: voiceId,
|
||||
request: self.makeIncrementalTTSRequest(
|
||||
text: text,
|
||||
context: context,
|
||||
outputFormat: mp3Format))
|
||||
playback = await self.mp3Player.play(stream: mp3Stream)
|
||||
}
|
||||
result = playback
|
||||
} else {
|
||||
self.lastPlaybackWasPCM = false
|
||||
result = await self.mp3Player.play(stream: stream)
|
||||
}
|
||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1874,7 +2062,7 @@ extension TalkModeManager {
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct IncrementalSpeechContext {
|
||||
private struct IncrementalSpeechContext: Equatable {
|
||||
let apiKey: String?
|
||||
let voiceId: String?
|
||||
let modelId: String?
|
||||
@@ -1884,4 +2072,18 @@ private struct IncrementalSpeechContext {
|
||||
let canUseElevenLabs: Bool
|
||||
}
|
||||
|
||||
private struct IncrementalSpeechPrefetchState {
|
||||
let id: UUID
|
||||
let segment: String
|
||||
let context: IncrementalSpeechContext
|
||||
let outputFormat: String?
|
||||
var chunks: [Data]?
|
||||
let task: Task<Void, Never>
|
||||
}
|
||||
|
||||
private struct IncrementalPrefetchedAudio {
|
||||
let chunks: [Data]
|
||||
let outputFormat: String?
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
@@ -480,8 +480,7 @@ final class AppState {
|
||||
remote.removeValue(forKey: "url")
|
||||
remoteChanged = true
|
||||
}
|
||||
} else {
|
||||
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
|
||||
} else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) {
|
||||
if (remote["url"] as? String) != normalizedUrl {
|
||||
remote["url"] = normalizedUrl
|
||||
remoteChanged = true
|
||||
|
||||
79
apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift
Normal file
79
apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
|
||||
enum ExecAllowlistMatcher {
|
||||
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||
guard let resolution, !entries.isEmpty else { return nil }
|
||||
let rawExecutable = resolution.rawExecutable
|
||||
let resolvedPath = resolution.resolvedPath
|
||||
|
||||
for entry in entries {
|
||||
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
|
||||
case .valid(let pattern):
|
||||
let target = resolvedPath ?? rawExecutable
|
||||
if self.matches(pattern: pattern, target: target) { return entry }
|
||||
case .invalid:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func matchAll(
|
||||
entries: [ExecAllowlistEntry],
|
||||
resolutions: [ExecCommandResolution]) -> [ExecAllowlistEntry]
|
||||
{
|
||||
guard !entries.isEmpty, !resolutions.isEmpty else { return [] }
|
||||
var matches: [ExecAllowlistEntry] = []
|
||||
matches.reserveCapacity(resolutions.count)
|
||||
for resolution in resolutions {
|
||||
guard let match = self.match(entries: entries, resolution: resolution) else {
|
||||
return []
|
||||
}
|
||||
matches.append(match)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
private static func matches(pattern: String, target: String) -> Bool {
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
|
||||
let normalizedPattern = self.normalizeMatchTarget(expanded)
|
||||
let normalizedTarget = self.normalizeMatchTarget(target)
|
||||
guard let regex = self.regex(for: normalizedPattern) else { return false }
|
||||
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
|
||||
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
|
||||
}
|
||||
|
||||
private static func normalizeMatchTarget(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
|
||||
}
|
||||
|
||||
private static func regex(for pattern: String) -> NSRegularExpression? {
|
||||
var regex = "^"
|
||||
var idx = pattern.startIndex
|
||||
while idx < pattern.endIndex {
|
||||
let ch = pattern[idx]
|
||||
if ch == "*" {
|
||||
let next = pattern.index(after: idx)
|
||||
if next < pattern.endIndex, pattern[next] == "*" {
|
||||
regex += ".*"
|
||||
idx = pattern.index(after: next)
|
||||
} else {
|
||||
regex += "[^/]*"
|
||||
idx = next
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == "?" {
|
||||
regex += "."
|
||||
idx = pattern.index(after: idx)
|
||||
continue
|
||||
}
|
||||
regex += NSRegularExpression.escapedPattern(for: String(ch))
|
||||
idx = pattern.index(after: idx)
|
||||
}
|
||||
regex += "$"
|
||||
return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive])
|
||||
}
|
||||
}
|
||||
67
apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift
Normal file
67
apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
|
||||
struct ExecApprovalEvaluation {
|
||||
let command: [String]
|
||||
let displayCommand: String
|
||||
let agentId: String?
|
||||
let security: ExecSecurity
|
||||
let ask: ExecAsk
|
||||
let env: [String: String]
|
||||
let resolution: ExecCommandResolution?
|
||||
let allowlistResolutions: [ExecCommandResolution]
|
||||
let allowlistMatches: [ExecAllowlistEntry]
|
||||
let allowlistSatisfied: Bool
|
||||
let allowlistMatch: ExecAllowlistEntry?
|
||||
let skillAllow: Bool
|
||||
}
|
||||
|
||||
enum ExecApprovalEvaluator {
|
||||
static func evaluate(
|
||||
command: [String],
|
||||
rawCommand: String?,
|
||||
cwd: String?,
|
||||
envOverrides: [String: String]?,
|
||||
agentId: String?) async -> ExecApprovalEvaluation
|
||||
{
|
||||
let trimmedAgent = agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedAgentId = (trimmedAgent?.isEmpty == false) ? trimmedAgent : nil
|
||||
let approvals = ExecApprovalsStore.resolve(agentId: normalizedAgentId)
|
||||
let security = approvals.agent.security
|
||||
let ask = approvals.agent.ask
|
||||
let env = HostEnvSanitizer.sanitize(overrides: envOverrides)
|
||||
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
|
||||
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: rawCommand,
|
||||
cwd: cwd,
|
||||
env: env)
|
||||
let allowlistMatches = security == .allowlist
|
||||
? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions)
|
||||
: []
|
||||
let allowlistSatisfied = security == .allowlist &&
|
||||
!allowlistResolutions.isEmpty &&
|
||||
allowlistMatches.count == allowlistResolutions.count
|
||||
|
||||
let skillAllow: Bool
|
||||
if approvals.agent.autoAllowSkills, !allowlistResolutions.isEmpty {
|
||||
let bins = await SkillBinsCache.shared.currentBins()
|
||||
skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) }
|
||||
} else {
|
||||
skillAllow = false
|
||||
}
|
||||
|
||||
return ExecApprovalEvaluation(
|
||||
command: command,
|
||||
displayCommand: displayCommand,
|
||||
agentId: normalizedAgentId,
|
||||
security: security,
|
||||
ask: ask,
|
||||
env: env,
|
||||
resolution: allowlistResolutions.first,
|
||||
allowlistResolutions: allowlistResolutions,
|
||||
allowlistMatches: allowlistMatches,
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,
|
||||
skillAllow: skillAllow)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,31 @@ enum ExecApprovalDecision: String, Codable, Sendable {
|
||||
case deny
|
||||
}
|
||||
|
||||
enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable {
|
||||
case empty
|
||||
case missingPathComponent
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .empty:
|
||||
"Pattern cannot be empty."
|
||||
case .missingPathComponent:
|
||||
"Path patterns only. Include '/', '~', or '\\\\'."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecAllowlistPatternValidation: Sendable, Equatable {
|
||||
case valid(String)
|
||||
case invalid(ExecAllowlistPatternValidationReason)
|
||||
}
|
||||
|
||||
struct ExecAllowlistRejectedEntry: Sendable, Equatable {
|
||||
let id: UUID
|
||||
let pattern: String
|
||||
let reason: ExecAllowlistPatternValidationReason
|
||||
}
|
||||
|
||||
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
|
||||
var id: UUID
|
||||
var pattern: String
|
||||
@@ -222,13 +247,25 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
agents.removeValue(forKey: "default")
|
||||
}
|
||||
if !agents.isEmpty {
|
||||
var normalizedAgents: [String: ExecApprovalsAgent] = [:]
|
||||
normalizedAgents.reserveCapacity(agents.count)
|
||||
for (key, var agent) in agents {
|
||||
if let allowlist = agent.allowlist {
|
||||
let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: false).entries
|
||||
agent.allowlist = normalized.isEmpty ? nil : normalized
|
||||
}
|
||||
normalizedAgents[key] = agent
|
||||
}
|
||||
agents = normalizedAgents
|
||||
}
|
||||
return ExecApprovalsFile(
|
||||
version: 1,
|
||||
socket: ExecApprovalsSocketConfig(
|
||||
path: socketPath.isEmpty ? nil : socketPath,
|
||||
token: token.isEmpty ? nil : token),
|
||||
defaults: file.defaults,
|
||||
agents: agents)
|
||||
agents: agents.isEmpty ? nil : agents)
|
||||
}
|
||||
|
||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||
@@ -306,7 +343,12 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func ensureFile() -> ExecApprovalsFile {
|
||||
var file = self.loadFile()
|
||||
let url = self.fileURL()
|
||||
let existed = FileManager().fileExists(atPath: url.path)
|
||||
let loaded = self.loadFile()
|
||||
let loadedHash = self.hashFile(loaded)
|
||||
|
||||
var file = self.normalizeIncoming(loaded)
|
||||
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
|
||||
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if path.isEmpty {
|
||||
@@ -317,7 +359,9 @@ enum ExecApprovalsStore {
|
||||
file.socket?.token = self.generateToken()
|
||||
}
|
||||
if file.agents == nil { file.agents = [:] }
|
||||
self.saveFile(file)
|
||||
if !existed || loadedHash != self.hashFile(file) {
|
||||
self.saveFile(file)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
@@ -339,16 +383,9 @@ enum ExecApprovalsStore {
|
||||
?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills
|
||||
?? resolvedDefaults.autoAllowSkills)
|
||||
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||
.map { entry in
|
||||
ExecAllowlistEntry(
|
||||
id: entry.id,
|
||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
lastUsedAt: entry.lastUsedAt,
|
||||
lastUsedCommand: entry.lastUsedCommand,
|
||||
lastResolvedPath: entry.lastResolvedPath)
|
||||
}
|
||||
.filter { !$0.pattern.isEmpty }
|
||||
let allowlist = self.normalizeAllowlistEntries(
|
||||
(wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []),
|
||||
dropInvalid: true).entries
|
||||
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
|
||||
let token = file.socket?.token ?? ""
|
||||
return ExecApprovalsResolved(
|
||||
@@ -398,20 +435,30 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
}
|
||||
|
||||
static func addAllowlistEntry(agentId: String?, pattern: String) {
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
@discardableResult
|
||||
static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? {
|
||||
let normalizedPattern: String
|
||||
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
|
||||
case .valid(let validPattern):
|
||||
normalizedPattern = validPattern
|
||||
case .invalid(let reason):
|
||||
return reason
|
||||
}
|
||||
|
||||
self.updateFile { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
var allowlist = entry.allowlist ?? []
|
||||
if allowlist.contains(where: { $0.pattern == trimmed }) { return }
|
||||
allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000))
|
||||
if allowlist.contains(where: { $0.pattern == normalizedPattern }) { return }
|
||||
allowlist.append(ExecAllowlistEntry(
|
||||
pattern: normalizedPattern,
|
||||
lastUsedAt: Date().timeIntervalSince1970 * 1000))
|
||||
entry.allowlist = allowlist
|
||||
agents[key] = entry
|
||||
file.agents = agents
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func recordAllowlistUse(
|
||||
@@ -439,25 +486,21 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
}
|
||||
|
||||
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) {
|
||||
@discardableResult
|
||||
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) -> [ExecAllowlistRejectedEntry] {
|
||||
var rejected: [ExecAllowlistRejectedEntry] = []
|
||||
self.updateFile { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
let cleaned = allowlist
|
||||
.map { item in
|
||||
ExecAllowlistEntry(
|
||||
id: item.id,
|
||||
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
lastUsedAt: item.lastUsedAt,
|
||||
lastUsedCommand: item.lastUsedCommand,
|
||||
lastResolvedPath: item.lastResolvedPath)
|
||||
}
|
||||
.filter { !$0.pattern.isEmpty }
|
||||
let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: true)
|
||||
rejected = normalized.rejected
|
||||
let cleaned = normalized.entries
|
||||
entry.allowlist = cleaned
|
||||
agents[key] = entry
|
||||
file.agents = agents
|
||||
}
|
||||
return rejected
|
||||
}
|
||||
|
||||
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
|
||||
@@ -500,6 +543,14 @@ enum ExecApprovalsStore {
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func hashFile(_ file: ExecApprovalsFile) -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = (try? encoder.encode(file)) ?? Data()
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func expandPath(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed == "~" {
|
||||
@@ -519,14 +570,101 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
private static func normalizedPattern(_ pattern: String?) -> String? {
|
||||
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed.lowercased()
|
||||
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
|
||||
case .valid(let normalized):
|
||||
return normalized.lowercased()
|
||||
case .invalid(.empty):
|
||||
return nil
|
||||
case .invalid:
|
||||
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateLegacyPattern(_ entry: ExecAllowlistEntry) -> ExecAllowlistEntry {
|
||||
let trimmedPattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedResolved = entry.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved
|
||||
|
||||
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
|
||||
case .valid(let pattern):
|
||||
return ExecAllowlistEntry(
|
||||
id: entry.id,
|
||||
pattern: pattern,
|
||||
lastUsedAt: entry.lastUsedAt,
|
||||
lastUsedCommand: entry.lastUsedCommand,
|
||||
lastResolvedPath: normalizedResolved)
|
||||
case .invalid:
|
||||
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) {
|
||||
case .valid(let migratedPattern):
|
||||
return ExecAllowlistEntry(
|
||||
id: entry.id,
|
||||
pattern: migratedPattern,
|
||||
lastUsedAt: entry.lastUsedAt,
|
||||
lastUsedCommand: entry.lastUsedCommand,
|
||||
lastResolvedPath: normalizedResolved)
|
||||
case .invalid:
|
||||
return ExecAllowlistEntry(
|
||||
id: entry.id,
|
||||
pattern: trimmedPattern,
|
||||
lastUsedAt: entry.lastUsedAt,
|
||||
lastUsedCommand: entry.lastUsedCommand,
|
||||
lastResolvedPath: normalizedResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizeAllowlistEntries(
|
||||
_ entries: [ExecAllowlistEntry],
|
||||
dropInvalid: Bool) -> (entries: [ExecAllowlistEntry], rejected: [ExecAllowlistRejectedEntry])
|
||||
{
|
||||
var normalized: [ExecAllowlistEntry] = []
|
||||
normalized.reserveCapacity(entries.count)
|
||||
var rejected: [ExecAllowlistRejectedEntry] = []
|
||||
|
||||
for entry in entries {
|
||||
let migrated = self.migrateLegacyPattern(entry)
|
||||
let trimmedPattern = migrated.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedResolvedPath = migrated.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath
|
||||
|
||||
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
|
||||
case .valid(let pattern):
|
||||
normalized.append(
|
||||
ExecAllowlistEntry(
|
||||
id: migrated.id,
|
||||
pattern: pattern,
|
||||
lastUsedAt: migrated.lastUsedAt,
|
||||
lastUsedCommand: migrated.lastUsedCommand,
|
||||
lastResolvedPath: normalizedResolvedPath))
|
||||
case .invalid(let reason):
|
||||
if dropInvalid {
|
||||
rejected.append(
|
||||
ExecAllowlistRejectedEntry(
|
||||
id: migrated.id,
|
||||
pattern: trimmedPattern,
|
||||
reason: reason))
|
||||
} else if reason != .empty {
|
||||
normalized.append(
|
||||
ExecAllowlistEntry(
|
||||
id: migrated.id,
|
||||
pattern: trimmedPattern,
|
||||
lastUsedAt: migrated.lastUsedAt,
|
||||
lastUsedCommand: migrated.lastUsedCommand,
|
||||
lastResolvedPath: normalizedResolvedPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (normalized, rejected)
|
||||
}
|
||||
|
||||
private static func mergeAgents(
|
||||
current: ExecApprovalsAgent,
|
||||
legacy: ExecApprovalsAgent) -> ExecApprovalsAgent
|
||||
{
|
||||
let currentAllowlist = self.normalizeAllowlistEntries(current.allowlist ?? [], dropInvalid: false).entries
|
||||
let legacyAllowlist = self.normalizeAllowlistEntries(legacy.allowlist ?? [], dropInvalid: false).entries
|
||||
var seen = Set<String>()
|
||||
var allowlist: [ExecAllowlistEntry] = []
|
||||
func append(_ entry: ExecAllowlistEntry) {
|
||||
@@ -536,10 +674,10 @@ enum ExecApprovalsStore {
|
||||
seen.insert(key)
|
||||
allowlist.append(entry)
|
||||
}
|
||||
for entry in current.allowlist ?? [] {
|
||||
for entry in currentAllowlist {
|
||||
append(entry)
|
||||
}
|
||||
for entry in legacy.allowlist ?? [] {
|
||||
for entry in legacyAllowlist {
|
||||
append(entry)
|
||||
}
|
||||
|
||||
@@ -552,286 +690,23 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
}
|
||||
|
||||
struct ExecCommandResolution: Sendable {
|
||||
let rawExecutable: String
|
||||
let resolvedPath: String?
|
||||
let executableName: String
|
||||
let cwd: String?
|
||||
|
||||
static func resolve(
|
||||
command: [String],
|
||||
rawCommand: String?,
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> ExecCommandResolution?
|
||||
{
|
||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
|
||||
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||
}
|
||||
return self.resolve(command: command, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
static func resolveForAllowlist(
|
||||
command: [String],
|
||||
rawCommand: String?,
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> [ExecCommandResolution]
|
||||
{
|
||||
let shell = self.extractShellCommandFromArgv(command: command, rawCommand: rawCommand)
|
||||
if shell.isWrapper {
|
||||
guard let shellCommand = shell.command,
|
||||
let segments = self.splitShellCommandChain(shellCommand)
|
||||
else {
|
||||
// Fail closed: if we cannot safely parse a shell wrapper payload,
|
||||
// treat this as an allowlist miss and require approval.
|
||||
return []
|
||||
}
|
||||
var resolutions: [ExecCommandResolution] = []
|
||||
resolutions.reserveCapacity(segments.count)
|
||||
for segment in segments {
|
||||
guard let token = self.parseFirstToken(segment),
|
||||
let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
resolutions.append(resolution)
|
||||
}
|
||||
return resolutions
|
||||
}
|
||||
|
||||
guard let resolution = self.resolve(command: command, rawCommand: rawCommand, cwd: cwd, env: env) else {
|
||||
return []
|
||||
}
|
||||
return [resolution]
|
||||
}
|
||||
|
||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
private static func resolveExecutable(
|
||||
rawExecutable: String,
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> ExecCommandResolution?
|
||||
{
|
||||
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
|
||||
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
||||
let resolvedPath: String? = {
|
||||
if hasPathSeparator {
|
||||
if expanded.hasPrefix("/") {
|
||||
return expanded
|
||||
}
|
||||
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath
|
||||
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||
}
|
||||
let searchPaths = self.searchPaths(from: env)
|
||||
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
|
||||
}()
|
||||
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
|
||||
return ExecCommandResolution(
|
||||
rawExecutable: expanded,
|
||||
resolvedPath: resolvedPath,
|
||||
executableName: name,
|
||||
cwd: cwd)
|
||||
}
|
||||
|
||||
private static func parseFirstToken(_ command: String) -> String? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard let first = trimmed.first else { return nil }
|
||||
if first == "\"" || first == "'" {
|
||||
let rest = trimmed.dropFirst()
|
||||
if let end = rest.firstIndex(of: first) {
|
||||
return String(rest[..<end])
|
||||
}
|
||||
return String(rest)
|
||||
}
|
||||
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||
}
|
||||
|
||||
private static func basenameLower(_ token: String) -> String {
|
||||
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
let normalized = trimmed.replacingOccurrences(of: "\\", with: "/")
|
||||
return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased()
|
||||
}
|
||||
|
||||
private static func extractShellCommandFromArgv(
|
||||
command: [String],
|
||||
rawCommand: String?) -> (isWrapper: Bool, command: String?)
|
||||
{
|
||||
guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
||||
return (false, nil)
|
||||
}
|
||||
let base0 = self.basenameLower(token0)
|
||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
|
||||
|
||||
if ["sh", "bash", "zsh", "dash", "ksh"].contains(base0) {
|
||||
let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
guard flag == "-lc" || flag == "-c" else { return (false, nil) }
|
||||
let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
|
||||
return (true, normalized)
|
||||
}
|
||||
|
||||
if base0 == "cmd.exe" || base0 == "cmd" {
|
||||
guard let idx = command
|
||||
.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" })
|
||||
else {
|
||||
return (false, nil)
|
||||
}
|
||||
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")
|
||||
let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
|
||||
return (true, normalized)
|
||||
}
|
||||
|
||||
return (false, nil)
|
||||
}
|
||||
|
||||
private static func splitShellCommandChain(_ command: String) -> [String]? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
var segments: [String] = []
|
||||
var current = ""
|
||||
var inSingle = false
|
||||
var inDouble = false
|
||||
var escaped = false
|
||||
let chars = Array(trimmed)
|
||||
var idx = 0
|
||||
|
||||
func appendCurrent() -> Bool {
|
||||
let segment = current.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !segment.isEmpty else { return false }
|
||||
segments.append(segment)
|
||||
current.removeAll(keepingCapacity: true)
|
||||
return true
|
||||
}
|
||||
|
||||
while idx < chars.count {
|
||||
let ch = chars[idx]
|
||||
let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil
|
||||
|
||||
if escaped {
|
||||
current.append(ch)
|
||||
escaped = false
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == "\\", !inSingle {
|
||||
current.append(ch)
|
||||
escaped = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == "'", !inDouble {
|
||||
inSingle.toggle()
|
||||
current.append(ch)
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == "\"", !inSingle {
|
||||
inDouble.toggle()
|
||||
current.append(ch)
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if !inSingle, !inDouble {
|
||||
if self.shouldFailClosedForUnquotedShell(ch: ch, next: next) {
|
||||
// Fail closed on command/process substitution in allowlist mode.
|
||||
return nil
|
||||
}
|
||||
let prev: Character? = idx > 0 ? chars[idx - 1] : nil
|
||||
if let delimiterStep = self.chainDelimiterStep(ch: ch, prev: prev, next: next) {
|
||||
guard appendCurrent() else { return nil }
|
||||
idx += delimiterStep
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
current.append(ch)
|
||||
idx += 1
|
||||
}
|
||||
|
||||
if escaped || inSingle || inDouble { return nil }
|
||||
guard appendCurrent() else { return nil }
|
||||
return segments
|
||||
}
|
||||
|
||||
private static func shouldFailClosedForUnquotedShell(ch: Character, next: Character?) -> Bool {
|
||||
if ch == "`" {
|
||||
return true
|
||||
}
|
||||
if ch == "$", next == "(" {
|
||||
return true
|
||||
}
|
||||
if ch == "<" || ch == ">", next == "(" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func chainDelimiterStep(ch: Character, prev: Character?, next: Character?) -> Int? {
|
||||
if ch == ";" || ch == "\n" {
|
||||
return 1
|
||||
}
|
||||
if ch == "&" {
|
||||
if next == "&" {
|
||||
return 2
|
||||
}
|
||||
// Keep fd redirections like 2>&1 or &>file intact.
|
||||
let prevIsRedirect = prev == ">"
|
||||
let nextIsRedirect = next == ">"
|
||||
return (!prevIsRedirect && !nextIsRedirect) ? 1 : nil
|
||||
}
|
||||
if ch == "|" {
|
||||
if next == "|" || next == "&" {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func searchPaths(from env: [String: String]?) -> [String] {
|
||||
let raw = env?["PATH"]
|
||||
if let raw, !raw.isEmpty {
|
||||
return raw.split(separator: ":").map(String.init)
|
||||
}
|
||||
return CommandResolver.preferredPaths()
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecCommandFormatter {
|
||||
static func displayString(for argv: [String]) -> String {
|
||||
argv.map { arg in
|
||||
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "\"\"" }
|
||||
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
||||
if !needsQuotes { return trimmed }
|
||||
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\"\(escaped)\""
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func displayString(for argv: [String], rawCommand: String?) -> String {
|
||||
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
return self.displayString(for: argv)
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalHelpers {
|
||||
static func validateAllowlistPattern(_ pattern: String?) -> ExecAllowlistPatternValidation {
|
||||
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return .invalid(.empty) }
|
||||
guard self.containsPathComponent(trimmed) else { return .invalid(.missingPathComponent) }
|
||||
return .valid(trimmed)
|
||||
}
|
||||
|
||||
static func isPathPattern(_ pattern: String?) -> Bool {
|
||||
switch self.validateAllowlistPattern(pattern) {
|
||||
case .valid:
|
||||
true
|
||||
case .invalid:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
@@ -853,86 +728,9 @@ enum ExecApprovalHelpers {
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
return pattern.isEmpty ? nil : pattern
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecAllowlistMatcher {
|
||||
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||
guard let resolution, !entries.isEmpty else { return nil }
|
||||
let rawExecutable = resolution.rawExecutable
|
||||
let resolvedPath = resolution.resolvedPath
|
||||
let executableName = resolution.executableName
|
||||
|
||||
for entry in entries {
|
||||
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if pattern.isEmpty { continue }
|
||||
let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
|
||||
if hasPath {
|
||||
let target = resolvedPath ?? rawExecutable
|
||||
if self.matches(pattern: pattern, target: target) { return entry }
|
||||
} else if self.matches(pattern: pattern, target: executableName) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func matchAll(
|
||||
entries: [ExecAllowlistEntry],
|
||||
resolutions: [ExecCommandResolution]) -> [ExecAllowlistEntry]
|
||||
{
|
||||
guard !entries.isEmpty, !resolutions.isEmpty else { return [] }
|
||||
var matches: [ExecAllowlistEntry] = []
|
||||
matches.reserveCapacity(resolutions.count)
|
||||
for resolution in resolutions {
|
||||
guard let match = self.match(entries: entries, resolution: resolution) else {
|
||||
return []
|
||||
}
|
||||
matches.append(match)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
private static func matches(pattern: String, target: String) -> Bool {
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
|
||||
let normalizedPattern = self.normalizeMatchTarget(expanded)
|
||||
let normalizedTarget = self.normalizeMatchTarget(target)
|
||||
guard let regex = self.regex(for: normalizedPattern) else { return false }
|
||||
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
|
||||
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
|
||||
}
|
||||
|
||||
private static func normalizeMatchTarget(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
|
||||
}
|
||||
|
||||
private static func regex(for pattern: String) -> NSRegularExpression? {
|
||||
var regex = "^"
|
||||
var idx = pattern.startIndex
|
||||
while idx < pattern.endIndex {
|
||||
let ch = pattern[idx]
|
||||
if ch == "*" {
|
||||
let next = pattern.index(after: idx)
|
||||
if next < pattern.endIndex, pattern[next] == "*" {
|
||||
regex += ".*"
|
||||
idx = pattern.index(after: next)
|
||||
} else {
|
||||
regex += "[^/]*"
|
||||
idx = next
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == "?" {
|
||||
regex += "."
|
||||
idx = pattern.index(after: idx)
|
||||
continue
|
||||
}
|
||||
regex += NSRegularExpression.escapedPattern(for: String(ch))
|
||||
idx = pattern.index(after: idx)
|
||||
}
|
||||
regex += "$"
|
||||
return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive])
|
||||
private static func containsPathComponent(_ pattern: String) -> Bool {
|
||||
pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -350,21 +350,7 @@ enum ExecApprovalsPromptPresenter {
|
||||
|
||||
@MainActor
|
||||
private enum ExecHostExecutor {
|
||||
private struct ExecApprovalContext {
|
||||
let command: [String]
|
||||
let displayCommand: String
|
||||
let trimmedAgent: String?
|
||||
let approvals: ExecApprovalsResolved
|
||||
let security: ExecSecurity
|
||||
let ask: ExecAsk
|
||||
let autoAllowSkills: Bool
|
||||
let env: [String: String]?
|
||||
let resolution: ExecCommandResolution?
|
||||
let allowlistResolutions: [ExecCommandResolution]
|
||||
let allowlistMatches: [ExecAllowlistEntry]
|
||||
let allowlistSatisfied: Bool
|
||||
let skillAllow: Bool
|
||||
}
|
||||
private typealias ExecApprovalContext = ExecApprovalEvaluation
|
||||
|
||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
@@ -395,7 +381,7 @@ private enum ExecHostExecutor {
|
||||
if ExecApprovalHelpers.requiresAsk(
|
||||
ask: context.ask,
|
||||
security: context.security,
|
||||
allowlistMatch: context.allowlistSatisfied ? context.allowlistMatches.first : nil,
|
||||
allowlistMatch: context.allowlistMatch,
|
||||
skillAllow: context.skillAllow),
|
||||
approvalDecision == nil
|
||||
{
|
||||
@@ -406,7 +392,7 @@ private enum ExecHostExecutor {
|
||||
host: "node",
|
||||
security: context.security.rawValue,
|
||||
ask: context.ask.rawValue,
|
||||
agentId: context.trimmedAgent,
|
||||
agentId: context.agentId,
|
||||
resolvedPath: context.resolution?.resolvedPath,
|
||||
sessionKey: request.sessionKey))
|
||||
|
||||
@@ -447,7 +433,7 @@ private enum ExecHostExecutor {
|
||||
? context.allowlistResolutions[idx].resolvedPath
|
||||
: nil
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: context.trimmedAgent,
|
||||
agentId: context.agentId,
|
||||
pattern: match.pattern,
|
||||
command: context.displayCommand,
|
||||
resolvedPath: resolvedPath)
|
||||
@@ -466,49 +452,12 @@ private enum ExecHostExecutor {
|
||||
}
|
||||
|
||||
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
|
||||
let displayCommand = ExecCommandFormatter.displayString(
|
||||
for: command,
|
||||
rawCommand: request.rawCommand)
|
||||
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
|
||||
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
|
||||
let security = approvals.agent.security
|
||||
let ask = approvals.agent.ask
|
||||
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||
let env = self.sanitizedEnv(request.env)
|
||||
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
await ExecApprovalEvaluator.evaluate(
|
||||
command: command,
|
||||
rawCommand: request.rawCommand,
|
||||
cwd: request.cwd,
|
||||
env: env)
|
||||
let resolution = allowlistResolutions.first
|
||||
let allowlistMatches = security == .allowlist
|
||||
? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions)
|
||||
: []
|
||||
let allowlistSatisfied = security == .allowlist &&
|
||||
!allowlistResolutions.isEmpty &&
|
||||
allowlistMatches.count == allowlistResolutions.count
|
||||
let skillAllow: Bool
|
||||
if autoAllowSkills, !allowlistResolutions.isEmpty {
|
||||
let bins = await SkillBinsCache.shared.currentBins()
|
||||
skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) }
|
||||
} else {
|
||||
skillAllow = false
|
||||
}
|
||||
return ExecApprovalContext(
|
||||
command: command,
|
||||
displayCommand: displayCommand,
|
||||
trimmedAgent: trimmedAgent,
|
||||
approvals: approvals,
|
||||
security: security,
|
||||
ask: ask,
|
||||
autoAllowSkills: autoAllowSkills,
|
||||
env: env,
|
||||
resolution: resolution,
|
||||
allowlistResolutions: allowlistResolutions,
|
||||
allowlistMatches: allowlistMatches,
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
skillAllow: skillAllow)
|
||||
envOverrides: request.env,
|
||||
agentId: request.agentId)
|
||||
}
|
||||
|
||||
private static func persistAllowlistEntry(
|
||||
@@ -525,7 +474,7 @@ private enum ExecHostExecutor {
|
||||
continue
|
||||
}
|
||||
if seenPatterns.insert(pattern).inserted {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,10 +535,6 @@ private enum ExecHostExecutor {
|
||||
payload: payload,
|
||||
error: nil)
|
||||
}
|
||||
|
||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String] {
|
||||
HostEnvSanitizer.sanitize(overrides: overrides)
|
||||
}
|
||||
}
|
||||
|
||||
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
|
||||
265
apps/macos/Sources/OpenClaw/ExecCommandResolution.swift
Normal file
265
apps/macos/Sources/OpenClaw/ExecCommandResolution.swift
Normal file
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
|
||||
struct ExecCommandResolution: Sendable {
|
||||
let rawExecutable: String
|
||||
let resolvedPath: String?
|
||||
let executableName: String
|
||||
let cwd: String?
|
||||
|
||||
static func resolve(
|
||||
command: [String],
|
||||
rawCommand: String?,
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> ExecCommandResolution?
|
||||
{
|
||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
|
||||
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||
}
|
||||
return self.resolve(command: command, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
static func resolveForAllowlist(
|
||||
command: [String],
|
||||
rawCommand: String?,
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> [ExecCommandResolution]
|
||||
{
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand)
|
||||
if shell.isWrapper {
|
||||
guard let shellCommand = shell.command,
|
||||
let segments = self.splitShellCommandChain(shellCommand)
|
||||
else {
|
||||
// Fail closed: if we cannot safely parse a shell wrapper payload,
|
||||
// treat this as an allowlist miss and require approval.
|
||||
return []
|
||||
}
|
||||
var resolutions: [ExecCommandResolution] = []
|
||||
resolutions.reserveCapacity(segments.count)
|
||||
for segment in segments {
|
||||
guard let token = self.parseFirstToken(segment),
|
||||
let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
resolutions.append(resolution)
|
||||
}
|
||||
return resolutions
|
||||
}
|
||||
|
||||
guard let resolution = self.resolve(command: command, rawCommand: rawCommand, cwd: cwd, env: env) else {
|
||||
return []
|
||||
}
|
||||
return [resolution]
|
||||
}
|
||||
|
||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
|
||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
private static func resolveExecutable(
|
||||
rawExecutable: String,
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> ExecCommandResolution?
|
||||
{
|
||||
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
|
||||
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
||||
let resolvedPath: String? = {
|
||||
if hasPathSeparator {
|
||||
if expanded.hasPrefix("/") {
|
||||
return expanded
|
||||
}
|
||||
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath
|
||||
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||
}
|
||||
let searchPaths = self.searchPaths(from: env)
|
||||
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
|
||||
}()
|
||||
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
|
||||
return ExecCommandResolution(
|
||||
rawExecutable: expanded,
|
||||
resolvedPath: resolvedPath,
|
||||
executableName: name,
|
||||
cwd: cwd)
|
||||
}
|
||||
|
||||
private static func parseFirstToken(_ command: String) -> String? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard let first = trimmed.first else { return nil }
|
||||
if first == "\"" || first == "'" {
|
||||
let rest = trimmed.dropFirst()
|
||||
if let end = rest.firstIndex(of: first) {
|
||||
return String(rest[..<end])
|
||||
}
|
||||
return String(rest)
|
||||
}
|
||||
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||
}
|
||||
|
||||
private enum ShellTokenContext {
|
||||
case unquoted
|
||||
case doubleQuoted
|
||||
}
|
||||
|
||||
private struct ShellFailClosedRule {
|
||||
let token: Character
|
||||
let next: Character?
|
||||
}
|
||||
|
||||
private static let shellFailClosedRules: [ShellTokenContext: [ShellFailClosedRule]] = [
|
||||
.unquoted: [
|
||||
ShellFailClosedRule(token: "`", next: nil),
|
||||
ShellFailClosedRule(token: "$", next: "("),
|
||||
ShellFailClosedRule(token: "<", next: "("),
|
||||
ShellFailClosedRule(token: ">", next: "("),
|
||||
],
|
||||
.doubleQuoted: [
|
||||
ShellFailClosedRule(token: "`", next: nil),
|
||||
ShellFailClosedRule(token: "$", next: "("),
|
||||
],
|
||||
]
|
||||
|
||||
private static func splitShellCommandChain(_ command: String) -> [String]? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
var segments: [String] = []
|
||||
var current = ""
|
||||
var inSingle = false
|
||||
var inDouble = false
|
||||
var escaped = false
|
||||
let chars = Array(trimmed)
|
||||
var idx = 0
|
||||
|
||||
func appendCurrent() -> Bool {
|
||||
let segment = current.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !segment.isEmpty else { return false }
|
||||
segments.append(segment)
|
||||
current.removeAll(keepingCapacity: true)
|
||||
return true
|
||||
}
|
||||
|
||||
while idx < chars.count {
|
||||
let ch = chars[idx]
|
||||
let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil
|
||||
|
||||
if escaped {
|
||||
current.append(ch)
|
||||
escaped = false
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == "\\", !inSingle {
|
||||
current.append(ch)
|
||||
escaped = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == "'", !inDouble {
|
||||
inSingle.toggle()
|
||||
current.append(ch)
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == "\"", !inSingle {
|
||||
inDouble.toggle()
|
||||
current.append(ch)
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next, inDouble: inDouble) {
|
||||
// Fail closed on command/process substitution in allowlist mode,
|
||||
// including command substitution inside double-quoted shell strings.
|
||||
return nil
|
||||
}
|
||||
|
||||
if !inSingle, !inDouble {
|
||||
let prev: Character? = idx > 0 ? chars[idx - 1] : nil
|
||||
if let delimiterStep = self.chainDelimiterStep(ch: ch, prev: prev, next: next) {
|
||||
guard appendCurrent() else { return nil }
|
||||
idx += delimiterStep
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
current.append(ch)
|
||||
idx += 1
|
||||
}
|
||||
|
||||
if escaped || inSingle || inDouble { return nil }
|
||||
guard appendCurrent() else { return nil }
|
||||
return segments
|
||||
}
|
||||
|
||||
private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool {
|
||||
let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted
|
||||
guard let rules = self.shellFailClosedRules[context] else {
|
||||
return false
|
||||
}
|
||||
for rule in rules {
|
||||
if ch == rule.token, rule.next == nil || next == rule.next {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func chainDelimiterStep(ch: Character, prev: Character?, next: Character?) -> Int? {
|
||||
if ch == ";" || ch == "\n" {
|
||||
return 1
|
||||
}
|
||||
if ch == "&" {
|
||||
if next == "&" {
|
||||
return 2
|
||||
}
|
||||
// Keep fd redirections like 2>&1 or &>file intact.
|
||||
let prevIsRedirect = prev == ">"
|
||||
let nextIsRedirect = next == ">"
|
||||
return (!prevIsRedirect && !nextIsRedirect) ? 1 : nil
|
||||
}
|
||||
if ch == "|" {
|
||||
if next == "|" || next == "&" {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func searchPaths(from env: [String: String]?) -> [String] {
|
||||
let raw = env?["PATH"]
|
||||
if let raw, !raw.isEmpty {
|
||||
return raw.split(separator: ":").map(String.init)
|
||||
}
|
||||
return CommandResolver.preferredPaths()
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecCommandFormatter {
|
||||
static func displayString(for argv: [String]) -> String {
|
||||
argv.map { arg in
|
||||
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "\"\"" }
|
||||
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
||||
if !needsQuotes { return trimmed }
|
||||
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\"\(escaped)\""
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func displayString(for argv: [String], rawCommand: String?) -> String {
|
||||
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
return self.displayString(for: argv)
|
||||
}
|
||||
}
|
||||
108
apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift
Normal file
108
apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
enum ExecCommandToken {
|
||||
static func basenameLower(_ token: String) -> String {
|
||||
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
let normalized = trimmed.replacingOccurrences(of: "\\", with: "/")
|
||||
return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecEnvInvocationUnwrapper {
|
||||
static let maxWrapperDepth = 4
|
||||
|
||||
private static let optionsWithValue = Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
])
|
||||
private static let flagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
|
||||
|
||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||
return token.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
static func unwrap(_ command: [String]) -> [String]? {
|
||||
var idx = 1
|
||||
var expectsOptionValue = false
|
||||
while idx < command.count {
|
||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if expectsOptionValue {
|
||||
expectsOptionValue = false
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if token == "--" || token == "-" {
|
||||
idx += 1
|
||||
break
|
||||
}
|
||||
if self.isEnvAssignment(token) {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if token.hasPrefix("-"), token != "-" {
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if self.flagOptions.contains(flag) {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if self.optionsWithValue.contains(flag) {
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
}
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if lower.hasPrefix("-u") ||
|
||||
lower.hasPrefix("-c") ||
|
||||
lower.hasPrefix("-s") ||
|
||||
lower.hasPrefix("--unset=") ||
|
||||
lower.hasPrefix("--chdir=") ||
|
||||
lower.hasPrefix("--split-string=") ||
|
||||
lower.hasPrefix("--default-signal=") ||
|
||||
lower.hasPrefix("--ignore-signal=") ||
|
||||
lower.hasPrefix("--block-signal=")
|
||||
{
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
guard idx < command.count else { return nil }
|
||||
return Array(command[idx...])
|
||||
}
|
||||
|
||||
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
|
||||
var current = command
|
||||
var depth = 0
|
||||
while depth < self.maxWrapperDepth {
|
||||
guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else {
|
||||
break
|
||||
}
|
||||
guard ExecCommandToken.basenameLower(token) == "env" else {
|
||||
break
|
||||
}
|
||||
guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else {
|
||||
break
|
||||
}
|
||||
current = unwrapped
|
||||
depth += 1
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
106
apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift
Normal file
106
apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
|
||||
enum ExecShellWrapperParser {
|
||||
struct ParsedShellWrapper {
|
||||
let isWrapper: Bool
|
||||
let command: String?
|
||||
|
||||
static let notWrapper = ParsedShellWrapper(isWrapper: false, command: nil)
|
||||
}
|
||||
|
||||
private enum Kind {
|
||||
case posix
|
||||
case cmd
|
||||
case powershell
|
||||
}
|
||||
|
||||
private struct WrapperSpec {
|
||||
let kind: Kind
|
||||
let names: Set<String>
|
||||
}
|
||||
|
||||
private static let posixInlineFlags = Set(["-lc", "-c", "--command"])
|
||||
private static let powershellInlineFlags = Set(["-c", "-command", "--command"])
|
||||
|
||||
private static let wrapperSpecs: [WrapperSpec] = [
|
||||
WrapperSpec(kind: .posix, names: ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"]),
|
||||
WrapperSpec(kind: .cmd, names: ["cmd.exe", "cmd"]),
|
||||
WrapperSpec(kind: .powershell, names: ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]),
|
||||
]
|
||||
|
||||
static func extract(command: [String], rawCommand: String?) -> ParsedShellWrapper {
|
||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
|
||||
return self.extract(command: command, preferredRaw: preferredRaw, depth: 0)
|
||||
}
|
||||
|
||||
private static func extract(command: [String], preferredRaw: String?, depth: Int) -> ParsedShellWrapper {
|
||||
guard depth < ExecEnvInvocationUnwrapper.maxWrapperDepth else {
|
||||
return .notWrapper
|
||||
}
|
||||
guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
||||
return .notWrapper
|
||||
}
|
||||
|
||||
let base0 = ExecCommandToken.basenameLower(token0)
|
||||
if base0 == "env" {
|
||||
guard let unwrapped = ExecEnvInvocationUnwrapper.unwrap(command) else {
|
||||
return .notWrapper
|
||||
}
|
||||
return self.extract(command: unwrapped, preferredRaw: preferredRaw, depth: depth + 1)
|
||||
}
|
||||
|
||||
guard let spec = self.wrapperSpecs.first(where: { $0.names.contains(base0) }) else {
|
||||
return .notWrapper
|
||||
}
|
||||
guard let payload = self.extractPayload(command: command, spec: spec) else {
|
||||
return .notWrapper
|
||||
}
|
||||
let normalized = preferredRaw ?? payload
|
||||
return ParsedShellWrapper(isWrapper: true, command: normalized)
|
||||
}
|
||||
|
||||
private static func extractPayload(command: [String], spec: WrapperSpec) -> String? {
|
||||
switch spec.kind {
|
||||
case .posix:
|
||||
return self.extractPosixInlineCommand(command)
|
||||
case .cmd:
|
||||
return self.extractCmdInlineCommand(command)
|
||||
case .powershell:
|
||||
return self.extractPowerShellInlineCommand(command)
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractPosixInlineCommand(_ command: [String]) -> String? {
|
||||
let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
guard self.posixInlineFlags.contains(flag.lowercased()) else {
|
||||
return nil
|
||||
}
|
||||
let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
return payload.isEmpty ? nil : payload
|
||||
}
|
||||
|
||||
private static func extractCmdInlineCommand(_ command: [String]) -> String? {
|
||||
guard let idx = command.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) else {
|
||||
return nil
|
||||
}
|
||||
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")
|
||||
let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return payload.isEmpty ? nil : payload
|
||||
}
|
||||
|
||||
private static func extractPowerShellInlineCommand(_ command: [String]) -> String? {
|
||||
for idx in 1..<command.count {
|
||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if token.isEmpty { continue }
|
||||
if token == "--" { break }
|
||||
if self.powershellInlineFlags.contains(token) {
|
||||
let payload = idx + 1 < command.count
|
||||
? command[idx + 1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: ""
|
||||
return payload.isEmpty ? nil : payload
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,34 @@ import Foundation
|
||||
import OpenClawDiscovery
|
||||
|
||||
enum GatewayDiscoveryHelpers {
|
||||
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||
static func resolvedServiceHost(
|
||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String?
|
||||
{
|
||||
self.resolvedServiceHost(gateway.serviceHost)
|
||||
}
|
||||
|
||||
static func resolvedServiceHost(_ host: String?) -> String? {
|
||||
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||
return host
|
||||
}
|
||||
|
||||
static func serviceEndpoint(
|
||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (host: String, port: Int)?
|
||||
{
|
||||
self.serviceEndpoint(serviceHost: gateway.serviceHost, servicePort: gateway.servicePort)
|
||||
}
|
||||
|
||||
static func serviceEndpoint(
|
||||
serviceHost: String?,
|
||||
servicePort: Int?) -> (host: String, port: Int)?
|
||||
{
|
||||
guard let host = self.resolvedServiceHost(serviceHost) else { return nil }
|
||||
guard let port = servicePort, port > 0, port <= 65535 else { return nil }
|
||||
return (host, port)
|
||||
}
|
||||
|
||||
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
guard let host = self.resolvedServiceHost(for: gateway) else { return nil }
|
||||
let user = NSUserName()
|
||||
var target = "\(user)@\(host)"
|
||||
if gateway.sshPort != 22 {
|
||||
@@ -16,42 +41,37 @@ enum GatewayDiscoveryHelpers {
|
||||
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
self.directGatewayUrl(
|
||||
serviceHost: gateway.serviceHost,
|
||||
servicePort: gateway.servicePort,
|
||||
lanHost: gateway.lanHost,
|
||||
gatewayPort: gateway.gatewayPort)
|
||||
servicePort: gateway.servicePort)
|
||||
}
|
||||
|
||||
static func directGatewayUrl(
|
||||
serviceHost: String?,
|
||||
servicePort: Int?,
|
||||
lanHost: String?,
|
||||
gatewayPort: Int?) -> String?
|
||||
servicePort: Int?) -> String?
|
||||
{
|
||||
// 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)"
|
||||
}
|
||||
|
||||
static func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
|
||||
guard let endpoint = self.serviceEndpoint(serviceHost: serviceHost, servicePort: servicePort) else {
|
||||
return nil
|
||||
}
|
||||
return host
|
||||
// Security: for non-loopback hosts, force TLS to avoid plaintext credential/session leakage.
|
||||
let scheme = self.isLoopbackHost(endpoint.host) ? "ws" : "wss"
|
||||
let portSuffix = endpoint.port == 443 ? "" : ":\(endpoint.port)"
|
||||
return "\(scheme)://\(endpoint.host)\(portSuffix)"
|
||||
}
|
||||
|
||||
private static func trimmed(_ value: String?) -> String? {
|
||||
value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func isLoopbackHost(_ rawHost: String) -> Bool {
|
||||
let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !host.isEmpty else { return false }
|
||||
if host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" {
|
||||
return true
|
||||
}
|
||||
if host.hasPrefix("::ffff:127.") {
|
||||
return true
|
||||
}
|
||||
return host.hasPrefix("127.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +303,9 @@ struct GeneralSettings: View {
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
|
||||
Text(
|
||||
"Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
@@ -546,7 +548,9 @@ extension GeneralSettings {
|
||||
return
|
||||
}
|
||||
guard Self.isValidWsUrl(trimmedUrl) else {
|
||||
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
|
||||
self.remoteStatus = .failed(
|
||||
"Gateway URL must use wss:// for remote hosts (ws:// only for localhost)"
|
||||
)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -603,11 +607,7 @@ extension GeneralSettings {
|
||||
}
|
||||
|
||||
private static func isValidWsUrl(_ raw: String) -> Bool {
|
||||
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
guard scheme == "ws" || scheme == "wss" else { return false }
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return !host.isEmpty
|
||||
GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil
|
||||
}
|
||||
|
||||
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
|
||||
@@ -675,22 +675,17 @@ extension GeneralSettings {
|
||||
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
|
||||
|
||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||
guard let host else { return }
|
||||
let user = NSUserName()
|
||||
if self.state.remoteTransport == .direct {
|
||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||
self.state.remoteUrl = url
|
||||
}
|
||||
self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
|
||||
} else {
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
|
||||
}
|
||||
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: gateway.serviceHost ?? host,
|
||||
port: gateway.servicePort ?? gateway.gatewayPort)
|
||||
host: endpoint.host,
|
||||
port: endpoint.port)
|
||||
} else {
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ enum HostEnvSanitizer {
|
||||
"RUBYOPT",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"SHELL",
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE",
|
||||
@@ -24,6 +25,10 @@ enum HostEnvSanitizer {
|
||||
"LD_",
|
||||
"BASH_FUNC_",
|
||||
]
|
||||
private static let blockedOverrideKeys: Set<String> = [
|
||||
"HOME",
|
||||
"ZDOTDIR",
|
||||
]
|
||||
|
||||
private static func isBlocked(_ upperKey: String) -> Bool {
|
||||
if self.blockedKeys.contains(upperKey) { return true }
|
||||
@@ -48,6 +53,7 @@ enum HostEnvSanitizer {
|
||||
// PATH is part of the security boundary (command resolution + safe-bin checks). Never
|
||||
// allow request-scoped PATH overrides from agents/gateways.
|
||||
if upper == "PATH" { continue }
|
||||
if self.blockedOverrideKeys.contains(upper) { continue }
|
||||
if self.isBlocked(upper) { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
@@ -441,48 +441,25 @@ actor MacNodeRuntime {
|
||||
guard !command.isEmpty else {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||
}
|
||||
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
|
||||
|
||||
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
|
||||
let security = approvals.agent.security
|
||||
let ask = approvals.agent.ask
|
||||
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: self.mainSessionKey
|
||||
let runId = UUID().uuidString
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
let evaluation = await ExecApprovalEvaluator.evaluate(
|
||||
command: command,
|
||||
rawCommand: params.rawCommand,
|
||||
cwd: params.cwd,
|
||||
env: env)
|
||||
let resolution = allowlistResolutions.first
|
||||
let allowlistMatches = security == .allowlist
|
||||
? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions)
|
||||
: []
|
||||
let allowlistSatisfied = security == .allowlist &&
|
||||
!allowlistResolutions.isEmpty &&
|
||||
allowlistMatches.count == allowlistResolutions.count
|
||||
let allowlistMatch = allowlistSatisfied ? allowlistMatches.first : nil
|
||||
let skillAllow: Bool
|
||||
if autoAllowSkills, !allowlistResolutions.isEmpty {
|
||||
let bins = await SkillBinsCache.shared.currentBins()
|
||||
skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) }
|
||||
} else {
|
||||
skillAllow = false
|
||||
}
|
||||
envOverrides: params.env,
|
||||
agentId: params.agentId)
|
||||
|
||||
if security == .deny {
|
||||
if evaluation.security == .deny {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
command: evaluation.displayCommand,
|
||||
reason: "security=deny"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -494,13 +471,13 @@ actor MacNodeRuntime {
|
||||
req: req,
|
||||
params: params,
|
||||
context: ExecRunContext(
|
||||
displayCommand: displayCommand,
|
||||
security: security,
|
||||
ask: ask,
|
||||
agentId: agentId,
|
||||
resolution: resolution,
|
||||
allowlistMatch: allowlistMatch,
|
||||
skillAllow: skillAllow,
|
||||
displayCommand: evaluation.displayCommand,
|
||||
security: evaluation.security,
|
||||
ask: evaluation.ask,
|
||||
agentId: evaluation.agentId,
|
||||
resolution: evaluation.resolution,
|
||||
allowlistMatch: evaluation.allowlistMatch,
|
||||
skillAllow: evaluation.skillAllow,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId))
|
||||
if let response = approval.response { return response }
|
||||
@@ -508,19 +485,19 @@ actor MacNodeRuntime {
|
||||
let persistAllowlist = approval.persistAllowlist
|
||||
self.persistAllowlistPatterns(
|
||||
persistAllowlist: persistAllowlist,
|
||||
security: security,
|
||||
agentId: agentId,
|
||||
security: evaluation.security,
|
||||
agentId: evaluation.agentId,
|
||||
command: command,
|
||||
allowlistResolutions: allowlistResolutions)
|
||||
allowlistResolutions: evaluation.allowlistResolutions)
|
||||
|
||||
if security == .allowlist, !allowlistSatisfied, !skillAllow, !approvedByAsk {
|
||||
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
command: evaluation.displayCommand,
|
||||
reason: "allowlist-miss"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -529,19 +506,19 @@ actor MacNodeRuntime {
|
||||
}
|
||||
|
||||
self.recordAllowlistMatches(
|
||||
security: security,
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
agentId: agentId,
|
||||
allowlistMatches: allowlistMatches,
|
||||
allowlistResolutions: allowlistResolutions,
|
||||
displayCommand: displayCommand)
|
||||
security: evaluation.security,
|
||||
allowlistSatisfied: evaluation.allowlistSatisfied,
|
||||
agentId: evaluation.agentId,
|
||||
allowlistMatches: evaluation.allowlistMatches,
|
||||
allowlistResolutions: evaluation.allowlistResolutions,
|
||||
displayCommand: evaluation.displayCommand)
|
||||
|
||||
if let permissionResponse = await self.validateScreenRecordingIfNeeded(
|
||||
req: req,
|
||||
needsScreenRecording: params.needsScreenRecording,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
displayCommand: displayCommand)
|
||||
displayCommand: evaluation.displayCommand)
|
||||
{
|
||||
return permissionResponse
|
||||
}
|
||||
@@ -550,10 +527,10 @@ actor MacNodeRuntime {
|
||||
req: req,
|
||||
params: params,
|
||||
command: command,
|
||||
env: env,
|
||||
env: evaluation.env,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
displayCommand: displayCommand)
|
||||
displayCommand: evaluation.displayCommand)
|
||||
}
|
||||
|
||||
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
@@ -947,10 +924,6 @@ extension MacNodeRuntime {
|
||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||
}
|
||||
|
||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String] {
|
||||
HostEnvSanitizer.sanitize(overrides: overrides)
|
||||
}
|
||||
|
||||
private nonisolated static func locationMode() -> OpenClawLocationMode {
|
||||
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
return OpenClawLocationMode(rawValue: raw) ?? .off
|
||||
|
||||
@@ -520,11 +520,12 @@ final class NodePairingApprovalPrompter {
|
||||
let preferred = GatewayDiscoveryPreferences.preferredStableID()
|
||||
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
|
||||
guard let gateway else { return nil }
|
||||
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
|
||||
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
||||
guard let host, !host.isEmpty else { return nil }
|
||||
let port = gateway.sshPort > 0 ? gateway.sshPort : 22
|
||||
return SSHTarget(host: host, port: port)
|
||||
guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway),
|
||||
let parsed = CommandResolver.parseSSHTarget(target)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return SSHTarget(host: parsed.host, port: parsed.port)
|
||||
}
|
||||
|
||||
private static func probeSSH(user: String, host: String, port: Int) async -> Bool {
|
||||
|
||||
@@ -26,20 +26,17 @@ extension OnboardingView {
|
||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||
|
||||
if self.state.remoteTransport == .direct {
|
||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||
self.state.remoteUrl = url
|
||||
}
|
||||
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||
let user = NSUserName()
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: gateway.serviceHost ?? host,
|
||||
port: gateway.servicePort ?? gateway.gatewayPort)
|
||||
self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
|
||||
} else {
|
||||
self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
|
||||
}
|
||||
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port)
|
||||
} else {
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
}
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
|
||||
self.state.connectionMode = .remote
|
||||
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
|
||||
|
||||
@@ -265,9 +265,11 @@ extension OnboardingView {
|
||||
if self.state.remoteTransport == .direct {
|
||||
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
|
||||
}
|
||||
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||
return "\(host)\(portSuffix)"
|
||||
if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway),
|
||||
let parsed = CommandResolver.parseSSHTarget(target)
|
||||
{
|
||||
let portSuffix = parsed.port != 22 ? " · ssh \(parsed.port)" : ""
|
||||
return "\(parsed.host)\(portSuffix)"
|
||||
}
|
||||
return "Gateway pairing only"
|
||||
}
|
||||
|
||||
@@ -223,6 +223,19 @@ enum OpenClawConfigFile {
|
||||
}
|
||||
}
|
||||
|
||||
static func clearRemoteGatewayUrl() {
|
||||
self.updateGatewayDict { gateway in
|
||||
guard var remote = gateway["remote"] as? [String: Any] else { return }
|
||||
guard remote["url"] != nil else { return }
|
||||
remote.removeValue(forKey: "url")
|
||||
if remote.isEmpty {
|
||||
gateway.removeValue(forKey: "remote")
|
||||
} else {
|
||||
gateway["remote"] = remote
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func remoteGatewayUrl() -> URL? {
|
||||
let root = self.loadDict()
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
|
||||
@@ -105,16 +105,24 @@ struct SystemRunSettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
||||
TextField("Add allowlist path pattern (case-insensitive globs)", text: self.$newPattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Add") {
|
||||
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !pattern.isEmpty else { return }
|
||||
self.model.addEntry(pattern)
|
||||
self.newPattern = ""
|
||||
if self.model.addEntry(self.newPattern) == nil {
|
||||
self.newPattern = ""
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.disabled(!self.model.isPathPattern(self.newPattern))
|
||||
}
|
||||
|
||||
Text("Path patterns only. Basename entries like \"echo\" are ignored.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if let validationMessage = self.model.allowlistValidationMessage {
|
||||
Text(validationMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if self.model.entries.isEmpty {
|
||||
@@ -234,6 +242,7 @@ final class ExecApprovalsSettingsModel {
|
||||
var autoAllowSkills = false
|
||||
var entries: [ExecAllowlistEntry] = []
|
||||
var skillBins: [String] = []
|
||||
var allowlistValidationMessage: String?
|
||||
|
||||
var agentPickerIds: [String] {
|
||||
[Self.defaultsScopeId] + self.agentIds
|
||||
@@ -289,6 +298,7 @@ final class ExecApprovalsSettingsModel {
|
||||
|
||||
func selectAgent(_ id: String) {
|
||||
self.selectedAgentId = id
|
||||
self.allowlistValidationMessage = nil
|
||||
self.loadSettings(for: id)
|
||||
Task { await self.refreshSkillBins() }
|
||||
}
|
||||
@@ -301,6 +311,7 @@ final class ExecApprovalsSettingsModel {
|
||||
self.askFallback = defaults.askFallback
|
||||
self.autoAllowSkills = defaults.autoAllowSkills
|
||||
self.entries = []
|
||||
self.allowlistValidationMessage = nil
|
||||
return
|
||||
}
|
||||
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
|
||||
@@ -310,6 +321,7 @@ final class ExecApprovalsSettingsModel {
|
||||
self.autoAllowSkills = resolved.agent.autoAllowSkills
|
||||
self.entries = resolved.allowlist
|
||||
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
|
||||
self.allowlistValidationMessage = nil
|
||||
}
|
||||
|
||||
func setSecurity(_ security: ExecSecurity) {
|
||||
@@ -367,32 +379,55 @@ final class ExecApprovalsSettingsModel {
|
||||
Task { await self.refreshSkillBins(force: enabled) }
|
||||
}
|
||||
|
||||
func addEntry(_ pattern: String) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
@discardableResult
|
||||
func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? {
|
||||
guard !self.isDefaultsScope else { return nil }
|
||||
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
|
||||
case .valid(let normalizedPattern):
|
||||
self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil))
|
||||
let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
self.allowlistValidationMessage = rejected.first?.reason.message
|
||||
return rejected.first?.reason
|
||||
case .invalid(let reason):
|
||||
self.allowlistValidationMessage = reason.message
|
||||
return reason
|
||||
}
|
||||
}
|
||||
|
||||
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
|
||||
self.entries[index] = entry
|
||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
@discardableResult
|
||||
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) -> ExecAllowlistPatternValidationReason? {
|
||||
guard !self.isDefaultsScope else { return nil }
|
||||
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
var next = entry
|
||||
switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) {
|
||||
case .valid(let normalizedPattern):
|
||||
next.pattern = normalizedPattern
|
||||
case .invalid(let reason):
|
||||
self.allowlistValidationMessage = reason.message
|
||||
return reason
|
||||
}
|
||||
self.entries[index] = next
|
||||
let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
self.allowlistValidationMessage = rejected.first?.reason.message
|
||||
return rejected.first?.reason
|
||||
}
|
||||
|
||||
func removeEntry(id: UUID) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
|
||||
self.entries.remove(at: index)
|
||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
self.allowlistValidationMessage = rejected.first?.reason.message
|
||||
}
|
||||
|
||||
func entry(for id: UUID) -> ExecAllowlistEntry? {
|
||||
self.entries.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func isPathPattern(_ pattern: String) -> Bool {
|
||||
ExecApprovalHelpers.isPathPattern(pattern)
|
||||
}
|
||||
|
||||
func refreshSkillBins(force: Bool = false) async {
|
||||
guard self.autoAllowSkills else {
|
||||
self.skillBins = []
|
||||
|
||||
@@ -281,8 +281,8 @@ actor GatewayWizardClient {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
var payloadParts = [
|
||||
connectNonce == nil ? "v1" : "v2",
|
||||
let payloadParts = [
|
||||
"v2",
|
||||
identity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
@@ -290,23 +290,19 @@ actor GatewayWizardClient {
|
||||
scopesValue,
|
||||
String(signedAtMs),
|
||||
self.token ?? "",
|
||||
connectNonce,
|
||||
]
|
||||
if let connectNonce {
|
||||
payloadParts.append(connectNonce)
|
||||
}
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
|
||||
{
|
||||
var device: [String: ProtoAnyCodable] = [
|
||||
let device: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(identity.deviceId),
|
||||
"publicKey": ProtoAnyCodable(publicKey),
|
||||
"signature": ProtoAnyCodable(signature),
|
||||
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||
"nonce": ProtoAnyCodable(connectNonce),
|
||||
]
|
||||
if let connectNonce {
|
||||
device["nonce"] = ProtoAnyCodable(connectNonce)
|
||||
}
|
||||
params["device"] = ProtoAnyCodable(device)
|
||||
}
|
||||
|
||||
@@ -333,29 +329,24 @@ actor GatewayWizardClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForConnectChallenge() async throws -> String? {
|
||||
guard let task = self.task else { return nil }
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: self.connectChallengeTimeoutSeconds,
|
||||
onTimeout: { ConnectChallengeError.timeout },
|
||||
operation: {
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
let frame = try await self.decodeFrame(message)
|
||||
if case let .event(evt) = frame, evt.event == "connect.challenge" {
|
||||
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||
let nonce = payload["nonce"]?.value as? String
|
||||
{
|
||||
return nonce
|
||||
}
|
||||
}
|
||||
private func waitForConnectChallenge() async throws -> String {
|
||||
guard let task = self.task else { throw ConnectChallengeError.timeout }
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: self.connectChallengeTimeoutSeconds,
|
||||
onTimeout: { ConnectChallengeError.timeout },
|
||||
operation: {
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
let frame = try await self.decodeFrame(message)
|
||||
if case let .event(evt) = frame, evt.event == "connect.challenge",
|
||||
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||
let nonce = payload["nonce"]?.value as? String,
|
||||
nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
{
|
||||
return nonce
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
if error is ConnectChallengeError { return nil }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,55 @@ import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
/// These cases cover optional `security=allowlist` behavior.
|
||||
/// Default install posture remains deny-by-default for exec on macOS node-host.
|
||||
struct ExecAllowlistTests {
|
||||
private struct ShellParserParityFixture: Decodable {
|
||||
struct Case: Decodable {
|
||||
let id: String
|
||||
let command: String
|
||||
let ok: Bool
|
||||
let executables: [String]
|
||||
}
|
||||
|
||||
let cases: [Case]
|
||||
}
|
||||
|
||||
private struct WrapperResolutionParityFixture: Decodable {
|
||||
struct Case: Decodable {
|
||||
let id: String
|
||||
let argv: [String]
|
||||
let expectedRawExecutable: String?
|
||||
}
|
||||
|
||||
let cases: [Case]
|
||||
}
|
||||
|
||||
private static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] {
|
||||
let fixtureURL = self.fixtureURL(filename: "exec-allowlist-shell-parser-parity.json")
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data)
|
||||
return fixture.cases
|
||||
}
|
||||
|
||||
private static func loadWrapperResolutionParityCases() throws -> [WrapperResolutionParityFixture.Case] {
|
||||
let fixtureURL = self.fixtureURL(filename: "exec-wrapper-resolution-parity.json")
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
let fixture = try JSONDecoder().decode(WrapperResolutionParityFixture.self, from: data)
|
||||
return fixture.cases
|
||||
}
|
||||
|
||||
private static func fixtureURL(filename: String) -> URL {
|
||||
var repoRoot = URL(fileURLWithPath: #filePath)
|
||||
for _ in 0..<5 {
|
||||
repoRoot.deleteLastPathComponent()
|
||||
}
|
||||
return repoRoot
|
||||
.appendingPathComponent("test")
|
||||
.appendingPathComponent("fixtures")
|
||||
.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
@Test func matchUsesResolvedPath() {
|
||||
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
|
||||
let resolution = ExecCommandResolution(
|
||||
@@ -14,7 +62,7 @@ struct ExecAllowlistTests {
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func matchUsesBasenameForSimplePattern() {
|
||||
@Test func matchIgnoresBasenamePattern() {
|
||||
let entry = ExecAllowlistEntry(pattern: "rg")
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
@@ -22,11 +70,22 @@ struct ExecAllowlistTests {
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
#expect(match == nil)
|
||||
}
|
||||
|
||||
@Test func matchIgnoresBasenameForRelativeExecutable() {
|
||||
let entry = ExecAllowlistEntry(pattern: "echo")
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "./echo",
|
||||
resolvedPath: "/tmp/oc-basename/echo",
|
||||
executableName: "echo",
|
||||
cwd: "/tmp/oc-basename")
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match == nil)
|
||||
}
|
||||
|
||||
@Test func matchIsCaseInsensitive() {
|
||||
let entry = ExecAllowlistEntry(pattern: "RG")
|
||||
let entry = ExecAllowlistEntry(pattern: "/OPT/HOMEBREW/BIN/RG")
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
@@ -80,6 +139,55 @@ struct ExecAllowlistTests {
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func resolveForAllowlistFailsClosedOnQuotedCommandSubstitution() {
|
||||
let command = ["/bin/sh", "-lc", "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\""]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\"",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func resolveForAllowlistFailsClosedOnQuotedBackticks() {
|
||||
let command = ["/bin/sh", "-lc", "echo \"ok `/usr/bin/id`\""]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo \"ok `/usr/bin/id`\"",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func resolveForAllowlistMatchesSharedShellParserFixture() throws {
|
||||
let fixtures = try Self.loadShellParserParityCases()
|
||||
for fixture in fixtures {
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: ["/bin/sh", "-lc", fixture.command],
|
||||
rawCommand: fixture.command,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
|
||||
#expect(!resolutions.isEmpty == fixture.ok)
|
||||
if fixture.ok {
|
||||
let executables = resolutions.map { $0.executableName.lowercased() }
|
||||
let expected = fixture.executables.map { $0.lowercased() }
|
||||
#expect(executables == expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func resolveMatchesSharedWrapperResolutionFixture() throws {
|
||||
let fixtures = try Self.loadWrapperResolutionParityCases()
|
||||
for fixture in fixtures {
|
||||
let resolution = ExecCommandResolution.resolve(
|
||||
command: fixture.argv,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolution?.rawExecutable == fixture.expectedRawExecutable)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() {
|
||||
let command = ["/bin/sh", "./script.sh"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
@@ -91,6 +199,30 @@ struct ExecAllowlistTests {
|
||||
#expect(resolutions[0].executableName == "sh")
|
||||
}
|
||||
|
||||
@Test func resolveForAllowlistUnwrapsEnvShellWrapperChains() {
|
||||
let command = ["/usr/bin/env", "/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 2)
|
||||
#expect(resolutions[0].executableName == "echo")
|
||||
#expect(resolutions[1].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func resolveForAllowlistUnwrapsEnvToEffectiveDirectExecutable() {
|
||||
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: nil,
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(resolutions[0].executableName == "printf")
|
||||
}
|
||||
|
||||
@Test func matchAllRequiresEverySegmentToMatch() {
|
||||
let first = ExecCommandResolution(
|
||||
rawExecutable: "echo",
|
||||
@@ -105,12 +237,12 @@ struct ExecAllowlistTests {
|
||||
let resolutions = [first, second]
|
||||
|
||||
let partial = ExecAllowlistMatcher.matchAll(
|
||||
entries: [ExecAllowlistEntry(pattern: "echo")],
|
||||
entries: [ExecAllowlistEntry(pattern: "/usr/bin/echo")],
|
||||
resolutions: resolutions)
|
||||
#expect(partial.isEmpty)
|
||||
|
||||
let full = ExecAllowlistMatcher.matchAll(
|
||||
entries: [ExecAllowlistEntry(pattern: "echo"), ExecAllowlistEntry(pattern: "touch")],
|
||||
entries: [ExecAllowlistEntry(pattern: "/USR/BIN/ECHO"), ExecAllowlistEntry(pattern: "/usr/bin/touch")],
|
||||
resolutions: resolutions)
|
||||
#expect(full.count == 2)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,24 @@ import Testing
|
||||
#expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil)
|
||||
}
|
||||
|
||||
@Test func validateAllowlistPatternReturnsReasons() {
|
||||
#expect(ExecApprovalHelpers.isPathPattern("/usr/bin/rg"))
|
||||
#expect(ExecApprovalHelpers.isPathPattern(" ~/bin/rg "))
|
||||
#expect(!ExecApprovalHelpers.isPathPattern("rg"))
|
||||
|
||||
if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern(" ") {
|
||||
#expect(reason == .empty)
|
||||
} else {
|
||||
Issue.record("Expected empty pattern rejection")
|
||||
}
|
||||
|
||||
if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern("echo") {
|
||||
#expect(reason == .missingPathComponent)
|
||||
} else {
|
||||
Issue.record("Expected basename pattern rejection")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func requiresAskMatchesPolicy() {
|
||||
let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil)
|
||||
#expect(ExecApprovalHelpers.requiresAsk(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ExecApprovalsStoreRefactorTests {
|
||||
@Test
|
||||
func ensureFileSkipsRewriteWhenUnchanged() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let url = ExecApprovalsStore.fileURL()
|
||||
let firstWriteDate = try Self.modificationDate(at: url)
|
||||
|
||||
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let secondWriteDate = try Self.modificationDate(at: url)
|
||||
|
||||
#expect(firstWriteDate == secondWriteDate)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func updateAllowlistReportsRejectedBasenamePattern() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
|
||||
let rejected = ExecApprovalsStore.updateAllowlist(
|
||||
agentId: "main",
|
||||
allowlist: [
|
||||
ExecAllowlistEntry(pattern: "echo"),
|
||||
ExecAllowlistEntry(pattern: "/bin/echo"),
|
||||
])
|
||||
#expect(rejected.count == 1)
|
||||
#expect(rejected.first?.reason == .missingPathComponent)
|
||||
#expect(rejected.first?.pattern == "echo")
|
||||
|
||||
let resolved = ExecApprovalsStore.resolve(agentId: "main")
|
||||
#expect(resolved.allowlist.map(\.pattern) == ["/bin/echo"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func updateAllowlistMigratesLegacyPatternFromResolvedPath() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
|
||||
let rejected = ExecApprovalsStore.updateAllowlist(
|
||||
agentId: "main",
|
||||
allowlist: [
|
||||
ExecAllowlistEntry(pattern: "echo", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: " /usr/bin/echo "),
|
||||
])
|
||||
#expect(rejected.isEmpty)
|
||||
|
||||
let resolved = ExecApprovalsStore.resolve(agentId: "main")
|
||||
#expect(resolved.allowlist.map(\.pattern) == ["/usr/bin/echo"])
|
||||
}
|
||||
}
|
||||
|
||||
private static func modificationDate(at url: URL) throws -> Date {
|
||||
let attributes = try FileManager().attributesOfItem(atPath: url.path)
|
||||
guard let date = attributes[.modificationDate] as? Date else {
|
||||
struct MissingDateError: Error {}
|
||||
throw MissingDateError()
|
||||
}
|
||||
return date
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,7 @@ import Testing
|
||||
|
||||
// First send is the connect handshake request. Subsequent sends are request frames.
|
||||
if currentSendCount == 0 {
|
||||
guard case let .data(data) = message else { return }
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
(obj["type"] as? String) == "req",
|
||||
(obj["method"] as? String) == "connect",
|
||||
let id = obj["id"] as? String
|
||||
{
|
||||
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
|
||||
self.connectRequestID.withLock { $0 = id }
|
||||
}
|
||||
return
|
||||
@@ -65,7 +60,7 @@ import Testing
|
||||
return
|
||||
}
|
||||
|
||||
let response = Self.responseData(id: id)
|
||||
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
|
||||
let handler = self.pendingReceiveHandler.withLock { $0 }
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
|
||||
}
|
||||
@@ -75,7 +70,7 @@ import Testing
|
||||
try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000)
|
||||
}
|
||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||
return .data(Self.connectOkData(id: id))
|
||||
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||
}
|
||||
|
||||
func receive(
|
||||
@@ -89,41 +84,6 @@ import Testing
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(data)))
|
||||
}
|
||||
|
||||
private static func connectOkData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"protocol": 2,
|
||||
"server": { "version": "test", "connId": "test" },
|
||||
"features": { "methods": [], "events": [] },
|
||||
"snapshot": {
|
||||
"presence": [ { "ts": 1 } ],
|
||||
"health": {},
|
||||
"stateVersion": { "presence": 0, "health": 0 },
|
||||
"uptimeMs": 0
|
||||
},
|
||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
private static func responseData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": { "ok": true }
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
|
||||
@@ -38,17 +38,7 @@ import Testing
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
let data: Data? = switch message {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else { return }
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
obj["type"] as? String == "req",
|
||||
obj["method"] as? String == "connect",
|
||||
let id = obj["id"] as? String
|
||||
{
|
||||
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
|
||||
self.connectRequestID.withLock { $0 = id }
|
||||
}
|
||||
}
|
||||
@@ -60,7 +50,7 @@ import Testing
|
||||
case let .helloOk(ms):
|
||||
delayMs = ms
|
||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||
msg = .data(Self.connectOkData(id: id))
|
||||
msg = .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||
case let .invalid(ms):
|
||||
delayMs = ms
|
||||
msg = .string("not json")
|
||||
@@ -77,29 +67,6 @@ import Testing
|
||||
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
||||
}
|
||||
|
||||
private static func connectOkData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"protocol": 2,
|
||||
"server": { "version": "test", "connId": "test" },
|
||||
"features": { "methods": [], "events": [] },
|
||||
"snapshot": {
|
||||
"presence": [ { "ts": 1 } ],
|
||||
"health": {},
|
||||
"stateVersion": { "presence": 0, "health": 0 },
|
||||
"uptimeMs": 0
|
||||
},
|
||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
|
||||
@@ -42,17 +42,7 @@ import Testing
|
||||
|
||||
// First send is the connect handshake. Second send is the request frame.
|
||||
if currentSendCount == 0 {
|
||||
let data: Data? = switch message {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else { return }
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
obj["type"] as? String == "req",
|
||||
obj["method"] as? String == "connect",
|
||||
let id = obj["id"] as? String
|
||||
{
|
||||
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
|
||||
self.connectRequestID.withLock { $0 = id }
|
||||
}
|
||||
}
|
||||
@@ -64,7 +54,7 @@ import Testing
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||
return .data(Self.connectOkData(id: id))
|
||||
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||
}
|
||||
|
||||
func receive(
|
||||
@@ -73,29 +63,6 @@ import Testing
|
||||
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
||||
}
|
||||
|
||||
private static func connectOkData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"protocol": 2,
|
||||
"server": { "version": "test", "connId": "test" },
|
||||
"features": { "methods": [], "events": [] },
|
||||
"snapshot": {
|
||||
"presence": [ { "ts": 1 } ],
|
||||
"health": {},
|
||||
"stateVersion": { "presence": 0, "health": 0 },
|
||||
"uptimeMs": 0
|
||||
},
|
||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
|
||||
@@ -32,24 +32,14 @@ import Testing
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
let data: Data? = switch message {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else { return }
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
obj["type"] as? String == "req",
|
||||
obj["method"] as? String == "connect",
|
||||
let id = obj["id"] as? String
|
||||
{
|
||||
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
|
||||
self.connectRequestID.withLock { $0 = id }
|
||||
}
|
||||
}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||
return .data(Self.connectOkData(id: id))
|
||||
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||
}
|
||||
|
||||
func receive(
|
||||
@@ -63,29 +53,6 @@ import Testing
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
|
||||
}
|
||||
|
||||
private static func connectOkData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"protocol": 2,
|
||||
"server": { "version": "test", "connId": "test" },
|
||||
"features": { "methods": [], "events": [] },
|
||||
"snapshot": {
|
||||
"presence": [ { "ts": 1 } ],
|
||||
"health": {},
|
||||
"stateVersion": { "presence": 0, "health": 0 },
|
||||
"uptimeMs": 0
|
||||
},
|
||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import OpenClawDiscovery
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite
|
||||
struct GatewayDiscoveryHelpersTests {
|
||||
private func makeGateway(
|
||||
serviceHost: String?,
|
||||
servicePort: Int?,
|
||||
lanHost: String? = "txt-host.local",
|
||||
tailnetDns: String? = "txt-host.ts.net",
|
||||
sshPort: Int = 22,
|
||||
gatewayPort: Int? = 18789) -> GatewayDiscoveryModel.DiscoveredGateway
|
||||
{
|
||||
GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: serviceHost,
|
||||
servicePort: servicePort,
|
||||
lanHost: lanHost,
|
||||
tailnetDns: tailnetDns,
|
||||
sshPort: sshPort,
|
||||
gatewayPort: gatewayPort,
|
||||
cliPath: "/tmp/openclaw",
|
||||
stableID: UUID().uuidString,
|
||||
debugID: UUID().uuidString,
|
||||
isLocal: false)
|
||||
}
|
||||
|
||||
@Test func sshTargetUsesResolvedServiceHostOnly() {
|
||||
let gateway = self.makeGateway(
|
||||
serviceHost: "resolved.example.ts.net",
|
||||
servicePort: 18789,
|
||||
sshPort: 2201)
|
||||
|
||||
guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else {
|
||||
Issue.record("expected ssh target")
|
||||
return
|
||||
}
|
||||
let parsed = CommandResolver.parseSSHTarget(target)
|
||||
#expect(parsed?.host == "resolved.example.ts.net")
|
||||
#expect(parsed?.port == 2201)
|
||||
}
|
||||
|
||||
@Test func sshTargetAllowsMissingResolvedServicePort() {
|
||||
let gateway = self.makeGateway(
|
||||
serviceHost: "resolved.example.ts.net",
|
||||
servicePort: nil,
|
||||
sshPort: 2201)
|
||||
|
||||
guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else {
|
||||
Issue.record("expected ssh target")
|
||||
return
|
||||
}
|
||||
let parsed = CommandResolver.parseSSHTarget(target)
|
||||
#expect(parsed?.host == "resolved.example.ts.net")
|
||||
#expect(parsed?.port == 2201)
|
||||
}
|
||||
|
||||
@Test func sshTargetRejectsTxtOnlyGateways() {
|
||||
let gateway = self.makeGateway(
|
||||
serviceHost: nil,
|
||||
servicePort: nil,
|
||||
lanHost: "txt-only.local",
|
||||
tailnetDns: "txt-only.ts.net",
|
||||
sshPort: 2222)
|
||||
|
||||
#expect(GatewayDiscoveryHelpers.sshTarget(for: gateway) == nil)
|
||||
}
|
||||
|
||||
@Test func directUrlUsesResolvedServiceEndpointOnly() {
|
||||
let tlsGateway = self.makeGateway(
|
||||
serviceHost: "resolved.example.ts.net",
|
||||
servicePort: 443)
|
||||
#expect(GatewayDiscoveryHelpers.directUrl(for: tlsGateway) == "wss://resolved.example.ts.net")
|
||||
|
||||
let wsGateway = self.makeGateway(
|
||||
serviceHost: "resolved.example.ts.net",
|
||||
servicePort: 18789)
|
||||
#expect(GatewayDiscoveryHelpers.directUrl(for: wsGateway) == "wss://resolved.example.ts.net:18789")
|
||||
|
||||
let localGateway = self.makeGateway(
|
||||
serviceHost: "127.0.0.1",
|
||||
servicePort: 18789)
|
||||
#expect(GatewayDiscoveryHelpers.directUrl(for: localGateway) == "ws://127.0.0.1:18789")
|
||||
}
|
||||
|
||||
@Test func directUrlRejectsTxtOnlyFallback() {
|
||||
let gateway = self.makeGateway(
|
||||
serviceHost: nil,
|
||||
servicePort: nil,
|
||||
lanHost: "txt-only.local",
|
||||
tailnetDns: "txt-only.ts.net",
|
||||
gatewayPort: 22222)
|
||||
|
||||
#expect(GatewayDiscoveryHelpers.directUrl(for: gateway) == nil)
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,7 @@ import Testing
|
||||
}
|
||||
|
||||
@Test func normalizeGatewayUrlRejectsNonLoopbackWs() {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789")
|
||||
#expect(url == nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,12 +39,7 @@ struct GatewayProcessManagerTests {
|
||||
}
|
||||
|
||||
if currentSendCount == 0 {
|
||||
guard case let .data(data) = message else { return }
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
(obj["type"] as? String) == "req",
|
||||
(obj["method"] as? String) == "connect",
|
||||
let id = obj["id"] as? String
|
||||
{
|
||||
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
|
||||
self.connectRequestID.withLock { $0 = id }
|
||||
}
|
||||
return
|
||||
@@ -59,14 +54,14 @@ struct GatewayProcessManagerTests {
|
||||
return
|
||||
}
|
||||
|
||||
let response = Self.responseData(id: id)
|
||||
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
|
||||
let handler = self.pendingReceiveHandler.withLock { $0 }
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
|
||||
}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||
return .data(Self.connectOkData(id: id))
|
||||
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
|
||||
}
|
||||
|
||||
func receive(
|
||||
@@ -75,41 +70,6 @@ struct GatewayProcessManagerTests {
|
||||
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
||||
}
|
||||
|
||||
private static func connectOkData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"protocol": 2,
|
||||
"server": { "version": "test", "connId": "test" },
|
||||
"features": { "methods": [], "events": [] },
|
||||
"snapshot": {
|
||||
"presence": [ { "ts": 1 } ],
|
||||
"health": {},
|
||||
"stateVersion": { "presence": 0, "health": 0 },
|
||||
"uptimeMs": 0
|
||||
},
|
||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
private static func responseData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": { "ok": true }
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
|
||||
extension WebSocketTasking {
|
||||
// Keep unit-test doubles resilient to protocol additions.
|
||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
||||
pongReceiveHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
enum GatewayWebSocketTestSupport {
|
||||
static func connectRequestID(from message: URLSessionWebSocketTask.Message) -> String? {
|
||||
let data: Data? = switch message {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else { return nil }
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else {
|
||||
return nil
|
||||
}
|
||||
return obj["id"] as? String
|
||||
}
|
||||
|
||||
static func connectOkData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"protocol": 2,
|
||||
"server": { "version": "test", "connId": "test" },
|
||||
"features": { "methods": [], "events": [] },
|
||||
"snapshot": {
|
||||
"presence": [ { "ts": 1 } ],
|
||||
"health": {},
|
||||
"stateVersion": { "presence": 0, "health": 0 },
|
||||
"uptimeMs": 0
|
||||
},
|
||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
static func okResponseData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": { "ok": true }
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import Testing
|
||||
configpath: nil,
|
||||
statedir: nil,
|
||||
sessiondefaults: nil,
|
||||
authmode: nil)
|
||||
authmode: nil,
|
||||
updateavailable: nil)
|
||||
|
||||
let hello = HelloOk(
|
||||
type: "hello",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import OpenClawDiscovery
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@@ -25,4 +26,36 @@ struct OnboardingViewSmokeTests {
|
||||
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
|
||||
#expect(!order.contains(8))
|
||||
}
|
||||
|
||||
@Test func selectRemoteGatewayClearsStaleSshTargetWhenEndpointUnresolved() async {
|
||||
let override = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("openclaw.json")
|
||||
.path
|
||||
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
||||
let state = AppState(preview: true)
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteTarget = "user@old-host:2222"
|
||||
let view = OnboardingView(
|
||||
state: state,
|
||||
permissionMonitor: PermissionMonitor.shared,
|
||||
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Unresolved",
|
||||
serviceHost: nil,
|
||||
servicePort: nil,
|
||||
lanHost: "txt-host.local",
|
||||
tailnetDns: "txt-host.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 18789,
|
||||
cliPath: "/tmp/openclaw",
|
||||
stableID: UUID().uuidString,
|
||||
debugID: UUID().uuidString,
|
||||
isLocal: false)
|
||||
|
||||
view.selectRemoteGateway(gateway)
|
||||
#expect(state.remoteTarget.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,31 @@ struct OpenClawConfigFileTests {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func clearRemoteGatewayUrlRemovesOnlyUrlField() async {
|
||||
let override = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("openclaw.json")
|
||||
.path
|
||||
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "wss://old-host:111",
|
||||
"token": "tok",
|
||||
],
|
||||
],
|
||||
])
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let remote = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
|
||||
#expect((remote["url"] as? String) == nil)
|
||||
#expect((remote["token"] as? String) == "tok")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func stateDirOverrideSetsConfigPath() async {
|
||||
let dir = FileManager().temporaryDirectory
|
||||
|
||||
@@ -146,8 +146,8 @@ public actor GatewayChannelActor {
|
||||
private var lastAuthSource: GatewayAuthSource = .none
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
// Remote gateways (tailscale/wan) can take a bit longer to deliver the connect.challenge event,
|
||||
// and we must include the nonce once the gateway requires v2 signing.
|
||||
// Remote gateways (tailscale/wan) can take longer to deliver connect.challenge.
|
||||
// Connect now requires this nonce before we send device-auth.
|
||||
private let connectTimeoutSeconds: Double = 12
|
||||
private let connectChallengeTimeoutSeconds: Double = 6.0
|
||||
// Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client,
|
||||
@@ -391,8 +391,8 @@ public actor GatewayChannelActor {
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let connectNonce = try await self.waitForConnectChallenge()
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
var payloadParts = [
|
||||
connectNonce == nil ? "v1" : "v2",
|
||||
let payloadParts = [
|
||||
"v2",
|
||||
identity?.deviceId ?? "",
|
||||
clientId,
|
||||
clientMode,
|
||||
@@ -400,23 +400,19 @@ public actor GatewayChannelActor {
|
||||
scopesValue,
|
||||
String(signedAtMs),
|
||||
authToken ?? "",
|
||||
connectNonce,
|
||||
]
|
||||
if let connectNonce {
|
||||
payloadParts.append(connectNonce)
|
||||
}
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if includeDeviceIdentity, let identity {
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||
var device: [String: ProtoAnyCodable] = [
|
||||
let device: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(identity.deviceId),
|
||||
"publicKey": ProtoAnyCodable(publicKey),
|
||||
"signature": ProtoAnyCodable(signature),
|
||||
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||
"nonce": ProtoAnyCodable(connectNonce),
|
||||
]
|
||||
if let connectNonce {
|
||||
device["nonce"] = ProtoAnyCodable(connectNonce)
|
||||
}
|
||||
params["device"] = ProtoAnyCodable(device)
|
||||
}
|
||||
}
|
||||
@@ -545,33 +541,26 @@ public actor GatewayChannelActor {
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForConnectChallenge() async throws -> String? {
|
||||
guard let task = self.task else { return nil }
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: self.connectChallengeTimeoutSeconds,
|
||||
onTimeout: { ConnectChallengeError.timeout },
|
||||
operation: { [weak self] in
|
||||
guard let self else { return nil }
|
||||
while true {
|
||||
let msg = try await task.receive()
|
||||
guard let data = self.decodeMessageData(msg) else { continue }
|
||||
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
|
||||
if case let .event(evt) = frame, evt.event == "connect.challenge" {
|
||||
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||
let nonce = payload["nonce"]?.value as? String {
|
||||
return nonce
|
||||
}
|
||||
}
|
||||
private func waitForConnectChallenge() async throws -> String {
|
||||
guard let task = self.task else { throw ConnectChallengeError.timeout }
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: self.connectChallengeTimeoutSeconds,
|
||||
onTimeout: { ConnectChallengeError.timeout },
|
||||
operation: { [weak self] in
|
||||
guard let self else { throw ConnectChallengeError.timeout }
|
||||
while true {
|
||||
let msg = try await task.receive()
|
||||
guard let data = self.decodeMessageData(msg) else { continue }
|
||||
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
|
||||
if case let .event(evt) = frame, evt.event == "connect.challenge",
|
||||
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||
let nonce = payload["nonce"]?.value as? String,
|
||||
nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
{
|
||||
return nonce
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
if error is ConnectChallengeError {
|
||||
self.logger.warning("gateway connect challenge timed out")
|
||||
return nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame {
|
||||
|
||||
@@ -398,6 +398,7 @@ Example:
|
||||
|
||||
- guild must match `channels.discord.guilds` (`id` preferred, slug accepted)
|
||||
- optional sender allowlists: `users` (IDs or names) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles`
|
||||
- names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used
|
||||
- if a guild has `channels` configured, non-listed channels are denied
|
||||
- if a guild has no `channels` block, all channels in that allowlisted guild are allowed
|
||||
|
||||
@@ -562,7 +563,9 @@ Default slash command settings:
|
||||
<Accordion title="Live stream preview">
|
||||
OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives.
|
||||
|
||||
- `channels.discord.streamMode` controls preview streaming (`off` | `partial` | `block`, default: `off`).
|
||||
- `channels.discord.streaming` controls preview streaming (`off` | `partial` | `block` | `progress`, default: `off`).
|
||||
- `progress` is accepted for cross-channel consistency and maps to `partial` on Discord.
|
||||
- `channels.discord.streamMode` is a legacy alias and is auto-migrated.
|
||||
- `partial` edits a single preview message as tokens arrive.
|
||||
- `block` emits draft-sized chunks (use `draftChunk` to tune size and breakpoints).
|
||||
|
||||
@@ -572,7 +575,7 @@ Default slash command settings:
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
streamMode: "partial",
|
||||
streaming: "partial",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -584,7 +587,7 @@ Default slash command settings:
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
streamMode: "block",
|
||||
streaming: "block",
|
||||
draftChunk: {
|
||||
minChars: 200,
|
||||
maxChars: 800,
|
||||
@@ -624,6 +627,49 @@ Default slash command settings:
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Thread-bound sessions for subagents">
|
||||
Discord can bind a thread to a session target so follow-up messages in that thread keep routing to the same session (including subagent sessions).
|
||||
|
||||
Commands:
|
||||
|
||||
- `/focus <target>` bind current/new thread to a subagent/session target
|
||||
- `/unfocus` remove current thread binding
|
||||
- `/agents` show active runs and binding state
|
||||
- `/session ttl <duration|off>` inspect/update auto-unfocus TTL for focused bindings
|
||||
|
||||
Config:
|
||||
|
||||
```json5
|
||||
{
|
||||
session: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
spawnSubagentSessions: false, // opt-in
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `session.threadBindings.*` sets global defaults.
|
||||
- `channels.discord.threadBindings.*` overrides Discord behavior.
|
||||
- `spawnSubagentSessions` must be true to auto-create/bind threads for `sessions_spawn({ thread: true })`.
|
||||
- If thread bindings are disabled for an account, `/focus` and related thread binding operations are unavailable.
|
||||
|
||||
See [Sub-agents](/tools/subagents) and [Configuration Reference](/gateway/configuration-reference).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Reaction notifications">
|
||||
Per-guild reaction notification mode:
|
||||
|
||||
@@ -976,7 +1022,7 @@ High-signal Discord fields:
|
||||
- command: `commands.native`, `commands.useAccessGroups`, `configWrites`, `slashCommand.*`
|
||||
- reply/history: `replyToMode`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
|
||||
- delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage`
|
||||
- streaming: `streamMode`, `draftChunk`, `blockStreaming`, `blockStreamingCoalesce`
|
||||
- streaming: `streaming` (legacy alias: `streamMode`), `draftChunk`, `blockStreaming`, `blockStreamingCoalesce`
|
||||
- media/retry: `mediaMaxMb`, `retry`
|
||||
- actions: `actions.*`
|
||||
- presence: `activity`, `status`, `activityType`, `activityUrl`
|
||||
|
||||
@@ -21,7 +21,7 @@ title: grammY
|
||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls).
|
||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`.
|
||||
- **Live stream preview:** optional `channels.telegram.streaming` sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming.
|
||||
- **Live stream preview:** `channels.telegram.streaming` (`off | partial | block | progress`) sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming.
|
||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||
|
||||
Open questions
|
||||
|
||||
@@ -465,14 +465,29 @@ openclaw pairing list slack
|
||||
|
||||
OpenClaw supports Slack native text streaming via the Agents and AI Apps API.
|
||||
|
||||
By default, streaming is enabled. Disable it per account:
|
||||
`channels.slack.streaming` controls live preview behavior:
|
||||
|
||||
- `off`: disable live preview streaming.
|
||||
- `partial` (default): replace preview text with the latest partial output.
|
||||
- `block`: append chunked preview updates.
|
||||
- `progress`: show progress status text while generating, then send final text.
|
||||
|
||||
`channels.slack.nativeStreaming` controls Slack's native streaming API (`chat.startStream` / `chat.appendStream` / `chat.stopStream`) when `streaming` is `partial` (default: `true`).
|
||||
|
||||
Disable native Slack streaming (keep draft preview behavior):
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
slack:
|
||||
streaming: false
|
||||
streaming: partial
|
||||
nativeStreaming: false
|
||||
```
|
||||
|
||||
Legacy keys:
|
||||
|
||||
- `channels.slack.streamMode` (`replace | status_final | append`) is auto-migrated to `channels.slack.streaming`.
|
||||
- boolean `channels.slack.streaming` is auto-migrated to `channels.slack.nativeStreaming`.
|
||||
|
||||
### Requirements
|
||||
|
||||
1. Enable **Agents and AI Apps** in your Slack app settings.
|
||||
@@ -498,7 +513,7 @@ Primary reference:
|
||||
- DM access: `dm.enabled`, `dmPolicy`, `allowFrom` (legacy: `dm.policy`, `dm.allowFrom`), `dm.groupEnabled`, `dm.groupChannels`
|
||||
- channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention`
|
||||
- threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
|
||||
- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`
|
||||
- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `nativeStreaming`
|
||||
- ops/features: `configWrites`, `commands.native`, `slashCommand.*`, `actions.*`, `userToken`, `userTokenReadOnly`
|
||||
|
||||
## Related
|
||||
|
||||
@@ -226,8 +226,9 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
|
||||
Requirement:
|
||||
|
||||
- `channels.telegram.streaming` is `true` (default)
|
||||
- legacy `channels.telegram.streamMode` values are auto-mapped to `streaming`
|
||||
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `off`)
|
||||
- `progress` maps to `partial` on Telegram (compat with cross-channel naming)
|
||||
- legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped
|
||||
|
||||
This works in direct chats and groups/topics.
|
||||
|
||||
@@ -708,7 +709,7 @@ Primary reference:
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streaming`: `true | false` (live stream preview; default: true).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`).
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts.
|
||||
|
||||
@@ -27,12 +27,67 @@ The audit warns when multiple DM senders share the main session and recommends *
|
||||
This is for cooperative/shared inbox hardening. A single Gateway shared by mutually untrusted/adversarial operators is not a recommended setup; split trust boundaries with separate gateways (or separate OS users/hosts).
|
||||
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||
For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.
|
||||
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy.
|
||||
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy.
|
||||
It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`.
|
||||
It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`.
|
||||
It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions.
|
||||
It warns when Discord allowlists (`channels.discord.allowFrom`, `channels.discord.guilds.*.users`, pairing store) use name or tag entries instead of stable IDs.
|
||||
It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint).
|
||||
|
||||
## Skill security
|
||||
|
||||
Community skills (installed from ClawHub) are subject to additional security enforcement:
|
||||
|
||||
- **SKILL.md scanning**: content is scanned for prompt injection patterns, capability inflation, and boundary spoofing before entering the system prompt. Skills with critical findings are blocked from loading.
|
||||
- **Capability enforcement**: community skills must declare `capabilities` (e.g., `shell`, `network`) in frontmatter. Undeclared dangerous tool usage is blocked at runtime by the before-tool-call hook — a hard code gate that prompt injection cannot bypass.
|
||||
- **Command dispatch gating**: community skills using `command-dispatch: tool` can't dispatch to dangerous tools without the matching capability.
|
||||
- **Audit logging**: all security events are tagged with `category: "security"` and include session context for forensics. View in the web UI Logs tab using the Security filter.
|
||||
|
||||
See `openclaw skills check` for a runtime security overview, `openclaw skills info <name>` for per-skill details, and [Skills — Tool enforcement matrix](/tools/skills#tool-enforcement-matrix) for the complete tool-by-tool breakdown.
|
||||
|
||||
### Tool enforcement matrix
|
||||
|
||||
Every tool falls into one of three tiers when community skills are loaded:
|
||||
|
||||
**Always denied** — blocked unconditionally, no capability can override:
|
||||
|
||||
| Tool | Reason |
|
||||
|------|--------|
|
||||
| `gateway` | Control-plane reconfiguration (restart, shutdown, auth changes) |
|
||||
| `nodes` | Cluster node management (add/remove compute, redirect traffic) |
|
||||
|
||||
**Capability-gated** — blocked by default, allowed if the skill declares the matching capability:
|
||||
|
||||
| Capability | Tools | What it unlocks |
|
||||
|------------|-------|-----------------|
|
||||
| `shell` | `exec`, `process`, `lobster` | Run shell commands and manage processes |
|
||||
| `filesystem` | `write`, `edit`, `apply_patch` | File mutations (read is always allowed) |
|
||||
| `network` | `web_fetch`, `web_search` | Outbound HTTP requests |
|
||||
| `browser` | `browser` | Browser automation |
|
||||
| `sessions` | `sessions_spawn`, `sessions_send`, `subagents` | Cross-session orchestration |
|
||||
| `messaging` | `message` | Send messages to configured channels |
|
||||
| `scheduling` | `cron` | Schedule recurring jobs |
|
||||
|
||||
**Always allowed** — safe read-only or output-only tools, no capability required:
|
||||
|
||||
| Tool | Why safe |
|
||||
|------|---------|
|
||||
| `read` | Read-only file access |
|
||||
| `memory_search`, `memory_get` | Read-only memory access |
|
||||
| `agents_list` | List agents (read-only) |
|
||||
| `sessions_list`, `sessions_history`, `session_status` | Session introspection (read-only) |
|
||||
| `canvas` | UI rendering (output-only) |
|
||||
| `image` | Image generation (output-only) |
|
||||
| `tts` | Text-to-speech (output-only) |
|
||||
|
||||
A community skill with no capabilities declared gets access only to the always-allowed tier. Declare capabilities in SKILL.md frontmatter:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
openclaw:
|
||||
capabilities: [shell, filesystem, network]
|
||||
```
|
||||
|
||||
## JSON output
|
||||
|
||||
Use `--json` for CI/policy checks:
|
||||
|
||||
@@ -18,9 +18,163 @@ Related:
|
||||
|
||||
## Commands
|
||||
|
||||
### `openclaw skills list`
|
||||
|
||||
List all skills with status, capabilities, and source.
|
||||
|
||||
```bash
|
||||
openclaw skills list
|
||||
openclaw skills list --eligible
|
||||
openclaw skills info <name>
|
||||
openclaw skills check
|
||||
openclaw skills list # all skills
|
||||
openclaw skills list --eligible # only ready-to-use skills
|
||||
openclaw skills list --json # JSON output
|
||||
openclaw skills list -v # verbose (show missing requirements)
|
||||
```
|
||||
|
||||
Output columns: **Status** (`+ ready`, `x missing`, `x blocked`), **Skill** (name + capability icons), **Description**, **Source**.
|
||||
|
||||
Capability icons displayed next to skill names:
|
||||
|
||||
| Icon | Capability |
|
||||
|------|-----------|
|
||||
| `>_` | `shell` — run shell commands |
|
||||
| `📂` | `filesystem` — read/write files |
|
||||
| `🌐` | `network` — outbound HTTP |
|
||||
| `🔍` | `browser` — browser automation |
|
||||
| `⚡` | `sessions` — cross-session orchestration |
|
||||
|
||||
Skills blocked by security scanning show `x blocked` instead of `x missing`.
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
Skills (10/12 ready)
|
||||
|
||||
Status Skill Description Source
|
||||
+ ready git-autopush >_ 🌐 Automate git workflows openclaw-managed
|
||||
+ ready think Extended thinking bundled
|
||||
+ ready peekaboo 🔍 ⚡ Browser peek and screenshot bundled
|
||||
x missing summarize >_ Summarize with CLI tool bundled
|
||||
x blocked evil-injector >_ Totally harmless skill openclaw-managed
|
||||
- disabled old-skill Deprecated skill workspace
|
||||
```
|
||||
|
||||
With `-v` (verbose), two extra columns appear — **Scan** and **Missing**:
|
||||
|
||||
```
|
||||
Status Skill Description Source Scan Missing
|
||||
+ ready git-autopush >_ 🌐 Automate git wor... openclaw-managed
|
||||
x missing summarize >_ Summarize with... bundled bins: summarize
|
||||
x blocked evil-injector >_ Totally harmless... openclaw-managed [blocked]
|
||||
+ ready sketch-tool 🌐 >_ Generate sketches openclaw-managed [warn]
|
||||
```
|
||||
|
||||
### `openclaw skills info <name>`
|
||||
|
||||
Show detailed information about a single skill including security status.
|
||||
|
||||
```bash
|
||||
openclaw skills info git-helper
|
||||
openclaw skills info git-helper --json
|
||||
```
|
||||
|
||||
Displays: description, source, file path, capabilities (with descriptions), security scan results, requirements (met/unmet), and install options.
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
git-autopush + Ready
|
||||
|
||||
Automate git commit, push, and PR workflows.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/git-autopush/SKILL.md
|
||||
Homepage https://github.com/example/git-autopush
|
||||
Primary env GH_TOKEN
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
🌐 network Make outbound HTTP requests
|
||||
|
||||
Security
|
||||
Scan + clean
|
||||
|
||||
Requirements
|
||||
bin git + ok
|
||||
bin gh + ok
|
||||
env GH_TOKEN + ok
|
||||
```
|
||||
|
||||
For a skill with missing requirements:
|
||||
|
||||
```
|
||||
summarize x Missing requirements
|
||||
|
||||
Summarize URLs and files using the summarize CLI.
|
||||
|
||||
Source bundled
|
||||
Path /opt/openclaw/skills/summarize/SKILL.md
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
|
||||
Security
|
||||
Scan + clean
|
||||
|
||||
Requirements
|
||||
bin summarize x missing
|
||||
|
||||
Install options
|
||||
brew Install summarize (brew install summarize)
|
||||
```
|
||||
|
||||
For a skill blocked by scanning:
|
||||
|
||||
```
|
||||
evil-injector x Blocked (security)
|
||||
|
||||
Totally harmless skill.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/evil-injector/SKILL.md
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
|
||||
Security
|
||||
Scan [blocked] prompt injection detected
|
||||
```
|
||||
|
||||
### `openclaw skills check`
|
||||
|
||||
Security-focused overview of all skills.
|
||||
|
||||
```bash
|
||||
openclaw skills check
|
||||
openclaw skills check --json
|
||||
```
|
||||
|
||||
Shows: total/eligible/disabled/blocked/missing counts, capabilities requested by community skills, runtime policy restrictions, and scan result summary.
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
Skills Status Check
|
||||
|
||||
Status Count
|
||||
Total 12
|
||||
Eligible 10
|
||||
Disabled 1
|
||||
Blocked (allowlist) 0
|
||||
Missing requirements 1
|
||||
|
||||
Community skill capabilities
|
||||
Icon Capability # Skills
|
||||
>_ shell 3 git-autopush, deploy-helper, node-runner
|
||||
📂 filesystem 2 git-autopush, file-editor
|
||||
🌐 network 2 git-autopush, sketch-tool
|
||||
|
||||
Scan results
|
||||
Result #
|
||||
Clean 11
|
||||
Warning 1
|
||||
Blocked 0
|
||||
```
|
||||
|
||||
@@ -97,8 +97,8 @@ sequenceDiagram
|
||||
for subsequent connects.
|
||||
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
|
||||
auto‑approved to keep same‑host UX smooth.
|
||||
- **Non‑local** connects must sign the `connect.challenge` nonce and require
|
||||
explicit approval.
|
||||
- All connects must sign the `connect.challenge` nonce.
|
||||
- **Non‑local** connects still require explicit approval.
|
||||
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
|
||||
remote.
|
||||
|
||||
|
||||
@@ -151,7 +151,10 @@ Parameters:
|
||||
- `label?` (optional; used for logs/UI)
|
||||
- `agentId?` (optional; spawn under another agent id if allowed)
|
||||
- `model?` (optional; overrides the sub-agent model; invalid values error)
|
||||
- `thinking?` (optional; overrides thinking level for the sub-agent run)
|
||||
- `runTimeoutSeconds?` (default 0; when set, aborts the sub-agent run after N seconds)
|
||||
- `thread?` (default false; request thread-bound routing for this spawn when supported by the channel/plugin)
|
||||
- `mode?` (`run|session`; defaults to `run`, but defaults to `session` when `thread=true`; `mode="session"` requires `thread=true`)
|
||||
- `cleanup?` (`delete|keep`, default `keep`)
|
||||
|
||||
Allowlist:
|
||||
@@ -168,6 +171,7 @@ Behavior:
|
||||
- Sub-agents default to the full tool set **minus session tools** (configurable via `tools.subagents.tools`).
|
||||
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
|
||||
- Always non-blocking: returns `{ status: "accepted", runId, childSessionKey }` immediately.
|
||||
- With `thread=true`, channel plugins can bind delivery/routing to a thread target (Discord support is controlled by `session.threadBindings.*` and `channels.discord.threadBindings.*`).
|
||||
- After completion, OpenClaw runs a sub-agent **announce step** and posts the result to the requester chat channel.
|
||||
- If the assistant final reply is empty, the latest `toolResult` from sub-agent history is included as `Result`.
|
||||
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
---
|
||||
summary: "Streaming + chunking behavior (block replies, Telegram preview streaming, limits)"
|
||||
summary: "Streaming + chunking behavior (block replies, channel preview streaming, mode mapping)"
|
||||
read_when:
|
||||
- Explaining how streaming or chunking works on channels
|
||||
- Changing block streaming or channel chunking behavior
|
||||
- Debugging duplicate/early block replies or Telegram preview streaming
|
||||
- Debugging duplicate/early block replies or channel preview streaming
|
||||
title: "Streaming and Chunking"
|
||||
---
|
||||
|
||||
# Streaming + chunking
|
||||
|
||||
OpenClaw has two separate “streaming” layers:
|
||||
OpenClaw has two separate streaming layers:
|
||||
|
||||
- **Block streaming (channels):** emit completed **blocks** as the assistant writes. These are normal channel messages (not token deltas).
|
||||
- **Token-ish streaming (Telegram only):** update a temporary **preview message** with partial text while generating.
|
||||
- **Preview streaming (Telegram/Discord/Slack):** update a temporary **preview message** while generating.
|
||||
|
||||
There is **no true token-delta streaming** to channel messages today. Telegram preview streaming is the only partial-stream surface.
|
||||
There is **no true token-delta streaming** to channel messages today. Preview streaming is message-based (send + edits/appends).
|
||||
|
||||
## Block streaming (channel messages)
|
||||
|
||||
@@ -98,34 +98,58 @@ This maps to:
|
||||
- **Stream everything at end:** `blockStreamingBreak: "message_end"` (flush once, possibly multiple chunks if very long).
|
||||
- **No block streaming:** `blockStreamingDefault: "off"` (only final reply).
|
||||
|
||||
**Channel note:** For non-Telegram channels, block streaming is **off unless**
|
||||
`*.blockStreaming` is explicitly set to `true`. Telegram can stream a live preview
|
||||
(`channels.telegram.streaming`) without block replies.
|
||||
**Channel note:** Block streaming is **off unless**
|
||||
`*.blockStreaming` is explicitly set to `true`. Channels can stream a live preview
|
||||
(`channels.<channel>.streaming`) without block replies.
|
||||
|
||||
Config location reminder: the `blockStreaming*` defaults live under
|
||||
`agents.defaults`, not the root config.
|
||||
|
||||
## Telegram preview streaming (token-ish)
|
||||
## Preview streaming modes
|
||||
|
||||
Telegram is the only channel with live preview streaming:
|
||||
Canonical key: `channels.<channel>.streaming`
|
||||
|
||||
- Uses Bot API `sendMessage` (first update) + `editMessageText` (subsequent updates).
|
||||
- `channels.telegram.streaming: true | false` (default: `true`).
|
||||
- Preview streaming is separate from block streaming.
|
||||
- When Telegram block streaming is explicitly enabled, preview streaming is skipped to avoid double-streaming.
|
||||
- Text-only finals are applied by editing the preview message in place.
|
||||
- Non-text/complex finals fall back to normal final message delivery.
|
||||
- `/reasoning stream` writes reasoning into the live preview (Telegram only).
|
||||
Modes:
|
||||
|
||||
```
|
||||
Telegram
|
||||
└─ sendMessage (temporary preview message)
|
||||
└─ streaming=true → edit latest text
|
||||
└─ final text-only reply → final edit on same message
|
||||
└─ fallback: cleanup preview + normal final delivery (media/complex)
|
||||
```
|
||||
- `off`: disable preview streaming.
|
||||
- `partial`: single preview that is replaced with latest text.
|
||||
- `block`: preview updates in chunked/appended steps.
|
||||
- `progress`: progress/status preview during generation, final answer at completion.
|
||||
|
||||
Legend:
|
||||
### Channel mapping
|
||||
|
||||
- `preview message`: temporary Telegram message updated during generation.
|
||||
- `final edit`: in-place edit on the same preview message (text-only).
|
||||
| Channel | `off` | `partial` | `block` | `progress` |
|
||||
| -------- | ----- | --------- | ------- | ----------------- |
|
||||
| Telegram | ✅ | ✅ | ✅ | maps to `partial` |
|
||||
| Discord | ✅ | ✅ | ✅ | maps to `partial` |
|
||||
| Slack | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
Slack-only:
|
||||
|
||||
- `channels.slack.nativeStreaming` toggles Slack native streaming API calls when `streaming=partial` (default: `true`).
|
||||
|
||||
Legacy key migration:
|
||||
|
||||
- Telegram: `streamMode` + boolean `streaming` auto-migrate to `streaming` enum.
|
||||
- Discord: `streamMode` + boolean `streaming` auto-migrate to `streaming` enum.
|
||||
- Slack: `streamMode` auto-migrates to `streaming` enum; boolean `streaming` auto-migrates to `nativeStreaming`.
|
||||
|
||||
### Runtime behavior
|
||||
|
||||
Telegram:
|
||||
|
||||
- Uses Bot API `sendMessage` + `editMessageText`.
|
||||
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
|
||||
- `/reasoning stream` can write reasoning to preview.
|
||||
|
||||
Discord:
|
||||
|
||||
- Uses send + edit preview messages.
|
||||
- `block` mode uses draft chunking (`draftChunk`).
|
||||
- Preview streaming is skipped when Discord block streaming is explicitly enabled.
|
||||
|
||||
Slack:
|
||||
|
||||
- `partial` can use Slack native streaming (`chat.startStream`/`append`/`stop`) when available.
|
||||
- `block` uses append-style draft previews.
|
||||
- `progress` uses status preview text, then final answer.
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
---
|
||||
summary: "Discord thread bound subagent sessions with plugin lifecycle hooks, routing, and config kill switches"
|
||||
owner: "onutc"
|
||||
status: "implemented"
|
||||
last_updated: "2026-02-21"
|
||||
title: "Thread Bound Subagents"
|
||||
---
|
||||
|
||||
# Thread Bound Subagents
|
||||
|
||||
## Overview
|
||||
|
||||
This feature lets users interact with spawned subagents directly inside Discord threads.
|
||||
|
||||
Instead of only waiting for a completion summary in the parent session, users can move into a dedicated thread that routes messages to the spawned subagent session. Replies are sent in-thread with a thread bound persona.
|
||||
|
||||
The implementation is split between channel agnostic core lifecycle hooks and Discord specific extension behavior.
|
||||
|
||||
## Goals
|
||||
|
||||
- Allow direct thread conversation with a spawned subagent session.
|
||||
- Keep default subagent orchestration channel agnostic.
|
||||
- Support both automatic thread creation on spawn and manual focus controls.
|
||||
- Provide predictable cleanup on completion, kill, timeout, and thread lifecycle changes.
|
||||
- Keep behavior configurable with global defaults plus channel and account overrides.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New ACP protocol features.
|
||||
- Non Discord thread binding implementations in this document.
|
||||
- New bot accounts or app level Discord identity changes.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `sessions_spawn` supports `thread: true` and `mode: "run" | "session"`.
|
||||
- Spawn flow supports persistent thread bound sessions.
|
||||
- Discord thread binding manager supports bind, unbind, TTL sweep, and persistence.
|
||||
- Plugin hook lifecycle for subagents:
|
||||
- `subagent_spawning`
|
||||
- `subagent_spawned`
|
||||
- `subagent_delivery_target`
|
||||
- `subagent_ended`
|
||||
- Discord extension implements thread auto bind, delivery target override, and unbind on end.
|
||||
- Text commands for manual control:
|
||||
- `/focus`
|
||||
- `/unfocus`
|
||||
- `/agents`
|
||||
- `/session ttl`
|
||||
- Global and Discord scoped enablement and TTL controls, including a global kill switch.
|
||||
|
||||
## Core concepts
|
||||
|
||||
### Spawn modes
|
||||
|
||||
- `mode: "run"`
|
||||
- one task lifecycle
|
||||
- completion announcement flow
|
||||
- `mode: "session"`
|
||||
- persistent thread bound session
|
||||
- supports follow up user messages in thread
|
||||
|
||||
Default mode behavior:
|
||||
|
||||
- if `thread: true` and mode omitted, mode defaults to `"session"`
|
||||
- otherwise mode defaults to `"run"`
|
||||
|
||||
Constraint:
|
||||
|
||||
- `mode: "session"` requires `thread: true`
|
||||
|
||||
### Thread binding target model
|
||||
|
||||
Bindings are generic targets, not only subagents.
|
||||
|
||||
- `targetKind: "subagent" | "acp"`
|
||||
- `targetSessionKey: string`
|
||||
|
||||
This allows the same routing primitive to support ACP/session bindings as well.
|
||||
|
||||
### Thread binding manager
|
||||
|
||||
The manager is responsible for:
|
||||
|
||||
- binding or creating threads for a session target
|
||||
- unbinding by thread or by target session
|
||||
- managing webhook reuse and recent unbound webhook echo suppression
|
||||
- TTL based unbind and stale thread cleanup
|
||||
- persistence load and save
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core and extension boundary
|
||||
|
||||
Core (`src/agents/*`) does not directly depend on Discord routing internals.
|
||||
|
||||
Core emits lifecycle intent through plugin hooks.
|
||||
|
||||
Discord extension (`extensions/discord/src/subagent-hooks.ts`) implements Discord specific behavior:
|
||||
|
||||
- pre spawn thread bind preparation
|
||||
- completion delivery target override to bound thread
|
||||
- unbind on subagent end
|
||||
|
||||
### Plugin hook flow
|
||||
|
||||
1. `subagent_spawning`
|
||||
- before run starts
|
||||
- can block spawn with `status: "error"`
|
||||
- used to prepare thread binding when `thread: true`
|
||||
2. `subagent_spawned`
|
||||
- post run registration event
|
||||
3. `subagent_delivery_target`
|
||||
- completion routing override hook
|
||||
- can redirect completion delivery to bound Discord thread origin
|
||||
4. `subagent_ended`
|
||||
- cleanup and unbind signal
|
||||
|
||||
### Account ID normalization contract
|
||||
|
||||
Thread binding and routing state must use one canonical account id abstraction.
|
||||
|
||||
Specification:
|
||||
|
||||
- Introduce a shared account id module (proposed: `src/routing/account-id.ts`) and stop defining local normalizers.
|
||||
- Expose two explicit helpers:
|
||||
- `normalizeAccountId(value): string`
|
||||
- returns canonical, defaulted id (current default is `default`)
|
||||
- use for map keys, manager registration and lookup, persistence keys, routing keys
|
||||
- `normalizeOptionalAccountId(value): string | undefined`
|
||||
- returns canonical id when present, `undefined` when absent
|
||||
- use for inbound optional context fields and merge logic
|
||||
- Do not implement ad hoc account normalization in feature modules.
|
||||
- This includes `trim`, `toLowerCase`, or defaulting logic in local helper functions.
|
||||
- Any map keyed by account id must only accept canonical ids from shared helpers.
|
||||
- Hook payloads and delivery context should carry raw optional account ids, and normalize at module boundaries only.
|
||||
|
||||
Migration guardrails:
|
||||
|
||||
- Replace duplicate normalizers in routing, reply payload, command context, and provider helpers with shared helpers.
|
||||
- Add contract tests that assert identical normalization behavior across:
|
||||
- route resolution
|
||||
- thread binding manager lookup
|
||||
- reply delivery target filtering
|
||||
- command run context merge
|
||||
|
||||
### Persistence and state
|
||||
|
||||
Binding state path:
|
||||
|
||||
- `${stateDir}/discord/thread-bindings.json`
|
||||
|
||||
Record shape contains:
|
||||
|
||||
- account, channel, thread
|
||||
- target kind and target session key
|
||||
- agent label metadata
|
||||
- webhook id/token
|
||||
- boundBy, boundAt, expiresAt
|
||||
|
||||
State is stored on `globalThis` to keep one shared registry across ESM and Jiti loader paths.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Effective precedence
|
||||
|
||||
For Discord thread binding options, account override wins, then channel, then global session default, then built in fallback.
|
||||
|
||||
- account: `channels.discord.accounts.<id>.threadBindings.<key>`
|
||||
- channel: `channels.discord.threadBindings.<key>`
|
||||
- global: `session.threadBindings.<key>`
|
||||
|
||||
### Keys
|
||||
|
||||
| Key | Scope | Default | Notes |
|
||||
| ------------------------------------------------------- | --------------- | --------------- | ----------------------------------------- |
|
||||
| `session.threadBindings.enabled` | global | `true` | master default kill switch |
|
||||
| `session.threadBindings.ttlHours` | global | `24` | default auto unfocus TTL |
|
||||
| `channels.discord.threadBindings.enabled` | channel/account | inherits global | Discord override kill switch |
|
||||
| `channels.discord.threadBindings.ttlHours` | channel/account | inherits global | Discord TTL override |
|
||||
| `channels.discord.threadBindings.spawnSubagentSessions` | channel/account | `false` | opt in for `thread: true` spawn auto bind |
|
||||
|
||||
### Runtime effect of enable switch
|
||||
|
||||
When effective `enabled` is false for a Discord account:
|
||||
|
||||
- provider creates a noop thread binding manager for runtime wiring
|
||||
- no real manager is registered for lookup by account id
|
||||
- inbound bound thread routing is effectively disabled
|
||||
- completion routing overrides do not resolve bound thread origins
|
||||
- `/focus`, `/unfocus`, and thread binding specific operations report unavailable
|
||||
- `thread: true` spawn path returns actionable error from Discord hook layer
|
||||
|
||||
## Flow and behavior
|
||||
|
||||
### Spawn with `thread: true`
|
||||
|
||||
1. Spawn validates mode and permissions.
|
||||
2. `subagent_spawning` hook runs.
|
||||
3. Discord extension checks effective flags:
|
||||
- thread bindings enabled
|
||||
- `spawnSubagentSessions` enabled
|
||||
4. Extension attempts auto bind and thread creation.
|
||||
5. If bind fails:
|
||||
- spawn returns error
|
||||
- provisional child session is deleted
|
||||
6. If bind succeeds:
|
||||
- child run starts
|
||||
- run is registered with spawn mode
|
||||
|
||||
### Manual focus and unfocus
|
||||
|
||||
- `/focus <target>`
|
||||
- Discord only
|
||||
- resolves subagent or session target
|
||||
- binds current or created thread to target session
|
||||
- `/unfocus`
|
||||
- Discord thread only
|
||||
- unbinds current thread
|
||||
|
||||
### Inbound routing
|
||||
|
||||
- Discord preflight checks current thread id against thread binding manager.
|
||||
- If bound, effective session routing uses bound target session key.
|
||||
- If not bound, normal routing path is used.
|
||||
|
||||
### Outbound routing
|
||||
|
||||
- Reply delivery checks whether current session has thread bindings.
|
||||
- Bound sessions deliver to thread via webhook aware path.
|
||||
- Unbound sessions use normal bot delivery.
|
||||
|
||||
### Completion routing
|
||||
|
||||
- Core completion flow calls `subagent_delivery_target`.
|
||||
- Discord extension returns bound thread origin when it can resolve one.
|
||||
- Core merges hook origin with requester origin and delivers completion.
|
||||
|
||||
### Cleanup
|
||||
|
||||
Cleanup occurs on:
|
||||
|
||||
- completion
|
||||
- error or timeout completion path
|
||||
- kill and terminate paths
|
||||
- TTL expiration
|
||||
- archived or deleted thread probes
|
||||
- manual `/unfocus`
|
||||
|
||||
Cleanup behavior includes unbind and optional farewell messaging.
|
||||
|
||||
## Commands and user UX
|
||||
|
||||
| Command | Purpose |
|
||||
| ---------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------- | --------------- | ------------------------------------------- |
|
||||
| `/subagents spawn <agentId> <task> [--model] [--thinking]` | spawn subagent; may be thread bound when `thread: true` path is used |
|
||||
| `/focus <subagent-label | session-key | session-id | session-label>` | manually bind thread to subagent or session |
|
||||
| `/unfocus` | remove binding from current thread |
|
||||
| `/agents` | list active agents and binding state |
|
||||
| `/session ttl <duration | off>` | update TTL for focused thread binding |
|
||||
|
||||
Notes:
|
||||
|
||||
- `/session ttl` is currently Discord thread focused behavior.
|
||||
- Thread intro and farewell text are generated by thread binding message helpers.
|
||||
|
||||
## Failure handling and safety
|
||||
|
||||
- Spawn returns explicit errors when thread binding cannot be prepared.
|
||||
- Spawn failure after provisional bind attempts best effort unbind and session delete.
|
||||
- Completion logic prevents duplicate ended hook emission.
|
||||
- Retry and expiry guards prevent infinite completion announce retry loops.
|
||||
- Webhook echo suppression avoids unbound webhook messages being reprocessed as inbound turns.
|
||||
|
||||
## Module map
|
||||
|
||||
### Core orchestration
|
||||
|
||||
- `src/agents/subagent-spawn.ts`
|
||||
- `src/agents/subagent-announce.ts`
|
||||
- `src/agents/subagent-registry.ts`
|
||||
- `src/agents/subagent-registry-cleanup.ts`
|
||||
- `src/agents/subagent-registry-completion.ts`
|
||||
|
||||
### Discord runtime
|
||||
|
||||
- `src/discord/monitor/provider.ts`
|
||||
- `src/discord/monitor/thread-bindings.manager.ts`
|
||||
- `src/discord/monitor/thread-bindings.state.ts`
|
||||
- `src/discord/monitor/thread-bindings.lifecycle.ts`
|
||||
- `src/discord/monitor/thread-bindings.messages.ts`
|
||||
- `src/discord/monitor/message-handler.preflight.ts`
|
||||
- `src/discord/monitor/message-handler.process.ts`
|
||||
- `src/discord/monitor/reply-delivery.ts`
|
||||
|
||||
### Plugin hooks and extension
|
||||
|
||||
- `src/plugins/types.ts`
|
||||
- `src/plugins/hooks.ts`
|
||||
- `extensions/discord/src/subagent-hooks.ts`
|
||||
|
||||
### Config and schema
|
||||
|
||||
- `src/config/types.base.ts`
|
||||
- `src/config/types.discord.ts`
|
||||
- `src/config/zod-schema.session.ts`
|
||||
- `src/config/zod-schema.providers-core.ts`
|
||||
- `src/config/schema.help.ts`
|
||||
- `src/config/schema.labels.ts`
|
||||
|
||||
## Test coverage highlights
|
||||
|
||||
- `extensions/discord/src/subagent-hooks.test.ts`
|
||||
- `src/discord/monitor/thread-bindings.ttl.test.ts`
|
||||
- `src/discord/monitor/thread-bindings.shared-state.test.ts`
|
||||
- `src/discord/monitor/reply-delivery.test.ts`
|
||||
- `src/discord/monitor/message-handler.preflight.test.ts`
|
||||
- `src/discord/monitor/message-handler.process.test.ts`
|
||||
- `src/auto-reply/reply/commands-subagents-focus.test.ts`
|
||||
- `src/auto-reply/reply/commands-session-ttl.test.ts`
|
||||
- `src/agents/subagent-registry.steer-restart.test.ts`
|
||||
- `src/agents/subagent-registry-completion.test.ts`
|
||||
|
||||
## Operational summary
|
||||
|
||||
- Use `session.threadBindings.enabled` as the global kill switch default.
|
||||
- Use `channels.discord.threadBindings.enabled` and account overrides for selective enablement.
|
||||
- Keep `spawnSubagentSessions` opt in for thread auto spawn behavior.
|
||||
- Use TTL settings for automatic unfocus policy control.
|
||||
|
||||
This model keeps subagent lifecycle orchestration generic while giving Discord a full thread bound interaction path.
|
||||
|
||||
## Related plan
|
||||
|
||||
For channel agnostic SessionBinding architecture and scoped iteration planning, see:
|
||||
|
||||
- `docs/experiments/plans/session-binding-channel-agnostic.md`
|
||||
|
||||
ACP remains a next step in that plan and is intentionally not implemented in this shipped Discord thread-bound flow.
|
||||
@@ -151,7 +151,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
historyLimit: 50,
|
||||
replyToMode: "first", // off | first | all
|
||||
linkPreview: true,
|
||||
streaming: true, // live preview on/off (default true)
|
||||
streaming: "partial", // off | partial | block | progress (default: off)
|
||||
actions: { reactions: true, sendMessage: true },
|
||||
reactionNotifications: "own", // off | own | all
|
||||
mediaMaxMb: 5,
|
||||
@@ -228,12 +228,18 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
historyLimit: 20,
|
||||
textChunkLimit: 2000,
|
||||
chunkMode: "length", // length | newline
|
||||
streaming: "off", // off | partial | block | progress (progress maps to partial on Discord)
|
||||
maxLinesPerMessage: 17,
|
||||
ui: {
|
||||
components: {
|
||||
accentColor: "#5865F2",
|
||||
},
|
||||
},
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
spawnSubagentSessions: false, // opt-in for sessions_spawn({ thread: true })
|
||||
},
|
||||
voice: {
|
||||
enabled: true,
|
||||
autoJoin: [
|
||||
@@ -263,8 +269,13 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
- Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs.
|
||||
- Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered).
|
||||
- `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars.
|
||||
- `channels.discord.threadBindings` controls Discord thread-bound routing:
|
||||
- `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session ttl`, and bound delivery/routing)
|
||||
- `ttlHours`: Discord override for auto-unfocus TTL (`0` disables)
|
||||
- `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding
|
||||
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
|
||||
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides.
|
||||
- `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated.
|
||||
|
||||
**Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds.<id>.users` on all messages).
|
||||
|
||||
@@ -348,6 +359,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
},
|
||||
textChunkLimit: 4000,
|
||||
chunkMode: "length",
|
||||
streaming: "partial", // off | partial | block | progress (preview mode)
|
||||
nativeStreaming: true, // use Slack native streaming API when streaming=partial
|
||||
mediaMaxMb: 20,
|
||||
},
|
||||
},
|
||||
@@ -357,6 +370,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
- **Socket mode** requires both `botToken` and `appToken` (`SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` for default account env fallback).
|
||||
- **HTTP mode** requires `botToken` plus `signingSecret` (at root or per-account).
|
||||
- `configWrites: false` blocks Slack-initiated config writes.
|
||||
- `channels.slack.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated.
|
||||
- Use `user:<id>` (DM) or `channel:<id>` for delivery targets.
|
||||
|
||||
**Reaction notification modes:** `off`, `own` (default), `all`, `allowlist` (from `reactionAllowlist`).
|
||||
@@ -1217,6 +1231,10 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
|
||||
maxEntries: 500,
|
||||
rotateBytes: "10mb",
|
||||
},
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24, // default auto-unfocus TTL for thread-bound sessions (0 disables)
|
||||
},
|
||||
mainKey: "main", // legacy (runtime always uses "main")
|
||||
agentToAgent: { maxPingPongTurns: 5 },
|
||||
sendPolicy: {
|
||||
@@ -1240,6 +1258,9 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
|
||||
- **`mainKey`**: legacy field. Runtime now always uses `"main"` for the main direct-chat bucket.
|
||||
- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins.
|
||||
- **`maintenance`**: `warn` warns the active session on eviction; `enforce` applies pruning and rotation.
|
||||
- **`threadBindings`**: global defaults for thread-bound session features.
|
||||
- `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`)
|
||||
- `ttlHours`: default auto-unfocus TTL in hours (`0` disables; providers can override)
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -182,6 +182,10 @@ When validation fails:
|
||||
{
|
||||
session: {
|
||||
dmScope: "per-channel-peer", // recommended for multi-user
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
},
|
||||
reset: {
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
@@ -192,6 +196,7 @@ When validation fails:
|
||||
```
|
||||
|
||||
- `dmScope`: `main` (shared) | `per-peer` | `per-channel-peer` | `per-account-channel-peer`
|
||||
- `threadBindings`: global defaults for thread-bound session routing (Discord supports `/focus`, `/unfocus`, `/agents`, and `/session ttl`).
|
||||
- See [Session Management](/concepts/session) for scoping, identity links, and send policy.
|
||||
- See [full reference](/gateway/configuration-reference#session) for all fields.
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
||||
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
|
||||
is enabled for break-glass use.
|
||||
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
||||
- All connections must sign the server-provided `connect.challenge` nonce.
|
||||
|
||||
## TLS + pinning
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ If more than one person can DM your bot:
|
||||
- **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints).
|
||||
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
|
||||
- **Plugins** (extensions exist without an explicit allowlist).
|
||||
- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy).
|
||||
- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns; dangerous `gateway.nodes.allowCommands` entries; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy).
|
||||
- **Runtime expectation drift** (for example `tools.exec.host="sandbox"` while sandbox mode is off, which runs directly on the gateway host).
|
||||
- **Model hygiene** (warn when configured models look legacy; not a hard block).
|
||||
|
||||
@@ -117,29 +117,31 @@ When the audit prints findings, treat this as a priority order:
|
||||
|
||||
High-signal `checkId` values you will most likely see in real deployments (not exhaustive):
|
||||
|
||||
| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
|
||||
| --------------------------------------------- | ------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------- | -------- |
|
||||
| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
|
||||
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
|
||||
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
|
||||
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
|
||||
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
|
||||
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
|
||||
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
|
||||
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
|
||||
| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
|
||||
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
|
||||
| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no |
|
||||
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
|
||||
| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
|
||||
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
|
||||
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
|
||||
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
|
||||
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
|
||||
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
|
||||
| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
|
||||
| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
|
||||
| -------------------------------------------------- | ------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- |
|
||||
| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
|
||||
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
|
||||
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
|
||||
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
|
||||
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
|
||||
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
|
||||
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
|
||||
| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no |
|
||||
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
|
||||
| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
|
||||
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
|
||||
| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no |
|
||||
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
|
||||
| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
|
||||
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
|
||||
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
|
||||
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
|
||||
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
|
||||
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
|
||||
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
|
||||
| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
|
||||
|
||||
## Control UI over HTTP
|
||||
|
||||
@@ -213,6 +215,18 @@ If a macOS node is paired, the Gateway can invoke `system.run` on that node. Thi
|
||||
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
|
||||
- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
|
||||
|
||||
## Skill security
|
||||
|
||||
Community skills (installed from ClawHub) are subject to runtime security enforcement:
|
||||
|
||||
- **Capabilities**: Skills declare what system access they need (`shell`, `filesystem`, `network`, `browser`, `sessions`) in `metadata.openclaw.capabilities`. No capabilities = read-only. Community skills that use tools without declaring the matching capability are blocked at runtime.
|
||||
- **SKILL.md scanning**: Content is scanned for prompt injection patterns, capability inflation, and boundary spoofing before entering the system prompt. Skills with critical findings are blocked from loading.
|
||||
- **Trust tiers**: Skills are classified as `builtin`, `community`, or `local`. Only `community` skills (installed from ClawHub) are subject to enforcement — builtin and local skills are exempt. Author verification may be introduced in a future release to provide an additional trust signal.
|
||||
- **Command dispatch gating**: Community skills using `command-dispatch: tool` can't dispatch to dangerous tools without declaring the matching capability.
|
||||
- **Audit logging**: All security events are tagged with `category: "security"` and include session context.
|
||||
|
||||
Use `openclaw skills check` for a security overview and `openclaw skills info <name>` for per-skill details. See [Skills CLI](/cli/skills) for full command reference.
|
||||
|
||||
## Dynamic skills (watcher / remote nodes)
|
||||
|
||||
OpenClaw can refresh the skills list mid-session:
|
||||
@@ -220,7 +234,7 @@ OpenClaw can refresh the skills list mid-session:
|
||||
- **Skills watcher**: changes to `SKILL.md` can update the skills snapshot on the next agent turn.
|
||||
- **Remote nodes**: connecting a macOS node can make macOS-only skills eligible (based on bin probing).
|
||||
|
||||
Treat skill folders as **trusted code** and restrict who can modify them.
|
||||
Restrict who can modify skill folders. Community skills are subject to scanning and capability enforcement (see above), but local and workspace skills are treated as trusted — if someone can write to your skill folders, they can inject instructions into the system prompt.
|
||||
|
||||
## The Threat Model
|
||||
|
||||
|
||||
@@ -1038,6 +1038,26 @@ cheaper model for sub-agents via `agents.defaults.subagents.model`.
|
||||
|
||||
Docs: [Sub-agents](/tools/subagents).
|
||||
|
||||
### How do thread-bound subagent sessions work on Discord
|
||||
|
||||
Use thread bindings. You can bind a Discord thread to a subagent or session target so follow-up messages in that thread stay on that bound session.
|
||||
|
||||
Basic flow:
|
||||
|
||||
- Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"` for persistent follow-up).
|
||||
- Or manually bind with `/focus <target>`.
|
||||
- Use `/agents` to inspect binding state.
|
||||
- Use `/session ttl <duration|off>` to control auto-unfocus.
|
||||
- Use `/unfocus` to detach the thread.
|
||||
|
||||
Required config:
|
||||
|
||||
- Global defaults: `session.threadBindings.enabled`, `session.threadBindings.ttlHours`.
|
||||
- Discord overrides: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`.
|
||||
- Auto-bind on spawn: set `channels.discord.threadBindings.spawnSubagentSessions: true`.
|
||||
|
||||
Docs: [Sub-agents](/tools/subagents), [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), [Slash commands](/tools/slash-commands).
|
||||
|
||||
### Cron or reminders do not fire What should I check
|
||||
|
||||
Cron runs inside the Gateway process. If the Gateway is not running continuously,
|
||||
|
||||
@@ -103,6 +103,7 @@ Example:
|
||||
Notes:
|
||||
|
||||
- `allowlist` entries are glob patterns for resolved binary paths.
|
||||
- Raw shell command text that contains shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss and requires explicit approval (or allowlisting the shell binary).
|
||||
- Choosing “Always Allow” in the prompt adds that command to the allowlist.
|
||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`) and then merged with the app’s environment.
|
||||
|
||||
|
||||
@@ -81,9 +81,15 @@ A typical skill includes:
|
||||
|
||||
- A `SKILL.md` file with the primary description and usage.
|
||||
- Optional configs, scripts, or supporting files used by the skill.
|
||||
- Metadata such as tags, summary, and install requirements.
|
||||
- Metadata such as tags, summary, install requirements, and capabilities.
|
||||
|
||||
ClawHub uses metadata to power discovery and display skill capabilities.
|
||||
Skills declare what system access they need via `capabilities` in frontmatter
|
||||
(e.g., `shell`, `filesystem`, `network`). OpenClaw enforces these at runtime —
|
||||
community skills that use tools without declaring the matching capability are
|
||||
blocked. See [Skills](/tools/skills#gating-load-time-filters) for the
|
||||
full capability reference.
|
||||
|
||||
ClawHub uses metadata to power discovery and safely expose skill capabilities.
|
||||
The registry also tracks usage signals (such as stars and downloads) to improve
|
||||
ranking and visibility.
|
||||
|
||||
@@ -103,7 +109,17 @@ ClawHub is open by default. Anyone can upload skills, but a GitHub account must
|
||||
be at least one week old to publish. This helps slow down abuse without blocking
|
||||
legitimate contributors.
|
||||
|
||||
Reporting and moderation:
|
||||
### Capabilities and enforcement
|
||||
|
||||
Skills declare `capabilities` in their SKILL.md frontmatter to describe what
|
||||
system access they need. ClawHub displays these to users before install.
|
||||
OpenClaw enforces them at runtime — community skills that attempt to use tools
|
||||
without the matching declared capability are blocked. Skills with no capabilities
|
||||
are treated as read-only (model-only instructions, no tool access).
|
||||
|
||||
Available capabilities: `shell`, `filesystem`, `network`, `browser`, `sessions`.
|
||||
|
||||
### Reporting and moderation
|
||||
|
||||
- Any signed in user can report a skill.
|
||||
- Report reasons are required and recorded.
|
||||
|
||||
@@ -35,11 +35,27 @@ description: A simple skill that says hello.
|
||||
When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!".
|
||||
```
|
||||
|
||||
### 3. Add Tools (Optional)
|
||||
### 3. Declare Capabilities
|
||||
|
||||
If your skill uses system tools, declare them in the `metadata.openclaw.capabilities` field:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: deploy_helper
|
||||
description: Automate deployment workflows.
|
||||
metadata: { "openclaw": { "capabilities": ["shell", "filesystem"] } }
|
||||
---
|
||||
```
|
||||
|
||||
Available capabilities: `shell`, `filesystem`, `network`, `browser`, `sessions`.
|
||||
|
||||
Skills without capabilities are treated as read-only (model-only instructions). Community skills published to ClawHub **must** declare capabilities matching their tool usage — undeclared capabilities are blocked at runtime.
|
||||
|
||||
### 4. Add Tools (Optional)
|
||||
|
||||
You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`).
|
||||
|
||||
### 4. Refresh OpenClaw
|
||||
### 5. Refresh OpenClaw
|
||||
|
||||
Ask your agent to "refresh skills" or restart the gateway. OpenClaw will discover the new directory and index the `SKILL.md`.
|
||||
|
||||
|
||||
@@ -127,9 +127,20 @@ positional file args and path-like tokens, so they can only operate on the incom
|
||||
Validation is deterministic from argv shape only (no host filesystem existence checks), which
|
||||
prevents file-existence oracle behavior from allow/deny differences.
|
||||
File-oriented options are denied for default safe bins (for example `sort -o`, `sort --output`,
|
||||
`sort --files0-from`, `wc --files0-from`, `jq -f/--from-file`, `grep -f/--file`).
|
||||
`sort --files0-from`, `sort --compress-program`, `wc --files0-from`, `jq -f/--from-file`,
|
||||
`grep -f/--file`).
|
||||
Safe bins also enforce explicit per-binary flag policy for options that break stdin-only
|
||||
behavior (for example `sort -o/--output` and grep recursive flags).
|
||||
behavior (for example `sort -o/--output/--compress-program` and grep recursive flags).
|
||||
Denied flags by safe-bin profile:
|
||||
|
||||
<!-- SAFE_BIN_DENIED_FLAGS:START -->
|
||||
|
||||
- `grep`: `--dereference-recursive`, `--directories`, `--exclude-from`, `--file`, `--recursive`, `-R`, `-d`, `-f`, `-r`
|
||||
- `jq`: `--argfile`, `--from-file`, `--library-path`, `--rawfile`, `--slurpfile`, `-L`, `-f`
|
||||
- `sort`: `--compress-program`, `--files0-from`, `--output`, `-o`
|
||||
- `wc`: `--files0-from`
|
||||
<!-- SAFE_BIN_DENIED_FLAGS:END -->
|
||||
|
||||
Safe bins also force argv tokens to be treated as **literal text** at execution time (no globbing
|
||||
and no `$VARS` expansion) for stdin-only segments, so patterns like `*` or `$HOME/...` cannot be
|
||||
used to smuggle file reads.
|
||||
@@ -141,6 +152,9 @@ Shell chaining (`&&`, `||`, `;`) is allowed when every top-level segment satisfi
|
||||
(including safe bins or skill auto-allow). Redirections remain unsupported in allowlist mode.
|
||||
Command substitution (`$()` / backticks) is rejected during allowlist parsing, including inside
|
||||
double quotes; use single quotes if you need literal `$()` text.
|
||||
On macOS companion-app approvals, raw shell text containing shell control or expansion syntax
|
||||
(`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss unless
|
||||
the shell binary itself is allowlisted.
|
||||
|
||||
Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`.
|
||||
|
||||
|
||||
@@ -464,7 +464,7 @@ Core parameters:
|
||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
||||
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
|
||||
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||
- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `runTimeoutSeconds?`, `cleanup?`
|
||||
- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `thinking?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`
|
||||
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
|
||||
|
||||
Notes:
|
||||
@@ -475,6 +475,10 @@ Notes:
|
||||
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
||||
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
|
||||
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
|
||||
- Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`).
|
||||
- If `thread: true` and `mode` is omitted, mode defaults to `session`.
|
||||
- `mode: "session"` requires `thread: true`.
|
||||
- Discord thread-bound flows depend on `session.threadBindings.*` and `channels.discord.threadBindings.*`.
|
||||
- Reply format includes `Status`, `Result`, and compact stats.
|
||||
- `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback.
|
||||
- Manual completion-mode spawns send directly first, with queue fallback and retry on transient failures (`status: "ok"` means run finished, not that announce delivered).
|
||||
|
||||
@@ -330,22 +330,29 @@ Plugins export either:
|
||||
|
||||
## Plugin hooks
|
||||
|
||||
Plugins can ship hooks and register them at runtime. This lets a plugin bundle
|
||||
event-driven automation without a separate hook pack install.
|
||||
Plugins can register hooks at runtime. This lets a plugin bundle event-driven
|
||||
automation without a separate hook pack install.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
import { registerPluginHooksFromDir } from "openclaw/plugin-sdk";
|
||||
|
||||
```ts
|
||||
export default function register(api) {
|
||||
registerPluginHooksFromDir(api, "./hooks");
|
||||
api.registerHook(
|
||||
"command:new",
|
||||
async () => {
|
||||
// Hook logic here.
|
||||
},
|
||||
{
|
||||
name: "my-plugin.command-new",
|
||||
description: "Runs when /new is invoked",
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Hook directories follow the normal hook structure (`HOOK.md` + `handler.ts`).
|
||||
- Register hooks explicitly via `api.registerHook(...)`.
|
||||
- Hook eligibility rules still apply (OS/bins/env/config requirements).
|
||||
- Plugin-managed hooks show up in `openclaw hooks list` with `plugin:<id>`.
|
||||
- You cannot enable/disable plugin-managed hooks via `openclaw hooks`; enable/disable the plugin instead.
|
||||
|
||||
@@ -68,12 +68,199 @@ that up as `<workspace>/skills` on the next session.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Treat third-party skills as **untrusted code**. Read them before enabling.
|
||||
- Treat third-party skills as **untrusted** until you have reviewed them. Runtime enforcement reduces blast radius but does not eliminate risk — read a skill's SKILL.md and declared capabilities before enabling it.
|
||||
- **Capabilities**: Community skills (from ClawHub) must declare `capabilities` in `metadata.openclaw` to describe what system access they need. Skills that don't declare capabilities are treated as read-only. Undeclared dangerous tool usage (e.g., `exec` without `shell` capability) is blocked at runtime for community skills. SKILL.md content is scanned for prompt injection before entering the system prompt.
|
||||
- Local and workspace skills are exempt from capability enforcement. If someone can write to your skill folders, they can inject instructions into the system prompt — restrict who can modify them.
|
||||
- Prefer sandboxed runs for untrusted inputs and risky tools. See [Sandboxing](/gateway/sandboxing).
|
||||
- `skills.entries.*.env` and `skills.entries.*.apiKey` inject secrets into the **host** process
|
||||
for that agent turn (not the sandbox). Keep secrets out of prompts and logs.
|
||||
- For a broader threat model and checklists, see [Security](/gateway/security).
|
||||
|
||||
### Tool enforcement matrix
|
||||
|
||||
When community skills are loaded, every tool falls into one of three tiers. Enforcement is applied by a hard code gate in the before-tool-call hook — prompt injection cannot bypass it.
|
||||
|
||||
**Always denied** — blocked unconditionally when community skills are loaded, regardless of capability declarations:
|
||||
|
||||
| Tool | Reason |
|
||||
|------|--------|
|
||||
| `gateway` | Control-plane reconfiguration (restart, shutdown, auth changes) |
|
||||
| `nodes` | Cluster node management (add/remove devices, redirect traffic) |
|
||||
|
||||
**Capability-gated** — blocked by default, allowed when the skill declares the matching capability in `metadata.openclaw.capabilities`:
|
||||
|
||||
| Capability | Tools | What it unlocks |
|
||||
|------------|-------|-----------------|
|
||||
| `shell` | `exec`, `process`, `lobster` | Run shell commands and manage processes |
|
||||
| `filesystem` | `write`, `edit`, `apply_patch` | File mutations (`read` is always allowed) |
|
||||
| `network` | `web_fetch`, `web_search` | Outbound HTTP requests |
|
||||
| `browser` | `browser` | Browser automation |
|
||||
| `sessions` | `sessions_spawn`, `sessions_send`, `subagents` | Cross-session orchestration |
|
||||
| `messaging` | `message` | Send messages to configured channels |
|
||||
| `scheduling` | `cron` | Schedule recurring jobs |
|
||||
|
||||
**Always allowed** — safe read-only or output-only tools, no capability required:
|
||||
|
||||
| Tool | Why safe |
|
||||
|------|---------|
|
||||
| `read` | Read-only file access |
|
||||
| `memory_search`, `memory_get` | Read-only memory access |
|
||||
| `agents_list` | List agents (read-only) |
|
||||
| `sessions_list`, `sessions_history`, `session_status` | Session introspection (read-only) |
|
||||
| `canvas` | UI rendering (output-only) |
|
||||
| `image` | Image generation (output-only) |
|
||||
| `tts` | Text-to-speech (output-only) |
|
||||
|
||||
A community skill with no capabilities declared gets access only to the always-allowed tier.
|
||||
|
||||
### Example: correct capability declaration
|
||||
|
||||
This skill runs shell commands and makes HTTP requests. It declares both capabilities, so OpenClaw allows the tool calls:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: git-autopush
|
||||
description: Automate git commit, push, and PR workflows.
|
||||
metadata: { "openclaw": { "capabilities": ["shell", "network"], "requires": { "bins": ["git", "gh"] } } }
|
||||
---
|
||||
|
||||
# git-autopush
|
||||
|
||||
When the user asks to push their changes:
|
||||
1. Run `git add -A && git commit` via the exec tool.
|
||||
2. Run `git push` via the exec tool.
|
||||
3. If requested, create a PR using `gh pr create`.
|
||||
```
|
||||
|
||||
`openclaw skills info git-autopush` shows:
|
||||
|
||||
```
|
||||
git-autopush + Ready
|
||||
|
||||
Automate git commit, push, and PR workflows.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/git-autopush/SKILL.md
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
🌐 network Make outbound HTTP requests
|
||||
|
||||
Security
|
||||
Scan + clean
|
||||
```
|
||||
|
||||
### Example: missing capability declaration
|
||||
|
||||
This skill runs shell commands but doesn't declare `shell`. OpenClaw blocks the `exec` calls at runtime:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: deploy-helper
|
||||
description: Deploy to production.
|
||||
metadata: { "openclaw": { "requires": { "bins": ["rsync"] } } }
|
||||
---
|
||||
|
||||
# deploy-helper
|
||||
|
||||
When the user asks to deploy, run `rsync -avz ./dist/ user@host:/var/www/` via the exec tool.
|
||||
```
|
||||
|
||||
This skill has no `capabilities` declared, so it's treated as read-only. When the model tries to call `exec` on behalf of this skill's instructions, OpenClaw denies it. `openclaw skills info deploy-helper` shows:
|
||||
|
||||
```
|
||||
deploy-helper + Ready
|
||||
|
||||
Deploy to production.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/deploy-helper/SKILL.md
|
||||
|
||||
Capabilities
|
||||
(none — read-only skill)
|
||||
|
||||
Security
|
||||
Scan + clean
|
||||
```
|
||||
|
||||
The fix is to add `"capabilities": ["shell"]` to the metadata.
|
||||
|
||||
### Example: blocked skill (failed security scan)
|
||||
|
||||
If a SKILL.md contains prompt injection patterns, the scan blocks it from loading entirely:
|
||||
|
||||
```
|
||||
evil-injector x Blocked (security)
|
||||
|
||||
Totally harmless skill.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/evil-injector/SKILL.md
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
|
||||
Security
|
||||
Scan [blocked] prompt injection detected
|
||||
```
|
||||
|
||||
This skill never enters the system prompt. It shows as `x blocked` in `openclaw skills list`.
|
||||
|
||||
### How the model sees skills
|
||||
|
||||
The model does not see the full SKILL.md in the system prompt. It only sees a compact XML listing with three fields per skill: `name`, `description`, and `location` (the file path). The model then uses the `read` tool to load the full SKILL.md on demand when the task matches.
|
||||
|
||||
This is what the model receives in the system prompt:
|
||||
|
||||
```
|
||||
## Skills (mandatory)
|
||||
Before replying: scan <available_skills> <description> entries.
|
||||
- If exactly one skill clearly applies: read its SKILL.md at <location> with `read`, then follow it.
|
||||
- If multiple could apply: choose the most specific one, then read/follow it.
|
||||
- If none clearly apply: do not read any SKILL.md.
|
||||
Constraints: never read more than one skill up front; only read after selecting.
|
||||
|
||||
The following skills provide specialized instructions for specific tasks.
|
||||
Use the read tool to load a skill's file when the task matches its description.
|
||||
When a skill file references a relative path, resolve it against the skill
|
||||
directory (parent of SKILL.md / dirname of the path) and use that absolute
|
||||
path in tool commands.
|
||||
|
||||
<available_skills>
|
||||
<skill>
|
||||
<name>git-autopush</name>
|
||||
<description>Automate git commit, push, and PR workflows.</description>
|
||||
<location>/home/user/.openclaw/skills/git-autopush/SKILL.md</location>
|
||||
</skill>
|
||||
<skill>
|
||||
<name>todoist-cli</name>
|
||||
<description>Manage Todoist tasks, projects, and labels.</description>
|
||||
<location>/home/user/.openclaw/skills/todoist-cli/SKILL.md</location>
|
||||
</skill>
|
||||
</available_skills>
|
||||
```
|
||||
|
||||
**What this means for skill authors:**
|
||||
|
||||
- **`description` is your pitch** — it's the only thing the model reads to decide whether to load your skill. Make it specific and task-oriented. "Manage Todoist tasks, projects, and labels from the command line" is better than "Todoist integration."
|
||||
- **`name` must be lowercase `[a-z0-9-]`**, max 64 characters, must match the parent directory name.
|
||||
- **`description` max 1024 characters.**
|
||||
- **Your SKILL.md body is loaded on demand** — it needs to be self-contained instructions the model can follow after reading.
|
||||
- **Relative paths in SKILL.md** are resolved against the skill directory. Use relative paths to reference supporting files.
|
||||
|
||||
The `Skill` type from `@mariozechner/pi-coding-agent`:
|
||||
|
||||
```typescript
|
||||
interface Skill {
|
||||
name: string; // from frontmatter (or parent dir name)
|
||||
description: string; // from frontmatter (required, max 1024 chars)
|
||||
filePath: string; // absolute path to SKILL.md
|
||||
baseDir: string; // parent directory of SKILL.md
|
||||
source: string; // origin identifier
|
||||
disableModelInvocation: boolean; // if true, excluded from prompt
|
||||
}
|
||||
```
|
||||
|
||||
## Format (AgentSkills + Pi-compatible)
|
||||
|
||||
`SKILL.md` must include at least:
|
||||
@@ -116,6 +303,7 @@ metadata:
|
||||
{
|
||||
"requires": { "bins": ["uv"], "env": ["GEMINI_API_KEY"], "config": ["browser.enabled"] },
|
||||
"primaryEnv": "GEMINI_API_KEY",
|
||||
"capabilities": ["browser", "network"],
|
||||
},
|
||||
}
|
||||
---
|
||||
@@ -125,8 +313,18 @@ Fields under `metadata.openclaw`:
|
||||
|
||||
- `always: true` — always include the skill (skip other gates).
|
||||
- `emoji` — optional emoji used by the macOS Skills UI.
|
||||
- `homepage` — optional URL shown as “Website” in the macOS Skills UI.
|
||||
- `homepage` — optional URL shown as "Website" in the macOS Skills UI.
|
||||
- `os` — optional list of platforms (`darwin`, `linux`, `win32`). If set, the skill is only eligible on those OSes.
|
||||
- `capabilities` — list of system access the skill needs. Used for security enforcement and user-facing display. Allowed values:
|
||||
- `shell` — run shell commands (maps to `exec`, `process`)
|
||||
- `filesystem` — read/write/edit files (maps to `write`, `edit`, `apply_patch`; `read` is always allowed)
|
||||
- `network` — outbound HTTP (maps to `web_search`, `web_fetch`)
|
||||
- `browser` — browser automation (maps to `browser`)
|
||||
- `sessions` — cross-session orchestration (maps to `sessions_spawn`, `sessions_send`, `subagents`)
|
||||
- `messaging` — send messages to configured channels (maps to `message`)
|
||||
- `scheduling` — schedule recurring jobs (maps to `cron`)
|
||||
|
||||
No capabilities declared = read-only, model-only skill. Community skills with undeclared capabilities that attempt to use dangerous tools will be blocked at runtime. See [Tool enforcement matrix](#tool-enforcement-matrix) below and [Security](/gateway/security) for full details.
|
||||
- `requires.bins` — list; each must exist on `PATH`.
|
||||
- `requires.anyBins` — list; at least one must exist on `PATH`.
|
||||
- `requires.env` — list; env var must exist **or** be provided in config.
|
||||
|
||||
@@ -124,6 +124,7 @@ Notes:
|
||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs.
|
||||
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
|
||||
- Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text).
|
||||
- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session ttl`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`).
|
||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
||||
- **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model).
|
||||
|
||||
@@ -3,6 +3,7 @@ summary: "Sub-agents: spawning isolated agent runs that announce results back to
|
||||
read_when:
|
||||
- You want background/parallel work via the agent
|
||||
- You are changing sessions_spawn or sub-agent tool policy
|
||||
- You are implementing or troubleshooting thread-bound subagent sessions
|
||||
title: "Sub-Agents"
|
||||
---
|
||||
|
||||
@@ -22,6 +23,13 @@ Use `/subagents` to inspect or control sub-agent runs for the **current session*
|
||||
- `/subagents steer <id|#> <message>`
|
||||
- `/subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]`
|
||||
|
||||
Discord thread binding controls:
|
||||
|
||||
- `/focus <subagent-label|session-key|session-id|session-label>`
|
||||
- `/unfocus`
|
||||
- `/agents`
|
||||
- `/session ttl <duration|off>`
|
||||
|
||||
`/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup).
|
||||
|
||||
### Spawn behavior
|
||||
@@ -40,6 +48,7 @@ Use `/subagents` to inspect or control sub-agent runs for the **current session*
|
||||
- compact runtime/token stats
|
||||
- `--model` and `--thinking` override defaults for that specific run.
|
||||
- Use `info`/`log` to inspect details and output after completion.
|
||||
- `/subagents spawn` is one-shot mode (`mode: "run"`). For persistent thread-bound sessions, use `sessions_spawn` with `thread: true` and `mode: "session"`.
|
||||
|
||||
Primary goals:
|
||||
|
||||
@@ -69,8 +78,40 @@ Tool params:
|
||||
- `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result)
|
||||
- `thinking?` (optional; overrides thinking level for the sub-agent run)
|
||||
- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds)
|
||||
- `thread?` (default `false`; when `true`, requests channel thread binding for this sub-agent session)
|
||||
- `mode?` (`run|session`)
|
||||
- default is `run`
|
||||
- if `thread: true` and `mode` omitted, default becomes `session`
|
||||
- `mode: "session"` requires `thread: true`
|
||||
- `cleanup?` (`delete|keep`, default `keep`)
|
||||
|
||||
## Discord thread-bound sessions
|
||||
|
||||
When thread bindings are enabled, a sub-agent can stay bound to a Discord thread so follow-up user messages in that thread keep routing to the same sub-agent session.
|
||||
|
||||
Quick flow:
|
||||
|
||||
1. Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"`).
|
||||
2. OpenClaw creates or binds a Discord thread to that session target.
|
||||
3. Replies and follow-up messages in that thread route to the bound session.
|
||||
4. Use `/session ttl` to inspect/update auto-unfocus TTL.
|
||||
5. Use `/unfocus` to detach manually.
|
||||
|
||||
Manual controls:
|
||||
|
||||
- `/focus <target>` binds the current thread (or creates one) to a sub-agent/session target.
|
||||
- `/unfocus` removes the binding for the current Discord thread.
|
||||
- `/agents` lists active runs and binding state (`thread:<id>` or `unbound`).
|
||||
- `/session ttl` only works for focused Discord threads.
|
||||
|
||||
Config switches:
|
||||
|
||||
- Global default: `session.threadBindings.enabled`, `session.threadBindings.ttlHours`
|
||||
- Discord override: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`
|
||||
- Spawn auto-bind opt-in: `channels.discord.threadBindings.spawnSubagentSessions`
|
||||
|
||||
See [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), and [Slash commands](/tools/slash-commands).
|
||||
|
||||
Allowlist:
|
||||
|
||||
- `agents.list[].subagents.allowAgents`: list of agent ids that can be targeted via `agentId` (`["*"]` to allow any). Default: only the requester agent.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,18 +1,64 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./test-mocks.js";
|
||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
const fetchRemoteMediaMock = vi.fn(
|
||||
async (params: {
|
||||
url: string;
|
||||
maxBytes?: number;
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}) => {
|
||||
const fetchFn = params.fetchImpl ?? fetch;
|
||||
const res = await fetchFn(params.url);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "unknown");
|
||||
throw new Error(
|
||||
`Failed to fetch media from ${params.url}: HTTP ${res.status}; body: ${text}`,
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
|
||||
const error = new Error(`payload exceeds maxBytes ${params.maxBytes}`) as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "max_bytes";
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
buffer,
|
||||
contentType: res.headers.get("content-type") ?? undefined,
|
||||
fileName: undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
installBlueBubblesFetchTestHooks({
|
||||
mockFetch,
|
||||
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
||||
});
|
||||
|
||||
const runtimeStub = {
|
||||
channel: {
|
||||
media: {
|
||||
fetchRemoteMedia:
|
||||
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
describe("downloadBlueBubblesAttachment", () => {
|
||||
beforeEach(() => {
|
||||
fetchRemoteMediaMock.mockClear();
|
||||
mockFetch.mockReset();
|
||||
setBlueBubblesRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("throws when guid is missing", async () => {
|
||||
const attachment: BlueBubblesAttachment = {};
|
||||
await expect(
|
||||
@@ -120,7 +166,7 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("download failed (404): Attachment not found");
|
||||
).rejects.toThrow("Attachment not found");
|
||||
});
|
||||
|
||||
it("throws when attachment exceeds max bytes", async () => {
|
||||
@@ -229,6 +275,8 @@ describe("sendBlueBubblesAttachment", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
fetchRemoteMediaMock.mockClear();
|
||||
setBlueBubblesRuntime(runtimeStub);
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { postMultipartFormData } from "./multipart.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { resolveRequestUrl } from "./request-url.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
||||
import { resolveChatGuidForTarget } from "./send.js";
|
||||
import {
|
||||
@@ -57,6 +59,18 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||
return resolveBlueBubblesServerAccount(params);
|
||||
}
|
||||
|
||||
type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
|
||||
|
||||
function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
|
||||
? code
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export async function downloadBlueBubblesAttachment(
|
||||
attachment: BlueBubblesAttachment,
|
||||
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
|
||||
@@ -71,20 +85,30 @@ export async function downloadBlueBubblesAttachment(
|
||||
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
|
||||
password,
|
||||
});
|
||||
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,
|
||||
);
|
||||
}
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
|
||||
if (buf.byteLength > maxBytes) {
|
||||
throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);
|
||||
try {
|
||||
const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
|
||||
url,
|
||||
filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
|
||||
maxBytes,
|
||||
fetchImpl: async (input, init) =>
|
||||
await blueBubblesFetchWithTimeout(
|
||||
resolveRequestUrl(input),
|
||||
{ ...init, method: init?.method ?? "GET" },
|
||||
opts.timeoutMs,
|
||||
),
|
||||
});
|
||||
return {
|
||||
buffer: new Uint8Array(fetched.buffer),
|
||||
contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if (readMediaFetchErrorCode(error) === "max_bytes") {
|
||||
throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`);
|
||||
}
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`BlueBubbles attachment download failed: ${text}`);
|
||||
}
|
||||
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
|
||||
}
|
||||
|
||||
export type SendBlueBubblesAttachmentResult = {
|
||||
|
||||
177
extensions/bluebubbles/src/history.ts
Normal file
177
extensions/bluebubbles/src/history.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
export type BlueBubblesHistoryEntry = {
|
||||
sender: string;
|
||||
body: string;
|
||||
timestamp?: number;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
export type BlueBubblesHistoryFetchResult = {
|
||||
entries: BlueBubblesHistoryEntry[];
|
||||
/**
|
||||
* True when at least one API path returned a recognized response shape.
|
||||
* False means all attempts failed or returned unusable data.
|
||||
*/
|
||||
resolved: boolean;
|
||||
};
|
||||
|
||||
export type BlueBubblesMessageData = {
|
||||
guid?: string;
|
||||
text?: string;
|
||||
handle_id?: string;
|
||||
is_from_me?: boolean;
|
||||
date_created?: number;
|
||||
date_delivered?: number;
|
||||
associated_message_guid?: string;
|
||||
sender?: {
|
||||
address?: string;
|
||||
display_name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type BlueBubblesChatOpts = {
|
||||
serverUrl?: string;
|
||||
password?: string;
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
cfg?: OpenClawConfig;
|
||||
};
|
||||
|
||||
function resolveAccount(params: BlueBubblesChatOpts) {
|
||||
return resolveBlueBubblesServerAccount(params);
|
||||
}
|
||||
|
||||
const MAX_HISTORY_FETCH_LIMIT = 100;
|
||||
const HISTORY_SCAN_MULTIPLIER = 8;
|
||||
const MAX_HISTORY_SCAN_MESSAGES = 500;
|
||||
const MAX_HISTORY_BODY_CHARS = 2_000;
|
||||
|
||||
function clampHistoryLimit(limit: number): number {
|
||||
if (!Number.isFinite(limit)) {
|
||||
return 0;
|
||||
}
|
||||
const normalized = Math.floor(limit);
|
||||
if (normalized <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(normalized, MAX_HISTORY_FETCH_LIMIT);
|
||||
}
|
||||
|
||||
function truncateHistoryBody(text: string): string {
|
||||
if (text.length <= MAX_HISTORY_BODY_CHARS) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, MAX_HISTORY_BODY_CHARS).trimEnd()}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch message history from BlueBubbles API for a specific chat.
|
||||
* This provides the initial backfill for both group chats and DMs.
|
||||
*/
|
||||
export async function fetchBlueBubblesHistory(
|
||||
chatIdentifier: string,
|
||||
limit: number,
|
||||
opts: BlueBubblesChatOpts = {},
|
||||
): Promise<BlueBubblesHistoryFetchResult> {
|
||||
const effectiveLimit = clampHistoryLimit(limit);
|
||||
if (!chatIdentifier.trim() || effectiveLimit <= 0) {
|
||||
return { entries: [], resolved: true };
|
||||
}
|
||||
|
||||
let baseUrl: string;
|
||||
let password: string;
|
||||
try {
|
||||
({ baseUrl, password } = resolveAccount(opts));
|
||||
} catch {
|
||||
return { entries: [], resolved: false };
|
||||
}
|
||||
|
||||
// Try different common API patterns for fetching messages
|
||||
const possiblePaths = [
|
||||
`/api/v1/chat/${encodeURIComponent(chatIdentifier)}/messages?limit=${effectiveLimit}&sort=DESC`,
|
||||
`/api/v1/messages?chatGuid=${encodeURIComponent(chatIdentifier)}&limit=${effectiveLimit}`,
|
||||
`/api/v1/chat/${encodeURIComponent(chatIdentifier)}/message?limit=${effectiveLimit}`,
|
||||
];
|
||||
|
||||
for (const path of possiblePaths) {
|
||||
try {
|
||||
const url = buildBlueBubblesApiUrl({ baseUrl, path, password });
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{ method: "GET" },
|
||||
opts.timeoutMs ?? 10000,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
continue; // Try next path
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle different response structures
|
||||
let messages: unknown[] = [];
|
||||
if (Array.isArray(data)) {
|
||||
messages = data;
|
||||
} else if (data.data && Array.isArray(data.data)) {
|
||||
messages = data.data;
|
||||
} else if (data.messages && Array.isArray(data.messages)) {
|
||||
messages = data.messages;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const historyEntries: BlueBubblesHistoryEntry[] = [];
|
||||
|
||||
const maxScannedMessages = Math.min(
|
||||
Math.max(effectiveLimit * HISTORY_SCAN_MULTIPLIER, effectiveLimit),
|
||||
MAX_HISTORY_SCAN_MESSAGES,
|
||||
);
|
||||
for (let i = 0; i < messages.length && i < maxScannedMessages; i++) {
|
||||
const item = messages[i];
|
||||
const msg = item as BlueBubblesMessageData;
|
||||
|
||||
// Skip messages without text content
|
||||
const text = msg.text?.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sender = msg.is_from_me
|
||||
? "me"
|
||||
: msg.sender?.display_name || msg.sender?.address || msg.handle_id || "Unknown";
|
||||
const timestamp = msg.date_created || msg.date_delivered;
|
||||
|
||||
historyEntries.push({
|
||||
sender,
|
||||
body: truncateHistoryBody(text),
|
||||
timestamp,
|
||||
messageId: msg.guid,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp (oldest first for context)
|
||||
historyEntries.sort((a, b) => {
|
||||
const aTime = a.timestamp || 0;
|
||||
const bTime = b.timestamp || 0;
|
||||
return aTime - bTime;
|
||||
});
|
||||
|
||||
return {
|
||||
entries: historyEntries.slice(0, effectiveLimit), // Ensure we don't exceed the requested limit
|
||||
resolved: true,
|
||||
};
|
||||
} catch (error) {
|
||||
// Continue to next path
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If none of the API paths worked, return empty history
|
||||
return { entries: [], resolved: false };
|
||||
}
|
||||
78
extensions/bluebubbles/src/monitor-normalize.test.ts
Normal file
78
extensions/bluebubbles/src/monitor-normalize.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
|
||||
|
||||
describe("normalizeWebhookMessage", () => {
|
||||
it("falls back to DM chatGuid handle when sender handle is missing", () => {
|
||||
const result = normalizeWebhookMessage({
|
||||
type: "new-message",
|
||||
data: {
|
||||
guid: "msg-1",
|
||||
text: "hello",
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
handle: null,
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.senderId).toBe("+15551234567");
|
||||
expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
|
||||
});
|
||||
|
||||
it("does not infer sender from group chatGuid when sender handle is missing", () => {
|
||||
const result = normalizeWebhookMessage({
|
||||
type: "new-message",
|
||||
data: {
|
||||
guid: "msg-1",
|
||||
text: "hello group",
|
||||
isGroup: true,
|
||||
isFromMe: false,
|
||||
handle: null,
|
||||
chatGuid: "iMessage;+;chat123456",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts array-wrapped payload data", () => {
|
||||
const result = normalizeWebhookMessage({
|
||||
type: "new-message",
|
||||
data: [
|
||||
{
|
||||
guid: "msg-1",
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.senderId).toBe("+15551234567");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeWebhookReaction", () => {
|
||||
it("falls back to DM chatGuid handle when reaction sender handle is missing", () => {
|
||||
const result = normalizeWebhookReaction({
|
||||
type: "updated-message",
|
||||
data: {
|
||||
guid: "msg-2",
|
||||
associatedMessageGuid: "p:0/msg-1",
|
||||
associatedMessageType: 2000,
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
handle: null,
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.senderId).toBe("+15551234567");
|
||||
expect(result?.messageId).toBe("p:0/msg-1");
|
||||
expect(result?.action).toBe("added");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
@@ -629,18 +629,42 @@ export function parseTapbackText(params: {
|
||||
}
|
||||
|
||||
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const parseRecord = (value: unknown): Record<string, unknown> | null => {
|
||||
const record = asRecord(value);
|
||||
if (record) {
|
||||
return record;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const parsedEntry = parseRecord(entry);
|
||||
if (parsedEntry) {
|
||||
return parsedEntry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseRecord(JSON.parse(trimmed));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const dataRaw = payload.data ?? payload.payload ?? payload.event;
|
||||
const data =
|
||||
asRecord(dataRaw) ??
|
||||
(typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null);
|
||||
const data = parseRecord(dataRaw);
|
||||
const messageRaw = payload.message ?? data?.message ?? data;
|
||||
const message =
|
||||
asRecord(messageRaw) ??
|
||||
(typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
|
||||
if (!message) {
|
||||
return null;
|
||||
const message = parseRecord(messageRaw);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
return message;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeWebhookMessage(
|
||||
@@ -700,7 +724,10 @@ export function normalizeWebhookMessage(
|
||||
: timestampRaw * 1000
|
||||
: undefined;
|
||||
|
||||
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
||||
// BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
|
||||
const senderFallbackFromChatGuid =
|
||||
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
||||
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
|
||||
if (!normalizedSender) {
|
||||
return null;
|
||||
}
|
||||
@@ -774,7 +801,9 @@ export function normalizeWebhookReaction(
|
||||
: timestampRaw * 1000
|
||||
: undefined;
|
||||
|
||||
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
||||
const senderFallbackFromChatGuid =
|
||||
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
||||
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
|
||||
if (!normalizedSender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
evictOldHistoryKeys,
|
||||
logAckFailure,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
resolveAckReaction,
|
||||
resolveDmGroupAccessDecision,
|
||||
resolveEffectiveAllowFromLists,
|
||||
resolveControlCommandGate,
|
||||
stripMarkdown,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { fetchBlueBubblesHistory } from "./history.js";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
import {
|
||||
buildMessagePlaceholder,
|
||||
@@ -237,6 +243,178 @@ function resolveBlueBubblesAckReaction(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory rolling history map keyed by account + chat identifier.
|
||||
* Populated from incoming messages during the session.
|
||||
* API backfill is attempted until one fetch resolves (or retries are exhausted).
|
||||
*/
|
||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||
type HistoryBackfillState = {
|
||||
attempts: number;
|
||||
firstAttemptAt: number;
|
||||
nextAttemptAt: number;
|
||||
resolved: boolean;
|
||||
};
|
||||
|
||||
const historyBackfills = new Map<string, HistoryBackfillState>();
|
||||
const HISTORY_BACKFILL_BASE_DELAY_MS = 5_000;
|
||||
const HISTORY_BACKFILL_MAX_DELAY_MS = 2 * 60 * 1000;
|
||||
const HISTORY_BACKFILL_MAX_ATTEMPTS = 6;
|
||||
const HISTORY_BACKFILL_RETRY_WINDOW_MS = 30 * 60 * 1000;
|
||||
const MAX_STORED_HISTORY_ENTRY_CHARS = 2_000;
|
||||
const MAX_INBOUND_HISTORY_ENTRY_CHARS = 1_200;
|
||||
const MAX_INBOUND_HISTORY_TOTAL_CHARS = 12_000;
|
||||
|
||||
function buildAccountScopedHistoryKey(accountId: string, historyIdentifier: string): string {
|
||||
return `${accountId}\u0000${historyIdentifier}`;
|
||||
}
|
||||
|
||||
function historyDedupKey(entry: HistoryEntry): string {
|
||||
const messageId = entry.messageId?.trim();
|
||||
if (messageId) {
|
||||
return `id:${messageId}`;
|
||||
}
|
||||
return `fallback:${entry.sender}\u0000${entry.body}\u0000${entry.timestamp ?? ""}`;
|
||||
}
|
||||
|
||||
function truncateHistoryBody(body: string, maxChars: number): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.length <= maxChars) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, maxChars).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function mergeHistoryEntries(params: {
|
||||
apiEntries: HistoryEntry[];
|
||||
currentEntries: HistoryEntry[];
|
||||
limit: number;
|
||||
}): HistoryEntry[] {
|
||||
if (params.limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const merged: HistoryEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
const appendUnique = (entry: HistoryEntry) => {
|
||||
const key = historyDedupKey(entry);
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
merged.push(entry);
|
||||
};
|
||||
|
||||
for (const entry of params.apiEntries) {
|
||||
appendUnique(entry);
|
||||
}
|
||||
for (const entry of params.currentEntries) {
|
||||
appendUnique(entry);
|
||||
}
|
||||
|
||||
if (merged.length <= params.limit) {
|
||||
return merged;
|
||||
}
|
||||
return merged.slice(merged.length - params.limit);
|
||||
}
|
||||
|
||||
function pruneHistoryBackfillState(): void {
|
||||
for (const key of historyBackfills.keys()) {
|
||||
if (!chatHistories.has(key)) {
|
||||
historyBackfills.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function markHistoryBackfillResolved(historyKey: string): void {
|
||||
const state = historyBackfills.get(historyKey);
|
||||
if (state) {
|
||||
state.resolved = true;
|
||||
historyBackfills.set(historyKey, state);
|
||||
return;
|
||||
}
|
||||
historyBackfills.set(historyKey, {
|
||||
attempts: 0,
|
||||
firstAttemptAt: Date.now(),
|
||||
nextAttemptAt: Number.POSITIVE_INFINITY,
|
||||
resolved: true,
|
||||
});
|
||||
}
|
||||
|
||||
function planHistoryBackfillAttempt(historyKey: string, now: number): HistoryBackfillState | null {
|
||||
const existing = historyBackfills.get(historyKey);
|
||||
if (existing?.resolved) {
|
||||
return null;
|
||||
}
|
||||
if (existing && now - existing.firstAttemptAt > HISTORY_BACKFILL_RETRY_WINDOW_MS) {
|
||||
markHistoryBackfillResolved(historyKey);
|
||||
return null;
|
||||
}
|
||||
if (existing && existing.attempts >= HISTORY_BACKFILL_MAX_ATTEMPTS) {
|
||||
markHistoryBackfillResolved(historyKey);
|
||||
return null;
|
||||
}
|
||||
if (existing && now < existing.nextAttemptAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attempts = (existing?.attempts ?? 0) + 1;
|
||||
const firstAttemptAt = existing?.firstAttemptAt ?? now;
|
||||
const backoffDelay = Math.min(
|
||||
HISTORY_BACKFILL_BASE_DELAY_MS * 2 ** (attempts - 1),
|
||||
HISTORY_BACKFILL_MAX_DELAY_MS,
|
||||
);
|
||||
const state: HistoryBackfillState = {
|
||||
attempts,
|
||||
firstAttemptAt,
|
||||
nextAttemptAt: now + backoffDelay,
|
||||
resolved: false,
|
||||
};
|
||||
historyBackfills.set(historyKey, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildInboundHistorySnapshot(params: {
|
||||
entries: HistoryEntry[];
|
||||
limit: number;
|
||||
}): Array<{ sender: string; body: string; timestamp?: number }> | undefined {
|
||||
if (params.limit <= 0 || params.entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const recent = params.entries.slice(-params.limit);
|
||||
const selected: Array<{ sender: string; body: string; timestamp?: number }> = [];
|
||||
let remainingChars = MAX_INBOUND_HISTORY_TOTAL_CHARS;
|
||||
|
||||
for (let i = recent.length - 1; i >= 0; i--) {
|
||||
const entry = recent[i];
|
||||
const body = truncateHistoryBody(entry.body, MAX_INBOUND_HISTORY_ENTRY_CHARS);
|
||||
if (!body) {
|
||||
continue;
|
||||
}
|
||||
if (selected.length > 0 && body.length > remainingChars) {
|
||||
break;
|
||||
}
|
||||
selected.push({
|
||||
sender: entry.sender,
|
||||
body,
|
||||
timestamp: entry.timestamp,
|
||||
});
|
||||
remainingChars -= body.length;
|
||||
if (remainingChars <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
selected.reverse();
|
||||
return selected;
|
||||
}
|
||||
|
||||
export async function processMessage(
|
||||
message: NormalizedWebhookMessage,
|
||||
target: WebhookTarget,
|
||||
@@ -323,41 +501,51 @@ export async function processMessage(
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
|
||||
const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
|
||||
const storeAllowFrom = await core.channel.pairing
|
||||
.readAllowFromStore("bluebubbles")
|
||||
.catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const effectiveGroupAllowFrom = [
|
||||
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
||||
...storeAllowFrom,
|
||||
]
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
|
||||
allowFrom: account.config.allowFrom,
|
||||
groupAllowFrom: account.config.groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
const groupAllowEntry = formatGroupAllowlistEntry({
|
||||
chatGuid: message.chatGuid,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
});
|
||||
const groupName = message.chatName?.trim() || undefined;
|
||||
const accessDecision = resolveDmGroupAccessDecision({
|
||||
isGroup,
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
isAllowedBlueBubblesSender({
|
||||
allowFrom,
|
||||
sender: message.senderId,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatGuid: message.chatGuid ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (isGroup) {
|
||||
if (groupPolicy === "disabled") {
|
||||
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
reason: "groupPolicy=disabled",
|
||||
entry: groupAllowEntry,
|
||||
chatName: groupName,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (effectiveGroupAllowFrom.length === 0) {
|
||||
if (accessDecision.decision !== "allow") {
|
||||
if (isGroup) {
|
||||
if (accessDecision.reason === "groupPolicy=disabled") {
|
||||
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
reason: "groupPolicy=disabled",
|
||||
entry: groupAllowEntry,
|
||||
chatName: groupName,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (accessDecision.reason === "groupPolicy=allowlist (empty allowlist)") {
|
||||
logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
@@ -368,14 +556,7 @@ export async function processMessage(
|
||||
});
|
||||
return;
|
||||
}
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
sender: message.senderId,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatGuid: message.chatGuid ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
});
|
||||
if (!allowed) {
|
||||
if (accessDecision.reason === "groupPolicy=allowlist (not allowlisted)") {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
@@ -395,70 +576,60 @@ export async function processMessage(
|
||||
});
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (dmPolicy === "disabled") {
|
||||
|
||||
if (accessDecision.reason === "dmPolicy=disabled") {
|
||||
logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
|
||||
logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
|
||||
return;
|
||||
}
|
||||
if (dmPolicy !== "open") {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
sender: message.senderId,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatGuid: message.chatGuid ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
|
||||
if (accessDecision.decision === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "bluebubbles",
|
||||
id: message.senderId,
|
||||
meta: { name: message.senderName },
|
||||
});
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "bluebubbles",
|
||||
id: message.senderId,
|
||||
meta: { name: message.senderName },
|
||||
});
|
||||
runtime.log?.(
|
||||
`[bluebubbles] pairing request sender=${message.senderId} created=${created}`,
|
||||
runtime.log?.(`[bluebubbles] pairing request sender=${message.senderId} created=${created}`);
|
||||
if (created) {
|
||||
logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
|
||||
try {
|
||||
await sendMessageBlueBubbles(
|
||||
message.senderId,
|
||||
core.channel.pairing.buildPairingReply({
|
||||
channel: "bluebubbles",
|
||||
idLine: `Your BlueBubbles sender id: ${message.senderId}`,
|
||||
code,
|
||||
}),
|
||||
{ cfg: config, accountId: account.accountId },
|
||||
);
|
||||
if (created) {
|
||||
logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
|
||||
try {
|
||||
await sendMessageBlueBubbles(
|
||||
message.senderId,
|
||||
core.channel.pairing.buildPairingReply({
|
||||
channel: "bluebubbles",
|
||||
idLine: `Your BlueBubbles sender id: ${message.senderId}`,
|
||||
code,
|
||||
}),
|
||||
{ cfg: config, accountId: account.accountId },
|
||||
);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
|
||||
);
|
||||
runtime.error?.(
|
||||
`[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
|
||||
`bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
|
||||
);
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
|
||||
runtime.error?.(
|
||||
`[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const chatId = message.chatId ?? undefined;
|
||||
@@ -813,9 +984,118 @@ export async function processMessage(
|
||||
.trim();
|
||||
};
|
||||
|
||||
// History: in-memory rolling map with bounded API backfill retries
|
||||
const historyLimit = isGroup
|
||||
? (account.config.historyLimit ?? 0)
|
||||
: (account.config.dmHistoryLimit ?? 0);
|
||||
|
||||
const historyIdentifier =
|
||||
chatGuid ||
|
||||
chatIdentifier ||
|
||||
(chatId ? String(chatId) : null) ||
|
||||
(isGroup ? null : message.senderId) ||
|
||||
"";
|
||||
const historyKey = historyIdentifier
|
||||
? buildAccountScopedHistoryKey(account.accountId, historyIdentifier)
|
||||
: "";
|
||||
|
||||
// Record the current message into rolling history
|
||||
if (historyKey && historyLimit > 0) {
|
||||
const nowMs = Date.now();
|
||||
const senderLabel = message.fromMe ? "me" : message.senderName || message.senderId;
|
||||
const normalizedHistoryBody = truncateHistoryBody(text, MAX_STORED_HISTORY_ENTRY_CHARS);
|
||||
const currentEntries = recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: chatHistories,
|
||||
limit: historyLimit,
|
||||
historyKey,
|
||||
entry: normalizedHistoryBody
|
||||
? {
|
||||
sender: senderLabel,
|
||||
body: normalizedHistoryBody,
|
||||
timestamp: message.timestamp ?? nowMs,
|
||||
messageId: message.messageId ?? undefined,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
pruneHistoryBackfillState();
|
||||
|
||||
const backfillAttempt = planHistoryBackfillAttempt(historyKey, nowMs);
|
||||
if (backfillAttempt) {
|
||||
try {
|
||||
const backfillResult = await fetchBlueBubblesHistory(historyIdentifier, historyLimit, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
if (backfillResult.resolved) {
|
||||
markHistoryBackfillResolved(historyKey);
|
||||
}
|
||||
if (backfillResult.entries.length > 0) {
|
||||
const apiEntries: HistoryEntry[] = [];
|
||||
for (const entry of backfillResult.entries) {
|
||||
const body = truncateHistoryBody(entry.body, MAX_STORED_HISTORY_ENTRY_CHARS);
|
||||
if (!body) {
|
||||
continue;
|
||||
}
|
||||
apiEntries.push({
|
||||
sender: entry.sender,
|
||||
body,
|
||||
timestamp: entry.timestamp,
|
||||
messageId: entry.messageId,
|
||||
});
|
||||
}
|
||||
const merged = mergeHistoryEntries({
|
||||
apiEntries,
|
||||
currentEntries:
|
||||
currentEntries.length > 0 ? currentEntries : (chatHistories.get(historyKey) ?? []),
|
||||
limit: historyLimit,
|
||||
});
|
||||
if (chatHistories.has(historyKey)) {
|
||||
chatHistories.delete(historyKey);
|
||||
}
|
||||
chatHistories.set(historyKey, merged);
|
||||
evictOldHistoryKeys(chatHistories);
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`backfilled ${backfillResult.entries.length} history messages for ${isGroup ? "group" : "DM"}: ${historyIdentifier}`,
|
||||
);
|
||||
} else if (!backfillResult.resolved) {
|
||||
const remainingAttempts = HISTORY_BACKFILL_MAX_ATTEMPTS - backfillAttempt.attempts;
|
||||
const nextBackoffMs = Math.max(backfillAttempt.nextAttemptAt - nowMs, 0);
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`history backfill unresolved for ${historyIdentifier}; retries left=${Math.max(remainingAttempts, 0)} next_in_ms=${nextBackoffMs}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const remainingAttempts = HISTORY_BACKFILL_MAX_ATTEMPTS - backfillAttempt.attempts;
|
||||
const nextBackoffMs = Math.max(backfillAttempt.nextAttemptAt - nowMs, 0);
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`history backfill failed for ${historyIdentifier}: ${String(err)} (retries left=${Math.max(remainingAttempts, 0)} next_in_ms=${nextBackoffMs})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build inbound history from the in-memory map
|
||||
let inboundHistory: Array<{ sender: string; body: string; timestamp?: number }> | undefined;
|
||||
if (historyKey && historyLimit > 0) {
|
||||
const entries = chatHistories.get(historyKey);
|
||||
if (entries && entries.length > 0) {
|
||||
inboundHistory = buildInboundHistorySnapshot({
|
||||
entries,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: rawBody,
|
||||
InboundHistory: inboundHistory,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
BodyForCommands: rawBody,
|
||||
@@ -1106,56 +1386,32 @@ export async function processReaction(
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
|
||||
const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
|
||||
const storeAllowFrom = await core.channel.pairing
|
||||
.readAllowFromStore("bluebubbles")
|
||||
.catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const effectiveGroupAllowFrom = [
|
||||
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
||||
...storeAllowFrom,
|
||||
]
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (reaction.isGroup) {
|
||||
if (groupPolicy === "disabled") {
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (effectiveGroupAllowFrom.length === 0) {
|
||||
return;
|
||||
}
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
|
||||
allowFrom: account.config.allowFrom,
|
||||
groupAllowFrom: account.config.groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
const accessDecision = resolveDmGroupAccessDecision({
|
||||
isGroup: reaction.isGroup,
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
isAllowedBlueBubblesSender({
|
||||
allowFrom,
|
||||
sender: reaction.senderId,
|
||||
chatId: reaction.chatId ?? undefined,
|
||||
chatGuid: reaction.chatGuid ?? undefined,
|
||||
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
||||
});
|
||||
if (!allowed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (dmPolicy === "disabled") {
|
||||
return;
|
||||
}
|
||||
if (dmPolicy !== "open") {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
sender: reaction.senderId,
|
||||
chatId: reaction.chatId ?? undefined,
|
||||
chatGuid: reaction.chatGuid ?? undefined,
|
||||
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
||||
});
|
||||
if (!allowed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
if (accessDecision.decision !== "allow") {
|
||||
return;
|
||||
}
|
||||
|
||||
const chatId = reaction.chatId ?? undefined;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { fetchBlueBubblesHistory } from "./history.js";
|
||||
import {
|
||||
handleBlueBubblesWebhookRequest,
|
||||
registerBlueBubblesWebhookTarget,
|
||||
@@ -38,6 +39,10 @@ vi.mock("./reactions.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./history.js", () => ({
|
||||
fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }),
|
||||
}));
|
||||
|
||||
// Mock runtime
|
||||
const mockEnqueueSystemEvent = vi.fn();
|
||||
const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE");
|
||||
@@ -86,6 +91,7 @@ const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockResolveChunkMode = vi.fn(() => "length");
|
||||
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
|
||||
|
||||
function createMockRuntime(): PluginRuntime {
|
||||
return {
|
||||
@@ -355,6 +361,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
vi.clearAllMocks();
|
||||
// Reset short ID state between tests for predictable behavior
|
||||
_resetBlueBubblesShortIdState();
|
||||
mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
|
||||
mockReadAllowFromStore.mockResolvedValue([]);
|
||||
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
||||
mockResolveRequireMention.mockReturnValue(false);
|
||||
@@ -1017,9 +1024,86 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks DM when dmPolicy=allowlist and allowFrom is empty", async () => {
|
||||
const account = createMockAccount({
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
});
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello from blocked sender",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(mockUpsertPairingRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers pairing flow for unknown sender when dmPolicy=pairing and allowFrom is empty", async () => {
|
||||
const account = createMockAccount({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
});
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => {
|
||||
// Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
|
||||
// allowlist that doesn't include the sender
|
||||
const account = createMockAccount({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["+15559999999"], // Different number than sender
|
||||
@@ -1061,8 +1145,6 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
it("does not resend pairing reply when request already exists", async () => {
|
||||
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false });
|
||||
|
||||
// Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
|
||||
// allowlist that doesn't include the sender
|
||||
const account = createMockAccount({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["+15559999999"], // Different number than sender
|
||||
@@ -2627,6 +2709,43 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
});
|
||||
|
||||
describe("reaction events", () => {
|
||||
it("drops DM reactions when dmPolicy=pairing and allowFrom is empty", async () => {
|
||||
mockEnqueueSystemEvent.mockClear();
|
||||
|
||||
const account = createMockAccount({ dmPolicy: "pairing", allowFrom: [] });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "message-reaction",
|
||||
data: {
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
associatedMessageGuid: "msg-original-123",
|
||||
associatedMessageType: 2000,
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enqueues system event for reaction added", async () => {
|
||||
mockEnqueueSystemEvent.mockClear();
|
||||
|
||||
@@ -2879,6 +2998,279 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("history backfill", () => {
|
||||
it("scopes in-memory history by account to avoid cross-account leakage", async () => {
|
||||
mockFetchBlueBubblesHistory.mockImplementation(async (_chatIdentifier, _limit, opts) => {
|
||||
if (opts?.accountId === "acc-a") {
|
||||
return {
|
||||
resolved: true,
|
||||
entries: [
|
||||
{ sender: "A", body: "a-history", messageId: "a-history-1", timestamp: 1000 },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (opts?.accountId === "acc-b") {
|
||||
return {
|
||||
resolved: true,
|
||||
entries: [
|
||||
{ sender: "B", body: "b-history", messageId: "b-history-1", timestamp: 1000 },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { resolved: true, entries: [] };
|
||||
});
|
||||
|
||||
const accountA: ResolvedBlueBubblesAccount = {
|
||||
...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }),
|
||||
accountId: "acc-a",
|
||||
};
|
||||
const accountB: ResolvedBlueBubblesAccount = {
|
||||
...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }),
|
||||
accountId: "acc-b",
|
||||
};
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const unregisterA = registerBlueBubblesWebhookTarget({
|
||||
account: accountA,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
const unregisterB = registerBlueBubblesWebhookTarget({
|
||||
account: accountB,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
unregister = () => {
|
||||
unregisterA();
|
||||
unregisterB();
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook?password=password-a", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "message for account a",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "a-msg-1",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: Date.now(),
|
||||
},
|
||||
}),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook?password=password-b", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "message for account b",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "b-msg-1",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: Date.now(),
|
||||
},
|
||||
}),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
|
||||
const firstCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
const secondCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[1]?.[0];
|
||||
const firstHistory = (firstCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
|
||||
const secondHistory = (secondCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
|
||||
expect(firstHistory.map((entry) => entry.body)).toContain("a-history");
|
||||
expect(secondHistory.map((entry) => entry.body)).toContain("b-history");
|
||||
expect(secondHistory.map((entry) => entry.body)).not.toContain("a-history");
|
||||
});
|
||||
|
||||
it("dedupes and caps merged history to dmHistoryLimit", async () => {
|
||||
mockFetchBlueBubblesHistory.mockResolvedValueOnce({
|
||||
resolved: true,
|
||||
entries: [
|
||||
{ sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 },
|
||||
{ sender: "Friend", body: "current text", messageId: "msg-1", timestamp: 2000 },
|
||||
],
|
||||
});
|
||||
|
||||
const account = createMockAccount({ dmHistoryLimit: 2 });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "current text",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
chatGuid: "iMessage;-;+15550002002",
|
||||
date: Date.now(),
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>;
|
||||
expect(inboundHistory).toHaveLength(2);
|
||||
expect(inboundHistory.map((entry) => entry.body)).toEqual(["older context", "current text"]);
|
||||
expect(inboundHistory.filter((entry) => entry.body === "current text")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses exponential backoff for unresolved backfill and stops after resolve", async () => {
|
||||
mockFetchBlueBubblesHistory
|
||||
.mockResolvedValueOnce({ resolved: false, entries: [] })
|
||||
.mockResolvedValueOnce({
|
||||
resolved: true,
|
||||
entries: [
|
||||
{ sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 },
|
||||
],
|
||||
});
|
||||
|
||||
const account = createMockAccount({ dmHistoryLimit: 4 });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const mkPayload = (guid: string, text: string, now: number) => ({
|
||||
type: "new-message",
|
||||
data: {
|
||||
text,
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid,
|
||||
chatGuid: "iMessage;-;+15550003003",
|
||||
date: now,
|
||||
},
|
||||
});
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
try {
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-1", "first text", now)),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += 1_000;
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-2", "second text", now)),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += 6_000;
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-3", "third text", now)),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2);
|
||||
|
||||
const thirdCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[2]?.[0];
|
||||
const thirdHistory = (thirdCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
|
||||
expect(thirdHistory.map((entry) => entry.body)).toContain("older context");
|
||||
expect(thirdHistory.map((entry) => entry.body)).toContain("third text");
|
||||
|
||||
now += 10_000;
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-4", "fourth text", now)),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("caps inbound history payload size to reduce prompt-bomb risk", async () => {
|
||||
const huge = "x".repeat(8_000);
|
||||
mockFetchBlueBubblesHistory.mockResolvedValueOnce({
|
||||
resolved: true,
|
||||
entries: Array.from({ length: 20 }, (_, idx) => ({
|
||||
sender: `Friend ${idx}`,
|
||||
body: `${huge} ${idx}`,
|
||||
messageId: `hist-${idx}`,
|
||||
timestamp: idx + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
const account = createMockAccount({ dmHistoryLimit: 20 });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "latest text",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-bomb-1",
|
||||
chatGuid: "iMessage;-;+15550004004",
|
||||
date: Date.now(),
|
||||
},
|
||||
}),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>;
|
||||
const totalChars = inboundHistory.reduce((sum, entry) => sum + entry.body.length, 0);
|
||||
expect(inboundHistory.length).toBeLessThan(20);
|
||||
expect(totalChars).toBeLessThanOrEqual(12_000);
|
||||
expect(inboundHistory.every((entry) => entry.body.length <= 1_203)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromMe messages", () => {
|
||||
it("ignores messages from self (fromMe=true)", async () => {
|
||||
const account = createMockAccount();
|
||||
|
||||
12
extensions/bluebubbles/src/request-url.ts
Normal file
12
extensions/bluebubbles/src/request-url.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function resolveRequestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
|
||||
return input.url;
|
||||
}
|
||||
return String(input);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isAllowedBlueBubblesSender,
|
||||
looksLikeBlueBubblesTargetId,
|
||||
normalizeBlueBubblesMessagingTarget,
|
||||
parseBlueBubblesTarget,
|
||||
@@ -181,3 +182,21 @@ describe("parseBlueBubblesAllowTarget", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedBlueBubblesSender", () => {
|
||||
it("denies when allowFrom is empty", () => {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: [],
|
||||
sender: "+15551234567",
|
||||
});
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("allows wildcard entries", () => {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: ["*"],
|
||||
sender: "+15551234567",
|
||||
});
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { tryRecordMessage } from "./dedup.js";
|
||||
import { tryRecordMessagePersistent } from "./dedup.js";
|
||||
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
import { downloadMessageResourceFeishu } from "./media.js";
|
||||
@@ -510,9 +510,9 @@ export async function handleFeishuMessage(params: {
|
||||
const log = runtime?.log ?? console.log;
|
||||
const error = runtime?.error ?? console.error;
|
||||
|
||||
// Dedup check: skip if this message was already processed
|
||||
// Dedup check: skip if this message was already processed (memory + disk).
|
||||
const messageId = event.message.message_id;
|
||||
if (!tryRecordMessage(messageId)) {
|
||||
if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
|
||||
log(`feishu: skipping duplicate message ${messageId}`);
|
||||
return;
|
||||
}
|
||||
@@ -630,7 +630,9 @@ export async function handleFeishuMessage(params: {
|
||||
cfg,
|
||||
);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
|
||||
!isGroup &&
|
||||
dmPolicy !== "allowlist" &&
|
||||
(dmPolicy !== "open" || shouldComputeCommandAuthorized)
|
||||
? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
|
||||
: [];
|
||||
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
|
||||
@@ -1,33 +1,54 @@
|
||||
// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
|
||||
const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const DEDUP_MAX_SIZE = 1_000;
|
||||
const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
|
||||
const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
|
||||
let lastCleanupTime = Date.now();
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk";
|
||||
|
||||
export function tryRecordMessage(messageId: string): boolean {
|
||||
const now = Date.now();
|
||||
// Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
|
||||
const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const MEMORY_MAX_SIZE = 1_000;
|
||||
const FILE_MAX_ENTRIES = 10_000;
|
||||
|
||||
// Throttled cleanup: evict expired entries at most once per interval.
|
||||
if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
|
||||
for (const [id, ts] of processedMessageIds) {
|
||||
if (now - ts > DEDUP_TTL_MS) {
|
||||
processedMessageIds.delete(id);
|
||||
}
|
||||
}
|
||||
lastCleanupTime = now;
|
||||
const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
|
||||
|
||||
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (stateOverride) {
|
||||
return stateOverride;
|
||||
}
|
||||
|
||||
if (processedMessageIds.has(messageId)) {
|
||||
return false;
|
||||
if (env.VITEST || env.NODE_ENV === "test") {
|
||||
return path.join(os.tmpdir(), `openclaw-vitest-${process.pid}`);
|
||||
}
|
||||
|
||||
// Evict oldest entries if cache is full.
|
||||
if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
|
||||
const first = processedMessageIds.keys().next().value!;
|
||||
processedMessageIds.delete(first);
|
||||
}
|
||||
|
||||
processedMessageIds.set(messageId, now);
|
||||
return true;
|
||||
return path.join(os.homedir(), ".openclaw");
|
||||
}
|
||||
|
||||
function resolveNamespaceFilePath(namespace: string): string {
|
||||
const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
return path.join(resolveStateDirFromEnv(), "feishu", "dedup", `${safe}.json`);
|
||||
}
|
||||
|
||||
const persistentDedupe = createPersistentDedupe({
|
||||
ttlMs: DEDUP_TTL_MS,
|
||||
memoryMaxSize: MEMORY_MAX_SIZE,
|
||||
fileMaxEntries: FILE_MAX_ENTRIES,
|
||||
resolveFilePath: resolveNamespaceFilePath,
|
||||
});
|
||||
|
||||
/**
|
||||
* Synchronous dedup — memory only.
|
||||
* Kept for backward compatibility; prefer {@link tryRecordMessagePersistent}.
|
||||
*/
|
||||
export function tryRecordMessage(messageId: string): boolean {
|
||||
return !memoryDedupe.check(messageId);
|
||||
}
|
||||
|
||||
export async function tryRecordMessagePersistent(
|
||||
messageId: string,
|
||||
namespace = "global",
|
||||
log?: (...args: unknown[]) => void,
|
||||
): Promise<boolean> {
|
||||
return persistentDedupe.checkAndRecord(messageId, {
|
||||
namespace,
|
||||
onDiskError: (error) => {
|
||||
log?.(`feishu-dedup: disk error, falling back to memory: ${String(error)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-antigravity-auth",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Antigravity OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-gemini-cli-auth",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"private": true,
|
||||
"description": "OpenClaw Gemini CLI OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Chat channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -485,7 +485,7 @@ async function processMessageWithPipeline(params: {
|
||||
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
|
||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
!isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => [])
|
||||
: [];
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/imessage",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"private": true,
|
||||
"description": "OpenClaw iMessage channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -89,7 +89,10 @@ export async function handleIrcInbound(params: {
|
||||
|
||||
const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom);
|
||||
const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom);
|
||||
const storeAllowFrom = await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []);
|
||||
const storeAllowFrom =
|
||||
dmPolicy === "allowlist"
|
||||
? []
|
||||
: await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []);
|
||||
const storeAllowList = normalizeIrcAllowlist(storeAllowFrom);
|
||||
|
||||
const groupMatch = resolveIrcGroupMatch({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"private": true,
|
||||
"description": "OpenClaw LINE channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/llm-task",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"private": true,
|
||||
"description": "OpenClaw JSON-only LLM task plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/lobster",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.22
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.1.14
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -218,9 +218,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
}
|
||||
|
||||
const senderName = await getMemberDisplayName(roomId, senderId);
|
||||
const storeAllowFrom = await core.channel.pairing
|
||||
.readAllowFromStore("matrix")
|
||||
.catch(() => []);
|
||||
const storeAllowFrom =
|
||||
dmPolicy === "allowlist"
|
||||
? []
|
||||
: await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
|
||||
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
|
||||
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
|
||||
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.2.21",
|
||||
"private": true,
|
||||
"version": "2026.2.22",
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -380,7 +380,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
|
||||
const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
|
||||
const storeAllowFrom = normalizeAllowList(
|
||||
await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
||||
dmPolicy === "allowlist"
|
||||
? []
|
||||
: await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
||||
);
|
||||
const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
|
||||
const effectiveGroupAllowFrom = Array.from(
|
||||
@@ -867,7 +869,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
if (dmPolicy !== "open") {
|
||||
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
|
||||
const storeAllowFrom = normalizeAllowList(
|
||||
await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
||||
dmPolicy === "allowlist"
|
||||
? []
|
||||
: await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
||||
);
|
||||
const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
|
||||
const allowed = isSenderAllowed({
|
||||
@@ -890,10 +894,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
const dmPolicyForStore = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
|
||||
const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
|
||||
const storeAllowFrom = normalizeAllowList(
|
||||
await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
||||
dmPolicyForStore === "allowlist"
|
||||
? []
|
||||
: await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
||||
);
|
||||
const effectiveGroupAllowFrom = Array.from(
|
||||
new Set([
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-core",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"private": true,
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-lancedb",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"private": true,
|
||||
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/minimax-portal-auth",
|
||||
"version": "2026.2.21",
|
||||
"version": "2026.2.22",
|
||||
"private": true,
|
||||
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.22
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Features
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user