Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc5f32dbf8 | ||
|
|
34593a6c98 | ||
|
|
5de5f88b0b | ||
|
|
655b94b85d | ||
|
|
9d0d89dd12 | ||
|
|
69553895f7 | ||
|
|
4dd5ff9058 | ||
|
|
e031f0945d | ||
|
|
fc041c6887 | ||
|
|
3c5e9f294f | ||
|
|
c18a553e9b | ||
|
|
cacd255182 | ||
|
|
a535e5942a | ||
|
|
a624f1dc29 | ||
|
|
94cf73d7d6 | ||
|
|
47e33865ef | ||
|
|
65937244c4 | ||
|
|
250cef7619 | ||
|
|
785759ee97 | ||
|
|
ec995a0983 | ||
|
|
6e6aac4803 | ||
|
|
874037326f | ||
|
|
9a76e429e0 | ||
|
|
be1bd12b0d | ||
|
|
28d3bd0523 | ||
|
|
67b0f5db06 | ||
|
|
e7c228cf43 | ||
|
|
3c8b877347 | ||
|
|
dab2768e7b | ||
|
|
1a2c497979 | ||
|
|
2cc146a2a4 | ||
|
|
953ba18bfa | ||
|
|
72ff8c273b | ||
|
|
dc4dd306b9 | ||
|
|
a100bf138f | ||
|
|
c84de2fb21 | ||
|
|
535d7f8f2f | ||
|
|
4c7cf6b661 | ||
|
|
39f75d790a | ||
|
|
6af8884601 | ||
|
|
1b2df8dbfe | ||
|
|
626d9aee6c | ||
|
|
188650d94d | ||
|
|
bbe0420619 | ||
|
|
38f54eebf6 | ||
|
|
740d7c4a36 | ||
|
|
b7e3251a4a | ||
|
|
76007ebd17 | ||
|
|
00a0353f6d | ||
|
|
1c8eb35ad5 | ||
|
|
fd1f785941 | ||
|
|
f1f6ab3b1d | ||
|
|
bea31af3e3 | ||
|
|
f2d4cf9c64 | ||
|
|
89efac6ae8 | ||
|
|
960dcdb8de | ||
|
|
7788d494d2 | ||
|
|
e9db23713e | ||
|
|
f0ba6335e0 | ||
|
|
335bbec0dc | ||
|
|
a7c3d3a84d | ||
|
|
84a14345a5 | ||
|
|
8c01e2d150 | ||
|
|
71bb4c6eda | ||
|
|
914f2d934b | ||
|
|
e83f790f62 | ||
|
|
920949deea | ||
|
|
d42f974d4e | ||
|
|
ac78959842 | ||
|
|
96aebb1d6c | ||
|
|
0f2babbcf8 | ||
|
|
7cdc88c4d1 | ||
|
|
589b9d6142 | ||
|
|
ff9100db05 | ||
|
|
b0a08d38dc | ||
|
|
3f1b6daeda | ||
|
|
990f65909b | ||
|
|
0a8f3750a5 | ||
|
|
e34719f6ad | ||
|
|
307708f3c1 | ||
|
|
312aefd1de | ||
|
|
bd1636eca1 | ||
|
|
1d7099a660 | ||
|
|
a6b2140052 | ||
|
|
a25a165b48 | ||
|
|
bcbae75f08 | ||
|
|
f6508d31c2 | ||
|
|
c03ad0e53b | ||
|
|
217a13d516 | ||
|
|
0589f77772 | ||
|
|
99d7ed9186 | ||
|
|
f9fc9ee35d | ||
|
|
69debbb07c | ||
|
|
86c1af7e8c | ||
|
|
6cffedac54 | ||
|
|
8f0fe4d01b | ||
|
|
14c23653ae | ||
|
|
7538076aef | ||
|
|
90e43a3011 | ||
|
|
4a9fa31812 | ||
|
|
2cb640ca99 | ||
|
|
f378bb3a27 | ||
|
|
617addecea | ||
|
|
155c528a62 | ||
|
|
783937f000 | ||
|
|
84cc159230 | ||
|
|
d3f523b6c9 | ||
|
|
d664b9cae4 | ||
|
|
4ff4740166 | ||
|
|
68ebce4fdf | ||
|
|
2a0b4fd3e9 | ||
|
|
cdad2847c8 | ||
|
|
9d5387762e | ||
|
|
e074d36514 | ||
|
|
6a992561f6 | ||
|
|
064b42df35 | ||
|
|
abe7c2da75 | ||
|
|
e77965357c | ||
|
|
167387bf29 | ||
|
|
475da8c319 | ||
|
|
442f5342b2 | ||
|
|
93b446df5b | ||
|
|
69aadfa4e7 | ||
|
|
d947e36855 | ||
|
|
2bb6ae4a8c | ||
|
|
25ffd811f2 | ||
|
|
01ee05b50a | ||
|
|
47949a8f45 | ||
|
|
d3286177f1 | ||
|
|
a48ef1ae07 | ||
|
|
5e0477b83f | ||
|
|
a6f6b3fb3f | ||
|
|
22f3ddf40b | ||
|
|
3c31d54f59 | ||
|
|
1408218aeb | ||
|
|
0a98ec0db1 | ||
|
|
414a7fdedc | ||
|
|
6d0f384221 | ||
|
|
4b4fe3538c | ||
|
|
72ac7c7b0a | ||
|
|
2f6b2272b9 | ||
|
|
96f4ac077d | ||
|
|
1c53e322b7 | ||
|
|
2b347d39d0 | ||
|
|
23d6fa0de6 | ||
|
|
c01b7f3cdc | ||
|
|
34d72f5bff | ||
|
|
e0cc9b1c88 | ||
|
|
69f979d63b | ||
|
|
98fb53ade1 | ||
|
|
f04f64eff7 | ||
|
|
381d7f452d | ||
|
|
c6f61787c2 | ||
|
|
3eb9071461 | ||
|
|
c6b6628f6c | ||
|
|
80d52bf53c | ||
|
|
dea8bdc2d8 | ||
|
|
3b20e20a1b | ||
|
|
bc546a76ab | ||
|
|
6cf274a603 | ||
|
|
5a9b81700b | ||
|
|
0a3afaac9e | ||
|
|
b3204b2e37 | ||
|
|
d91d0b788b | ||
|
|
40dface2cf | ||
|
|
174cbd6a0e | ||
|
|
2cc3efe551 | ||
|
|
742abe5971 | ||
|
|
35455e7975 | ||
|
|
b5f8181e89 | ||
|
|
515ca64ac3 | ||
|
|
abf6b3a23f | ||
|
|
63af207a1b | ||
|
|
9012728f18 | ||
|
|
f9578a20ab | ||
|
|
6d65a46ab7 | ||
|
|
001026d622 | ||
|
|
9328551c40 | ||
|
|
21df47cca3 | ||
|
|
0106aecf18 | ||
|
|
6a08bdd29a | ||
|
|
2c92de46bb | ||
|
|
e8a44eac95 | ||
|
|
909d901811 | ||
|
|
ed651e419a | ||
|
|
c3c0b25168 | ||
|
|
2b73250b4e | ||
|
|
e5d12a1121 | ||
|
|
b18889221a | ||
|
|
411528b83c | ||
|
|
8a86b918e9 | ||
|
|
76269d296a | ||
|
|
b77daa62cb | ||
|
|
c36ce21ce6 | ||
|
|
f4b2d90c64 | ||
|
|
978be9caa6 | ||
|
|
164225f887 | ||
|
|
05b42e8ab8 | ||
|
|
a08385500c | ||
|
|
8b5c5eabd7 | ||
|
|
a71d9597e8 | ||
|
|
b183e95b62 | ||
|
|
30c39aa869 | ||
|
|
8f8f0867f9 | ||
|
|
e2265dd01b | ||
|
|
d3eda7e3bf | ||
|
|
0e92487be0 | ||
|
|
b10193d7c3 | ||
|
|
973d0e3b1c | ||
|
|
63323bc1b0 | ||
|
|
3de72d8c5a | ||
|
|
85a858e897 | ||
|
|
12a7b576b7 | ||
|
|
5be3a5cb67 | ||
|
|
d4d87d3471 | ||
|
|
81da8996aa | ||
|
|
9149018f88 | ||
|
|
f7bb81ca63 | ||
|
|
bc2f6f3814 | ||
|
|
690ae55c0c | ||
|
|
aacc022f90 | ||
|
|
be9bce90e8 | ||
|
|
ca299edc3f | ||
|
|
5d545222c1 | ||
|
|
bdab379500 | ||
|
|
deeaeaa496 | ||
|
|
8d5f0e44ae | ||
|
|
49e4150f49 | ||
|
|
3a10b15db7 | ||
|
|
13d8a5b41a | ||
|
|
2efb822cba | ||
|
|
a17601674f | ||
|
|
eab07839f6 | ||
|
|
58d902c824 | ||
|
|
65b718050d | ||
|
|
6811398c54 | ||
|
|
b388695b71 | ||
|
|
3ca0cddb8b | ||
|
|
7627ffbf61 | ||
|
|
9f2789fd59 | ||
|
|
533b6ef9ff | ||
|
|
46c88f9eac | ||
|
|
125f784c3d | ||
|
|
1cacf22c03 | ||
|
|
050a7a34dc | ||
|
|
dc12892449 | ||
|
|
30fdbfd438 | ||
|
|
3aa7b39094 | ||
|
|
e0a18d0f2a | ||
|
|
3df757707a | ||
|
|
c6d1c506ca | ||
|
|
640b93f997 | ||
|
|
c6cf99338c | ||
|
|
aa4c90660a | ||
|
|
8b6fd95680 | ||
|
|
ebbd73d8d5 | ||
|
|
7104847430 | ||
|
|
9d740e71c5 | ||
|
|
46e8220e43 | ||
|
|
2a71019caa | ||
|
|
762f60a130 | ||
|
|
d9e30d6801 | ||
|
|
47836a04d2 | ||
|
|
12275575c5 | ||
|
|
c6de6239a0 | ||
|
|
890f652b7d | ||
|
|
0ba6668ac8 | ||
|
|
94a98b6ab9 | ||
|
|
e0b448f236 | ||
|
|
f4e3ac75b3 | ||
|
|
58d4706311 | ||
|
|
5dc260ca34 | ||
|
|
84896722f5 | ||
|
|
ca5c913597 | ||
|
|
bd3ed50b07 | ||
|
|
339312cfe1 | ||
|
|
96481946e4 | ||
|
|
3e293016a8 | ||
|
|
e5371b8a2b | ||
|
|
72398d9dce | ||
|
|
bcfb081e2c | ||
|
|
178b8a1b59 | ||
|
|
4e4ba35900 | ||
|
|
d53e23859c | ||
|
|
fd1fbc7e0a | ||
|
|
c628f50f2e | ||
|
|
ca0ed07d8b | ||
|
|
80b6a10f19 | ||
|
|
f15f35826b | ||
|
|
d16af4c68c | ||
|
|
e87031a31f | ||
|
|
06dfabbee5 | ||
|
|
220a821240 | ||
|
|
66e5d4673b | ||
|
|
574828196c | ||
|
|
ea3e7c879c | ||
|
|
4caafc7d29 | ||
|
|
9e262a978e | ||
|
|
b3282dd608 | ||
|
|
5b67a85819 | ||
|
|
935d8c48ce | ||
|
|
1344fa3859 | ||
|
|
9073eb4e2e | ||
|
|
21fc213730 | ||
|
|
48606bd574 | ||
|
|
dbc873d2d8 | ||
|
|
c32903b12d | ||
|
|
c861c8644d | ||
|
|
a657b6cc11 | ||
|
|
74ebf2c10b | ||
|
|
859efaddd9 | ||
|
|
9f548abfd8 | ||
|
|
8c0620c18a | ||
|
|
f7d21efe33 | ||
|
|
833870de0d | ||
|
|
2509be188e | ||
|
|
e9e8aeb184 | ||
|
|
17c8a26c21 | ||
|
|
f011c03977 | ||
|
|
ad7ed95851 | ||
|
|
9c5d46549d | ||
|
|
eb8ee15679 | ||
|
|
1eb19e0774 | ||
|
|
a30da5cd5a | ||
|
|
aac1eff0ac | ||
|
|
7eb619e0ea | ||
|
|
ffaea9ff87 | ||
|
|
e086117556 | ||
|
|
e7eabe6147 | ||
|
|
371d85c62b | ||
|
|
b908deea1d | ||
|
|
9250f0e933 | ||
|
|
cfd4b160b6 | ||
|
|
af01f2adf0 | ||
|
|
193dd5da07 | ||
|
|
738678b526 | ||
|
|
c28a6e66d1 | ||
|
|
030cccf669 | ||
|
|
3a9de6966f | ||
|
|
3350485e77 | ||
|
|
9d26ecdc20 | ||
|
|
ef8b1b8ae3 | ||
|
|
2c5da3e3df | ||
|
|
66ca299b7c | ||
|
|
0a38ddd80d | ||
|
|
3a529c7b83 | ||
|
|
d9392924aa | ||
|
|
03d25b8330 | ||
|
|
abd9f544b0 | ||
|
|
50e8517912 | ||
|
|
ec5ecbc3a7 | ||
|
|
f74a6ed096 | ||
|
|
1cbaaa9990 | ||
|
|
1c4266f503 | ||
|
|
769031edfd | ||
|
|
0bae4004e7 | ||
|
|
6b8c2c7ce8 | ||
|
|
d0e42fe0da | ||
|
|
e86dd9e0ca | ||
|
|
4a1f9a64ba | ||
|
|
55686d0f10 | ||
|
|
6cbf618eb0 | ||
|
|
a0888914c9 | ||
|
|
fb73e11af1 | ||
|
|
73f10b85e2 | ||
|
|
7a30f32603 | ||
|
|
2ee8e7d05c | ||
|
|
b4a15a7402 | ||
|
|
4be378944b | ||
|
|
75a4bd792e | ||
|
|
62854754f6 | ||
|
|
6a6fa6d0ca | ||
|
|
c8dc5a4420 | ||
|
|
0acfa91483 | ||
|
|
6127340e1b | ||
|
|
45868c6635 | ||
|
|
054f15e7a2 | ||
|
|
2247510b8e | ||
|
|
1d2bcbaacd | ||
|
|
fb8af87891 | ||
|
|
fce6d30564 | ||
|
|
4f1f954d67 | ||
|
|
06dc55beb7 | ||
|
|
3d5f9bed0f | ||
|
|
048d0764a0 | ||
|
|
3b3df763c4 | ||
|
|
f6318db809 | ||
|
|
58862159fa | ||
|
|
dacb581a1e | ||
|
|
d04656b127 | ||
|
|
bd7832de00 | ||
|
|
60251525cb | ||
|
|
b26312dfd7 | ||
|
|
eb85ee8bd9 | ||
|
|
0832b34f75 | ||
|
|
7f8b195c46 | ||
|
|
9da5541115 | ||
|
|
8d4fa426b5 | ||
|
|
fc29c87c4b | ||
|
|
42a82f12b5 | ||
|
|
11bcf8d206 | ||
|
|
3b11e82e63 | ||
|
|
b136a9e04e | ||
|
|
a21f6bb598 | ||
|
|
b271e7976b | ||
|
|
d468f4ef8e | ||
|
|
ac37df35c4 | ||
|
|
651774cdf5 | ||
|
|
daba7acee5 | ||
|
|
cca6739c13 | ||
|
|
2098539921 | ||
|
|
ce7741bd5d | ||
|
|
936af81088 | ||
|
|
5da56371ac | ||
|
|
2d4003f747 | ||
|
|
ded3500472 | ||
|
|
b67beabfcb | ||
|
|
3ae4db03ed | ||
|
|
6466fcebac | ||
|
|
1f0870ee86 | ||
|
|
2cd6f6c457 | ||
|
|
8ee20e26ee | ||
|
|
ee457b563e | ||
|
|
3b1a618b37 | ||
|
|
c44ccf51d6 | ||
|
|
5bee16150e | ||
|
|
3d87add235 | ||
|
|
5dbf5f1b82 | ||
|
|
d3b66111f7 | ||
|
|
34945ac668 | ||
|
|
641f59d826 | ||
|
|
6ba1c9b585 | ||
|
|
4a4d8882ec | ||
|
|
f627f7cb5f | ||
|
|
7571f7ff1f | ||
|
|
e0eba65456 | ||
|
|
582694b32f | ||
|
|
ba22c5d2c2 | ||
|
|
9c8945fb76 | ||
|
|
a2c3e57220 | ||
|
|
56a88d7299 | ||
|
|
791340d8f6 | ||
|
|
f4eab41ab2 | ||
|
|
7e988504bd | ||
|
|
4bda69eb7a | ||
|
|
ab2a875e7d | ||
|
|
27d8ebe071 | ||
|
|
01f97661d7 | ||
|
|
867593a86f | ||
|
|
896ee03698 | ||
|
|
10844da6f1 | ||
|
|
2385e74996 | ||
|
|
46478315c2 | ||
|
|
874cfbf9cc | ||
|
|
8063fda452 | ||
|
|
74fefb8637 | ||
|
|
49e9749dd6 | ||
|
|
0f92d70cf7 | ||
|
|
0ebe4527a2 | ||
|
|
831cf2d732 | ||
|
|
8e65afaa30 | ||
|
|
68769c650f | ||
|
|
5eaf4cf989 | ||
|
|
18d590f305 | ||
|
|
ed24c2ea3f | ||
|
|
315dac808f | ||
|
|
4f91eb14c3 | ||
|
|
593e1d6fc4 | ||
|
|
c96bb9e7a4 | ||
|
|
f4cb6d1372 | ||
|
|
025034de7f | ||
|
|
9131fdcd06 | ||
|
|
8099b09fb0 | ||
|
|
c42f8a49e8 | ||
|
|
38eeac21f6 | ||
|
|
f448fde78b | ||
|
|
ed6f974e97 | ||
|
|
26523eca8f | ||
|
|
816778b5fe | ||
|
|
d288235a2c | ||
|
|
0a11a7f6b8 | ||
|
|
75e9bcc760 | ||
|
|
f8f3cf5b21 | ||
|
|
0b3d6aba39 | ||
|
|
63bfe50c46 | ||
|
|
53a83207ad | ||
|
|
619ce1f31c | ||
|
|
d32596bbcf | ||
|
|
13fa59ae9b | ||
|
|
c8a95366ad | ||
|
|
99dbfcad81 | ||
|
|
d79745ccfd | ||
|
|
96d164382b | ||
|
|
20a3074f7a | ||
|
|
bd44f4cd9f | ||
|
|
bc1db86761 | ||
|
|
d6865768b0 | ||
|
|
6e8b96f6c4 | ||
|
|
93898506f0 | ||
|
|
0224d09cf3 | ||
|
|
36dcc03455 | ||
|
|
dd813b1000 | ||
|
|
5b53b7358d | ||
|
|
5d1aad12fd | ||
|
|
9a2ab7716c | ||
|
|
1741778857 | ||
|
|
c051722b71 | ||
|
|
294c0aeca2 | ||
|
|
27a2281590 | ||
|
|
47d7e9d400 | ||
|
|
d1fa43271a | ||
|
|
413a2182db | ||
|
|
3e023313ed | ||
|
|
802ef27217 | ||
|
|
41f43bb119 | ||
|
|
16f62cad09 | ||
|
|
0282379178 | ||
|
|
c3f5d01cb8 | ||
|
|
599f1e6969 | ||
|
|
66a9fe62ed | ||
|
|
9832736ada | ||
|
|
76814b9ee9 | ||
|
|
53c8d995b4 | ||
|
|
82653406bd | ||
|
|
a65577ab24 | ||
|
|
0a223fa64f | ||
|
|
a27d034088 | ||
|
|
d684cc78b4 | ||
|
|
6312f288e3 | ||
|
|
6b36d620eb | ||
|
|
cfba8bd81d | ||
|
|
dab9b0ef7f | ||
|
|
a8da328e6c | ||
|
|
7b441c2b80 | ||
|
|
8dedf2c41d | ||
|
|
ea0d596bfa | ||
|
|
ccc68ba642 | ||
|
|
f7b97e3b52 | ||
|
|
7d4820d04d | ||
|
|
5c8a2c5dc4 | ||
|
|
2f7036b588 | ||
|
|
dbb40a7027 | ||
|
|
fe1635746a | ||
|
|
b3aefbfbcd | ||
|
|
1cdab02454 | ||
|
|
4efa569f1b | ||
|
|
56b5368f8f | ||
|
|
0dcd652b03 | ||
|
|
706936d961 | ||
|
|
d5acec0fc2 | ||
|
|
6f998b3eeb | ||
|
|
71337581cf | ||
|
|
2af10e11ef | ||
|
|
607a5dd325 | ||
|
|
1ddf3fbdd7 | ||
|
|
ef405bb721 | ||
|
|
525901dd9a | ||
|
|
c6b08f4ce1 | ||
|
|
d2f6171ddd | ||
|
|
05ec2bfbf0 | ||
|
|
59a546e071 | ||
|
|
9ed1552cec | ||
|
|
4f10fd14d9 | ||
|
|
d11deef490 | ||
|
|
bd395b7a2f | ||
|
|
4c522c0997 | ||
|
|
9971659e8a | ||
|
|
83709a13e8 | ||
|
|
5a938f60d2 | ||
|
|
a97fd91975 | ||
|
|
fbc6e08e80 | ||
|
|
0591472bc4 | ||
|
|
35b8075b87 | ||
|
|
c92b30c2c6 | ||
|
|
67d4084de6 | ||
|
|
7f9e95d86c | ||
|
|
f77fd8b0c0 | ||
|
|
6983e58791 | ||
|
|
c01f96b4c7 | ||
|
|
a501718400 | ||
|
|
7deb8a3d12 | ||
|
|
11a25df584 | ||
|
|
ce995cb93d | ||
|
|
ef9933d571 | ||
|
|
17c57f57f8 | ||
|
|
5054553a16 | ||
|
|
e1500a73ef | ||
|
|
4a0915594f | ||
|
|
5641969445 | ||
|
|
4b8ddd917f | ||
|
|
0e4f1620e6 | ||
|
|
958d18152b | ||
|
|
bf49afcd92 | ||
|
|
49ed8aee0f | ||
|
|
462c1bd783 | ||
|
|
a97afa9b5b | ||
|
|
3ce566f152 | ||
|
|
68145eb319 | ||
|
|
c3c090f83b | ||
|
|
26d4be2404 | ||
|
|
00ba2d3b38 | ||
|
|
17cbd32387 | ||
|
|
8d8f9e65f6 | ||
|
|
b2249f0859 | ||
|
|
6e0becfb70 | ||
|
|
6c0f773d3f | ||
|
|
d22c8eb1f0 | ||
|
|
f994225990 | ||
|
|
06e1e8772c | ||
|
|
6522d357e0 | ||
|
|
28fb01eff5 | ||
|
|
5fc70afde9 | ||
|
|
35ed0d4e4a | ||
|
|
624a099bdd | ||
|
|
2da40db63d | ||
|
|
1033d086d3 | ||
|
|
4f31b77503 | ||
|
|
6b6be1403e | ||
|
|
eac015dbfa | ||
|
|
22f811cde2 | ||
|
|
bb55fad008 | ||
|
|
8995f5f627 | ||
|
|
f562c68075 | ||
|
|
6aef211188 | ||
|
|
28127b3706 | ||
|
|
d47bff5433 | ||
|
|
1c9aabbf8e | ||
|
|
eb2234b598 | ||
|
|
e7c0223bfa | ||
|
|
3ce8ee4f49 | ||
|
|
07396fbf29 | ||
|
|
9198bf6548 | ||
|
|
956ddd0dd9 | ||
|
|
5465609d54 | ||
|
|
31f2d43826 | ||
|
|
2d0d7a9b55 | ||
|
|
6f52081e51 | ||
|
|
2c0857a85b | ||
|
|
411d0f92d1 | ||
|
|
58054c5e3b | ||
|
|
e2b8b08eef | ||
|
|
2acabc56cd | ||
|
|
38c480575d | ||
|
|
717d21373d | ||
|
|
95d5c93165 | ||
|
|
6e8f59ce16 | ||
|
|
eb0e1bb2ef | ||
|
|
ee0bd66708 | ||
|
|
17c38f6ede | ||
|
|
af7e4c5d30 | ||
|
|
9229046e3b | ||
|
|
f3794a9732 | ||
|
|
f08fe96529 | ||
|
|
f61d3beecd | ||
|
|
9867ad9b4e | ||
|
|
3f0ba50130 | ||
|
|
ff226e8bdd | ||
|
|
2750a344cb | ||
|
|
489f3d615e | ||
|
|
6021ca6a64 | ||
|
|
d288ee3a1b | ||
|
|
2df032a596 | ||
|
|
d0115c2908 | ||
|
|
67cc32d714 | ||
|
|
006d433c11 | ||
|
|
ffdcc12747 | ||
|
|
6fe4314360 | ||
|
|
011da08524 | ||
|
|
21a647161b | ||
|
|
381b8fefef | ||
|
|
709a9e9391 | ||
|
|
897b4ee054 | ||
|
|
fa56c3575d | ||
|
|
fad1cba603 | ||
|
|
050a457a40 | ||
|
|
7ab51fd621 | ||
|
|
91a0aad3f2 | ||
|
|
a68c53bd86 | ||
|
|
3a539d8e7c | ||
|
|
2faad0c322 | ||
|
|
33842bc959 | ||
|
|
61a71f3439 | ||
|
|
5fef68fc4b | ||
|
|
21a85b682c | ||
|
|
a54e84a208 | ||
|
|
e68699e61f | ||
|
|
540a96fa5e | ||
|
|
5efe559c03 | ||
|
|
c6cb5f108d | ||
|
|
a165ed3019 | ||
|
|
092f38bd37 | ||
|
|
71715ed967 | ||
|
|
782f839d4f | ||
|
|
7752489936 | ||
|
|
da0108abb1 | ||
|
|
2ff1884961 | ||
|
|
97cac8d185 | ||
|
|
5c1680d0ab | ||
|
|
01a1f51464 | ||
|
|
02d922e805 | ||
|
|
0ae3542367 |
No files matched your search
@@ -8,6 +8,73 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-process-tree:
|
||||
name: Windows process tree termination
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22.12"
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: npm ci
|
||||
working-directory: desktop
|
||||
|
||||
- name: Test actual process tree termination
|
||||
run: node --test desktop/test/agents/cli/windows-process-tree.test.cjs
|
||||
|
||||
- name: Test native environment key casing
|
||||
run: node --test --test-name-pattern="Windows native environment canonicalizes" desktop/test/agents/local-agent-readiness.test.cjs
|
||||
|
||||
- name: Test selected native configuration directories
|
||||
run: node --test desktop/test/agents/native-config-directory.test.cjs
|
||||
|
||||
- name: Test Skills in selected native directories
|
||||
run: node --test --test-name-pattern="selected native directories control" desktop/test/skills/local-skill-catalog.test.cjs
|
||||
|
||||
- name: Test Connector package storage
|
||||
run: node --test --test-name-pattern="persists the complete trust lifecycle|reimporting a removed package|uncertain audit append|upgrades one installed Connector" desktop/test/agents/connectors/agent-connector-package-store.test.cjs
|
||||
|
||||
- name: Build unpacked Windows application
|
||||
run: npm run pack
|
||||
working-directory: desktop
|
||||
|
||||
- name: Test packaged process tree termination
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:ELECTRON_RUN_AS_NODE = '1'
|
||||
$env:MELDWORK_TEST_PACKAGED_APP = (Resolve-Path 'desktop/dist/win-unpacked/resources/app.asar').Path
|
||||
& 'desktop/dist/win-unpacked/Meldwork.exe' --test desktop/test/agents/cli/windows-process-tree.test.cjs
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
private-file-portability:
|
||||
name: Private file portability (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22.12"
|
||||
|
||||
- name: Test private file persistence
|
||||
run: node --test desktop/test/security/private-file.test.cjs
|
||||
|
||||
frontend:
|
||||
name: Frontend tests and builds
|
||||
runs-on: ubuntu-latest
|
||||
@@ -31,6 +98,9 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Check website and release licensing consistency
|
||||
run: node --test ../meldwork-landing/test/landing.test.cjs
|
||||
|
||||
- name: Build web application
|
||||
run: npm run build
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
|
||||
- Meldwork is a local-first desktop application. Do not introduce a required server, container, or cluster dependency unless the task explicitly calls for it.
|
||||
- Keep Agent execution in the Electron main process. Renderer access must go through narrow, validated preload APIs.
|
||||
- Never expose executable paths, tokens, provider credentials, or unrestricted shell execution to renderer code.
|
||||
- Respect native Agent configuration and keep CLI integration provider-agnostic. Do not add gateway-specific adapters, provider overrides, or bundled patches for external tools such as Magpie.
|
||||
- Never expose executable paths, tokens, credentials, or unrestricted shell execution to renderer code.
|
||||
- Store secrets only through operating-system-backed secure storage. Do not add plaintext fallbacks.
|
||||
- Keep workspace write access opt-in. Conversations and groups stay local; do not add remote participants, cloud channels, or remote conversation storage.
|
||||
|
||||
|
||||
@@ -1,5 +1,102 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.7 - 2026-10-27 (Meldwork-V1.0.7 prerelease)
|
||||
|
||||
- Preserve streamed discussion replies across cancellation and reload; scope ACP notifications to the active session.
|
||||
- Support Hermes native stream JSON and avoid MiMo interactive plan prompts in headless runs.
|
||||
- Add Zcode with its official logo before Pi Agent, native default configuration, and explicit missing-key errors.
|
||||
- Let WorkBuddy use an explicitly selected model from its desktop configuration, without exposing credentials to the renderer.
|
||||
- Keep Chinese slash-separated text intact while retaining credential and local-path redaction.
|
||||
- Unify application, artifact, README, and website versions at 1.0.7.
|
||||
- License Meldwork's original source, documentation, and website under Apache-2.0: commercial use is permitted without separate authorization or license fees. Third-party notices remain applicable. Older entries below describe their historical release terms.
|
||||
- Native acceptance includes a real WorkBuddy response; six-Agent unlimited discussion cancellation and reload were checked with simulated adapters. Zcode's default provider still needs a valid API key; the observed OpenCode router's invalid `tool_calls` finish reason remains an upstream issue.
|
||||
- Apple silicon DMG and ZIP remain ad-hoc signed and unnotarized.
|
||||
|
||||
## 0.1.6 - 2026-10-17 (Meldwork-V1.0.6 prerelease)
|
||||
|
||||
### Agent Reliability
|
||||
|
||||
- Tier authentication failures instead of treating any authentication-looking diagnostic as a sign-in problem, and retry transient provider errors.
|
||||
- Keep long-lived ACP Agents (Hermes, Kimi, MiMo, OpenCode) usable across stops, restarts, and hangs, including when a group alternates between read-only and write turns.
|
||||
- Stop the watchdog from killing Agents that are still thinking or building, and keep an ACP turn alive through traffic this client does not recognise.
|
||||
- Keep an Agent available through an inconclusive readiness probe and preserve its shell environment.
|
||||
- Let MiMo and OpenCode turns that call tools finish instead of returning a partial answer, and report a CLI's own error instead of EPIPE.
|
||||
- Launch WorkBuddy's app-bundled CLI again (it now ships only the lite bundle) and stop skipping every WorkBuddy permission check in write mode.
|
||||
- Refuse unsafe ACP permission requests during read-only turns.
|
||||
- Give direct chats the current request and the delivery contract again after a native session is rebuilt.
|
||||
- Compare installed Agent CLI versions as releases, so a newer installed CLI is not offered a downgrade.
|
||||
- Write workspace state compactly and durably, and record unhandled main-process faults in the diagnostics log.
|
||||
- Internal: split the auto-runner and the Agent invocation into modules grouped by purpose.
|
||||
|
||||
### Group Conversations
|
||||
|
||||
- Show group replies in the order Agents were dispatched.
|
||||
- Keep natural discussions going instead of ending on one slip, ask Agents to engage with each other, and let peers see workspace file paths.
|
||||
- Keep group members after an Agent failure, so one proxy 403 or an expired sign-in no longer removes an Agent from later turns, and recover groups that were emptied.
|
||||
- A member that is not ready no longer blocks a message to the whole group; skipped members are named.
|
||||
- Deliver attachments and knowledge bases to group Agents, and send Hermes image attachments over ACP.
|
||||
- Conversations no longer wait for each other, even in the same folder; writers inside one conversation still take turns.
|
||||
- Import only files an Agent wrote, including paths with spaces.
|
||||
- Explain run failures in Chinese and English instead of showing raw codes.
|
||||
|
||||
### Interface
|
||||
|
||||
- Start without waiting for Agent detection: the sidebar, home page, and composer show that detection is running.
|
||||
- Mark conversations that wait for your decision in the sidebar and list them on the home page; mark group runs that finished while you were in another conversation.
|
||||
- Add a General settings tab with language and a System, Light, or Dark appearance; System follows the operating system until you choose.
|
||||
- Explain on each Agent card why an installed Agent is unavailable: its installed version, the verified range, and the failed check.
|
||||
- Use a shared type scale and semantic status colors, keep secondary text within contrast guidelines, and keep long notices on screen long enough to read.
|
||||
- Keep unsent text per conversation, add Skip and arrow-key navigation to first-run onboarding, ask before deleting a cloud server connection, and show Gemini CLI's own logo.
|
||||
|
||||
### Verification And Distribution
|
||||
|
||||
- Frontend 357/357, desktop 1603/1603, and deterministic evaluation 6 cases / 18 results passed on a clean checkout.
|
||||
- DMG and ZIP are ad-hoc signed, without Apple Developer ID signing or notarization. No installed user application is replaced automatically.
|
||||
- Live coverage is limited to observed CLI versions and configurations; this release did not repeat the live Agent acceptance run.
|
||||
|
||||
## 0.1.5 - 2026-09-09 (Meldwork-V1.0.5 prerelease)
|
||||
|
||||
### Local Agent Reliability
|
||||
|
||||
- Accept validated OpenClaw gateway configuration migrations without disabling path, permission, or credential isolation checks.
|
||||
- Keep installed Agents visible when credential or capability checks fail; isolate failed probes and refresh the catalog after current readiness settles.
|
||||
- Refresh native shell settings before manual detection and preserve supported proxy, CA, cloud Provider, and model-region settings without silently replacing a selected Provider.
|
||||
|
||||
### Group Conversations
|
||||
|
||||
- Route peer requests through explicit Agent decisions instead of interpreting prose mentions or media keywords as commands.
|
||||
- Persist AI task completion separately from individual invocation success and human adoption; support single-Agent automatic work and exact visible answers.
|
||||
- Resume human clarification gates after restart, reject conflicting responses, and prevent repeated responses from replaying calls.
|
||||
- Serialize workspace writers, recover interrupted writes through bound approvals, retain partial results, and send acknowledged session context incrementally.
|
||||
- Preserve completed and stopped task states after restart and show healthy contributions alongside failed participants.
|
||||
|
||||
### Verification And Distribution
|
||||
|
||||
- Frontend 343/343, desktop 1562/1562, and deterministic evaluation 6 cases / 18 results passed.
|
||||
- Web and desktop builds, packaging, real packaged Codex/OpenClaw group execution, cancellation, and restart acceptance passed on Apple silicon macOS.
|
||||
- DMG and ZIP are ad-hoc signed, without Apple Developer ID signing or notarization. No installed user application is replaced automatically.
|
||||
- Live coverage is limited to observed CLI versions and configurations; cloud credential forwarding is not cloud-account certification. Full evidence: `docs/releases/Meldwork-V1.0.5.md`.
|
||||
|
||||
## 0.1.4 - 2026-09-01 (Meldwork-V1.0.4 prerelease)
|
||||
|
||||
### Natural Group Discussions
|
||||
|
||||
- Sequential discussion mode now runs one Agent at a time in the configured CLI order and records every round visibly.
|
||||
- Auto Discussion starts with concurrent proposals, then lets Agent responses route the next turn through exact final-line `@Agent` mentions. One mention runs one Agent; multiple mentions run concurrently.
|
||||
- Natural discussion prompts allow Agents to exchange views before work and review instead of forcing every turn into a review-only role.
|
||||
|
||||
### Session And Recovery
|
||||
|
||||
- Reuses one native Agent session per Agent task across discussion rounds and restarts.
|
||||
- Persists round messages and checkpoints before and after Agent execution so completed turns are recovered without duplicate reruns.
|
||||
- Rejects stale operation and snapshot bindings before late results can be committed.
|
||||
|
||||
### Verification And Distribution
|
||||
|
||||
- Focused natural discussion coverage: 17/17 tests passed.
|
||||
- Desktop syntax checks and `git diff --check` passed.
|
||||
- The Apple silicon DMG and ZIP are ad-hoc signed and not notarized. Live CLI behavior still depends on each installed Agent's version, authentication, Provider, and capabilities.
|
||||
|
||||
## 0.1.3 - 2026-08-18 (Meldwork-V1.0.3 prerelease)
|
||||
|
||||
### Concurrent Conversations
|
||||
@@ -66,7 +163,7 @@
|
||||
- Sets the desktop package version to `0.1.2` with permanent Bundle ID `com.rydersun.meldwork`.
|
||||
- Produces Apple silicon DMG and ZIP prerelease artifacts using ad-hoc signing. This is not Apple Developer ID signing or notarization and may require Open Anyway on first launch.
|
||||
- Keeps `dist:public` as the fail-closed future formal-distribution path requiring a Developer ID identity, Hardened Runtime, complete Apple notarization credentials, and post-sign validation.
|
||||
- Uses the Meldwork Non-Commercial Source License; commercial use requires prior written permission.
|
||||
- This historical release used the Meldwork Non-Commercial Source License and required prior written permission for commercial use. Current releases use Apache-2.0 as stated above.
|
||||
|
||||
### Verification
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Code of Conduct
|
||||
|
||||
Meldwork is a workspace for people and Agents to build together. We want issues,
|
||||
pull requests, discussions, and community spaces to stay useful, direct, and
|
||||
respectful.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
- Be clear, constructive, and respectful when giving feedback.
|
||||
- Assume good intent while keeping discussions focused on facts, tradeoffs, and outcomes.
|
||||
- Respect different experience levels, backgrounds, languages, and working styles.
|
||||
- Keep disagreements technical, specific, and connected to the project.
|
||||
- Help maintainers keep issues and pull requests focused.
|
||||
- Protect private prompts, credentials, customer information, and other sensitive data.
|
||||
|
||||
## Unacceptable Behavior
|
||||
|
||||
The following behavior is not acceptable in Meldwork project spaces:
|
||||
|
||||
- Personal attacks, insults, harassment, or sustained disruption.
|
||||
- Demeaning comments about identity, background, language, ability, or experience.
|
||||
- Threats, intimidation, stalking, or unwanted contact.
|
||||
- Publishing another person's private information without consent.
|
||||
- Spam, trolling, discrimination, or repeated off-topic posting.
|
||||
- Submitting malicious code, secrets, private data, or intentionally misleading reports.
|
||||
- Any behavior maintainers reasonably judge to make the project unsafe or unproductive.
|
||||
|
||||
## Maintainer Responsibilities
|
||||
|
||||
Maintainers may edit or remove comments, close issues, limit participation, or
|
||||
block participants when needed to protect the project community. Enforcement
|
||||
should be proportional, documented when practical, and focused on keeping
|
||||
collaboration healthy.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report conduct concerns privately through the repository's GitHub contact or
|
||||
security reporting channel. Do not open a public issue for sensitive reports.
|
||||
|
||||
Please include:
|
||||
|
||||
- a short description of what happened;
|
||||
- links, screenshots, or message references when available;
|
||||
- whether you want follow-up, confidentiality, or no direct reply.
|
||||
|
||||
Reports will be reviewed by project maintainers. We will handle reports with
|
||||
care and limit access to people who need the information to respond.
|
||||
|
||||
This Code of Conduct applies wherever the project or its maintainers represent
|
||||
the Meldwork community. It is adapted from the Contributor Covenant and the
|
||||
community standards used by comparable open-source projects.
|
||||
@@ -1,17 +1,23 @@
|
||||
# Commercial Use And Licensing
|
||||
# Commercial use / 商业使用
|
||||
|
||||
Meldwork is source-available under the [Meldwork Non-Commercial Source License](LICENSE). The public repository can be inspected, evaluated, run, and modified for non-commercial purposes, but it is not licensed for commercial use.
|
||||
Meldwork's original source code, documentation, README files, and official website
|
||||
are licensed under [Apache License 2.0](LICENSE). Commercial use, private use,
|
||||
modification, redistribution, and inclusion in paid products or services are
|
||||
permitted under its terms. No separate commercial authorization or license fee
|
||||
is required.
|
||||
|
||||
Any commercial use requires prior written permission from the copyright holder. This includes monetizing Meldwork, reselling or hosting it, embedding it in a proprietary product, using it to provide paid services, or using it as part of a for-profit organization's business operations.
|
||||
When redistributing, preserve the license, applicable copyright and attribution
|
||||
notices, and NOTICE information, and identify modified files as required by the
|
||||
license. Apache-2.0 does not grant trademark rights (Section 6).
|
||||
|
||||
The copyright holder may offer a separate commercial license for organizations that need permission for:
|
||||
Third-party dependencies, fonts, Agent names, and logos retain their own licenses
|
||||
and terms; their inclusion does not imply endorsement. In particular, the
|
||||
website's bundled Fusion Pixel font retains its OFL license and notices.
|
||||
|
||||
- proprietary redistribution or resale of Meldwork or a modified version;
|
||||
- embedding Meldwork code in a closed-source product;
|
||||
- offering a hosted, managed, or network-accessible version;
|
||||
- internal business operations at a for-profit organization;
|
||||
- separately licensed commercial features, enterprise integrations, support, or managed deployment.
|
||||
Meldwork 原创源代码、文档、README 和官网均采用 Apache-2.0。依照许可证条款,
|
||||
允许商业使用、私人使用、修改、再分发,以及用于收费产品或服务;无需另行取得商业
|
||||
授权或支付许可费。再分发时应保留许可证、适用的版权与归属声明及 NOTICE 信息,并
|
||||
按许可证要求标明修改。Apache-2.0 第 6 条不授予商标权。第三方依赖、字体、Agent
|
||||
名称和标志仍遵循各自条款;官网附带的 Fusion Pixel 字体保留 OFL 许可证与声明。
|
||||
|
||||
A commercial license does not change the licenses of third-party dependencies, Agent products, names, models, or logos. The repository license also does not grant trademark rights to the Meldwork name, logo, screenshots, or other brand assets.
|
||||
|
||||
This page is a practical summary and does not replace the license text or legal advice. When a planned use may be commercial, contact the repository owner through GitHub before using, distributing, hosting, reselling, or integrating the product.
|
||||
This summary does not replace LICENSE. 本说明不替代 LICENSE 正文。
|
||||
@@ -1,18 +1,19 @@
|
||||
# Contributing
|
||||
|
||||
Meldwork is source-available under the [Meldwork Non-Commercial Source License](LICENSE). Contributions are welcome when they improve the local-first desktop product, documentation, tests, or security posture.
|
||||
Meldwork is licensed under the [Apache License 2.0](LICENSE). Contributions are welcome when they improve the local-first desktop product, documentation, tests, or security posture.
|
||||
|
||||
Unless a separate written agreement applies, a contribution accepted into this repository is available under the repository license. The project may also offer separately licensed commercial editions. Before external code is incorporated into a separately licensed commercial build, the maintainer may require a reviewed contributor-license agreement that grants the necessary relicensing rights. Opening a pull request by itself does not transfer copyright or silently grant those additional rights.
|
||||
Unless a separate written agreement applies, a contribution accepted into this repository is available under the repository license. Opening a pull request does not transfer copyright beyond the rights granted by the Apache License 2.0.
|
||||
|
||||
## Ground Rules
|
||||
|
||||
- Keep changes small and tied to a clear product or engineering need.
|
||||
- Do not submit secrets, tokens, private logs, private prompts, or user data.
|
||||
- Do not add remote services, telemetry, credential collection, or server dependencies without explicit project approval.
|
||||
- Preserve the local-first boundary: renderer code must not gain unrestricted filesystem, shell, Provider credential, or executable-path access.
|
||||
- Preserve the local-first boundary: renderer code must not gain unrestricted filesystem, shell, credential, or executable-path access.
|
||||
- Include focused tests for behavior, security boundary, storage, or IPC changes.
|
||||
- Confirm that all submitted code and assets are yours to license and do not introduce incompatible license obligations.
|
||||
- Confirm that all submitted code and assets are yours to contribute and do not introduce incompatible license obligations.
|
||||
|
||||
## Commercial Editions
|
||||
## Community Standards
|
||||
|
||||
Commercial use of Meldwork requires prior written permission. Commercial editions, proprietary integrations, support, and other paid offerings may use separate terms; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
|
||||
Please follow the [Code of Conduct](CODE_OF_CONDUCT.md) in issues, pull
|
||||
requests, discussions, and other project spaces.
|
||||
@@ -1,82 +1,201 @@
|
||||
Meldwork Non-Commercial Source License 1.0
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright (c) 2026 Ryder-MHumble.
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
This license applies to Meldwork and the source code, documentation, assets, and
|
||||
other materials in this repository, except where a file or third-party notice
|
||||
states a different license.
|
||||
1. Definitions.
|
||||
|
||||
Meldwork is source-available, not open source in the OSI sense. Commercial use is
|
||||
not permitted without prior written permission from the copyright holder.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
1. Allowed non-commercial use
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
You may download, inspect, run, copy, and modify Meldwork for personal learning,
|
||||
research, evaluation, security review, and other non-commercial purposes.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
You may share unmodified or modified copies for non-commercial purposes if you:
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
- keep this license and all copyright, attribution, and trademark notices;
|
||||
- clearly mark your changes when you distribute a modified copy;
|
||||
- do not remove or obscure third-party notices; and
|
||||
- do not imply that the copyright holder endorses your copy or service.
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
2. Commercial use requires permission
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
You may not use Meldwork for a commercial purpose without a separate written
|
||||
license or other written permission from the copyright holder.
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
Commercial purpose includes, without limitation:
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
- selling, renting, sublicensing, or otherwise monetizing Meldwork or a modified
|
||||
version;
|
||||
- offering Meldwork or a modified version as a hosted, managed, consulting,
|
||||
outsourcing, integration, or support service;
|
||||
- embedding Meldwork code, UI, workflow, or packaged binaries in a proprietary,
|
||||
paid, or revenue-generating product;
|
||||
- using Meldwork to provide paid deliverables, paid automation, paid Agent
|
||||
operations, or other business services to a customer; and
|
||||
- use by or for a for-profit company, commercial product team, or revenue-driven
|
||||
organization as part of its business operations.
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
If you want to use Meldwork commercially, contact the repository owner and obtain
|
||||
written permission before using, distributing, hosting, reselling, or integrating
|
||||
the product.
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
3. No trademark license
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
This license does not grant rights to use the Meldwork name, logo, product
|
||||
identity, screenshots, or other brand assets except as needed to describe an
|
||||
unaltered copy of the software in a truthful, non-confusing way.
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Third-party materials
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
Third-party software, Agent products, models, names, logos, dependencies, and
|
||||
other materials remain subject to their own licenses and terms. This license does
|
||||
not give you rights that the copyright holder does not own.
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
5. Contributions
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
Unless a separate written agreement says otherwise, contributions submitted to
|
||||
this repository may be used under this license. The copyright holder may require
|
||||
a separate contributor agreement before accepting contributions intended for a
|
||||
commercially licensed edition.
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
6. Termination
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
If you violate this license, your rights under it terminate automatically. The
|
||||
copyright holder may reinstate rights in writing after the violation is cured.
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
7. No warranty
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
Meldwork is provided "as is", without warranties or conditions of any kind,
|
||||
including implied warranties of merchantability, fitness for a particular
|
||||
purpose, title, and non-infringement. You are responsible for evaluating whether
|
||||
the software is suitable and lawful for your use.
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
8. Limitation of liability
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
To the maximum extent permitted by law, the copyright holder and contributors
|
||||
will not be liable for any damages arising from or related to the software or
|
||||
this license, including direct, indirect, incidental, consequential, special,
|
||||
exemplary, punitive, or business-interruption damages.
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,9 +1,7 @@
|
||||
Meldwork
|
||||
Copyright 2026 Ryder-MHumble and Meldwork contributors.
|
||||
|
||||
Meldwork is licensed under the Meldwork Non-Commercial Source License. See
|
||||
LICENSE. Commercial use requires separate written permission from the copyright
|
||||
holder.
|
||||
Meldwork is licensed under the Apache License 2.0. See LICENSE.
|
||||
|
||||
Third-party Agent names, product names, and logos remain the property of their
|
||||
respective owners. They are used only to identify compatible integrations and
|
||||
@@ -12,3 +10,7 @@ do not imply sponsorship or endorsement.
|
||||
The OpenCodeReview logo is sourced from Alibaba's OpenCodeReview project:
|
||||
https://github.com/alibaba/open-code-review/blob/main/imgs/logo-core.svg
|
||||
The upstream project is licensed under Apache License 2.0.
|
||||
|
||||
The Zcode logo is sourced unchanged from the official Zcode website:
|
||||
https://zcode.z.ai/favicon-192x192.png?v=20260707-transparent
|
||||
The logo identifies the integration; its trademark rights remain with its owner.
|
||||
@@ -1,162 +1,154 @@
|
||||
<!-- <p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="frontend/public/logos/meldwork-wordmark-v3-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="frontend/public/logos/meldwork-wordmark-v3.svg">
|
||||
<img src="frontend/public/logos/meldwork-wordmark-v3.svg" alt="Meldwork" width="360">
|
||||
</picture>
|
||||
</p> -->
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/meldwork-readme-banner-en.png" alt="Meldwork README banner" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
# Meldwork — Let Agents collaborate to move tasks forward
|
||||
|
||||
# Meldwork builds the organization layer for AI agents.
|
||||
**Set the goal. Let Agents work together. Meldwork brings your existing Agents into one working group to share context, discuss approaches, carry out work, and check each other's results — with less relaying, prompting, and stitching outputs together.**
|
||||
|
||||
**Most tools help humans run more agents. Meldwork is built for a different job: make multi-agent work legible, accountable, and reusable.**
|
||||
Meldwork is a local-first multi-Agent collaboration workspace for the Agent tools you already use on your Mac. Once you choose the participants, goal, context, and permissions, Agents in Auto Discussion can build on each other's work, request contributions or reviews, and advance the task within the authorized scope. Direct conversations and independent concurrent responses also support research, analysis, writing, planning, review, and implementation.
|
||||
|
||||
The current preview is a local work cell for supported Agent CLIs. It keeps participants explicit, context scoped, collaboration bounded, run state inspectable, and human review in the loop. Selected Agents can now respond concurrently or move through independent proposals, peer negotiation, responsibility-based work, single-writer integration, and independent verification around one goal.
|
||||
You do not need to direct every round, but you still set the goal, grant permissions, and make key decisions. Missing information, permission requests, or tradeoffs may require your input. Workspace writes are opt-in; collaboration history, run state, and deliverables stay local so you can revisit the work and continue it.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg"><strong>Download Meldwork V1.0.3 for Apple silicon macOS</strong></a>
|
||||
· <a href="architecture.md">Architecture</a>
|
||||
· <a href="LICENSE">License</a>
|
||||
<a href="https://github.com/Ryder-Sun/Meldwork/releases"><img src="https://img.shields.io/badge/download-macOS-1677FF?style=plastic&logo=apple&logoColor=white" alt="Download for macOS" height="26"></a>
|
||||
<a href="https://youtu.be/ttyJxo1s9ns"><img src="https://img.shields.io/badge/demo-YouTube-EC4899?style=plastic&logo=youtube&logoColor=white" alt="Watch demo on YouTube" height="26"></a>
|
||||
<a href="architecture.md"><img src="https://img.shields.io/badge/architecture-docs-8B5CF6?style=plastic" alt="Architecture documentation" height="26"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-view-14B8A6?style=plastic" alt="View license" height="26"></a>
|
||||
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/语言-简体中文-1677FF?style=plastic" alt="切换为简体中文" height="26"></a>
|
||||
</p>
|
||||
|
||||
## The missing layer
|
||||
## What Meldwork is for
|
||||
|
||||
Agent supply is growing quickly. The organization around it is not.
|
||||
[](https://youtu.be/ttyJxo1s9ns)
|
||||
|
||||
Today, people still choose every Agent, move every piece of context, explain every role, reconcile conflicting answers, and carry the final accountability. Parallel managers improve throughput. Agent frameworks let developers program workflows. Single-Agent products improve one worker.
|
||||
[Watch the product video on YouTube](https://youtu.be/ttyJxo1s9ns)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" align="center" valign="top">
|
||||
<a href="assets/meldwork-agent-discovery.png"><img src="assets/readme/agent-discovery.svg" alt="Agent discovery" width="240" height="150"></a>
|
||||
<p><sub>Agent discovery</sub></p>
|
||||
</td>
|
||||
<td width="33%" align="center" valign="top">
|
||||
<a href="assets/meldwork-auto-discussion.png"><img src="assets/readme/auto-discussion.svg" alt="Auto discussion & run details" width="240" height="150"></a>
|
||||
<p><sub>Auto discussion & run details</sub></p>
|
||||
</td>
|
||||
<td width="33%" align="center" valign="top">
|
||||
<a href="assets/meldwork-direct-multimodal.png"><img src="assets/readme/direct-multimodal.svg" alt="Direct multimodal conversation" width="240" height="150"></a>
|
||||
<p><sub>Direct multimodal conversation</sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Meldwork is focused on the work between them:
|
||||
Meldwork is useful when several Agents need to advance the same task and you want to spend less time passing context and coordinating next steps. Typical work includes:
|
||||
|
||||
- one shared goal and acceptance boundary;
|
||||
- independent viewpoints instead of hidden routing;
|
||||
- explicit authority, responsibility, handoffs, and stopping conditions;
|
||||
- artifacts and evidence that survive Agent changes;
|
||||
- a human-controlled adoption decision.
|
||||
- research and information synthesis across local Agent tools and selected knowledge sources;
|
||||
- product, market, operational, and technical analysis;
|
||||
- writing, planning, review, and structured document work;
|
||||
- multimodal work with local documents, images, audio, video, PDFs, and code or configuration files;
|
||||
- implementation in an authorized workspace, with other Agents checking the deliverables and requesting revisions.
|
||||
|
||||
## What works today
|
||||
## How a task moves through Meldwork
|
||||
|
||||
- **Supported local Agent CLIs:** detect, inspect, and invoke approved Agents without exposing unrestricted shell execution to the renderer.
|
||||
- **Persistent direct work:** keep local conversation history, attachments, permissions, compatible native sessions, and sanitized execution details together.
|
||||
- **Concurrent responses:** send one message to multiple selected Agents, freeze the same task context before execution, and publish their independent replies as one stable batch.
|
||||
- **Agent-negotiated collaboration:** start with independent proposals, let the selected Agents challenge proposals and unanimously agree one responsibility graph, execute distinct work packages, integrate through one writer, and verify through different Agents.
|
||||
- **Inspectable execution:** preserve bounded run events, warnings, completion states, checkpoints, and recovery actions without exposing credentials or private reasoning.
|
||||
- **Local control:** keep Meldwork conversations and orchestration state on the computer; workspace writes remain opt-in.
|
||||
1. **Select** the Agents that will work on the task together.
|
||||
2. **Scope** the goal, working directory, context, attachments, Skills, knowledge sources, and permissions.
|
||||
3. **Collaborate** in Auto Discussion: let Agents discuss approaches, carry out authorized work, and check results. Direct conversations and Concurrent Responses are also available.
|
||||
4. **Decide** when the work needs information, permission, or a tradeoff resolved.
|
||||
5. **Check the result** and request changes when more work is needed.
|
||||
|
||||
Capability still depends on the selected Agent, installed version, authentication state, Provider, and declared tool or attachment support.
|
||||
## Collaboration modes
|
||||
|
||||
## What Meldwork is building
|
||||
|
||||
The collaboration mechanism is not a hidden preset A → B → C assignment. The user selects the participants; those Agents expose the solution space, negotiate responsibilities, and execute inside Harness-enforced context, permission, budget, recovery, and evidence boundaries.
|
||||
|
||||
~~~mermaid
|
||||
flowchart LR
|
||||
G["Goal + outcome contract"] --> P["Independent proposals"]
|
||||
P --> C["Visible challenge"]
|
||||
C --> B["Role claims + team bids"]
|
||||
B --> R["Responsibility contract"]
|
||||
R --> X["Bounded execution"]
|
||||
X --> E["Artifact + evidence"]
|
||||
E --> H["Human adoption"]
|
||||
H --> O["Outcome receipt"]
|
||||
O -. "Agent fit + team fit" .-> P
|
||||
~~~
|
||||
|
||||
The selected-Agent proposal, challenge, responsibility, work, integration, and verification loop is available today. Automatic participant selection from a broader roster, remote Agents, enterprise governance, and outcome-driven reputation remain future direction.
|
||||
|
||||
## The Harness
|
||||
|
||||
The Harness is the control plane that keeps conversations, context, adapters, streams, and durable state aligned in one workspace. It is what makes a run continuous across sessions, timeouts, recovery, and Agent changes.
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
|
||||
</p>
|
||||
|
||||
## Where Meldwork fits
|
||||
|
||||
This is a comparison of working models, not a feature-scorecard claiming Meldwork should replace every tool.
|
||||
|
||||
| Approach | Examples | Best at | Main tradeoff | Meldwork's choice |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| One Agent CLI or AI IDE | Claude Code, Codex, Cursor, Windsurf | Deep work inside one Agent or product ecosystem | Moving to another Agent still requires a manual handoff | Preserve work across heterogeneous Agents |
|
||||
| Parallel coding-Agent managers | Superset, Vibe Kanban, Nimbalyst, Conductor | Running multiple coding tasks and isolated workspaces in parallel | Primarily optimized for throughput and code delivery | Optimize proposal quality, responsibility, evidence, and adoption |
|
||||
| Cloud Agent control planes | GitHub Agent HQ, Warp Oz, Devin, Factory, OpenHands Cloud | Dispatching and observing remote jobs, sandboxes, and Agent fleets | Work is organized around cloud or platform infrastructure | Start local-first and bring your existing Agents |
|
||||
| Programmable Agent frameworks | LangGraph, CrewAI, AutoGen, Agents SDK, ADK | Building custom graphs, roles, routing, and automation | Developers must design and maintain the orchestration | Deliver an organization and acceptance experience to users |
|
||||
| Multiple terminals and scripts | tmux, shell scripts, manual copy and paste | Maximum access to native Agent capabilities | The user becomes the router, context bus, and final judge | Make the work, boundaries, and decisions durable |
|
||||
| **Meldwork** | Supported local Agent CLIs; remote supply is direction | Concurrent independent responses and selected-Agent proposal, negotiation, responsibility, evidence, and verification | Local single-user preview today; participants are still selected by the user | Build the organization layer for the Agent workforce |
|
||||
|
||||
If one Agent and one work surface are enough, use the native tool. Use Meldwork when handoff, independent judgment, authority, evidence, and acceptance become part of the problem.
|
||||
|
||||
## See the current work cell
|
||||
|
||||
| Local Agent discovery | Direct multimodal work | Concurrent multi-Agent work |
|
||||
| Mode | What happens | Best for |
|
||||
| --- | --- | --- |
|
||||
| [](assets/meldwork-agent-discovery.png) | [](assets/meldwork-direct-multimodal.png) | [](assets/meldwork-group-collaboration.png) |
|
||||
| See which supported Agents are ready. | Keep prompts, files, permissions, and compatible sessions together. | Run concurrent replies or an Agent-negotiated collaboration with the participants you selected. |
|
||||
| **Direct** | One selected Agent keeps its conversation and native session when supported. | Focused research, writing, analysis, or execution. |
|
||||
| **Concurrent Responses** | Selected Agents receive the same frozen task snapshot and return independent responses in stable order. | Comparing perspectives before choosing a path. |
|
||||
| **Auto Discussion V4** | Agents propose independently, discuss, request peer contributions, carry out authorized work, and check deliverables. A delivery owner judges progress and requests user decisions when needed. Native sessions continue when supported. | Multi-step collaboration with less manual coordination. |
|
||||
|
||||
## Connected Agents
|
||||
Participants are selected by the user. Automatic selection from a larger roster is outside the current preview.
|
||||
|
||||
Current integrations include:
|
||||
## Revisit the process and continue the work
|
||||
|
||||
**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code**
|
||||
Meldwork keeps task context, Agent replies, run state, and deliverables locally so you can see how work progressed, where it stalled, and what needs attention next. The Run Ledger preserves phase, participant, attempt, artifact, recovery, and Human Gate state. Diagnostic tool output and sensitive runtime details stay behind the Electron main-process boundary.
|
||||
|
||||
Specialized review:
|
||||
These records help you inspect collaboration; they do not establish that every claim has been fact-checked. Peer review does not guarantee correctness, and important deliverables still need acceptance checks appropriate to their use.
|
||||
|
||||
**OpenCodeReview**
|
||||
## General Agent catalog
|
||||
|
||||
Availability depends on installation, version, authentication, Provider, and declared capabilities. Each Agent keeps its own tools, session behavior, and permission model.
|
||||
Meldwork detects and invokes compatible locally installed Agent commands. The catalog currently includes:
|
||||
|
||||
## Install
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/zcode.png" alt="Zcode logo" width="32" height="32"><br><strong>Zcode</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/deepseek.png" alt="DeepSeek Harness icon" width="32" height="32"><br><strong>DeepSeek Harness</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
The current release-validated target is Apple silicon macOS.
|
||||
Approved Agent Connectors and custom executable Agents use the desktop connector paths. The [desktop guide](desktop/README.md) contains the current adapter, installation, provider, and capability matrix.
|
||||
The [Agent Connector SDK guide](desktop/AGENT_CONNECTOR_SDK.md) explains supported packages, approval, upgrades, and conformance checks.
|
||||
|
||||
1. Open the [Meldwork V1.0.3 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3).
|
||||
2. Download Meldwork-0.1.3-arm64.dmg.
|
||||
3. Drag Meldwork into Applications and try to open it once.
|
||||
4. This prerelease is ad-hoc signed, not signed with an Apple Developer ID, and not Apple-notarized. If macOS blocks it, open **System Settings → Privacy & Security**, find the Meldwork warning, choose **Open Anyway**, and confirm.
|
||||
5. Connect a supported Agent CLI already installed on the computer, or configure an independent Provider profile where supported.
|
||||
DeepSeek Harness (`dsh`) supports direct chats and group replies through ACP, with separate resumable sessions and workspace permissions. Its default route is ZGCI `smart-router-v2`; provide `ZGCI_API_KEY` through native credentials or the launch environment. In Settings → Agents → DeepSeek Harness, you can select an existing native `DSH_HOME` directory; detection, execution, and local Skills use that selection. The native `DSH_AGENTS_HOME` environment variable also controls the shared Skills directory. The icon comes from the official macOS application.
|
||||
An explicit ACP provider or model in the native profile or DSH home patch takes precedence over this default; credential environment variables declared there are passed to DSH.
|
||||
|
||||
Use only artifacts downloaded from the official Release.
|
||||
WorkBuddy also supports selecting its existing native configuration directory in Settings → Agents. Its model list, execution environment, and local and plugin Skills use the selected directory.
|
||||
|
||||
<details>
|
||||
<summary><strong>Build from source</strong></summary>
|
||||
OpenClaw also supports selecting its native state directory in Settings → Agents. Meldwork reads the selected Agent's model configuration from that directory and runs it in its guarded local runtime.
|
||||
|
||||
Requires Node.js 22.12 or newer and npm.
|
||||
When switching an Agent's native configuration directory, Meldwork keeps separate local conversation sessions for each selected directory and restores them when switching back. Configuration changes are blocked while a run is active or awaiting recovery.
|
||||
|
||||
~~~bash
|
||||
For Zcode, choose an existing `.zcode/cli` directory containing `config.json`. Zcode runs with that directory's parent user home, which also affects its home-relative session and other file paths; project configuration may still override user configuration.
|
||||
|
||||
For Qwen Code, choose an existing `.qwen` directory containing settings or credentials. Qwen uses its parent user home for native sessions, Skills and other home-relative files; project and system settings retain their native precedence.
|
||||
|
||||
For OpenCodeReview, choose an existing `.opencodereview` directory containing `config.json`. OCR runs with that directory's parent user home, including its native session and other home-relative paths.
|
||||
|
||||
Hermes uses its native `config.yaml` and Provider settings. When its model, fallback model, or provider entries declare credential variables with `key_env` or `api_key_env`, Meldwork reads those variables from the launch environment or login shell for Hermes; unrelated variables and unsafe process controls are excluded. Hermes may prepare its own dependencies when started with a new native data directory.
|
||||
|
||||
## Context and local boundaries
|
||||
|
||||
Meldwork runs as a local Electron application. Conversations, group configuration, run records, and app-owned attachments stay in the local user data directory. The renderer receives only validated snapshots through a narrow preload API; executable paths, credentials, native session references, Skill paths, and unrestricted shell access remain in the main process.
|
||||
|
||||
Selected Agents may send prompts, attachments, or Skills to the model provider configured in their own CLI. Knowledge access is explicit and bounded: the current implementation supports local Obsidian retrieval and CLI-owned Feishu or DingTalk access modes. Local-first describes where Meldwork stores and coordinates work; it does not promise that an Agent's model provider is offline.
|
||||
|
||||
## Run from source
|
||||
|
||||
Prerequisites: Node.js `22.12+` and npm.
|
||||
|
||||
```bash
|
||||
npm --prefix frontend ci
|
||||
npm --prefix desktop ci
|
||||
npm --prefix desktop run dev
|
||||
~~~
|
||||
```
|
||||
|
||||
Run the repository checks before distributing a build:
|
||||
Before distributing a build:
|
||||
|
||||
~~~bash
|
||||
```bash
|
||||
npm --prefix frontend test
|
||||
npm --prefix frontend run build
|
||||
npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
~~~
|
||||
```
|
||||
|
||||
</details>
|
||||
## Documentation
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
- Conversations and Meldwork orchestration state stay local.
|
||||
- Model requests still follow the Agent and Provider you choose; local-first does not mean every model runs offline.
|
||||
- Credentials use operating-system-backed secure storage where supported.
|
||||
- Workspace writes are explicit and opt-in.
|
||||
- Raw chain-of-thought, executable paths, secrets, and unrestricted command output are not exposed to the renderer.
|
||||
- Cloud, Channel, automatic participant selection, enterprise governance, and an Outcome Network are not current release promises.
|
||||
|
||||
Read the [architecture](architecture.md) and [Agent Connector contract](docs/agent-connector-sdk.md) for the public technical boundary.
|
||||
- [Architecture and product boundary](architecture.md)
|
||||
- [Desktop setup and Agent matrix](desktop/README.md)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
- [Security policy](SECURITY.md)
|
||||
|
||||
## License
|
||||
|
||||
Meldwork is source-available under the [Meldwork Non-Commercial Source License](LICENSE). You may inspect, run, modify, and share it for non-commercial purposes. Commercial use requires prior written permission; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
|
||||
Meldwork is open source under the [Apache License 2.0](LICENSE). Commercial use, private use, modification, and redistribution are permitted under the license terms. No separate commercial authorization or license fee is required. Apache-2.0 does not grant trademark rights; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
|
||||
@@ -1,162 +1,152 @@
|
||||
<!-- <p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="frontend/public/logos/meldwork-wordmark-v3-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="frontend/public/logos/meldwork-wordmark-v3.svg">
|
||||
<img src="frontend/public/logos/meldwork-wordmark-v3.svg" alt="Meldwork" width="360">
|
||||
</picture>
|
||||
</p> -->
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/meldwork-readme-banner-cn.png" alt="Meldwork README banner" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> · <strong>简体中文</strong>
|
||||
</p>
|
||||
# Meldwork:让 Agent 自主协作推进任务
|
||||
|
||||
# Meldwork 为 AI Agent 构建组织层
|
||||
**提出目标,让 Agent 们协作完成。Meldwork 把你已有的 Agent 组织成一个工作小组,共享上下文、讨论方案、协作执行并相互检查,减少你在它们之间转述、催办和整合结果的工作。**
|
||||
|
||||
**多数工具是在帮人类管更多 Agent;Meldwork 解决的是另一件事:让多 Agent 协作变得可见、可追责、可复用。**
|
||||
Meldwork 是一个本地优先的多 Agent 协作工作空间,用来连接你已经在电脑上使用的 Agent 工具。你选定参与者、目标、上下文和权限后,自动讨论中的 Agent 可以接续彼此的工作,请求同伴补充或检查,并在授权范围内推进任务。它也支持单个 Agent 的直接会话和多个 Agent 的独立并发回复,覆盖研究、分析、写作、规划、评审和实施。
|
||||
|
||||
当前预览版是一个面向已支持本地 Agent CLI 的本地工作单元。它把参与者、上下文、协作边界、运行状态和人类复核放在同一个工作空间里。已选 Agent 现在可以并发回复,也可以围绕同一目标先独立提案、再协商职责、分工执行、单写者整合并由其他 Agent 独立复核。
|
||||
你无需安排每一轮发言,但仍负责目标、权限和关键决策。遇到信息缺失、权限请求或需要取舍的问题时,任务可能等待你处理。工作区写入需要明确授权;协作过程、运行状态和产物保存在本地,方便回看和继续工作。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg"><strong>下载 Meldwork V1.0.3 Apple 芯片 macOS 版</strong></a>
|
||||
· <a href="architecture.md">架构</a>
|
||||
· <a href="LICENSE">许可证</a>
|
||||
<a href="https://github.com/Ryder-Sun/Meldwork/releases"><img src="https://img.shields.io/badge/下载-macOS-1677FF?style=plastic&logo=apple&logoColor=white" alt="下载 macOS 安装包" height="26"></a>
|
||||
<a href="https://youtu.be/ttyJxo1s9ns"><img src="https://img.shields.io/badge/演示-YouTube-EC4899?style=plastic&logo=youtube&logoColor=white" alt="在 YouTube 观看演示" height="26"></a>
|
||||
<a href="architecture.md"><img src="https://img.shields.io/badge/架构-文档-8B5CF6?style=plastic" alt="项目架构文档" height="26"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/许可-查看-14B8A6?style=plastic" alt="查看许可证" height="26"></a>
|
||||
<a href="README.md"><img src="https://img.shields.io/badge/language-English-1677FF?style=plastic" alt="Switch to English" height="26"></a>
|
||||
</p>
|
||||
|
||||
## Agent workforce 缺少的那一层
|
||||
## Meldwork 适合什么工作
|
||||
|
||||
Agent 的供给正在快速增长,但围绕它们工作的组织能力并没有跟上。
|
||||
[](https://youtu.be/ttyJxo1s9ns)
|
||||
|
||||
今天,人类仍然需要自己选 Agent、搬运上下文、解释角色、处理冲突答案,并承担最终责任。并行管理器优化吞吐量,多 Agent 框架让开发者编写 Workflow,单 Agent 产品优化一个 Worker。
|
||||
[在 YouTube 观看产品宣传视频](https://youtu.be/ttyJxo1s9ns)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" align="center" valign="top">
|
||||
<a href="assets/meldwork-agent-discovery.png"><img src="assets/readme/agent-discovery.svg" alt="Agent 发现" width="240" height="150"></a>
|
||||
<p><sub>Agent 发现</sub></p>
|
||||
</td>
|
||||
<td width="33%" align="center" valign="top">
|
||||
<a href="assets/meldwork-auto-discussion.png"><img src="assets/readme/auto-discussion.svg" alt="自动讨论与运行详情" width="240" height="150"></a>
|
||||
<p><sub>自动讨论与运行详情</sub></p>
|
||||
</td>
|
||||
<td width="33%" align="center" valign="top">
|
||||
<a href="assets/meldwork-direct-multimodal.png"><img src="assets/readme/direct-multimodal.svg" alt="多模态直接会话" width="240" height="150"></a>
|
||||
<p><sub>多模态直接会话</sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Meldwork 关注的是中间那层工作:
|
||||
当一个任务需要多个 Agent 共同推进,而你不想反复传递上下文、协调下一步时,Meldwork 更适合:
|
||||
|
||||
- 一个共享 Goal 和明确验收边界;
|
||||
- 独立判断,而不是隐藏路由;
|
||||
- 明确的权限、责任、交接和停止条件;
|
||||
- 不随 Agent 切换而丢失的 Artifact 与 Evidence;
|
||||
- 由人类掌握的最终采用决定。
|
||||
- 研究与信息综合:结合本地 Agent 工具和明确选择的知识源;
|
||||
- 产品、市场、运营和技术分析;
|
||||
- 写作、规划、评审和结构化文档工作;
|
||||
- 处理本地文档、图片、音频、视频、PDF、代码和配置文件的多模态工作;
|
||||
- 在授权工作目录内执行、由其他 Agent 检查成果并继续修改的实施工作。
|
||||
|
||||
## 当前已经可用
|
||||
## 一个任务如何在 Meldwork 中推进
|
||||
|
||||
- **支持的本地 Agent CLI**:检测、检查并受控调用已批准的 Agent,不把不受限的 Shell 暴露给 Renderer。
|
||||
- **持续的直接工作**:把本地历史、附件、权限、兼容的原生 Session 和脱敏执行详情放在一起,尽量不让上下文断线。
|
||||
- **并发回复**:一条消息同时发给多个已选 Agent,执行前冻结同一份任务上下文,并以稳定批次公布各自独立回复。
|
||||
- **Agent 自主协商协作**:先独立提案,再互相质询并全员同意一份职责图,随后执行不同工作包,由唯一写入者整合,并由不同 Agent 独立复核。
|
||||
- **可检查执行**:保留有界运行事件、警告、终态、检查点和恢复动作,不暴露凭据或私有推理。
|
||||
- **本地控制**:Meldwork 的会话和编排状态都留在电脑上;工作目录写入必须主动开启。
|
||||
1. **选择**一起处理任务的 Agent。
|
||||
2. **限定**目标、工作目录、上下文、附件、Skill、知识源和权限。
|
||||
3. **协作**:启动 Auto Discussion,让 Agent 讨论方案、接续执行并检查成果;也可选择直接会话或并发回复。
|
||||
4. **决策**:在需要补充信息、授权或取舍时介入。
|
||||
5. **查看结果**:检查交付物,必要时提出修改,让工作继续推进。
|
||||
|
||||
具体能力仍取决于所选 Agent、本机版本、认证状态、Provider 和声明的工具/附件支持。
|
||||
## 协作模式
|
||||
|
||||
## Meldwork 正在构建什么
|
||||
|
||||
当前协作机制不是 Harness 暗中预设的 A → B → C 分配链。用户选定参与者后,Agent 先展开解空间、自主协商职责,再在 Harness 管理的上下文、权限、预算、恢复和证据边界内执行。
|
||||
|
||||
~~~mermaid
|
||||
flowchart LR
|
||||
G["目标与验收契约"] --> P["独立竞案"]
|
||||
P --> C["公开质询"]
|
||||
C --> B["角色声明与团队竞案"]
|
||||
B --> R["责任契约"]
|
||||
R --> X["有界执行"]
|
||||
X --> E["产物与证据"]
|
||||
E --> H["人类采用"]
|
||||
H --> O["结果回执"]
|
||||
O -. "Agent Fit + Team Fit" .-> P
|
||||
~~~
|
||||
|
||||
已选 Agent 的独立提案、交叉质询、职责协商、分工执行、整合和复核现在已经可用。从更大候选池自动选人组队、远程 Agent、企业治理和 Outcome 驱动的声誉系统仍是未来方向。
|
||||
|
||||
## Harness
|
||||
|
||||
Harness 是把会话、上下文、适配器、流和持久化状态拴在一起的控制平面。它让一次任务可以跨会话、超时、恢复和 Agent 切换继续下去。
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
|
||||
</p>
|
||||
|
||||
## Meldwork 在哪里
|
||||
|
||||
这张表比较的是不同工作范式,不是一张声称 Meldwork 应当替代所有工具的功能勾选表。
|
||||
|
||||
| 范式 | 例子 | 最擅长什么 | 主要代价 | Meldwork 的选择 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 单 Agent CLI / AI IDE | Claude Code、Codex、Cursor、Windsurf | 在一个 Agent 或产品生态内完成深度工作 | 换 Agent 时仍需手工交接 | 保留跨异构 Agent 的工作 |
|
||||
| 并行 Coding-Agent 工作台 | Superset、Vibe Kanban、Nimbalyst、Conductor | 并行运行多个 Coding 任务和隔离工作区 | 主要优化吞吐量和代码交付 | 优化方案质量、责任、证据和采用 |
|
||||
| Cloud Agent 控制面 | GitHub Agent HQ、Warp Oz、Devin、Factory、OpenHands Cloud | 派发和观察远程 Job、Sandbox 与 Agent Fleet | 工作围绕云基础设施和平台边界 | 先从 Local-first 和 BYO Agent 切入 |
|
||||
| 可编程多 Agent 框架 | LangGraph、CrewAI、AutoGen、Agents SDK、ADK | 开发者自定义 Graph、角色、路由和自动化 | 需要开发者设计并维护编排 | 面向使用者交付组织与验收体验 |
|
||||
| 多终端与脚本 | tmux、Shell、手工复制粘贴 | 最大化原生 Agent 访问权 | 人类成为 Router、Context Bus 和最终裁决者 | 让工作、边界和决定保持持续 |
|
||||
| **Meldwork** | 已支持本地 Agent CLI;远程供给属于方向 | 并发独立回复,以及已选 Agent 之间的提案、协商、责任、Evidence 和复核 | 当前仍是本地单用户预览;参与者仍由用户选定 | 构建 Agent workforce 的组织层 |
|
||||
|
||||
如果一个 Agent 和一个工作面已经足够,直接使用原生工具。只有当交接、独立判断、权限、证据和验收成为问题时,才需要 Meldwork。
|
||||
|
||||
## 看看当前 Work Cell
|
||||
|
||||
| 本地 Agent 检测 | 单聊中的多模态工作 | 并发多 Agent 协作 |
|
||||
| 模式 | 运行方式 | 适合场景 |
|
||||
| --- | --- | --- |
|
||||
| [](assets/meldwork-agent-discovery.png) | [](assets/meldwork-direct-multimodal.png) | [](assets/meldwork-group-collaboration.png) |
|
||||
| 查看哪些受支持 Agent 已就绪。 | Prompt、文件、权限和兼容 Session 留在一起。 | 让已选 Agent 并发回复,或进入由 Agent 自主协商职责的协作流程。 |
|
||||
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 聚焦研究、写作、分析或执行。 |
|
||||
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同视角。 |
|
||||
| **Auto Discussion V4** | Agent 独立提出方案后,接续讨论、请求同伴贡献、在授权范围内执行并检查成果。交付负责人判断任务进度,必要时请求用户决策;支持时保持原生会话。 | 希望减少人工协调的多步骤协作任务。 |
|
||||
|
||||
## 当前可连接的 Agent
|
||||
参与者始终由用户选择。从更大候选池自动选人组队不属于当前预览版。
|
||||
|
||||
当前集成包括:
|
||||
## 过程可回看,工作可继续
|
||||
|
||||
**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code**
|
||||
Meldwork 在本地保留任务上下文、Agent 回复、运行状态和产物,方便了解任务如何推进、哪里遇到阻塞,以及接下来需要处理什么。Run Ledger 保留阶段、参与者、尝试、产物、恢复状态和人工审批门;诊断工具输出与敏感运行信息留在 Electron 主进程边界内。
|
||||
|
||||
专项审查:
|
||||
过程记录帮助检查协作情况,不代表每条结论都经过事实核验。Agent 的相互检查也不能保证结果正确,重要交付物仍需按实际用途验收。
|
||||
|
||||
**OpenCodeReview**
|
||||
## General Agent 目录
|
||||
|
||||
能否使用取决于安装、版本、认证、Provider 和声明的能力。每个 Agent 保留自己的工具、Session 行为和权限模型。
|
||||
当适配器和 CLI 版本兼容时,Meldwork 会检测并调用已安装的 Agent 命令。当前目录包括:
|
||||
|
||||
## 安装
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/zcode.png" alt="Zcode logo" width="32" height="32"><br><strong>Zcode</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/deepseek.png" alt="DeepSeek Harness 图标" width="32" height="32"><br><strong>DeepSeek Harness</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
当前经过发布验证的目标是 Apple 芯片 macOS。
|
||||
已批准的 Agent Connector 和自定义可执行 Agent 使用桌面端的连接器入口。完整的适配器、安装、Provider 和能力矩阵见[桌面端指南](desktop/README.md)。
|
||||
[Agent Connector SDK 指南](desktop/AGENT_CONNECTOR_SDK.md)介绍当前支持的包、审批、升级和一致性检查。
|
||||
|
||||
1. 打开 [Meldwork V1.0.3 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3)。
|
||||
2. 下载 Meldwork-0.1.3-arm64.dmg。
|
||||
3. 将 Meldwork 拖入“应用程序”,然后先尝试打开一次。
|
||||
4. 当前预发布版使用临时签名,没有 Apple Developer ID 签名,也尚未经过 Apple 公证。如果 macOS 阻止启动,请打开“系统设置 → 隐私与安全性”,找到 Meldwork 提示,点击“仍要打开”并确认。
|
||||
5. 连接电脑上已安装的受支持 Agent CLI,或在兼容情况下单独配置 Provider。
|
||||
DeepSeek Harness(`dsh`)通过 ACP 支持私聊与群聊,各对话使用独立、可续接的原生会话,并遵守工作区权限。默认模型为 ZGCI `smart-router-v2`;凭据通过原生服务或启动环境中的 `ZGCI_API_KEY` 提供。在「设置 → Agent → DeepSeek Harness」中可以选择已有的原生 `DSH_HOME` 目录,探测、运行和本地 Skill 都会使用该目录。原生环境变量 `DSH_AGENTS_HOME` 也可指定共享 Skill 目录。图标使用官方 macOS 客户端资源。
|
||||
如果原生 ACP profile 或 DSH home 补丁显式指定了 Provider 或模型,Meldwork 会优先使用该配置,并传递其中声明的凭据环境变量。
|
||||
|
||||
请只使用官方 Release 下载的产物。
|
||||
WorkBuddy 也可以在「设置 → Agent」中选择已有的原生配置目录;模型列表、执行环境以及本地和插件 Skill 都会使用所选目录。
|
||||
|
||||
<details>
|
||||
<summary><strong>从源码运行</strong></summary>
|
||||
OpenClaw 也可以在「设置 → Agent」中选择原生状态目录。Meldwork 会从该目录读取所选 Agent 的模型配置,并在受保护的本地运行环境中执行。
|
||||
|
||||
需要 Node.js 22.12 或更高版本以及 npm。
|
||||
切换 Agent 的原生配置目录时,Meldwork 会按目录分别保存本地会话,切回原目录后恢复对应会话。任务运行中或等待恢复时,暂时不能切换配置。
|
||||
|
||||
~~~bash
|
||||
Zcode 请选择包含 `config.json` 的 `.zcode/cli` 目录。运行时 Zcode 会使用该目录所属的用户主目录,这也会影响其会话及其他文件中的主目录相对路径;项目配置仍可能覆盖用户配置。
|
||||
|
||||
Qwen Code 请选择包含设置或认证文件的 `.qwen` 目录。Qwen 会使用其所属的用户主目录保存原生会话、读取 Skill 和其他主目录相对文件;项目和系统设置仍遵循原生优先级。
|
||||
|
||||
Hermes 使用自身的 `config.yaml` 和 Provider 设置。如果模型、备用模型或 Provider 条目通过 `key_env` 或 `api_key_env` 声明凭据变量,Meldwork 会从启动环境或登录 shell 中读取这些变量并交给 Hermes;无关变量和不安全的进程控制变量会被排除。首次使用新的原生数据目录启动时,Hermes 可能会自行准备依赖。
|
||||
|
||||
## 上下文与本地边界
|
||||
|
||||
Meldwork 作为本地 Electron 应用运行。对话、群组配置、运行记录和应用管理的附件保存在本地用户数据目录。渲染进程只通过受约束的 preload API 获取校验后的状态;可执行路径、凭据、原生会话引用、Skill 路径和任意 Shell 权限都留在主进程中。
|
||||
|
||||
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送到其自身 CLI 凭据对应的模型服务商。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表 Agent 使用的模型服务商一定离线。
|
||||
|
||||
## 从源码运行
|
||||
|
||||
前置条件:Node.js `22.12+` 和 npm。
|
||||
|
||||
```bash
|
||||
npm --prefix frontend ci
|
||||
npm --prefix desktop ci
|
||||
npm --prefix desktop run dev
|
||||
~~~
|
||||
```
|
||||
|
||||
分发前运行:
|
||||
分发构建前运行:
|
||||
|
||||
~~~bash
|
||||
```bash
|
||||
npm --prefix frontend test
|
||||
npm --prefix frontend run build
|
||||
npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
~~~
|
||||
```
|
||||
|
||||
</details>
|
||||
## 文档
|
||||
|
||||
## 信任边界
|
||||
|
||||
- Meldwork 会话和编排状态保存在本地。
|
||||
- 模型请求仍然按照你选择的 Agent 和 Provider 发出;Local-first 不等于所有模型离线运行。
|
||||
- 兼容环境使用操作系统安全存储保存凭据。
|
||||
- 工作目录写入必须显式开启。
|
||||
- Renderer 不暴露 raw chain-of-thought、可执行路径、Secret 或不受限命令输出。
|
||||
- Cloud、Channel、自动选人组队、企业治理和 Outcome Network 不是当前发布承诺。
|
||||
|
||||
公开技术边界见[架构](architecture.md)和 [Agent Connector 契约](docs/agent-connector-sdk.md)。
|
||||
- [架构与产品边界](architecture.md)
|
||||
- [桌面端设置与 Agent 矩阵](desktop/README.md)
|
||||
- [贡献指南](CONTRIBUTING.md)
|
||||
- [安全策略](SECURITY.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
Meldwork 采用 [Meldwork 非商用源码许可](LICENSE)。你可以为非商用目的查看、运行、修改和分享;商业使用需要事先书面许可,详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
|
||||
Meldwork 以 [Apache License 2.0](LICENSE) 开源。在许可证条款范围内,可以商用、私用、修改和再分发。商用无需另行授权或支付许可费。Apache-2.0 不授予商标权;详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Security Policy
|
||||
|
||||
Meldwork is a local-first desktop application that invokes local Agent CLIs, stores local conversations, imports validated image, media, document, code, and archive attachments, and manages Provider credentials through the Electron main process.
|
||||
Meldwork is a local-first desktop application that invokes local Agent CLIs, stores local conversations, imports validated image, media, document, code, and archive attachments, and runs each Agent with its own native CLI credentials through the Electron main process.
|
||||
|
||||
## Reporting
|
||||
|
||||
@@ -11,10 +11,10 @@ Do not open a public issue with exploit details, secrets, local file paths, or p
|
||||
## In Scope
|
||||
|
||||
- Renderer-to-main IPC authorization bypasses.
|
||||
- Exposure of executable paths, attachment paths, native session references, Provider API keys, or Skill file paths to renderer code.
|
||||
- Exposure of executable paths, attachment paths, native session references, CLI API keys, or Skill file paths to renderer code.
|
||||
- Arbitrary shell execution, arbitrary file read/write, unsafe navigation, or unsafe installer behavior.
|
||||
- Attachment validation, private storage, checksum, or preview isolation failures.
|
||||
- Credential storage or Provider configuration failures that expose secrets.
|
||||
- Credential storage failures that expose secrets.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Product Boundary
|
||||
|
||||
Meldwork is a local Electron desktop product for direct and multi-Agent work with explicitly selected Skill, image, and knowledge-source context. It has no account system, tenant model, required application server, remote conversation store, email delivery, scheduled jobs, or public SEO surface. Optional Agent, Cloud, and Channel Connectors run behind the Electron main-process trust boundary and are disabled until explicitly configured.
|
||||
Meldwork is a local Electron desktop product for multi-agent orchestration and review with explicitly selected Skill, image, and knowledge-source context. It has no account system, tenant model, required application server, remote conversation store, email delivery, scheduled jobs, or hosted web surface. Optional Agent, Cloud, and Channel Connectors run behind the Electron main-process trust boundary and are disabled until explicitly configured. The landing page at https://ryder-mhumble.github.io/Meldwork/ is the public marketing surface for search engine and AI assistant discoverability.
|
||||
|
||||
The supported product surface is the packaged Electron application. The frontend is a renderer bundle; without the local preload bridge it shows a desktop-required state and cannot operate the workspace.
|
||||
|
||||
@@ -11,15 +11,15 @@ The supported product surface is the packaged Electron application. The frontend
|
||||
| Component | Responsibility | Primary evidence |
|
||||
| --- | --- | --- |
|
||||
| Vue renderer | Agent-first multi-session UI, direct-session rename/delete, live direct-chat conclusions and inline execution summaries, group-chat result streaming with an expandable per-Agent/right-side trace panel, per-round navigation, background completion markers, first-run carousel, conversation/Skill/image/knowledge composer UI, bounded attachment-preview cache, theme/i18n, user confirmation and input collection | `frontend/src/App.vue`, `frontend/src/components/RunTracePanel.vue`, `frontend/src/composables/useAttachmentPreviews.js`, `frontend/src/desktop.js`, `frontend/src/knowledgeBaseCatalog.js` |
|
||||
| Electron preload | Exposes narrow workspace, Human Gate, Cloud control, installer/Skill, attachment, Provider, and knowledge-source methods through `window.meldworkDesktop` only to a local `file:` document; Channel ingress has no renderer API | `desktop/src/preload.cjs` |
|
||||
| Electron main | Trust boundary for IPC authorization and shutdown gating, persistence, attachment import/preview, Connector registration, Skill and knowledge-source validation, local/Cloud execution, Channel receivers, navigation, sanitized run events, operating-system notifications, Provider storage, and installer control | `desktop/src/main.cjs` |
|
||||
| Electron preload | Exposes narrow workspace, Human Gate, Cloud control, installer/Skill, attachment, and knowledge-source methods through `window.meldworkDesktop` only to a local `file:` document; Channel ingress has no renderer API | `desktop/src/preload.cjs` |
|
||||
| Electron main | Trust boundary for IPC authorization and shutdown gating, persistence, attachment import/preview, Connector registration, Skill and knowledge-source validation, local/Cloud execution, Channel receivers, navigation, sanitized run events, operating-system notifications, and installer control | `desktop/src/main.cjs` |
|
||||
| Local workspace | Persists Meldwork conversations, final replies, compact evidence capsules, safe message metadata, task-scoped group Sessions, conversation-scoped direct Sessions, legacy Session migration, Agent-specific transcript deltas, Agent preferences/runtime evidence, and active run coordination | `desktop/src/workspace/local-workspace.cjs` |
|
||||
| V4 orchestration | Validates frozen snapshots, phase-aware slots, typed collaboration receipts, negotiated responsibility graphs, work receipts, synthesis recovery, verification, and idempotent commits | `desktop/src/collaboration/orchestration-v4-records.cjs`, `desktop/src/workspace/local-workspace-message-submission.cjs`, `desktop/src/workspace/local-workspace-auto-runner.cjs` |
|
||||
| V4 orchestration | Validates frozen snapshots, phase-aware slots, typed collaboration receipts, negotiated responsibility graphs, work receipts, synthesis recovery, verification, and idempotent commits | `desktop/src/collaboration/orchestration-v4-records.cjs`, `desktop/src/workspace/local-workspace-message-submission.cjs`, `desktop/src/workspace/local-workspace-auto-runner.cjs` and its `local-workspace-auto-runner-*.cjs` parts (see Code Map) |
|
||||
| Run Ledger | Atomically checkpoints bounded, sanitized per-run and per-Agent state for stop/crash recovery, marks nonterminal runs and Agent attempts interrupted after restart, supports idempotent reconciliation of missing terminal Agent messages, and purges records with their conversation | `desktop/src/runs/run-ledger.cjs` |
|
||||
| Context Pack and delivery stores | Capture immutable, content-addressed Task inputs, attempt-specific approved prompts, exact outbound fingerprints, source hashes, target scope, and native Session provenance | `desktop/src/collaboration/context-pack-store.cjs`, `desktop/src/workspace/local-workspace-context-packs.cjs` |
|
||||
| Outcome store | Persists typed Artifacts, Evidence, Findings, and Adoption records separately from diagnostic reasoning and tool summaries | `desktop/src/collaboration/outcome-store.cjs`, `desktop/src/collaboration/outcome-records.cjs` |
|
||||
| Human Gate store | Persists permission, budget, and decision requests and their terminal user/system decisions without exposing raw request payloads to the renderer | `desktop/src/gates/human-gate-store.cjs`, `desktop/src/gates/human-gate-coordinator.cjs` |
|
||||
| Scheduling and failure policy | Enforces run budgets, retry policy, per-Agent controls, and global/workspace scheduling | `desktop/src/runs/run-scheduler.cjs`, `desktop/src/runs/failure-policy.cjs` |
|
||||
| Scheduling and failure policy | Enforces run budgets, per-Agent controls, and one writer at a time within a conversation (conversations never wait for each other); classifies failures (sign-in, rate limit, network, provider, context overflow) and decides retries with `Retry-After`; stops a silent Agent through the invocation watchdog | `desktop/src/runs/run-scheduler.cjs`, `desktop/src/runs/failure-policy.cjs`, `desktop/src/runs/failure-signals.cjs`, `desktop/src/runs/invocation-watchdog.cjs` |
|
||||
| Agent Connector registry/runtime | Validates approved manifests and instances, separates CredentialRefs, records Connector/upstream versions, and reduces versioned idempotent Run Events | `desktop/src/agents/connectors/agent-connector-manifest.cjs`, `desktop/src/agents/connectors/agent-connector-registry.cjs`, `desktop/src/agents/connectors/agent-connector-runtime.cjs` |
|
||||
| Attachment store | Imports validated local images, media, documents, code/configuration files, Office files, and archives into private app-owned storage, verifies metadata/checksums on read, and removes unreferenced entries | `desktop/src/attachments/attachment-store.cjs`, `desktop/src/attachments/image-dimensions.cjs` |
|
||||
| Local Skill catalog | Scans bounded known roots per Agent and returns sanitized Skill coordinates without exposing paths or contents | `desktop/src/skills/local-skill-catalog.cjs` |
|
||||
@@ -30,9 +30,53 @@ The supported product surface is the packaged Electron application. The frontend
|
||||
| Knowledge-source store | Persists only the user-selected Obsidian Vault path; Feishu and DingTalk credentials remain owned by their local CLIs | `desktop/src/knowledge/knowledge-base-store.cjs` |
|
||||
| CLI adapters | Detect and invoke supported local Agent executables with per-Agent arguments and constrained child environments | `desktop/src/agents/cli/cli-adapters.cjs` |
|
||||
| Agent installer | Detects Agents and runs allowlisted npm packages or official installer scripts after user confirmation | `desktop/src/agents/installer/agent-installer.cjs` |
|
||||
| Provider store | Validates Provider metadata and encrypts the API key with Electron `safeStorage` | `desktop/src/providers/provider-store.cjs` |
|
||||
| Managed OpenClaw runtime | Creates isolated local configuration and a restricted tool policy for OpenClaw | `desktop/src/agents/cli/openclaw-runtime.cjs` |
|
||||
|
||||
## Code Map
|
||||
|
||||
The workspace is composed of small modules around a thin coordinator. A module either owns one piece of state or is a pure function of its inputs; each has a test file with the same name under `desktop/test/`.
|
||||
|
||||
**One Agent invocation** (`desktop/src/workspace/local-workspace-agent-invocation.cjs`) is the run lifecycle: lease, run, race against stop and timeout, record the result. It delegates to:
|
||||
|
||||
| Module | Owns |
|
||||
| --- | --- |
|
||||
| `invocation-session.cjs` | The native Agent session of one invocation: resume, adopt a new id, discard and rebuild, record the turn |
|
||||
| `invocation-outputs.cjs` | Workspace snapshot before a writable turn, media import and artifact descriptors after it |
|
||||
| `invocation-prompt.cjs` | The prompt one Agent receives (frozen, direct chat, group) |
|
||||
| `invocation-budget.cjs` | Usage recorded against the run budget |
|
||||
| `invocation-failure.cjs` | Why an invocation ended (stopped, timed out, interrupted, cancelled, failed) and the hidden error metadata |
|
||||
| `desktop/src/runs/invocation-watchdog.cjs` | Startup, inactivity and stall windows; liveness versus substantive progress |
|
||||
| `desktop/src/agents/agent-permission-policy.cjs` | Automatic answers to permission requests; a read-only turn only approves side-effect-free tools |
|
||||
| `desktop/src/collaboration/collaboration-receipt.cjs` | Parsing and normalising the control receipt at the end of a turn (lenient for natural discussion, strict for phased V4) |
|
||||
| `desktop/src/collaboration/answer-marker-filter.cjs` | Hides control markers while an answer streams |
|
||||
|
||||
**Prompts** live in `desktop/src/workspace/prompts/`: `natural-discussion-prompt.cjs` (named sections: brief, writer, owner, routing, receipt), `manual-reply-prompt.cjs` (concurrent replies in a group), and `prompt-contracts.cjs` (the language and deliverable contracts shared by all of them). Direct chats are built by `directPromptFor` in `local-workspace-context.cjs`.
|
||||
|
||||
**The automatic run** is `LocalWorkspaceAutoRunner`; its methods are grouped into files by what they do and assembled with `Object.assign` onto the class prototype:
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `local-workspace-auto-runner.cjs` | Constructor, `start` and `resume`, run-level helpers |
|
||||
| `…-natural-discussion.cjs`, `…-natural-decisions.cjs` | The production path: transcript, prompts, the sequential and agent-led loops, and what happens after each round (owner takeover, ignored routes, peer blockers, missing decisions) |
|
||||
| `…-v4-discussion.cjs`, `…-v4-phase.cjs`, `…-v4-results.cjs` | The phased V4 run, one phase, and turning results into receipts and commits |
|
||||
| `…-v4-context.cjs`, `…-v4-delivery.cjs`, `…-v4-synthesis.cjs`, `…-v4-outcomes.cjs` | What a run freezes and shares, how context reaches each Agent, synthesis recovery, and the receipts, review issues, convergence and acceptance checks |
|
||||
| `…-recovery.cjs` | Retries, pausing a failed Agent for the run, unauthorized-session recovery |
|
||||
| `…-collaboration.cjs` | Collaboration records written as a run progresses |
|
||||
| `…-task-graph.cjs`, `…-rounds.cjs` | V3 task-graph and V2 round runs, kept only to resume older runs |
|
||||
|
||||
Splitting by file did not change any method; the class still shares one `this`. New behaviour should go into a small module with its own tests and be called from the group that owns the topic, not appended to a large method.
|
||||
|
||||
## Reliability Rules
|
||||
|
||||
- Conversations are independent. A running chat, direct or group, never makes an Agent in another conversation wait, even when both use the same folder and the same Agent. The writer lease is scoped to the conversation; inside one conversation writers still take turns.
|
||||
- A failure pauses an Agent for the current run only. Group membership is never rewritten because an Agent failed; a group whose members were all removed by an older build is rebuilt from the Agents that replied in it.
|
||||
- Only a definitive sign-in failure marks credentials missing. A refusal that could be a proxy or a tool's own output is retried once and reported as a provider failure.
|
||||
- An Agent that was available stays available through an inconclusive readiness probe for ten minutes. A definitive answer applies immediately.
|
||||
- A stopped or failed turn keeps a healthy long-lived ACP process and its native session. The process is replaced only when it ignores cancellation or the protocol breaks.
|
||||
- Unknown ACP traffic (banners, new update kinds, unknown notifications) is ignored; unknown requests are answered with "method not found". Malformed JSON, invalid responses and invalid permission requests still fail closed.
|
||||
- Messages in a group topic carry a dispatch number (`turnSeq`) assigned before the Agent starts, so live and saved replies share one order. Failure and stop notices take the turn's number.
|
||||
- A discussion does not end on a malformed receipt, an absent recipient, a peer reporting a blocker or an owner who forgot the decision: those are discarded, handed to the owner, or asked for once.
|
||||
|
||||
## Concurrent Collaboration V4
|
||||
|
||||
- A group with multiple selected Agents in `manual` mode runs as one concurrent batch. Harness freezes every selected Agent's task snapshot and delivery plan before any Scheduler lease, preserves all selected members, and publishes the batch in stable member order after the barrier. Direct conversations, one-Agent targets, and regeneration keep their existing execution paths.
|
||||
@@ -45,11 +89,11 @@ Automatic participant selection from a larger roster, production remote Agents,
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
1. The renderer is not trusted with Node.js, Agent executable paths, native Agent session IDs or storage paths, attachment paths, Skill paths/contents, raw Provider credentials after saving, arbitrary filesystem reads, or arbitrary process execution. User-selected conversation workdirs and the user-selected Obsidian Vault path are intentional UI-visible paths; pasted image bytes use one exact import payload.
|
||||
1. The renderer is not trusted with Node.js, Agent executable paths, native Agent session IDs or storage paths, attachment paths, Skill paths/contents, raw CLI credentials, arbitrary filesystem reads, or arbitrary process execution. User-selected conversation workdirs and the user-selected Obsidian Vault path are intentional UI-visible paths; pasted image bytes use one exact import payload.
|
||||
2. Preload exposes only named IPC methods. Main validates that every request comes from the current main frame and exact bundled frontend file.
|
||||
3. Main owns local files, OS dialogs, attachment validation/storage, Skill and knowledge-source discovery/revalidation, Connector approval/registration, Agent discovery, child processes, Cloud observation, Channel authentication, installer downloads, and credential encryption.
|
||||
4. Agent CLIs are external processes. They receive a selected working directory, a constrained environment, permission flags, main-resolved image paths, target-scoped immutable Skill snapshots, and validated knowledge snapshots or read-only hints after capability checks.
|
||||
5. Provider, Agent Connector, Cloud Connector, Channel Connector, and installer destinations are external trust boundaries. Provider URLs are user supplied and validated; manifests and Connector instances fail closed unless explicitly approved; Channel signatures and Workspace scope are verified before Task delivery.
|
||||
5. Agent Connector, Cloud Connector, Channel Connector, and installer destinations are external trust boundaries. Manifests and Connector instances fail closed unless explicitly approved; Channel signatures and Workspace scope are verified before Task delivery.
|
||||
6. Harness events expose only allowlisted status, conclusion deltas, reasoning summaries, plans, tool lifecycle summaries, bounded result metadata, and source message IDs. Raw chain-of-thought, commands, executable paths, native session references, credentials, unrestricted stdout/stderr, and arbitrary tool payloads remain outside the renderer and shared Agent context.
|
||||
|
||||
## Local Data
|
||||
@@ -61,7 +105,6 @@ Automatic participant selection from a larger roster, production remote Agents,
|
||||
| Immutable Context Packs, delivery records, content blobs, typed outcomes, and Human Gates | Electron user data under `meldwork-private/` | Content-addressed or atomically written main-only records; renderer snapshots expose only bounded IDs, provenance summaries, budgets, and pending Gate choices |
|
||||
| Cloud operations and Channel Inbox | Electron user data under `meldwork-private/` | Private `0600` stores keep opaque remote references, idempotency state, signed-event-derived Task data, delivery cursors, Task mappings, and outbound audit records; raw signatures, headers, credentials, and complete IM history are not exposed to the renderer |
|
||||
| Imported attachments | Electron user data under `attachments/` | App-owned copy; `0700` directories, `0600` files, type/MIME/size validation, image-dimension checks where applicable, metadata checksum, ID-only renderer access |
|
||||
| Provider metadata and encrypted key | Electron user data: `meldwork-provider.json` | API key encrypted with `safeStorage`; file written atomically with mode `0600` |
|
||||
| Knowledge-source preference | Electron user data: `meldwork-knowledge-base.json` | Stores the selected absolute Obsidian Vault path only; written atomically as a private file |
|
||||
| Managed OpenClaw state/config | Electron user data under `openclaw-managed/` | Per-scope directories, mode `0700`; config mode `0600`; API key passed by child environment |
|
||||
| Theme, locale, and onboarding completion | Renderer `localStorage` | Non-secret UI preferences |
|
||||
@@ -74,7 +117,6 @@ There is no Meldwork-hosted remote data store or application account namespace.
|
||||
|
||||
- Agent CLIs may call their own configured model providers.
|
||||
- Selected attachments and Skill-influenced prompts may be sent by an Agent CLI to its configured model provider after local validation and capability checks.
|
||||
- Compatible Agents may receive the user-configured Provider URL/model/key from main.
|
||||
- Feishu and DingTalk readiness probes may invoke their configured local CLIs; a selected Agent may then use those CLI connections with read-only instructions.
|
||||
- An explicit Obsidian selection is searched and fetched through the main-owned read-only filesystem Connector, then captured as an immutable snapshot or explicit live reference before Agent invocation.
|
||||
- Approved Agent Connectors may contact only their declared outbound destinations and receive main-owned CredentialRefs rather than renderer-provided secrets.
|
||||
@@ -86,6 +128,7 @@ There is no Meldwork-hosted remote data store or application account namespace.
|
||||
|
||||
## Known Risks And Assumptions
|
||||
|
||||
- Writable conversations that share a folder are not coordinated. Two Agents in different conversations can edit the same files at the same time, as two terminals in one directory can. Output capture compares `.meldwork-output/` before and after a run, so when two conversations produce deliverables in the same folder at the same time, one may attach or record a file the other wrote.
|
||||
- Installer recipes use mutable upstream packages or scripts and currently do not verify a pinned digest or signature (`desktop/src/agents/installer/agent-installer.cjs`).
|
||||
- Conversation history is local but not application-encrypted (`desktop/src/workspace/local-workspace.cjs`).
|
||||
- Sanitized Run Ledger checkpoints are local and private but not application-encrypted. The store assumes one Electron main-process writer and recovers only the last persisted bounded checkpoint, not every in-memory event (`desktop/src/runs/run-ledger.cjs`).
|
||||
@@ -95,7 +138,7 @@ There is no Meldwork-hosted remote data store or application account namespace.
|
||||
- Obsidian selections use a main-owned read-only Connector with bounded retrieval, immutable snapshots, and citation hash verification. Generic Feishu and DingTalk access remains CLI-owned and prompt-mediated; their authentication and document permissions are not controlled by Meldwork. Notion, Confluence, Google Drive, and SharePoint are reference-only planned sources and are not active Connectors.
|
||||
- Native image support is currently limited to Codex, Hermes, and OpenCode, with different per-run limits; mixed groups fail before execution when they cannot receive equal image context (`desktop/src/agents/cli/cli-adapters.cjs`).
|
||||
- Read-only enforcement is owned by the Obsidian Connector for captured selections. Agent workspace and CLI-mediated remote access still depends partly on each upstream CLI honoring its permission flags; Meldwork adds per-CLI hardening where available (`desktop/src/agents/cli/cli-adapters.cjs`).
|
||||
- Provider requests and native Agent authentication are external dependencies; Meldwork cannot guarantee their availability or data handling.
|
||||
- Native Agent authentication and model requests are external dependencies; Meldwork cannot guarantee their availability or data handling.
|
||||
- Agent/Cloud/Channel Connector contracts validate provenance, scope, and idempotency, but the external implementation must honor its CredentialRef, outbound destination, cancellation, and signature-verification obligations. The production build registers no Cloud or Channel Connector by default.
|
||||
- macOS packaging uses ad-hoc signing for local validation, not a notarized public release (`desktop/scripts/after-pack.cjs`).
|
||||
|
||||
@@ -103,14 +146,11 @@ There is no Meldwork-hosted remote data store or application account namespace.
|
||||
|
||||
- No email sending, so there is no `emails.md`.
|
||||
- No cron or scheduled background work, so there is no `cron.md`.
|
||||
- No supported public or indexable routes, so there is no `seo.md`.
|
||||
- No supported public or indexable routes in the app itself, so there is no `seo.md`. The landing page at https://ryder-mhumble.github.io/Meldwork/ serves as the public discoverability surface for search engines and AI assistants.
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Flows](docs/flows.md)
|
||||
- [Permissions](docs/permissions.md)
|
||||
- [Variables And Secrets](docs/variables.md)
|
||||
- [Test Coverage](docs/tests.md)
|
||||
- [Automation](docs/automation.md)
|
||||
- [Public MVP Release Checklist](docs/public-mvp-release.md)
|
||||
- [macOS Signing And Notarization](docs/macos-signing.md)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
- [Security Policy](SECURITY.md)
|
||||
- [Repository Guidelines For Agents](AGENTS.md)
|
||||
- [Changelog](CHANGELOG.md)
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 865 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="720" height="450" viewBox="0 0 720 450"><rect width="720" height="450" rx="12" fill="#edf2f4"/><image x="8" y="8" width="704" height="434" preserveAspectRatio="xMidYMid meet" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABaAAAAOYCAIAAACsO8NlAAAQAElEQVR4nOz9B5wV1Zro/a9qMnSTwQCIEhUJCgjmnEVyzlkF9ZwJ9/POzL33P3Pf+87Me9+Zc+YcESTnnJMEcxaQoIKgEkRBJEjOobv+tWtV2t1Vu6p672r27v5954wuVz+9au3atUM9XbWe0mcvXBIAAAAAAACZLEsAAAAAAABkOBIcAAAAAAAg45HgAAAAAAAAGa+0SB1VVWVDUZSAnUGCUzixlG8u1AjB90/h5iBS/UCie3RFfKhE9OgSRAszOuAcxHV9dEXNbf94xwZ+II6dKVL6qNNhZ4Z6maf1s4/Uie5NrHi87qL7eC3iz5TkP14TDyt4rwCADJFxn+ahFOLzzvWhKZEsMhpqH4U520mL4Ex8dMFFd3xHNGEAQMbh07wIggEAKHm4RQUAAAAAAGS8VN6iktGivjuG6z8BAIhaRPdo8GkOAEBGiOYWFQAAAAAAgCLkdwWH/icL7Q8XSpbjZpa8PKORFX+Hi1u/mpsrG0qpUi6R+QZxHTnM5nw6k9iczwNJ9eaK+NElv7kQ+0dE9ejc5+Al+UcXauTEkaGCfSPTJJhHV4hhwwaHEt3IyFDJH5lp/ImQJp/mRf3lwVWavAUBADJLxn2L1hXmFhX7Qs0A/UrCEbwCCr05r2CvoYN2+j2QfIu6iuDBnlNL9tGFmptrf6gRQuyfMI/O81Bxm0a464YTPmQR4DAuYil49lMR7HkIeQ8rkniZF3FwcNE9uuikyTSQPlL4qSTCfPSn/BMh1Cdm8Lml5GWe/OZS8G0nDN4oAACFEfgcIURkwmopXgqT4FDC9AfPeoQNVsLPQSTxpSH59E2oL3zJP7rkv18mvzOTf+5CHSrJf1kPtblQIyeODBIc6rtmdMHBH12aTDjjHl10ZxecriCf5A/jIv40T34EEWZuKXl0yW8uVHDwOYgwmwMAlEDpcAZUCKzBAQAAAAAAMh5VVAAAQIZRr12TDaW0/zcZ9epVI7hMGd9gYY4sAowcPNiaQ9BpAACA8EhwAACATBPmDt5C3O4rUn1FbrgJAwCAQiHBAQAAirUw9/gmv151KsYFAACF4bcGh/XJ7Vi5VM3NNf4jvmSmeuWyEVu2nN1rXZPpuCDTiswXrF69YnSWKZu4Ux/E7C/rE+y5ObcJuw4bKtiag9c0fOeQos25BXtuzjU40Qgi0M702FzCp8l1BBHkUAnz6NyPTI+d6Sr5QyXsyEkGh3x07jsz8ci+w8a47XnvaQR+dB6HSqhhQx1XwUcO9ULwGDbUows14aie6FCHMQoh+SMz1MjunaE+7zw/zaP58hDd5124zQXdFan5eA3+RDtumRHcMgMAJVhKvkWnJljPOQS6kVOmKdyqq6T4Cg5VVfOX5xDJFiv1DPYo8iGCcw1WvGJdL0MNM+HgpVi9hHvIYeYmQuwKjwGS3RVRjaD/wK0rzJHpynvR/BDBHkNEEhzZRdrhRhZJH/PJC/XchRPNZfPpcTV+yP3DBfnRSsHHh/fQIuDmUnFkulfFKuIvD6E+XjPt885j1GgXsQcAZIzovuBdjw+Y6G9Rie5TvGi/NIR4dsJsTgk1SLhvOa6xSZ/MRPbcJa78KoJ8zU3+q3ZKTiOTHzkiKXl0aRuczs9dmPO6UCMkH5wmJWxRCFHdSSHClEEt2k/zUJ8InodrEWcckt5c8mXRPaTBpxIAIB1Ed55yPc6AKBMLAAAAAAAy3vVfZFS9dFE2lPIVREpFNHKeOawmK9VzBgCg2FMvXpANpUJF/+Awn+bBR1Yv23/gUcqVDxjsGynCTvjCeSO4YiUBAACSkwYJjsiueY5oZK7NBgAgKUV8c2WSkSI9JgwAAPxc/wSHEtlHe1Qj810EAIBkhFopJppMRKhVNkPlLJTIUicAACAx1uAAAAAAAAAZz+8KDusPC84as7m58k8ZSmn/C0DyLl1U9D99ZDnuhlXPn7PaSqVsu//saaMzp0riTr3/jNlf2e48d9bozM4p2Jm/33UEt07PkROOEGTkvDOnZCOrctW4zZm7KG7/JOzM3+8+N/edmWf2Zzn3vPtDPu14dFUSb85L8GCv586deSezCHAns+ujK+Jgr0MlyWFDB4d57ryO2ARzEIF2hfuR6REcYsLhjorg07CGDTKy9SJ1vEI95xDdE+32BuIe6fEyRwnh/jJP+jD2/HgN/nkX7tPc48tDwu8JQb48uO4Kr9dXiC8Pob4aRfZGEeo9EwCAsNSLF9RYdkGJ/b/vylZ5eUYjK0sEc51uUQlVNNEzOC9ocLhy8cnXAQ6zucgKy6XF5jxFco90hLc9RxWcDnOIbBqhnrs8t5ezV7AI8URHdFREd3F75tXcRfETquqq+wCulU09XuYhysTmWe34QYr08y7UdxWP/jCbywv8bcdLdJ+kAACkmeu6BkfBT9yka8u7nvmowuNURI3kK5HXmY/HVyKR5OY8uc4tz+OLS+BvYNY3LRHk0YWZm1eoCC7Ud7hQJ8lhgoPP2WtnJjls2OCQz50ILNRzFyI281IAkeWbwh2Z0aRvUAy5fyAkfRgXbcbB8/MuxF8mkp6wxyAhUyQheoPPwTuW/CYAIIOxBgcAACjO1NMnZUOpUi21wcHv2gMAAEXg+ldRAQAASBP24mOhogEAQBogwQEAAIqzCHMW5DcAAEgnJDgAAEBx5lzwKEA0SQsAADKV3xocHmVi9Z+o+cvEXr0aWzdLEUqZsvYAly7K4LgysefOmiOrzrJnuSeOK3p0Vo2aVmfesaOykVWrtnNruWZ/KUe/a2feqZNWO6tqtYL9zk7XEbyC834/ZnTWrGV3Hv/d3pzzgbgGuw3rHCRuhJMnjM5q1QtOON+ctZ1pdFavkXgEr37X+5C9Nuf66HLNzlKOTuG1MxN2iiB73nzIWY6HnKC/IM/Nmbsiy7ErXJ8jL167wn0aYUb2ek4TRAYJDjdhjxep+8geLzH3kT1eIC7DHj1itUvVvkGkbg7hJhzqqHB9v3J97Xscme5zCHxIiDB7WIR5KYUS6siEJaKXkvA4hEJ9ALl+fHgdma7vNrnmC6FUgBeC+6eV24eg8PqscX3I5sREgM8a108lz5HDvMxdgz2/qwR++ec6vquUCvB+BQAorrw+zV2F+p4ZO+vX0whKkBrzV68qiqLlCPInGdyjY9kERXG5NDMDruDw/MNL0uuup6Boi9vcvCbs3u9XFq7QI6Tg0ble05uSzSU9Qgp2RahINfAcwo5chMHRTTgtdkWoYdP4uQv1rhJ82NSIZmSVP5gXTnSFQoO/wXp+QIugI4iUfJoHHtZL4BejCPsiDT5ymP2jhPpwDDwsAKAkiu5r//WQCbeohPpKlPx5bxrnU6KasAjzDSwV6ZuIdma41FLgYT370yBnES44TSYc5m0xqi/r6bwrkj7Y0iWTFc0cYCviwzjMkZlnHiqlnLEex48SeGSvEVw3V8QfQNFtLvl8SrKRAIBirRBfHV0vnUgTlIkFAAAAAAAZj0VGAQBAkco9clg2St1wo39wNIvmWHMIMo1wEw6zhlEooVb5AQCgBCLBAQAAihYL9xQO95UAAJAQCQ4AAFC0WJaoUNJ/aTcAAK6vwqzBoebmGouKlIpfz+vKZWPRkXLl7c5LF2MFZbVOZ5nYs6f1f8Xkr7upd8aVOz16JDas1nnjTc7NuV4veu3Qr7JR+uY6BSPzBece/MXorHtL4mFj/b8dMvpvutnu/PWg0VmnbsE5eE0jbg4HfjY669X33Zzro/PanKs8s5RmVnwdTfdHF+qK3MO/GcGOp8lzZ7oFu7pmTkxT2jE3V16Pzn3Cbg/ZM9jtUEl+Grm/7LfapW651Sc4zISt/VY6QHAooY6KiIZN/shMfuRcx5Hp+4xYT7Tvsyy83xPcg8PsimvmNEoHmEZwER0SSDe5vx6QjVJ16vkHu34ieLyRuvZf+/kn2Shd/zY70uM9M/eQ+Vl8c93Ec/Dqd33PtF6MIv716P7oPF7m18z9Vtqx3xLvH5FvV7jOLczTEUqotyAAAKJlZfazsgL+RiZfwZEWC6EXad3WULXi3EdNSQ0U9yEiKWuSkkfnER3VyMGDIxpWj86wC6QzruZumpQ1CRWsRHRUcM18CZH8Yex1qLiNrIT5TElBAbXIyngFrxETriZadJdy8IoGAGSyDL9FxeVjuIgTHK7BISq9hat7L0JsLviwnoMkfwoXamcmGalfEhQ8OMIzw8DBIU840+IC6ahGTpNrxQMHq2p6lGKNbuRo5oAMFlnKO0Rw0X6ahyvFGiZ9E2oEJdTmksZdMACAjJbxa3DkO6cN9T0g5PenoMFK8t/hvDaXF7hTeE0huu9PoUYIPnJ0Z/VRjRw8zxLqe2SE2ZBQ0iDBEe7KlzCZiIiuLRLRPdHRBV/3YZFmkn/dKcl/Foe7KjBUhj3pj7BQH/1u+8d7D4f58pA0hVc0ACCTFWYNDgAAgGIp1KJLwVmrioj4hUUAAEAKUUUFAADAFM0lDFwZAQBAESDBAQAAYOJmLgAAMha3qAAAAAAAgIzndwWH/gcHVVWVAIVn1StXYv9ShFKmrE/k2TP6qKqihVeumjg478RxPVYtVbOW8BOr364HO29wzd2/z2qXurWB1b76/Q7ZKHN7c9+Rr+7eZQQ3vsPqvLbnR9ko3aiJvTnHfbal/O6zvfbTXmOE2xoKP9f2Line truncated
|
||||
|
After Width: | Height: | Size: 204 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="720" height="450" viewBox="0 0 720 450"><rect width="720" height="450" rx="12" fill="#edf2f4"/><image x="8" y="8" width="704" height="434" preserveAspectRatio="xMidYMid meet" xlink:href="data:image/png;base64,/9j/4AAQSkZJRgABAQAAkACQAAD/4QB0RXhpZgAATU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAACQAAAAAQAAAJAAAAABAAKgAgAEAAAAAQAADwCgAwAEAAAAAQAACDQAAAAA/+0AOFBob3Rvc2hvcCAzLjAAOEJJTQQEAAAAAAAAOEJJTQQlAAAAAAAQ1B2M2Y8AsgTpgAmY7PhCfv/AABEICDQPAAMBIgACEQEDEQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2wBDAAICAgICAgMCAgMFAwMDBQYFBQUFBggGBgYGBggKCAgICAgICgoKCgoKCgoMDAwMDAwODg4ODg8PDw8PDw8PDw//2wBDAQICAgQEBAcEBAcQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/3QAEAPD/2gAMAwEAAhEDEQA/APCHfFeifC/4R+PPjHrjaN4KsfMjgKi6vZiUtLUN0MjgHLEchFBY+mOaT4R/C/W/jJ48sfBOjM0EcmZr26C7ha2iECSQjoWOQqA9WI7Zr93PAngTwx8NvDFn4Q8I2a2en2a4A6vI5+9JI3V3c8sx/lgD9s4o4p+qL2VLWb/A/COFuFPrj9rW0gvxPkzwB+wh8LdBgiufHlzc+Kr8DLqZGtLQNjoscLByAf78hB9O1fRml/An4LaPAtvYeBdEQL/E+nwSSH6u6M5/E16vRX5Pis1xNd3q1G/n+h+t4TKsNQVqVNL5frucrY+BPA+mHOm+HdNtP+uVnCn/AKCgro4LW1tuLaCOL/cUL/IVPSVwNnoEnmSf3jR5sn941HRSsO7JPNk/vGjzJP7xqOiiwXZJ5sn940ebJ/eNR0UBck82T+8aPNk/vGo6KAuSebJ/eNHmyf3jUf4UfhQF2SebL/eNJ5sv941HiigLsk82X+8aPNl/vGo6KAuyTzZf7xo82X+8ajooC7JPNl/vGjzZf7xqOigLsk82X+8aPNl/vGo8UfhQF2SebL/eNHmy/wB41H+FJQF2S+bL/eNHmy/3jUVFAXZL5sv940nmy/3jUdFAXZJ5sv8AeNHmy/3jUdFAXZJ5sv8AeNHmy/3jUdFAXZJ5sv8AeNHmy/3jUdFAXZJ5sv8AeNHmy/3jUVFFguyXzZf7xo82X+8aioosF2SebL/eNHnS/wB41HRRYLsk86X+8aPOl/vGo6KLBdknmy/3jR5sn941HRRYLsk82X+8aTzZf7xqOiiwXZJ5sv8AeNHmy/3jUdFFguyTzZf7xo82X+8ajo/CiwXZJ5sv940ebL/eNR/hSUWC7JPNl/vGjzZf7xqOiiwXZJ5sv940ebL/AHjUdFFguyTzZf7xo82X+8ajo/CiwXJPNl/vGjzZf7xqKiiwXJfNl/vGk82X+8ajoosFyTzZf7xo82X+8ajoosFyTzZf7xo86X+8ajooC5J50v8AeNHnS/3jUVFAXJfOl/vGjzpf7xqKigLkvnS/3jR50v8AeNRUUBck86X+8aPOl/vVHSUBcl86X+9R50v941FRQFyXzpf7xo86X+8aiooC7JfOl/vGjzpf7xqKj8KAuyXzpf7xo86X+8ai/CkoC7JfOl/vGjzpf7xqKigLsl86X+8aPOl/vGosUUBzMl86X+8aPOl/vGovwo/CgVyXzpf7xpPOl/vGoqKBXJfOl/vGjzpf7xqKigLkvnS/3jR50v8AeNRUUBdkvnS/3jR50v8AeNRUlAXZL50v940edL/eNRUUCuS+dL/fNHnS/wB81FRQFyXzpf75o8+b+8aiooC7JfPm/vGjz5v7xqKkoC7JvPm/vGjz5v7xqGigXMyXz5v75o8+b++aiooC7JfPm/vGjz5v7xqKj8KAuyXz5v7xo8+b+8ahophdk3nzf3jR583981DRQF2TefN/fNHnzf3zUNFAXZL583940efN/eNRfhSU7Bdk3nzf3jR583941DRRYLsm8+b++aPPm/vmoaKdguybz5v75o8+b++ahoosF2TfaJv7xo+0Tf3jUNJRYLsm+0Tf3zR9om/vmoaKdguyb7RN/fNH2ib++ahoosF2TfaJv75o+0Tf3zUNFFguyb7RN/fNH2ib++ahoosF2TfaJv75o+0Tf3zUNJRYLsn+0T/3zR9om/vmoKKLBdk/2ib++aT7RP8A3zUNFFguyb7RN/fNH2ib++ahoosF2TfaJv75o+0T/wB81DRRYLsm+0T/AN80faJ/75qH8KPwosF2TfaJ/wC+aPtE/wDfNQYoosFyf7RP/fNH2if++agoosFyb7RP/fNH2if++ahoosFyb7RP/fNH2if++ahoosFyb7RP/fNH2if++ahxR+FFguTfaJ/75o+0T/3zUP4UlFguT/aJ/wC+aPtE/wDfNQUUWC5P9on/AL5pPtE/981DRRYLk32if++aPtE/981DRRYLk32if++aPtE/981DRRYLk32if++aPtE/981DRRYLsm+0T/3zR9on/vmoKKLBdk/2mf8Avmj7TP8A3zUFFFguyf7RP/fNH2mf++agoosHMyf7TP8A3zR9pn/vmoKKLBzMm+0z/wB80faZ/wC+ahoosLmZN9pn/vmj7TP/AHzUNJRYXMyf7TP/AHzR9pn/AL5qCiiwczJ/tM/980faZ/75qCiiwczJ/tM/980faZ/75qCiiwczJ/tM/wDfNH2mf++agoosLmfcn+0z/wB80faZ/wC+ago/CiwczJ/tNx/fNH2m4/vmoPwpKLBzMn+03H980fabj++agoxRYOZk/wBpuP75o+03H981BRRYOZk/2m4/vmj7Tcf3zUGKKLBzMn+03H980fabj++agoosHMyf7Tcf3zR9puP75qD8KPwosHMyf7Tcf3zSfabj++agoosHMyf7Tcf3zR9puP75qCiiwczJ/tNx/fNH2m4/vmoKKLBzMn+0z/3zR9pn/vmoKKLBzMn+0z/3zSfaZ/75qGiiwczJvtM/980faZ/75qGkosHMyf7TP/fNH2mf++agoosHMyf7TP8A3zR9pn/vmoKKLBzMn+0z/wB80faZ/wC+agoosHMyf7TP/fNH2mf++agoosHMyf7TP/fNJ9pn/vmoaKLBzMm+0z/3zR9pn/vmoKKLBzMn+0z/AN80faZ/75qCiiwczJ/tM/8AfNH2mf8AvmoKKLBzMn+0z/3zR9pn/vmoKKLBzMn+0z/3zR9pn/vmoKPwosHMyf7TP/fNJ9on/vmofwpMUWFzMn+0T/3zR9pn/vmoKKLBdk/2mf8Avmj7Rcf3zUFFFguyf7TP/fNH2if++agoxRYLsn+0T/3zSfaJ/wC+ahoosF2TfaJ/75o+0T/3zUP4UfhRYLsm+0T/AN80faJ/75qCiiwXZP8AaZ/75o+0z/3zUFFFguyf7Tcf3zR9pn/vmoKKLBdk/wBpn/vmj7TP/fNQUUWDmZP9puP75o+03H981BRRYOZk/wBpuP75o+03H981BSUWHzMsfabj++aT7Tcf3zUFFFg5mT/abj++aPtNx/fNQUUWDmZP9puP75o+03H981BRRYXMyf7Tcf3zR9pn/wCeh/OoKKLBzMn+03H98/nR9puP75/OoKKLBdk/2m4/vn86PtNx/fP51BSUWC7J/tNx/wA9D+dH2m4/56H86gooC7J/tNx/z0P50fabj/nofzqCigLsn+03H/PQ/nR9puP+eh/OoKKAuyf7Tcf89D+dH2m4/wCeh/OoKKAuyf7Tcf8APQ/nR9puP+eh/OoPwo/CgLsn+03H/PQ/nSfabj/nofzqDFFAXZP9puP+eh/Oj7Tcf89D+dQUUBdk/wBpuP8Ano350fabj/no351BRQF2T/abj/no350fabj/AJ6N+dQUUBdk/wBpuP8Ano350fabj/no351Bij8KAuyf7Tcf32/Oj7Tcf32/OoPwpKAuyx9puP77fnR9puP+ejfnVeigLssfabj/AJ6N+dJ9puP+ejfnUFFAXZP9quP+eh/Oj7Vcf89D+dQUUBdk/wBquP8Anofzo+1XH/PRvzqCigLsn+1XH/PRvzo+1XH/AD0b86gooC7J/tVx/wA9G/Oj7Vc/89GqvRQFLine truncated
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="720" height="450" viewBox="0 0 720 450"><rect width="720" height="450" rx="12" fill="#edf2f4"/><image x="8" y="8" width="704" height="434" preserveAspectRatio="xMidYMid meet" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABaAAAAOYCAIAAACsO8NlAAAQAElEQVR4nOydBYAcx7Gwe/budKgTnFgnZpZskW1ZtsCWQbZklhnisB0nL/CCf/KS98J5iZ+dxE7MzCSzLFnMzHySDsR0jNt/7fZAz3T37Mzu7IFUn8+lutnZaZy5qeqamtQzVVWaRhiUUs34hemUEthg6DEk25XXY0gCZZl6FF5nnzYWrDYq3ZdsgtqTOBrAet6p63MgusWpE40SY8RFXZDs0Lyu7qHG7q2YJH0+BFiAv0oYhfG6umA2mirdlzSqYOnNcNy9wE4XXvclbZe9+CaQW7e5TyxeT1S6zge2n0pvuhmQhC7R54OpM1Wle7w+NIeG+by4sPHl9XiO0rTXySAGJsFq+iy2WXWQrdaS6yTbo1lcJxuPxEZF/zavx3kkX5JdsAiR6zEvaqpD2g/vPExjXCebHm+jyP6SSnT2r/O+2tm5dt2fTHhgvA1SgrPZT1HNGt2C1q+N/u3rxuoVj+MVuwa+B97+x8PPTNHOVleRpiYpE108qKQAuZEf10XB9S+zp4kY/A1iCz35bcPlf4gU3a8+kKfxSkQyfI9M4OdFY8wHT+edP+n291q/jLn8LdYrodCTKgMegaTPh4DGqzG7x35I5U2hu+NV5oQNtjEJjbunfgjsOtlo0zlBqPRCE4fj1ZtTPjkTV6UnLP3ZLx5a2Gzw13GBzgeGyvgPbOySf50MoAqNQvAniWFMRsdU1C3Jzhen3gKvk3Ka33xQjHWQjhuJdBlryXVSuGYmoR+CQTl2StPH2w1EnCeA/3oGNN+UDg7lpUXZD2LEB99xfiI7fN+Ric0PbMop+6FZXBQSwGdjjGgOuS6XiogP+01DfD3XLHq0UedDgBPRX+WMwnjdXnDCkR2q+SD2UHM5e6QohyVegzPhsZPp8bdG0jKpPKcifRI477zej7FdBJ2RtJYn0DCfUpgP7DO36J4Wc530U9EEqxPr8B5p+g5SLuS0yOtk/CR/PriPddwle7vYWbriAqe1iOtk45HYqOjf5nW3I7mNl18ZY3xb5nWyabC1RR3Z4cmmTmavxDFGXg/k46Ayb41dNosIDhWBT/RAO1rp6fF9gSDCX2z7BA38BrFFn/y87mlYvHazrxPJflIFcLflY/S4G8TknxfJJoELXDx/l/XLHndj5LxJSqBCSRhrvyOT9FJbSPf4P7zeGIXe3Mfdfbgk0tufr4Qub0lslm9iRffwetyRHcnslIDOuwRtFj81bibIO65RI31YJ/J6IJeNIKZY4EU1G/ydMMqx9hbxYTc4FSdP0JeEZNBy5oNifANy3LgmZGC7qCI+2CJuo/VDbDyNXexThCR8o8BQzhRPVZB+U412pqrSmXdDr39y8m7YOoVVQaE37vTw2LnN4MR2rbSPBsjvcMUcHDEjMuL/w990PReTpM+HhMYukQr5KzjQ6AyxV5rduKvQ/1ZyuvQ6792HmdBkarqJ5Tof2H5ueuzqJ5uAusTt3oltdr2/ZQTaD418QeHngN4ajw73FnKd9FI5t2omWAXPNM24qzrL5nwPYt2nEcYuGQQ3Ek00vqJThtd9Hcau+7pOJnBeNC/8j5zSCeu1o+WdnoB3UxyMlnSdbF7IbG3WzX7ybiTfXo5jvAI6EBUWld3+MKjuS5ssgiPpE516+bsgN/LjuijE6c1Shmuebyc8P0RBGJMxThj3kyfOO6wYY63Q1f2gmrbNeT4E1ACvf6+J/O8vJ/VK8Ho8FUryuCe5O9U1CLCAZHaPt2p6Wpln24JYjQ94rL2g7Id4r5M+LnWN1ERPGDc0cj0Jq/FBd4S3CZ3gNdPPdTLuljQysTsuUKc836GsIC0Rg78xLy0JTis/RQWI+/jyuldpmw/+c3AY10k+OiMp45Vsmvd8UIx1kI4b28Itp1tjbVwnFTk4mt2Q2vA0ji4fqG4gjBPA541CjHrGrDMjwX72kYND1g9KLxTbidODkMqOFrsisOnXvC8KPlFcQ7w0RpZ3Q+JxVEV86DcNnJ5YLzZZjzbZfEhg7BLrZnnBSbmJbMbjrsLbdTK4vx3JPV3cJ5mklcQ+BwKaD/E3IHh8nmvx3IOx3QW90RvmuZEeZAKRPvLp3Dya5eFci1WdBItKmEbuIH4O6J1lG3dmYMS6TnpYwAhi7ALtaI+9n8z50Ihj7bzwsYKUFzg27i3hOplcghsVn2MdpONAMTB+zrXGPS+aF7b6x7CpveXdsJ0YWlAXuDjGyGfjvUijFryuPlCzy8Hh8zQNbqL7LtibBROv40a8QeR0VlRybhCbGZ6GJSnGpPqg4oGSf+cl3iBK+iHeozclPs+7eP4u65c6+Y2R0A8+KxSkFGukHKUAaxmjpOQVHET3WPfSiVZH/zavN2XDvI2GxyGisS5pCVwnG61ZCfaDp+iexCI74usIb51C4z/vEnS6+allM8TqrEaN9BGNfF4P8OLh/7zzOX1iH75ZEvuE8eSU95p3w6fB72e8kk3LnA+K8U3AceP2eL4qB4c+vFzejebhdPM9dsqdArxRsErzW0/xKB7Rc3AEn2vD1nixYY1+OhgE/rcjuRVV6T6k/K42jrefnMMr8EmcDwn+9fAyvqwAue5VxrUCz75vrbrbV+BbJPrfRE6nsmu7H1+lzwnk9RQJ7ALhfz5Y4y7TG/dsT243uN4jsc2u97FJ6IckN9htPuit8ehYT/Q6GXizxEr4H5gEq+ah2KYZX78d1HKuk8kluPnQiOPuvKh5uJD5PWTC9n4LxdvIuTtb+fngoaPlne7Ti8nrDK0pr5PnznzwltdSaV/bLpSMpPRKHOMV0IFojMVjtfRwX0qSHsGR9IlOVX8X5IZ9XBcCv3+ZGTY9wVX3c+iET+jGSByKGCeM+8nj4w7L+8iodHs/JPu8SDYBNcD/jZH7DUHCFUrCWMfXnXG3JOkFxNc9ifZDnCvwzXCsPfWDcJ3k9eD/ZDVxc5WIq+68nsQV+KA6IqDzzs2ukV8nAz81k4R7B/F6RLpGtiYyHxhGxzl13+OV7CkW0LRqwvkQTAuokF+D12OvwHN6i75O8jTv+aAYd7cLXJzSGZGhXxsVenM5L2LjaRxdPlAZXXGeADHqGbPOjET7WVGAdqa6StEPgueJ14N5i4rQoUpPVdKnWRwXguZ1MiiuG34bg7k2+G7j9UaaDwkW7K9yRmG8bi840JvIFjDuKpTD4s2PKl7qGv1CE8wFosVH+gR2nfR/D8Z25/7E2f7cNfMGxzMfrHGX6UFP58ClWDl1RROsTqzDx90pSe0gSQtoYrk2knidTLhzfRFg73suIZDSvFzsuAuZRA/yOileM1sowY2Qz3EP0nHgOjDezrXknxfNF1vbJbaVrittavHiqLSXA65zAGPhe4CN3uJ19YFU95/n7ltUvBes694slYQcN6qBYXi9KTxnTnge5XzwPyy+TyRVAX7GMYGZa9PPqUgf5bkWuwHx/F0mbn9/g6xcEsZdNWIB1tIfjdw9MbohjurECP31s5KfxPH1i7If4r1OtlBjUj0faHyRPkHMhyA6wtvkTt51MvlDlxSaLNKHmlEeMj3BCZXYFPM5lWIX1aSoz/hYrRGjPCifU0OWX8O+iMuuk/xbVOIaX0bTdW7LnA+KcU/AceM114Y+1IpcG83jvPA9dsqd1DcNxgng+UbBKs1vPcWjxN8prBIuOTiUniTfhr3Y4EadHi3yxFac194qLb/blUVneMvBEeeNoNhzzeKykPT5kNDY+ZVGAXLdTca1Gs/PB1aUPcS3GYyvX2xD1AiGIiPR0yL4SeZ5PrDvqKIzGncGJOFca36GYqNeUNTzQT7uwUzzRmiWvwoFVjWfxTbNWHupKQ36OpnYPXOTkZz5kORxdzMIFRcy/4dM8DrZPIY3IbyNh+hw53XXe2yfxrzXwQh6YII7L1oe+hDpOubdcJeO9sb4god7Udv8aaQcHHFLs6I+OlfX/f8FDuwvs1hrr1EY8fdDCyHBYfHQ/eoC/JxIAY2ScsQCL6kxUJ5r8TfG/9/iGDcHyZRirwc2GkmfD0kYuwSmfxz94Gllnm0LOjojiIb5JPnXyUQmU+MhW42XjHtyojMY/jslCeeam10jv0422yF14K+zEnbK8/OBYe8hSgMfu2ScawlWLd5iAySYk0SSdyNWRIZx7qiiM5J+eUgGLWc+KMY9UceNLt2iM/Rro1sOjkbsB394GkejOy2d+rmBCGKmxD334u8U14LjycGh9DwF5jgQOzEpU67lXBQ8oLhueGmM/dqifDYsOXk33Hu0UXu3yeZDggXHX1HxisiqEOBNZDMday94GpZG8Kcxgp9kMVpG7HMggfnAqqyK+mlc4u+GRO/BGKpFsubY4ETnA/tO7Egfr/0QfFMSOu8SrEKsw8dBI3cQPwf0FihX1fxcJ5NyzWxckj8fkjzW4sXO0pnq6lEL9jrJmwUtk+Cms89xD8hx4DbuXOUkerL6oWVgq7/tuqe0rfw7IAKrp6TOiZSa0GUp9k5e7j+bOAdHE0x05fVB/Rc4MMeNapDY75rH0N8WfcKrcJnnXoYlVjd7LoBNCP4EC+Buy/vonVuRPuK55lnSxP7+MoQbI+XJH39FAxp31YgFXlL8Yxd49/johjiq0GwjfRJC2Q9Jv04mY5IloR/s4y7ogUR2BD3uCZxrNA77he1+LhiQVmfJIn0Cd8qrBoAVK9MbLine truncated
|
||||
|
After Width: | Height: | Size: 270 KiB |
@@ -0,0 +1,63 @@
|
||||
# Agent Connector SDK
|
||||
|
||||
Meldwork 的 Connector 包是经校验的本地 JSON 文件。包通过内容哈希标识,导入后必须经用户批准和安装,才能配置实例。每个实例独立保存 CredentialRef;凭据由 Electron 的系统安全存储加密,Connector 列表不会返回凭据明文。
|
||||
|
||||
使用实例凭据的 Codex 委托 Connector 在每个实例独立的私有原生配置目录中运行,不读取全局 Codex 或 XDG 配置。实例密钥通过 Codex 原生 `CODEX_API_KEY` 环境变量传入,不写入该目录;目录保留会话数据,以支持实例自己的续聊。
|
||||
|
||||
## 当前支持范围
|
||||
|
||||
SDK 版本为 `1.0.0`,目前提供两个内置 Provider:
|
||||
|
||||
- `sdk.local-echo.v1`:本地回显,仅支持文本、只读,无需网络或凭据。
|
||||
- `sdk.http-json.v1`:向清单声明的 HTTPS 目标发送 JSON 请求,支持文本和只读权限,可选一个必填的 API key 或 token 凭据槽;不跟随 HTTP 重定向。
|
||||
|
||||
HTTP 响应为 JSON 对象,必须包含字符串 `text`,可包含字符串 `sessionRef` 和 `outcome`。`outcome` 可为 `completed`、`partial`、`failed` 或 `cancelled`;省略时视为 `completed`。如果响应同时包含非空 `error` 或 `success: false`,则视为失败。失败结果使用通用错误码,避免把服务返回的敏感信息写入运行记录。取消结果要求清单声明取消能力。
|
||||
|
||||
HTTP 请求有十分钟传输超时。对于未声明取消能力的 Connector,停止任务会中止本地连接,但不会声称远端执行已取消。
|
||||
|
||||
第三方可以为这些 Provider 制作包,无需重建 Meldwork;**当前尚不能通过包注册任意第三方可执行代码或新的传输实现**。清单本身支持的能力不代表每个 Provider 都实现了该能力,包校验会拒绝超出 Provider 范围的声明。参见 [包校验实现](src/agents/connectors/agent-connector-package-store.cjs) 和 [端到端示例测试](test/agents/connectors/agent-connector-sdk.test.cjs)。
|
||||
|
||||
这两个 Provider 当前不支持检查点或持久幂等,也不产生权限、来源、产物、证据、用量或等待输入事件;包含这些能力声明的包会被拒绝导入。HTTP 请求会携带 `operationId`,但这本身不构成服务端持久去重的保证。
|
||||
|
||||
## 生成本地示例包
|
||||
|
||||
在仓库根目录安装桌面依赖后,执行以下命令生成示例,不需要提供凭据:
|
||||
|
||||
```sh
|
||||
node - <<'NODE'
|
||||
const fs = require('node:fs')
|
||||
const { SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_PACKAGE } = require('./desktop/src/agents/connectors/agent-connector-local.cjs')
|
||||
const { serializeAgentConnectorPackage } = require('./desktop/src/agents/connectors/agent-connector-package-store.cjs')
|
||||
fs.writeFileSync('/tmp/meldwork-local-echo.connector.json', serializeAgentConnectorPackage(SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_PACKAGE))
|
||||
NODE
|
||||
```
|
||||
|
||||
该命令输出到 macOS/Linux 的临时目录;Windows 用户可将目标改为自己可写的绝对路径。HTTP 包的完整构造示例见上述 `agent-connector-sdk.test.cjs` 中的 `httpPackage()`;应使用 `createAgentConnectorManifest` 和 `createAgentConnectorPackage` 生成哈希,而不是手工修改已有包的标识。
|
||||
|
||||
## 导入与生命周期
|
||||
|
||||
桌面主进程和预加载层提供 `localAgentConnector` 接口:`import`、`inspect`、`approve`、`install`、`configure`、`test`、`disable`、`revoke`、`upgrade`、`remove` 和 `audit`。导入通过系统文件选择器完成;批准前会展示发布者、来源、哈希、传输、权限、凭据槽和外联目标,并要求用户确认。如果包在确认期间发生变化,需要重新查看和批准。
|
||||
|
||||
已有实例时,同一声明发布者和 Provider 的新版本可以在凭据声明完全一致的情况下升级;实例 ID 和 CredentialRef 会保留。如果升级后的实例写入中断,Meldwork 会依据持久化的升级记录在后续刷新时恢复;旧包已撤销时不会自动迁移。凭据声明不兼容、有请求正在运行,或先前迁移尚未完成时,升级会被阻止。发布者 ID 是包内声明,并不构成身份认证。存在实例的包不能直接删除;被会话引用的实例也不能直接删除。
|
||||
|
||||
设置页面的“Connector 管理”提供导入、查看、批准、安装、测试和信任管理入口,不向网页提供任意命令执行能力。创建带凭据的实例时,填写本机 Shell 中已有的环境变量名;经本机确认后,主进程读取变量并使用系统安全存储保存其值,页面不会收到密钥。
|
||||
|
||||
预加载接口的 `configure` 仅接受 `credentials: null`;带凭据的实例使用 `configureFromEnvironment` 提交变量名,不能通过渲染进程提交密钥值。
|
||||
|
||||
`test` 执行一次只读提示,在独立的临时工作目录运行,并在结束后清理。它是连通性检查,不是对 Provider 所有功能和安全行为的完整认证。
|
||||
|
||||
## 能力与事件
|
||||
|
||||
清单声明的输入类型、权限、会话、取消、事件类型和用量字段在运行时受约束。对于 SDK 的 HTTP 传输,目标必须与声明的 HTTPS origin 匹配,且不会跟随重定向。委托给本地 Agent CLI 的 Connector 不提供进程级网络隔离:CLI 自行发出的网络请求不受 Connector 外联目标清单强制限制,不能把该清单视为这类 Connector 的完整网络边界。
|
||||
|
||||
`Usage` 只统计事件实际报告的字段;缺失的回复 token 用量由运行预算估算,返回值不得伪造未声明或与事件不一致的用量。
|
||||
|
||||
事件及其必填字段以 [Run Event 协议](src/runs/run-event-protocol.cjs) 和 [Manifest 校验](src/agents/connectors/agent-connector-manifest.cjs) 为准。不要把 API key 放入清单、请求正文或示例包。
|
||||
|
||||
## 验证
|
||||
|
||||
```sh
|
||||
npm --prefix desktop run connector:conformance
|
||||
```
|
||||
|
||||
该命令运行 Manifest、注册表、运行时、实例、安装包、SDK、实际 Connector 接线和主进程安全测试,包括非委托的本地回展示例、多账号凭据隔离以及渲染进程边界。它验证当前内置 Provider 的行为,不代表第三方服务已经通过独立认证。
|
||||
@@ -13,12 +13,18 @@ API Key 或任意 Shell 执行能力。
|
||||
- Hermes
|
||||
- OpenClaw
|
||||
- WorkBuddy
|
||||
- Zcode
|
||||
- Pi Agent
|
||||
- Kimi Code
|
||||
- MiMo Code
|
||||
- DeepSeek Harness
|
||||
- Claude Code
|
||||
- Gemini CLI
|
||||
- OpenCode
|
||||
- Qwen Code
|
||||
- OpenCodeReview
|
||||
|
||||
Connector 包的当前支持范围、示例与验证方法见 [Agent Connector SDK](AGENT_CONNECTOR_SDK.md)。
|
||||
|
||||
Agent 管理面板提供经过白名单约束的安装入口。安装命令和下载地址由主进程固定,不会根据页面
|
||||
输入执行任意命令。内置 Agent 通过各自支持的原生权限模式或 Meldwork 托管工具策略执行;无法
|
||||
@@ -28,7 +34,7 @@ Agent 管理面板提供经过白名单约束的安装入口。安装命令和
|
||||
|
||||
## 首次启动与本地 Agent 检测
|
||||
|
||||
V1.0.3 GitHub 预发布使用 ad-hoc 临时签名,不使用 Apple Developer ID,也不提交 Apple 公证。
|
||||
V1.0.7 GitHub 预发布使用 ad-hoc 临时签名,不使用 Apple Developer ID,也不提交 Apple 公证。
|
||||
如果 macOS 首次启动时拦截候选应用,请打开
|
||||
“系统设置 → 隐私与安全性”,找到 Meldwork 的拦截提示,点击“仍要打开 / Open Anyway”,
|
||||
再确认启动。
|
||||
@@ -66,34 +72,49 @@ Agent 原生支持的图片会按其数量限制直接传递。其他文件,
|
||||
|
||||
## Provider 配置
|
||||
|
||||
用户可以在客户端内填写一个 OpenAI-compatible Provider:
|
||||
Agent 使用各自本机的原生鉴权、Provider 和默认模型配置。Meldwork 不要求统一 Provider,
|
||||
也不会将一个 Agent 的密钥注入其他 Agent。WorkBuddy 桌面账号的加密登录不能直接供独立
|
||||
CLI 解锁;可在 Meldwork 中选择桌面配置里已有且已配置鉴权的模型。
|
||||
Meldwork 仅保存模型标识,运行时由主进程读取原生配置,不向渲染层返回密钥。
|
||||
Zcode 沿用默认配置;若其默认 Provider 缺少 API Key,需要先修复本机原生配置。
|
||||
|
||||
```text
|
||||
provider: Provider 显示名称
|
||||
baseUrl: HTTPS 地址,或本机 loopback HTTP 地址
|
||||
model: 模型名称
|
||||
apiKey: API Key
|
||||
```
|
||||
Hermes 的原生配置目录设置会显示 `config.yaml` 中显式指定的 Provider(包括嵌套模型及旧版根级配置),或配置中没有指定时的
|
||||
`HERMES_INFERENCE_PROVIDER`。显示 `auto` 表示交由 Hermes 自动选择,并不代表已经确认最终路由;
|
||||
无法读取或没有明确配置时显示未指定。此摘要不返回凭据。
|
||||
Codex 设置显示用户配置(含所选 profile)中的 `model_provider`;项目配置或运行参数仍可覆盖它,
|
||||
因此该值不等同于每次调用的最终 Provider。
|
||||
|
||||
API Key 仅通过 Electron `safeStorage` 加密保存到本机;操作系统安全存储不可用时拒绝落盘。
|
||||
Provider 不设品牌、服务地址或模型默认值,也不会从远端自动获取配置。配置后可供 Hermes、
|
||||
OpenClaw、WorkBuddy、Qwen 和 OpenCodeReview 使用;其他 Agent 沿用各自的本机鉴权和 Provider 配置。
|
||||
DeepSeek Harness 使用 `dsh` 的 ACP profile,未指定原生 ACP Provider 或模型时默认连接 ZGCI 的 `smart-router-v2`;
|
||||
若 ACP profile 或 DSH home 的 `cordis.patch.yml` 已指定 Provider 或模型,则沿用原生配置及其声明的凭据环境变量。
|
||||
可在 DeepSeek Harness
|
||||
的原生凭据服务中配置 `ZGCI_API_KEY`,或在启动 Meldwork 的环境中提供该变量;
|
||||
Meldwork 不保存该密钥。默认只读,授权工作区写入后才允许修改工作区;权限模式改变时会建立
|
||||
新的原生会话并由 Meldwork 补充对话上下文。需要超出所选工作区权限的原生升级请求会被拒绝。
|
||||
|
||||
客户端「设置 → Agent → DeepSeek Harness → 模型与使用说明」列出默认模型、凭据配置和能力范围。
|
||||
配置凭据后点击刷新,再打开私聊或将其加入群聊。私聊与群聊分别保留原生会话;群聊可选择并发回复、
|
||||
顺序讨论或自动讨论。文件附件通过受控暂存路径提供,本地 Skill 可从输入框选择;当前没有原生图片输入。
|
||||
图标取自官方 macOS 客户端的 `Contents/Resources/icon.png`,资源来源记录在
|
||||
`frontend/public/agent-logos/deepseek-source.md`。
|
||||
|
||||
## 本地 CLI 支持矩阵
|
||||
|
||||
| Agent | 检测命令 | macOS 安装 | Windows 安装 | 用户 Provider |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Codex | `codex` | npm:`@openai/codex@latest` | npm:`@openai/codex@latest` | 不注入;沿用 Codex 本机配置 |
|
||||
| Hermes | `hermes` | 官方 `install.sh` | 官方 `install.ps1` | 支持 |
|
||||
| OpenClaw | `openclaw` | npm:`openclaw@latest` | npm:`openclaw@latest` | 支持,使用客户端隔离的托管配置 |
|
||||
| WorkBuddy | `codebuddy` | npm:`@tencent-ai/[email protected]` | npm:`@tencent-ai/[email protected]` | 实验性支持 |
|
||||
| Hermes | `hermes` | 官方 `install.sh` | 官方 `install.ps1` | 沿用本机配置 |
|
||||
| OpenClaw | `openclaw` | npm:`openclaw@latest` | npm:`openclaw@latest` | 沿用本机配置 |
|
||||
| WorkBuddy | `codebuddy` | npm:`@tencent-ai/[email protected]` | npm:`@tencent-ai/[email protected]` | 可选择桌面已配置模型 |
|
||||
| Zcode | `zcode` | 官方 CLI 安装 | 官方 CLI 安装 | 沿用默认配置 |
|
||||
| Pi Agent | `pi` | npm:`@mariozechner/pi-coding-agent` | npm:`@mariozechner/pi-coding-agent` | 沿用本机配置 |
|
||||
| Kimi Code | `kimi` | 官方 `install.sh` | 官方 `install.ps1` | 不注入;沿用 Kimi 本机配置 |
|
||||
| MiMo Code | `mimo` | npm:`@mimo-ai/cli@latest` | npm:`@mimo-ai/cli@latest` | 不注入;沿用 MiMo 本机配置,Meldwork 固定使用只读 `plan` Agent |
|
||||
| MiMo Code | `mimo` | npm:`@mimo-ai/cli@latest` | npm:`@mimo-ai/cli@latest` | 不注入;沿用 MiMo 本机配置,按授权使用 `plan` / `build` 模式 |
|
||||
| DeepSeek Harness | `dsh` | npm:`@deepseek-ai/[email protected]` | npm:`@deepseek-ai/[email protected]` | ZGCI `smart-router-v2`,使用原生凭据或环境变量 |
|
||||
| Claude Code | `claude` | npm:`@anthropic-ai/claude-code@latest` | npm:`@anthropic-ai/claude-code@latest` | 不注入;沿用 Claude 本机配置 |
|
||||
| Gemini CLI | `gemini` | npm:`@google/gemini-cli@latest` | npm:`@google/gemini-cli@latest` | 不注入;沿用 Gemini 本机配置 |
|
||||
| OpenCode | `opencode` | npm:`opencode-ai@latest` | npm:`opencode-ai@latest` | 不注入;沿用 OpenCode 本机配置 |
|
||||
| Qwen Code | `qwen` | npm:`@qwen-code/qwen-code@latest` | npm:`@qwen-code/qwen-code@latest` | 支持 |
|
||||
| OpenCodeReview | `ocr` | npm:`@alibaba-group/open-code-review@latest` | npm:`@alibaba-group/open-code-review@latest` | 支持 |
|
||||
| Qwen Code | `qwen` | npm:`@qwen-code/qwen-code@latest` | npm:`@qwen-code/qwen-code@latest` | 沿用本机配置 |
|
||||
| OpenCodeReview | `ocr` | npm:`@alibaba-group/open-code-review@latest` | npm:`@alibaba-group/open-code-review@latest` | 沿用本机配置 |
|
||||
|
||||
macOS 会在当前 `PATH` 之外搜索 Volta、pnpm、fnm、asdf、mise、bun、
|
||||
`~/.local/bin`、`~/.kimi-code/bin`、`~/.mimocode/bin`、Homebrew 等常见目录。Windows 会额外搜索用户 npm、
|
||||
@@ -108,12 +129,12 @@ Hermes 和 Kimi 的安装脚本来自固定 HTTPS 白名单地址;客户端不
|
||||
- `src/agents/`:Agent 兼容层、路由、运行时、CLI、安装器、连接器
|
||||
- `src/attachments/`:附件、内容存储、图片尺寸校验
|
||||
- `src/collaboration/`:对话、上下文包、产物、任务图
|
||||
- `src/workspace/`:本地工作区与自动调度
|
||||
- `src/workspace/`:本地工作区与自动调度;自动运行器的通用失败、重试和等待语义位于 `local-workspace-auto-runner-support.cjs`
|
||||
- `src/runs/`:运行、预算、事件、调度、日志
|
||||
- `src/knowledge/`:知识库与知识连接器
|
||||
- `src/skills/`:Skill 目录、快照、信任
|
||||
- `src/security/`:私有文件与敏感信息脱敏
|
||||
- `test/`:按同名领域镜像组织测试,`support/` 放测试辅助代码
|
||||
- `test/`:按同名领域镜像组织测试,`support/` 放测试辅助代码;测试发现与 suite 选择位于 `scripts/test-discovery.cjs`
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -130,6 +151,10 @@ npm run dev
|
||||
|
||||
## 测试与打包
|
||||
|
||||
任务图的 `primary` 节点可以设置可选的 `claimKey`:相同键的结论会进行冲突检查,不同键适合独立交付物。未设置时沿用共享结论的检查方式;该字段是工作流输入的一部分,不会自动推断任务含义。
|
||||
|
||||
当前默认的 V4 群聊尚不支持任务图。旧版 V3 任务图的审查节点可能把指出问题的回复判为已通过;在此问题修复前,请人工检查审查内容,不要仅依据节点完成状态判断结果。
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run pack
|
||||
@@ -137,7 +162,7 @@ npm run dist
|
||||
npm run dist:public
|
||||
```
|
||||
|
||||
`pack` 用于生成未打包应用并验证 Electron 产物;`dist` 生成 V1.0.3 GitHub 预发布候选使用的 ad-hoc 签名 DMG 和 ZIP。这些产物不具备 Developer ID 信任链且未公证,不能声称通过 Gatekeeper。
|
||||
`pack` 用于生成未打包应用并验证 Electron 产物;`dist` 生成 V1.0.7 GitHub 预发布候选使用的 ad-hoc 签名 DMG 和 ZIP。这些产物不具备 Developer ID 信任链且未公证,不能声称通过 Gatekeeper。
|
||||
|
||||
`dist:public` 是未来正式签名公开发行的专用命令:它要求
|
||||
Developer ID 证书和完整公证凭据,强制代码签名,在签名后校验永久 Bundle ID、Team ID 与
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,16 +1,19 @@
|
||||
{
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.3",
|
||||
"version": "1.0.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.3",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"version": "1.0.9",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.23.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@streamparser/json": "0.0.22",
|
||||
"js-yaml": "^4.3.2",
|
||||
"json5": "^2.2.3",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -459,6 +462,12 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@iarna/toml": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz",
|
||||
"integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -983,7 +992,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
@@ -2623,10 +2631,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2671,7 +2678,6 @@
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.3",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"description": "A local-first desktop workspace for multi-Agent collaboration",
|
||||
"author": "Ryder-MHumble",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
@@ -12,17 +12,24 @@
|
||||
"scripts": {
|
||||
"build:frontend": "npm --prefix ../frontend run build:desktop",
|
||||
"dev": "npm run build:frontend && electron .",
|
||||
"connector:conformance": "node --test test/agents/connectors/agent-connector-manifest.test.cjs test/agents/connectors/agent-connector-registry.test.cjs test/agents/connectors/agent-connector-runtime.test.cjs test/agents/connectors/agent-connector-instance-store.test.cjs test/agents/connectors/agent-connector-package-store.test.cjs test/agents/connectors/agent-connector-sdk.test.cjs",
|
||||
"connector:conformance": "node --test test/agents/connectors/agent-connector-manifest.test.cjs test/agents/connectors/agent-connector-registry.test.cjs test/agents/connectors/agent-connector-runtime.test.cjs test/agents/connectors/agent-connector-instance-store.test.cjs test/agents/connectors/agent-connector-package-store.test.cjs test/agents/connectors/agent-connector-sdk.test.cjs test/agents/connectors/agent-connector-local.test.cjs test/security/main-security.test.cjs test/security/preload-security.test.cjs",
|
||||
"eval:deterministic": "node scripts/run-evals.cjs --suite deterministic",
|
||||
"eval:provider": "node scripts/run-evals.cjs --suite provider",
|
||||
"test": "node scripts/run-tests.cjs",
|
||||
"test:agents": "node scripts/run-tests.cjs --suite agents",
|
||||
"test:runs": "node scripts/run-tests.cjs --suite runs",
|
||||
"test:workspace": "node scripts/run-tests.cjs --suite workspace",
|
||||
"test:security": "node scripts/run-tests.cjs --suite security",
|
||||
"pack": "npm run build:frontend && electron-builder --dir",
|
||||
"dist": "npm run build:frontend && electron-builder --mac dmg zip",
|
||||
"dist:public": "node scripts/public-release-preflight.cjs && npm run build:frontend && electron-builder --config electron-builder.public.cjs --mac dmg zip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.23.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@streamparser/json": "0.0.22",
|
||||
"js-yaml": "^4.3.2",
|
||||
"json5": "^2.2.3",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -34,6 +41,10 @@
|
||||
"productName": "Meldwork",
|
||||
"artifactName": "Meldwork-${version}-${arch}.${ext}",
|
||||
"afterPack": "scripts/after-pack.cjs",
|
||||
"afterAllArtifactBuild": "scripts/after-all-artifact-build.cjs",
|
||||
"asarUnpack": [
|
||||
"src/agents/cli/deepseek-*.yml"
|
||||
],
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"samples/**/*",
|
||||
@@ -55,6 +66,10 @@
|
||||
{
|
||||
"from": "../COMMERCIAL_USE.md",
|
||||
"to": "COMMERCIAL_USE.md"
|
||||
},
|
||||
{
|
||||
"from": "build/dmg-background-ai-v4.tiff",
|
||||
"to": "dmg-background.tiff"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -66,21 +81,23 @@
|
||||
]
|
||||
},
|
||||
"dmg": {
|
||||
"background": "build/dmg-background-ai-v2.png",
|
||||
"background": "build/dmg-background-ai-v4.png",
|
||||
"icon": null,
|
||||
"writeUpdateInfo": false,
|
||||
"window": {
|
||||
"width": 540,
|
||||
"height": 380
|
||||
"width": 640,
|
||||
"height": 420
|
||||
},
|
||||
"iconSize": 128,
|
||||
"iconSize": 112,
|
||||
"contents": [
|
||||
{
|
||||
"x": 145,
|
||||
"y": 210,
|
||||
"x": 110,
|
||||
"y": 205,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"x": 395,
|
||||
"y": 210,
|
||||
"x": 520,
|
||||
"y": 205,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"manifest":{"connectorId":"external.local-echo-sample","connectorVersion":"1.0.0","credentials":{"mode":"none","slots":[]},"description":"Local SDK sample that returns text without delegating to a built-in Agent.","domains":["general"],"eventProtocolVersion":1,"eventTypes":["Completed","Failed","Cancelled"],"inputTypes":["text"],"invocation":{"recipeId":"sdk.local-echo.v1"},"kind":"agent","label":"Local Echo Connector Sample","license":"LicenseRef-Meldwork-NC","manifestId":"connector-manifest-6ce79bf9cbe282e9f537a1b45d742cc507e21ade5ea2859dff9e007bd4f850db","outboundDestinations":[],"permissionModes":["read-only"],"recordType":"agent-connector-manifest","schemaVersion":1,"session":{"cancel":true,"checkpoint":false,"resume":true,"supported":true},"transport":{"protocol":"json","type":"cli"},"upstream":{"id":"meldwork-sdk","maxVersion":"1.0.0","minVersion":"1.0.0"},"usage":{"costMicros":false,"elapsedMs":false,"inputTokens":false,"outboundBytes":false,"outputTokens":false,"toolCalls":false}},"packageId":"connector-package-f4908cee8fa7acf5d17a403b39bd58cb8d4a1dfec3fbfb43c62c5727ee793cd4","provider":{"config":{},"id":"sdk.local-echo.v1"},"publisher":{"id":"meldwork","name":"Meldwork"},"recordType":"agent-connector-package","schemaVersion":1}
|
||||
{"manifest":{"connectorId":"external.local-echo-sample","connectorVersion":"1.0.0","credentials":{"mode":"none","slots":[]},"description":"Local SDK sample that returns text without delegating to a built-in Agent.","domains":["general"],"eventProtocolVersion":1,"eventTypes":["Completed","Failed","Cancelled"],"inputTypes":["text"],"invocation":{"recipeId":"sdk.local-echo.v1"},"kind":"agent","label":"Local Echo Connector Sample","license":"Apache-2.0","manifestId":"connector-manifest-6ce79bf9cbe282e9f537a1b45d742cc507e21ade5ea2859dff9e007bd4f850db","outboundDestinations":[],"permissionModes":["read-only"],"recordType":"agent-connector-manifest","schemaVersion":1,"session":{"cancel":true,"checkpoint":false,"resume":true,"supported":true},"transport":{"protocol":"json","type":"cli"},"upstream":{"id":"meldwork-sdk","maxVersion":"1.0.0","minVersion":"1.0.0"},"usage":{"costMicros":false,"elapsedMs":false,"inputTokens":false,"outboundBytes":false,"outputTokens":false,"toolCalls":false}},"packageId":"connector-package-f4908cee8fa7acf5d17a403b39bd58cb8d4a1dfec3fbfb43c62c5727ee793cd4","provider":{"config":{},"id":"sdk.local-echo.v1"},"publisher":{"id":"meldwork","name":"Meldwork"},"recordType":"agent-connector-package","schemaVersion":1}
|
||||
@@ -0,0 +1,126 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
|
||||
const ROOT_HIDDEN_RESOURCES = ['.background.tiff', '.VolumeIcon.icns']
|
||||
|
||||
function selectDmgArtifacts(artifactPaths) {
|
||||
return artifactPaths.filter(artifactPath => artifactPath.endsWith('.dmg'))
|
||||
}
|
||||
|
||||
function findBundledPython(cacheRoot = process.env.ELECTRON_BUILDER_CACHE) {
|
||||
const root = cacheRoot || path.join(os.homedir(), 'Library', 'Caches', 'electron-builder')
|
||||
const pending = [root]
|
||||
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop()
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(current, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(candidate)
|
||||
} else if (
|
||||
entry.isFile()
|
||||
&& /^python3(?:\.\d+)?$/.test(entry.name)
|
||||
&& path.basename(current) === 'bin'
|
||||
&& candidate.includes(`${path.sep}dmg-builder@`)
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to locate dmg-builder's bundled Python under ${root}`)
|
||||
}
|
||||
|
||||
function parseAttachedVolume(plist, workDir, run = execFileSync) {
|
||||
const plistPath = path.join(workDir, 'attach.plist')
|
||||
fs.writeFileSync(plistPath, plist)
|
||||
const parsed = JSON.parse(run('plutil', ['-convert', 'json', '-o', '-', plistPath], {
|
||||
encoding: 'utf8',
|
||||
}))
|
||||
const mounted = parsed['system-entities'].find(entity => entity['mount-point'])
|
||||
if (!mounted) {
|
||||
throw new Error('hdiutil did not return a mounted DMG volume')
|
||||
}
|
||||
return { device: mounted['dev-entry'], mountPath: mounted['mount-point'] }
|
||||
}
|
||||
|
||||
function repointDmgBackground(dmgPath, options = {}) {
|
||||
const run = options.execFile || execFileSync
|
||||
const pythonPath = options.pythonPath || findBundledPython(options.cacheRoot)
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-dmg-'))
|
||||
const readWriteDmg = path.join(workDir, 'writable.dmg')
|
||||
const repackedDmg = `${dmgPath}.repacked-${process.pid}.dmg`
|
||||
let device
|
||||
|
||||
try {
|
||||
run('hdiutil', ['convert', dmgPath, '-format', 'UDRW', '-o', readWriteDmg], {
|
||||
stdio: 'pipe',
|
||||
})
|
||||
const attached = parseAttachedVolume(run('hdiutil', [
|
||||
'attach', readWriteDmg, '-nobrowse', '-readwrite', '-plist',
|
||||
], { encoding: 'utf8' }), workDir, run)
|
||||
device = attached.device
|
||||
|
||||
const backgroundPath = path.join(
|
||||
attached.mountPath,
|
||||
'Meldwork.app',
|
||||
'Contents',
|
||||
'Resources',
|
||||
'dmg-background.tiff',
|
||||
)
|
||||
if (!fs.existsSync(backgroundPath)) {
|
||||
throw new Error(`Packaged DMG background is missing: ${backgroundPath}`)
|
||||
}
|
||||
|
||||
run(pythonPath, [
|
||||
path.join(__dirname, 'repoint-dmg-background.py'),
|
||||
path.join(attached.mountPath, '.DS_Store'),
|
||||
backgroundPath,
|
||||
], { stdio: 'pipe' })
|
||||
|
||||
for (const resource of ROOT_HIDDEN_RESOURCES) {
|
||||
fs.rmSync(path.join(attached.mountPath, resource), { force: true })
|
||||
}
|
||||
run('/usr/bin/SetFile', ['-a', 'c', attached.mountPath], { stdio: 'pipe' })
|
||||
run('/bin/sync', [], { stdio: 'pipe' })
|
||||
run('hdiutil', ['detach', device], { stdio: 'pipe' })
|
||||
device = null
|
||||
|
||||
run('hdiutil', [
|
||||
'convert', readWriteDmg, '-format', 'UDZO', '-imagekey', 'zlib-level=9', '-o', repackedDmg,
|
||||
], { stdio: 'pipe' })
|
||||
fs.renameSync(repackedDmg, dmgPath)
|
||||
} finally {
|
||||
if (device) {
|
||||
try {
|
||||
run('hdiutil', ['detach', '-force', device], { stdio: 'pipe' })
|
||||
} catch {}
|
||||
}
|
||||
fs.rmSync(repackedDmg, { force: true })
|
||||
fs.rmSync(workDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function afterAllArtifactBuild(buildResult) {
|
||||
for (const dmgPath of selectDmgArtifacts(buildResult.artifactPaths)) {
|
||||
repointDmgBackground(dmgPath)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
afterAllArtifactBuild.ROOT_HIDDEN_RESOURCES = ROOT_HIDDEN_RESOURCES
|
||||
afterAllArtifactBuild.selectDmgArtifacts = selectDmgArtifacts
|
||||
afterAllArtifactBuild.findBundledPython = findBundledPython
|
||||
afterAllArtifactBuild.parseAttachedVolume = parseAttachedVolume
|
||||
afterAllArtifactBuild.repointDmgBackground = repointDmgBackground
|
||||
|
||||
module.exports = afterAllArtifactBuild
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env swift
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
let arguments = CommandLine.arguments
|
||||
guard arguments.count == 3 else {
|
||||
fputs("Usage: render-dmg-background.swift <source.png> <output.png>\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let sourceURL = URL(fileURLWithPath: arguments[1])
|
||||
let outputURL = URL(fileURLWithPath: arguments[2])
|
||||
guard let source = NSImage(contentsOf: sourceURL) else {
|
||||
fputs("Unable to read source image\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
guard let bitmap = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: 1280,
|
||||
pixelsHigh: 840,
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0
|
||||
) else {
|
||||
fputs("Unable to create output bitmap\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
|
||||
source.draw(
|
||||
in: NSRect(x: 0, y: 0, width: 1280, height: 840),
|
||||
from: NSRect(x: 0, y: 4, width: 1280, height: 840),
|
||||
operation: .copy,
|
||||
fraction: 1
|
||||
)
|
||||
|
||||
let centered = NSMutableParagraphStyle()
|
||||
centered.alignment = .center
|
||||
|
||||
let title = "INSTALL MELDWORK" as NSString
|
||||
title.draw(
|
||||
in: NSRect(x: 0, y: 712, width: 1280, height: 52),
|
||||
withAttributes: [
|
||||
.font: NSFont(name: "DIN Alternate Bold", size: 38) ?? NSFont.boldSystemFont(ofSize: 38),
|
||||
.foregroundColor: NSColor(white: 0.96, alpha: 0.94),
|
||||
.kern: 4.5,
|
||||
.paragraphStyle: centered,
|
||||
]
|
||||
)
|
||||
|
||||
let explanation = "DRAG MELDWORK INTO APPLICATIONS / YOUR WORKSPACE STAYS LOCAL" as NSString
|
||||
explanation.draw(
|
||||
in: NSRect(x: 0, y: 675, width: 1280, height: 30),
|
||||
withAttributes: [
|
||||
.font: NSFont(name: "Menlo-Regular", size: 14) ?? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular),
|
||||
.foregroundColor: NSColor(white: 0.82, alpha: 0.74),
|
||||
.kern: 1.5,
|
||||
.paragraphStyle: centered,
|
||||
]
|
||||
)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let png = bitmap.representation(using: .png, properties: [:]) else {
|
||||
fputs("Unable to encode PNG\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
try png.write(to: outputURL)
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
|
||||
from ds_store import DSStore
|
||||
from mac_alias import Alias
|
||||
|
||||
|
||||
def main():
|
||||
ds_store_path, background_path = sys.argv[1:3]
|
||||
with DSStore.open(ds_store_path, "r+") as store:
|
||||
icon_view = store["."]["icvp"]
|
||||
icon_view["backgroundImageAlias"] = Alias.for_file(background_path).to_bytes()
|
||||
store["."]["icvp"] = icon_view
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,16 +1,17 @@
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { discoverTestFiles } = require('./test-discovery.cjs')
|
||||
|
||||
function testFiles(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const filename = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) return testFiles(filename)
|
||||
return entry.isFile() && entry.name.endsWith('.test.cjs') ? [filename] : []
|
||||
})
|
||||
function option(name, fallback) {
|
||||
const index = process.argv.indexOf(name)
|
||||
return index === -1 ? fallback : process.argv[index + 1] || fallback
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, ['--test', '--test-concurrency=2', ...testFiles('test').sort()], {
|
||||
const suite = option('--suite', 'all')
|
||||
const result = spawnSync(process.execPath, [
|
||||
'--test',
|
||||
'--test-concurrency=2',
|
||||
...discoverTestFiles({ suite }),
|
||||
], {
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const TEST_SUITES = Object.freeze({
|
||||
agents: ['agents'],
|
||||
attachments: ['attachments'],
|
||||
collaboration: ['collaboration'],
|
||||
core: ['channels', 'knowledge', 'media', 'skills'],
|
||||
runs: ['runs'],
|
||||
security: ['security', 'shell'],
|
||||
workspace: ['workspace', 'gates'],
|
||||
})
|
||||
|
||||
function collectTestFiles(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const filename = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) return collectTestFiles(filename)
|
||||
return entry.isFile() && entry.name.endsWith('.test.cjs') ? [filename] : []
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeSuite(suite) {
|
||||
const value = String(suite || 'all').trim().toLowerCase()
|
||||
if (value === 'all') return null
|
||||
if (!Object.hasOwn(TEST_SUITES, value)) {
|
||||
throw new Error(`Unknown desktop test suite: ${value}`)
|
||||
}
|
||||
return new Set(TEST_SUITES[value])
|
||||
}
|
||||
|
||||
function testSuiteForFile(filename, directory = 'test') {
|
||||
const relative = path.relative(path.resolve(directory), filename)
|
||||
return relative.split(path.sep)[0] || 'core'
|
||||
}
|
||||
|
||||
function discoverTestFiles({ directory = 'test', suite = 'all' } = {}) {
|
||||
const selectedRoots = normalizeSuite(suite)
|
||||
return collectTestFiles(path.resolve(directory))
|
||||
.filter((filename) => !selectedRoots || selectedRoots.has(testSuiteForFile(filename, directory)))
|
||||
.sort()
|
||||
}
|
||||
|
||||
module.exports = { TEST_SUITES, discoverTestFiles, normalizeSuite, testSuiteForFile }
|
||||
@@ -1,12 +1,15 @@
|
||||
const VERSION_PATTERN = /\bv?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z][0-9A-Za-z.-]*))?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?\b/
|
||||
const ANSI_PATTERN = /\x1b\[[0-?]*[ -\/]*[@-~]/g
|
||||
const ANSI_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -\/]*[@-~]/g
|
||||
const AGENT_VERSION_IDENTITIES = Object.freeze({
|
||||
codex: [/\bcodex(?:-cli)?\b/i],
|
||||
hermes: [/\bhermes(?:\s+agent)?\b/i],
|
||||
openclaw: [/\bopenclaw\b/i],
|
||||
workbuddy: [/\b(?:workbuddy|codebuddy)\b/i],
|
||||
zcode: [/\bzcode\b(?![-_])/i, /\bzcode-runtime\b/i],
|
||||
pi: [/\bpi(?:\s+agent)?\b/i],
|
||||
kimi: [/\bkimi(?:\s+code)?\b/i],
|
||||
mimo: [/\bmimo(?:\s+code)?\b/i, /\bmimocode\b/i],
|
||||
deepseek: [/\b(?:deepseek|dsh)\b/i],
|
||||
claude: [/\bclaude(?:\s+code)?\b/i],
|
||||
gemini: [/\bgemini(?:\s+cli)?\b/i],
|
||||
opencode: [/\bopencode\b/i, /\bopen\s+code\b/i],
|
||||
@@ -18,8 +21,11 @@ const AGENT_EXECUTABLE_NAMES = Object.freeze({
|
||||
hermes: ['hermes'],
|
||||
openclaw: ['openclaw'],
|
||||
workbuddy: ['codebuddy'],
|
||||
zcode: ['zcode'],
|
||||
pi: ['pi', 'pi-agent', 'piagent'],
|
||||
kimi: ['kimi'],
|
||||
mimo: ['mimo'],
|
||||
deepseek: ['dsh'],
|
||||
claude: ['claude'],
|
||||
gemini: ['gemini'],
|
||||
opencode: ['opencode'],
|
||||
@@ -54,6 +60,19 @@ const AGENT_COMPATIBILITY = Object.freeze({
|
||||
'json', 'plan', 'acceptEdits',
|
||||
]),
|
||||
]),
|
||||
zcode: exactProfile('0.16.1', [
|
||||
probe('zcode-prompt', ['--help'], ['--prompt', '--json', '--mode', '--resume', 'plan', 'edit']),
|
||||
]),
|
||||
pi: exactProfile('0.84.2', [
|
||||
probe('pi-cli', ['--help'], [
|
||||
'--mode', '--print', '--session', '--tools', '--approve', '--no-approve', 'json',
|
||||
]),
|
||||
probe('pi-json', [
|
||||
'--mode', 'json', '--print', '--no-approve', '--tools', 'read,grep,find,ls',
|
||||
'--no-session', '--no-extensions', '--no-skills', '--no-prompt-templates',
|
||||
'--no-themes', '--no-context-files', '--offline',
|
||||
], [], piSessionEvent),
|
||||
]),
|
||||
kimi: profile('0.19.2', '0.32.0', [
|
||||
probe('kimi-stream', ['--help'], [
|
||||
'--output-format', '--auto', '--session', '--prompt', 'stream-json',
|
||||
@@ -65,6 +84,9 @@ const AGENT_COMPATIBILITY = Object.freeze({
|
||||
'--pure', '--agent', '--format', '--dir', '--session', 'json',
|
||||
]),
|
||||
]),
|
||||
deepseek: profile('0.2.1', '0.2.1', [
|
||||
probe('deepseek-acp', ['--profile', 'acp', '--help'], ['ACP', 'stdio']),
|
||||
], ['0.2.1-rc.2']),
|
||||
claude: profile('2.1.165', '2.1.221', [
|
||||
probe('claude-print', ['--help'], [
|
||||
'--print', '--output-format', '--include-partial-messages', '--verbose',
|
||||
@@ -94,11 +116,23 @@ const AGENT_COMPATIBILITY = Object.freeze({
|
||||
]),
|
||||
})
|
||||
|
||||
function probe(id, args, requiredText) {
|
||||
function piSessionEvent(output) {
|
||||
return String(output || '').split(/\r?\n/).some(line => {
|
||||
try {
|
||||
const event = JSON.parse(line)
|
||||
return event?.type === 'session' && Number.isInteger(event.version)
|
||||
&& event.version > 0 && typeof event.id === 'string' && event.id.length > 0
|
||||
} catch { return false }
|
||||
})
|
||||
}
|
||||
|
||||
function probe(id, args, requiredText, validateOutput = null, timeout = null) {
|
||||
return Object.freeze({
|
||||
id,
|
||||
args: Object.freeze([...args]),
|
||||
requiredText: Object.freeze([...requiredText]),
|
||||
validateOutput,
|
||||
timeout,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,12 +190,19 @@ function identifiedVersionCandidate(kind, lines) {
|
||||
const identities = AGENT_VERSION_IDENTITIES[kind] || []
|
||||
const candidates = []
|
||||
for (const line of lines) {
|
||||
const identityMatches = identities.map(identity => identity.exec(line)).filter(Boolean)
|
||||
const identityMatches = identities.flatMap(identity => (
|
||||
[...line.matchAll(new RegExp(identity.source, `${identity.flags.replace(/g/g, '')}g`))]
|
||||
))
|
||||
const versions = versionMatches(line)
|
||||
const weighted = []
|
||||
for (const identity of identityMatches) {
|
||||
const weighted = []
|
||||
const identityEnd = identity.index + identity[0].length
|
||||
for (const version of versions) {
|
||||
const beforeIdentity = version.end <= identity.index
|
||||
if (beforeIdentity && line.slice(0, version.index).trim()) continue
|
||||
const gap = beforeIdentity ? line.slice(version.end, identity.index)
|
||||
: version.index >= identityEnd ? line.slice(identityEnd, version.index) : ''
|
||||
if (!/^[\s:()[\]@_-]*(?:(?:version|ver)\.?[\s:()[\]@_-]*)?$/i.test(gap)) continue
|
||||
const distance = version.end <= identity.index
|
||||
? identity.index - version.end
|
||||
: version.index >= identityEnd
|
||||
@@ -169,13 +210,22 @@ function identifiedVersionCandidate(kind, lines) {
|
||||
: 0
|
||||
weighted.push({ ...version, distance })
|
||||
}
|
||||
}
|
||||
const closestDistance = Math.min(...weighted.map(candidate => candidate.distance))
|
||||
const closestVersions = [...new Set(weighted
|
||||
.filter(candidate => candidate.distance === closestDistance)
|
||||
.map(candidate => candidate.resolvedVersion))]
|
||||
if (closestVersions.length === 1) {
|
||||
candidates.push({ line, resolvedVersion: closestVersions[0] })
|
||||
const closestDistance = Math.min(...weighted.map(candidate => candidate.distance))
|
||||
const closestVersions = [...new Set(weighted
|
||||
.filter(candidate => candidate.distance === closestDistance)
|
||||
.map(candidate => candidate.resolvedVersion))]
|
||||
if (closestVersions.length > 1) return null
|
||||
const closest = weighted.filter(candidate => candidate.distance === closestDistance)
|
||||
for (let index = 1; index < versions.length; index += 1) {
|
||||
const left = versions[index - 1]
|
||||
const right = versions[index]
|
||||
if (closest.some(candidate => candidate.index === left.index || candidate.index === right.index)
|
||||
&& /^\s*(?:or|\/)\s*$/i.test(line.slice(left.end, right.index))
|
||||
&& left.resolvedVersion !== right.resolvedVersion) return null
|
||||
}
|
||||
if (closestVersions.length === 1) {
|
||||
candidates.push({ line, resolvedVersion: closestVersions[0] })
|
||||
}
|
||||
}
|
||||
}
|
||||
const versions = [...new Set(candidates.map(candidate => candidate.resolvedVersion))]
|
||||
@@ -194,6 +244,9 @@ function identifyAgentVersion(kind, outputs, options = {}) {
|
||||
const lines = normalizedVersionLines(outputs)
|
||||
const identified = identifiedVersionCandidate(kind, lines)
|
||||
if (identified) return identified
|
||||
// A bare version must not resolve contradictory or ambiguous product lines.
|
||||
if (lines.some(line => (AGENT_VERSION_IDENTITIES[kind] || []).some(identity => identity.test(line))
|
||||
&& versionMatches(line).length)) return null
|
||||
|
||||
if (!executableIdentifiesAgent(kind, options.executable)) return null
|
||||
return uniqueVersionCandidate(lines.filter(line => (
|
||||
@@ -256,6 +309,7 @@ module.exports = {
|
||||
AGENT_COMPATIBILITY,
|
||||
assessAgentVersion,
|
||||
capabilityProbes,
|
||||
compareReleaseVersions,
|
||||
extractAgentVersion,
|
||||
identifyAgentVersion,
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
const { nativeSecretEnvKeys } = require('./cli/native-secret-environment.cjs')
|
||||
const { PI_BUILTIN_CREDENTIAL_KEYS, PI_BUILTIN_CONFIG_KEYS } = require('./cli/pi-provider-environment.cjs')
|
||||
const { deepseekNativeConfig } = require('./cli/deepseek-native-config.cjs')
|
||||
const { ocrSelectedProvider } = require('./cli/ocr-provider-environment.cjs')
|
||||
|
||||
const VERTEX_MODEL_REGION_KEY = /^VERTEX_REGION_[A-Z0-9_]{1,120}$/
|
||||
const CREDENTIAL_ENV_KEYS = Object.freeze({
|
||||
codex: ['CODEX_API_KEY'],
|
||||
hermes: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY'],
|
||||
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY'],
|
||||
workbuddy: ['CODEBUDDY_API_KEY', 'CODEBUDDY_AUTH_TOKEN'],
|
||||
zcode: ['ANTHROPIC_API_KEY', 'ZCODE_API_KEY'],
|
||||
pi: ['PI_API_KEY', ...PI_BUILTIN_CREDENTIAL_KEYS],
|
||||
kimi: ['MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY', 'OPENAI_API_KEY'],
|
||||
mimo: ['MIMO_API_KEY'],
|
||||
deepseek: ['ZGCI_API_KEY'],
|
||||
claude: ['ANTHROPIC_API_KEY'],
|
||||
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY'],
|
||||
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
|
||||
opencode: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY'],
|
||||
opencodereview: ['OCR_LLM_TOKEN', 'OPENAI_API_KEY'],
|
||||
})
|
||||
const GOOGLE_CLOUD_ENV_KEYS = [
|
||||
'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT',
|
||||
'GOOGLE_CLOUD_LOCATION', 'GOOGLE_CLOUD_QUOTA_PROJECT', 'CLOUDSDK_CONFIG',
|
||||
]
|
||||
const AWS_SDK_ENV_KEYS = [
|
||||
'AWS_SDK_LOAD_CONFIG', 'AWS_EC2_METADATA_DISABLED', 'AWS_STS_REGIONAL_ENDPOINTS',
|
||||
'AWS_RETRY_MODE', 'AWS_MAX_ATTEMPTS', 'AWS_DEFAULT_PROFILE', 'AWS_CA_BUNDLE',
|
||||
]
|
||||
const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
codex: ['CODEX_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'CODEX_HOME'],
|
||||
hermes: [
|
||||
'OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY',
|
||||
'OPENAI_BASE_URL', 'OPENAI_MODEL', 'HERMES_INFERENCE_PROVIDER', 'HERMES_INFERENCE_MODEL',
|
||||
'OPENROUTER_BASE_URL', 'CUSTOM_BASE_URL', 'OLLAMA_HOST', 'OLLAMA_API_KEY',
|
||||
'HERMES_HOME', 'HERMES_DATA_DIR_SUFFIX', 'HERMES_INSTALL_DIR',
|
||||
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK',
|
||||
'AWS_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE',
|
||||
'AWS_ROLE_ARN', 'AWS_ROLE_SESSION_NAME', 'AWS_WEB_IDENTITY_TOKEN_FILE',
|
||||
'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
|
||||
'AWS_CONTAINER_AUTHORIZATION_TOKEN', 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE', ...AWS_SDK_ENV_KEYS,
|
||||
'VERTEX_CREDENTIALS_PATH', 'VERTEX_PROJECT_ID', 'VERTEX_REGION', ...GOOGLE_CLOUD_ENV_KEYS,
|
||||
],
|
||||
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'OPENCLAW_STATE_DIR', 'OPENCLAW_HOME'],
|
||||
workbuddy: [
|
||||
'CODEBUDDY_API_KEY', 'CODEBUDDY_AUTH_TOKEN', 'CODEBUDDY_BASE_URL', 'CODEBUDDY_MODEL',
|
||||
'CODEBUDDY_CONFIG_DIR',
|
||||
],
|
||||
zcode: ['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ZCODE_API_KEY', 'ZCODE_BASE_URL',
|
||||
'ZCODE_MODEL', 'ZCODE_HTTP_PROXY', 'ZCODE_NO_PROXY', 'ZCODE_AGENT_CA_CERT', 'HOME', 'USERPROFILE'],
|
||||
pi: [
|
||||
'PI_API_KEY', 'PI_BASE_URL', 'PI_MODEL', ...PI_BUILTIN_CREDENTIAL_KEYS, ...PI_BUILTIN_CONFIG_KEYS,
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'ANTHROPIC_API_KEY',
|
||||
'OPENROUTER_API_KEY', 'GEMINI_API_KEY', 'PI_CODING_AGENT_DIR',
|
||||
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN',
|
||||
'AWS_BEARER_TOKEN_BEDROCK', 'AWS_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION',
|
||||
'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE', 'AWS_ROLE_ARN',
|
||||
'AWS_ROLE_SESSION_NAME', 'AWS_WEB_IDENTITY_TOKEN_FILE',
|
||||
'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
|
||||
'AWS_CONTAINER_AUTHORIZATION_TOKEN', 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE',
|
||||
'AWS_BEDROCK_FORCE_CACHE', 'AWS_BEDROCK_FORCE_HTTP', 'AWS_BEDROCK_SKIP_AUTH',
|
||||
...AWS_SDK_ENV_KEYS,
|
||||
'AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_API_VERSION', 'AZURE_API_VERSION',
|
||||
'AZURE_OPENAI_BASE_URL', 'AZURE_OPENAI_RESOURCE_NAME', 'AZURE_OPENAI_DEPLOYMENT_NAME_MAP',
|
||||
'GOOGLE_CLOUD_API_KEY', ...GOOGLE_CLOUD_ENV_KEYS,
|
||||
],
|
||||
kimi: [
|
||||
'MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY',
|
||||
'KIMI_MODEL_BASE_URL', 'KIMI_MODEL_NAME',
|
||||
'KIMI_MODEL_PROVIDER_TYPE', 'KIMI_MODEL_CAPABILITIES',
|
||||
'KIMI_MODEL_MAX_CONTEXT_SIZE', 'KIMI_MODEL_MAX_OUTPUT_SIZE',
|
||||
'KIMI_CODE_HOME',
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||
],
|
||||
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL', 'MIMOCODE_CONFIG', 'MIMOCODE_CONFIG_DIR',
|
||||
'MIMOCODE_CONFIG_CONTENT', 'MIMOCODE_DISABLE_PROJECT_CONFIG'],
|
||||
deepseek: ['ZGCI_API_KEY', 'DSH_HOME', 'DSH_AGENTS_HOME'],
|
||||
claude: [
|
||||
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'CLAUDE_CONFIG_DIR',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_MANTLE',
|
||||
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK',
|
||||
'AWS_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE',
|
||||
'AWS_ROLE_ARN', 'AWS_ROLE_SESSION_NAME', 'AWS_WEB_IDENTITY_TOKEN_FILE',
|
||||
'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
|
||||
'AWS_CONTAINER_AUTHORIZATION_TOKEN', 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE',
|
||||
...AWS_SDK_ENV_KEYS,
|
||||
'ANTHROPIC_BEDROCK_BASE_URL', 'ANTHROPIC_BEDROCK_MANTLE_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
|
||||
...GOOGLE_CLOUD_ENV_KEYS,
|
||||
'CLOUD_ML_REGION', 'ANTHROPIC_VERTEX_PROJECT_ID', 'ANTHROPIC_VERTEX_BASE_URL',
|
||||
'ANTHROPIC_FOUNDRY_RESOURCE', 'ANTHROPIC_FOUNDRY_BASE_URL',
|
||||
'ANTHROPIC_FOUNDRY_API_KEY', 'ANTHROPIC_FOUNDRY_AUTH_TOKEN',
|
||||
'AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_CLIENT_SECRET', 'AZURE_AUTHORITY_HOST',
|
||||
'AZURE_CLIENT_CERTIFICATE_PATH', 'AZURE_CLIENT_CERTIFICATE_PASSWORD', 'AZURE_FEDERATED_TOKEN_FILE',
|
||||
'AZURE_CONFIG_DIR',
|
||||
],
|
||||
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||
'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL',
|
||||
'GEMINI_API_KEY', 'GEMINI_MODEL', 'GOOGLE_API_KEY', 'GOOGLE_MODEL', 'QWEN_OAUTH', 'QWEN_HOME', 'HOME', 'USERPROFILE',
|
||||
'QWEN_CODE_SYSTEM_SETTINGS_PATH', 'QWEN_CODE_SYSTEM_DEFAULTS_PATH', 'QWEN_DEFAULT_AUTH_TYPE',
|
||||
'GEMINI_CLI_TRUSTED_FOLDERS_PATH'],
|
||||
gemini: [
|
||||
'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL', 'GEMINI_CLI_HOME',
|
||||
'GEMINI_DEFAULT_AUTH_TYPE', 'GEMINI_CLI_AUTH_OVERRIDE', 'GEMINI_CLI_USE_COMPUTE_ADC', 'CLOUD_SHELL',
|
||||
'GEMINI_CLI_SYSTEM_SETTINGS_PATH', 'GEMINI_CLI_SYSTEM_DEFAULTS_PATH',
|
||||
'GEMINI_CLI_TRUSTED_FOLDERS_PATH', 'GEMINI_CLI_TRUST_WORKSPACE', 'GEMINI_RESTRICTED_MODE',
|
||||
'GOOGLE_GEMINI_BASE_URL', 'GOOGLE_VERTEX_BASE_URL', 'GOOGLE_GENAI_API_VERSION',
|
||||
'GOOGLE_GENAI_USE_VERTEXAI', 'GOOGLE_GENAI_USE_GCA', 'GOOGLE_CLOUD_ACCESS_TOKEN',
|
||||
'GOOGLE_CLOUD_PROJECT_ID', 'GOOGLE_CLOUD_UNIVERSE_DOMAIN', ...GOOGLE_CLOUD_ENV_KEYS,
|
||||
],
|
||||
opencode: [
|
||||
'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY',
|
||||
'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||
'OPENCODE_CONFIG', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG_CONTENT', 'OPENCODE_DISABLE_PROJECT_CONFIG',
|
||||
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK',
|
||||
'AWS_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE',
|
||||
'AWS_ROLE_ARN', 'AWS_ROLE_SESSION_NAME', 'AWS_WEB_IDENTITY_TOKEN_FILE',
|
||||
'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
|
||||
'AWS_CONTAINER_AUTHORIZATION_TOKEN', 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE', ...AWS_SDK_ENV_KEYS,
|
||||
'AZURE_OPENAI_API_KEY', 'AZURE_RESOURCE_NAME', 'GOOGLE_API_KEY', 'GEMINI_API_KEY',
|
||||
...GOOGLE_CLOUD_ENV_KEYS,
|
||||
],
|
||||
opencodereview: [
|
||||
'HOME', 'USERPROFILE',
|
||||
'OCR_LLM_TOKEN', 'OCR_LLM_URL', 'OCR_LLM_MODEL', 'OCR_USE_ANTHROPIC',
|
||||
'OCR_LLM_PROTOCOL', 'OCR_LLM_AUTH_HEADER', 'OCR_LLM_TIMEOUT',
|
||||
'OPENAI_API_KEY', 'OPENAI_RESPONSES_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
|
||||
'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY',
|
||||
],
|
||||
})
|
||||
|
||||
function nativeCredentialEnvironment(kind, env = process.env, declaredKeys = [], platform = process.platform) {
|
||||
const result = {}
|
||||
const sourceKeys = Object.keys(env)
|
||||
let runtimeKeys = kind === 'deepseek' && deepseekNativeConfig({ env }).explicitModel
|
||||
? RUNTIME_ENV_KEYS.deepseek.filter(key => key !== 'ZGCI_API_KEY') : (RUNTIME_ENV_KEYS[kind] || [])
|
||||
if (kind === 'opencodereview' && ocrSelectedProvider({ env, platform })) {
|
||||
const selected = new Set(nativeSecretEnvKeys(declaredKeys))
|
||||
const credentials = new Set(['OCR_LLM_TOKEN', 'OPENAI_API_KEY', 'OPENAI_RESPONSES_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'])
|
||||
runtimeKeys = runtimeKeys.filter(key => !credentials.has(key) || selected.has(key))
|
||||
}
|
||||
const keys = [...runtimeKeys, ...(kind === 'claude'
|
||||
? sourceKeys.map(key => platform === 'win32' ? key.toUpperCase() : key)
|
||||
.filter(key => VERTEX_MODEL_REGION_KEY.test(key)) : []),
|
||||
...nativeSecretEnvKeys(declaredKeys)]
|
||||
for (const key of keys) {
|
||||
const sourceKey = Object.hasOwn(env, key) ? key
|
||||
: platform === 'win32' ? sourceKeys.find(name => name.toUpperCase() === key.toUpperCase()) : undefined
|
||||
if (sourceKey !== undefined && typeof env[sourceKey] === 'string') result[key] = env[sourceKey]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = { CREDENTIAL_ENV_KEYS, RUNTIME_ENV_KEYS, VERTEX_MODEL_REGION_KEY, nativeCredentialEnvironment }
|
||||
@@ -426,7 +426,7 @@ function importAgentOutputs(input, attachmentStore) {
|
||||
&& !Array.isArray(input.baseline.files)
|
||||
? input.baseline.files
|
||||
: {}
|
||||
const candidates = outputFiles(safeOutputDirectory(workdirRealPath))
|
||||
const controlledCandidates = outputFiles(safeOutputDirectory(workdirRealPath))
|
||||
.filter(file => !sameFileState(baselineFiles[file.name], file))
|
||||
.filter(file => Math.max(file.mtimeMs, file.ctimeMs) >= startedAt - 2000)
|
||||
.sort((left, right) => (
|
||||
@@ -434,6 +434,30 @@ function importAgentOutputs(input, attachmentStore) {
|
||||
|| left.name.localeCompare(right.name)
|
||||
))
|
||||
|
||||
const reportedCandidates = []
|
||||
const reportedPaths = Array.isArray(input.reportedPaths) ? input.reportedPaths : []
|
||||
for (const reportedPath of reportedPaths.slice(0, MAX_SCANNED_ENTRIES)) {
|
||||
if (typeof reportedPath !== 'string' || !path.isAbsolute(reportedPath)) continue
|
||||
try {
|
||||
const realPath = fs.realpathSync(reportedPath)
|
||||
const stat = fs.statSync(realPath)
|
||||
const extension = path.extname(realPath).toLowerCase()
|
||||
// A path the Agent merely mentioned (a file it read, the user's input,
|
||||
// a staged attachment) is not an output. Only files written during this
|
||||
// run outside the input staging folder are imported.
|
||||
if (!stat.isFile() || !SUPPORTED_EXTENSIONS.has(extension)
|
||||
|| stat.size > 64 * 1024 * 1024 || !isInside(workdirRealPath, realPath)
|
||||
|| realPath.split(path.sep).includes('.meldwork-input')
|
||||
|| stat.mtimeMs < startedAt - 2000) continue
|
||||
reportedCandidates.push({
|
||||
name: path.basename(realPath), path: realPath, size: stat.size,
|
||||
mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs,
|
||||
})
|
||||
} catch { /* reported paths are best effort */ }
|
||||
}
|
||||
const candidates = [...controlledCandidates, ...reportedCandidates]
|
||||
.filter((file, index, all) => all.findIndex(candidate => candidate.path === file.path) === index)
|
||||
|
||||
const imported = []
|
||||
if (input.signal?.aborted) return imported
|
||||
for (const file of candidates) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
const { createHash } = require('node:crypto')
|
||||
const { canonicalJson } = require('../collaboration/context-pack-records.cjs')
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(String(value || '')).digest('hex')
|
||||
}
|
||||
|
||||
// Tool kinds an Agent may use without approval during a read-only turn.
|
||||
const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
|
||||
|
||||
function terminalPlanRejection(options = []) {
|
||||
// Kimi's first reject option is "Revise", which loops without feedback.
|
||||
return options.find(option => option?.optionId === 'plan_reject_and_exit'
|
||||
&& ['reject_once', 'reject_always'].includes(option.kind))
|
||||
}
|
||||
|
||||
// Automatic answer to an Agent's permission request when the run is not
|
||||
// waiting for a person. Returns the response to send, `null` when the request
|
||||
// is refused but the Agent offered nothing to refuse with, or `undefined`
|
||||
// when no automatic answer exists and the caller must ask the user.
|
||||
//
|
||||
// A read-only turn stays read-only even when the Agent asks: only
|
||||
// side-effect-free tools are approved, while edits, deletes, moves, commands
|
||||
// and mode switches are refused.
|
||||
function automaticPermissionResponse(request, { allowWrite }) {
|
||||
const options = Array.isArray(request?.options) ? request.options : []
|
||||
if (!allowWrite && !READ_ONLY_SAFE_TOOL_KINDS.has(request?.toolCall?.kind)) {
|
||||
const rejection = terminalPlanRejection(options)
|
||||
|| options.find(candidate => candidate?.kind === 'reject_once')
|
||||
|| options.find(candidate => candidate?.kind === 'reject_always')
|
||||
return rejection?.optionId ? { status: 'rejected', optionId: rejection.optionId } : null
|
||||
}
|
||||
const option = options.find(candidate => candidate?.kind === 'allow_once')
|
||||
|| options.find(candidate => candidate?.kind === 'allow_always')
|
||||
|| options[0]
|
||||
return option?.optionId ? { status: 'approved', optionId: option.optionId } : undefined
|
||||
}
|
||||
|
||||
// A person's decision applies only to the exact request it was made for.
|
||||
function matchesResumedPermission(request, resumedGate) {
|
||||
if (!request || !resumedGate?.request) return false
|
||||
try {
|
||||
if (canonicalJson(request) !== canonicalJson(resumedGate.request)) return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const selected = request.options?.find(option => option.optionId === resumedGate.optionId)
|
||||
if (!selected) return false
|
||||
return resumedGate.status === 'approved'
|
||||
? selected.kind.startsWith('allow_')
|
||||
: selected.kind.startsWith('reject_')
|
||||
}
|
||||
|
||||
function permissionContinuationRequestId(requestHash, binding) {
|
||||
return sha256(canonicalJson({
|
||||
requestHash,
|
||||
sessionRefHash: binding.sessionRefHash,
|
||||
sessionProvenanceHash: binding.sessionProvenanceHash,
|
||||
}))
|
||||
}
|
||||
|
||||
// A resumed permission decision must belong to the native session the
|
||||
// request came from; anything else is refused before the Agent runs.
|
||||
function assertResumedPermissionBinding(resumed, currentBinding, sessionRef) {
|
||||
const requestHash = sha256(canonicalJson(resumed.request))
|
||||
const persistedBinding = {
|
||||
sessionRefHash: resumed.sessionRefHash,
|
||||
sessionProvenanceHash: resumed.sessionProvenanceHash,
|
||||
}
|
||||
if (resumed.requestId !== permissionContinuationRequestId(requestHash, persistedBinding)
|
||||
|| resumed.requestHash !== requestHash
|
||||
|| resumed.sessionProvenanceHash !== currentBinding.sessionProvenanceHash
|
||||
|| (sessionRef && resumed.sessionRefHash !== currentBinding.sessionRefHash)) {
|
||||
throw new Error('LOCAL_RUN_PERMISSION_REQUEST_MISMATCH')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
terminalPlanRejection,
|
||||
READ_ONLY_SAFE_TOOL_KINDS,
|
||||
assertResumedPermissionBinding,
|
||||
automaticPermissionResponse,
|
||||
matchesResumedPermission,
|
||||
permissionContinuationRequestId,
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const LIMIT_BANDS = new Set(['low', 'medium', 'high', 'any'])
|
||||
const BAND_ORDER = new Map([['low', 0], ['medium', 1], ['high', 2]])
|
||||
const EXCLUSION_REASONS = new Set([
|
||||
'unavailable', 'domain', 'input-type', 'output-type', 'tool-class',
|
||||
'permission', 'latency', 'cost', 'context', 'resumability',
|
||||
'permission', 'latency', 'cost', 'context', 'resumability', 'unverified',
|
||||
])
|
||||
const RATIONALES = new Set([
|
||||
'explicit-user-selection', 'smallest-suitable-team', 'evidence-ranked-team',
|
||||
@@ -80,7 +80,7 @@ function normalizeRoutingRequirements(value, defaults = {}) {
|
||||
const minContextChars = input.minContextChars ?? defaults.minContextChars ?? 1
|
||||
const resumableRequired = input.resumableRequired ?? false
|
||||
if (!AGENT_PERMISSION_MODES.includes(permissionMode)
|
||||
|| (defaults.permissionMode && permissionMode !== defaults.permissionMode)
|
||||
|| (defaults.permissionMode === 'read-only' && permissionMode !== 'read-only')
|
||||
|| !COLLABORATION_MODES.has(collaboration)
|
||||
|| !LIMIT_BANDS.has(maxLatencyBand)
|
||||
|| !LIMIT_BANDS.has(maxCostBand)
|
||||
@@ -184,7 +184,8 @@ function evaluateCandidate(agent, requirements, options = {}) {
|
||||
attachmentSupport: options.attachmentSupport?.(agent.kind) || {},
|
||||
})
|
||||
const exclusions = []
|
||||
if (!(agent.invocable ?? agent.available)) exclusions.push('unavailable')
|
||||
if (agent.invocable !== true) exclusions.push('unavailable')
|
||||
if (options.requireVerified && !options.isRecentlyVerified(agent)) exclusions.push('unverified')
|
||||
if (requirements.domains.some(domain => !capabilities.domains.includes(domain))) {
|
||||
exclusions.push('domain')
|
||||
}
|
||||
@@ -197,7 +198,9 @@ function evaluateCandidate(agent, requirements, options = {}) {
|
||||
if (requirements.toolClasses.some(type => !capabilities.toolClasses.includes(type))) {
|
||||
exclusions.push('tool-class')
|
||||
}
|
||||
if (!capabilities.permissionModes.includes(requirements.permissionMode)) {
|
||||
if (!capabilities.permissionModes.includes(requirements.permissionMode)
|
||||
&& !(options.allowReadOnlyPeer && requirements.permissionMode === 'workspace-write'
|
||||
&& capabilities.permissionModes.includes('read-only'))) {
|
||||
exclusions.push('permission')
|
||||
}
|
||||
if (exceedsBand(capabilities.latencyBand, requirements.maxLatencyBand)) {
|
||||
@@ -285,11 +288,14 @@ function normalizeRoutingDecision(value) {
|
||||
'candidates', 'evidenceVersion', 'mode', 'rationale', 'requirements',
|
||||
'selectedKinds', 'version',
|
||||
])
|
||||
if (Object.hasOwn(input || {}, 'permissionCap')) fields.add('permissionCap')
|
||||
if (!exactKeys(input, fields) || input.version !== ROUTING_DECISION_VERSION
|
||||
|| !ROUTING_MODES.has(input.mode) || !RATIONALES.has(input.rationale)
|
||||
|| (Object.hasOwn(input, 'permissionCap') && input.permissionCap !== 'read-only')
|
||||
|| (input.evidenceVersion && !PUBLIC_ID.test(input.evidenceVersion))) return null
|
||||
let requirements
|
||||
try { requirements = normalizeRoutingRequirements(input.requirements) } catch { return null }
|
||||
if (input.permissionCap && requirements.permissionMode !== 'read-only') return null
|
||||
let selectedKinds
|
||||
try { selectedKinds = targetList(input.selectedKinds) } catch { return null }
|
||||
const candidates = Array.isArray(input.candidates) && input.candidates.length <= MAX_ROUTING_CANDIDATES
|
||||
@@ -309,6 +315,7 @@ function normalizeRoutingDecision(value) {
|
||||
selectedKinds,
|
||||
rationale: input.rationale,
|
||||
evidenceVersion: input.evidenceVersion,
|
||||
...(input.permissionCap ? { permissionCap: input.permissionCap } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +323,7 @@ class AgentRouter {
|
||||
constructor(options = {}) {
|
||||
this.attachmentSupport = options.attachmentSupport || (() => ({}))
|
||||
this.fitMatrix = normalizeFitMatrix(options.fitMatrix)
|
||||
this.isRecentlyVerified = options.isRecentlyVerified || (agent => agent.recentlyVerified === true)
|
||||
}
|
||||
|
||||
route({ agents, group, input, mode, inputTypes, minContextChars }) {
|
||||
@@ -339,27 +347,58 @@ class AgentRouter {
|
||||
const explicitReadOnlyTargets = effectiveMode === 'explicit'
|
||||
&& explicitTargets.length > 0
|
||||
&& explicitTargets.every(kind => isCodeReviewAgentKind(kind))
|
||||
const permissionMode = explicitReadOnlyTargets
|
||||
const detected = new Map((Array.isArray(agents) ? agents : []).map(agent => [agent.kind, agent]))
|
||||
const permissionTargets = effectiveMode === 'explicit' ? explicitTargets : approvedKinds
|
||||
const targetWriteSupport = permissionTargets.map((kind) => {
|
||||
const candidate = detected.get(kind)
|
||||
return (effectiveMode === 'explicit' || (candidate
|
||||
&& candidate.invocable === true && this.isRecentlyVerified(candidate, group.workdir)))
|
||||
&& agentRuntimeCapabilities(kind, {
|
||||
agent: detected.get(kind),
|
||||
attachmentSupport: this.attachmentSupport(kind),
|
||||
}).permissionModes.includes('workspace-write')
|
||||
})
|
||||
const writePermissionCompatible = effectiveMode === 'explicit'
|
||||
? targetWriteSupport.every(Boolean)
|
||||
: targetWriteSupport.some(Boolean)
|
||||
const permissionMode = explicitReadOnlyTargets || !writePermissionCompatible
|
||||
? 'read-only'
|
||||
: group.allowWrite === true ? 'workspace-write' : 'read-only'
|
||||
const requirements = normalizeRoutingRequirements(input.routingRequirements, {
|
||||
const defaults = {
|
||||
domains: effectiveMode === 'automatic' ? ['general'] : [],
|
||||
inputTypes,
|
||||
outputTypes: ['text'],
|
||||
permissionMode,
|
||||
minContextChars,
|
||||
collaboration: mode === 'auto' ? 'required' : 'none',
|
||||
})
|
||||
}
|
||||
let requirements = normalizeRoutingRequirements(input.routingRequirements, defaults)
|
||||
if (explicitTargets.some(kind => !approvedKinds.includes(kind))) {
|
||||
const kind = explicitTargets.find(target => !approvedKinds.includes(target))
|
||||
throw routingError('LOCAL_MESSAGE_TARGET_OUT_OF_GROUP', { kind })
|
||||
}
|
||||
const detected = new Map((Array.isArray(agents) ? agents : []).map(agent => [agent.kind, agent]))
|
||||
const candidates = approvedKinds.map((kind) => evaluateCandidate(
|
||||
let allowReadOnlyPeer = effectiveMode === 'automatic'
|
||||
&& requirements.collaboration === 'required'
|
||||
&& requirements.permissionMode === 'workspace-write'
|
||||
&& input.routingRequirements?.permissionMode == null
|
||||
const canWrite = kind => agentRuntimeCapabilities(kind, {
|
||||
agent: detected.get(kind), attachmentSupport: this.attachmentSupport(kind),
|
||||
}).permissionModes.includes('workspace-write')
|
||||
const evaluateCandidates = () => approvedKinds.map((kind) => evaluateCandidate(
|
||||
detected.get(kind) || { kind, available: false },
|
||||
requirements,
|
||||
{ attachmentSupport: this.attachmentSupport, fitMatrix: this.fitMatrix },
|
||||
{ attachmentSupport: this.attachmentSupport, fitMatrix: this.fitMatrix,
|
||||
requireVerified: effectiveMode === 'automatic', isRecentlyVerified: agent => this.isRecentlyVerified(agent, group.workdir),
|
||||
allowReadOnlyPeer },
|
||||
))
|
||||
let candidates = evaluateCandidates()
|
||||
if (effectiveMode === 'automatic' && requirements.permissionMode === 'workspace-write'
|
||||
&& input.routingRequirements?.permissionMode == null
|
||||
&& !candidates.some(candidate => candidate.eligible && canWrite(candidate.kind))) {
|
||||
requirements = normalizeRoutingRequirements(input.routingRequirements, { ...defaults, permissionMode: 'read-only' })
|
||||
allowReadOnlyPeer = false
|
||||
candidates = evaluateCandidates()
|
||||
}
|
||||
let selectedKinds
|
||||
if (effectiveMode === 'explicit') {
|
||||
selectedKinds = explicitTargets
|
||||
@@ -375,12 +414,24 @@ class AgentRouter {
|
||||
|| approvedKinds.indexOf(left.kind) - approvedKinds.indexOf(right.kind))
|
||||
const requiredCount = requirements.collaboration === 'required' ? 2 : 1
|
||||
if (eligible.length < requiredCount) {
|
||||
const unverified = candidates.filter(candidate => candidate.exclusions.length === 1
|
||||
&& candidate.exclusions[0] === 'unverified').length
|
||||
if (eligible.length + unverified >= requiredCount) {
|
||||
throw routingError('LOCAL_AGENT_ROUTING_UNVERIFIED', { eligibleCount: eligible.length, requiredCount })
|
||||
}
|
||||
throw routingError(
|
||||
requiredCount > 1 ? 'LOCAL_AUTO_AGENT_COUNT' : 'LOCAL_AGENT_ROUTING_NO_MATCH',
|
||||
{ eligibleCount: eligible.length, requiredCount },
|
||||
)
|
||||
}
|
||||
selectedKinds = eligible.slice(0, requiredCount).map(candidate => candidate.kind)
|
||||
if (allowReadOnlyPeer) {
|
||||
if (!selectedKinds.some(canWrite)) {
|
||||
const writer = eligible.find(candidate => canWrite(candidate.kind))
|
||||
if (!writer) throw routingError('LOCAL_AGENT_ROUTING_NO_MATCH', { eligibleCount: eligible.length, requiredCount })
|
||||
selectedKinds[selectedKinds.length - 1] = writer.kind
|
||||
}
|
||||
}
|
||||
}
|
||||
const selectedHasEvidence = selectedKinds.some(kind => (
|
||||
candidates.find(candidate => candidate.kind === kind)?.evidence
|
||||
@@ -395,6 +446,7 @@ class AgentRouter {
|
||||
? 'explicit-user-selection'
|
||||
: (selectedHasEvidence ? 'evidence-ranked-team' : 'smallest-suitable-team'),
|
||||
evidenceVersion: selectedHasEvidence ? this.fitMatrix.version : '',
|
||||
...(input.routingRequirements?.permissionMode === 'read-only' ? { permissionCap: 'read-only' } : {}),
|
||||
})
|
||||
if (!decision) throw routingError('LOCAL_AGENT_ROUTING_DECISION_INVALID')
|
||||
return Object.freeze(decision)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { authFailureCertainty } = require('../runs/failure-signals.cjs')
|
||||
const AGENT_OUTCOMES = Object.freeze([
|
||||
'accepted',
|
||||
'running',
|
||||
@@ -79,6 +80,14 @@ const AGENT_RUNTIME_CAPABILITIES = Object.freeze({
|
||||
domains: ['general', 'software-development', 'document-production'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
}),
|
||||
zcode: generalProfile({
|
||||
domains: ['general', 'software-development', 'research'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
}),
|
||||
pi: generalProfile({
|
||||
domains: ['general', 'software-development'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
}),
|
||||
kimi: generalProfile({
|
||||
domains: ['general', 'software-development', 'research', 'document-production'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
@@ -87,6 +96,10 @@ const AGENT_RUNTIME_CAPABILITIES = Object.freeze({
|
||||
domains: ['general', 'software-development'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
}),
|
||||
deepseek: generalProfile({
|
||||
domains: ['general', 'software-development', 'research', 'tool-use'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
}),
|
||||
claude: generalProfile({
|
||||
domains: ['general', 'software-development', 'research', 'document-production'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
@@ -206,10 +219,35 @@ function normalizeExternalRunRef(value) {
|
||||
return EXTERNAL_RUN_REF.test(ref) ? ref : ''
|
||||
}
|
||||
|
||||
function terminalAuthenticationDiagnostic(value) {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
||||
if (!text || text.length > 2000) return ''
|
||||
return /^HTTP\s+(?:401|403)\s*:\s*(?:unauthorized|forbidden|(?:invalid|expired|missing)\s+(?:access\s+token|token|credentials?|api[ _-]?key|authentication))(?:\s*\(request[ _-]?id:\s*[^()]{1,160}\))?\.?$/i
|
||||
.test(text)
|
||||
? text
|
||||
: ''
|
||||
}
|
||||
|
||||
// True only for explicit sign-in or API-key statements. Ambiguous refusals
|
||||
// (HTTP 403, proxy or region blocks, "provider rejected") are classified as
|
||||
// LOCAL_AGENT_ACCESS_DENIED so they are retried instead of signing the Agent out.
|
||||
function authConfigurationFailure(detail) {
|
||||
return authFailureCertainty(detail) === 'definitive'
|
||||
}
|
||||
|
||||
function authFailureCode(detail) {
|
||||
const certainty = authFailureCertainty(detail)
|
||||
if (certainty === 'definitive') return 'LOCAL_AGENT_AUTH_REQUIRED'
|
||||
if (certainty === 'ambiguous') return 'LOCAL_AGENT_ACCESS_DENIED'
|
||||
return ''
|
||||
}
|
||||
|
||||
function failureCategory(code) {
|
||||
const value = String(code || '')
|
||||
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
|
||||
if (value === 'LOCAL_AGENT_AUTH_REQUIRED') return 'authentication'
|
||||
if (value === 'LOCAL_AGENT_AUTH_REQUIRED' || value === 'LOCAL_WORKBUDDY_PROVIDER_AUTH') return 'authentication'
|
||||
if (value === 'LOCAL_AGENT_ACCESS_DENIED') return 'provider'
|
||||
if (value === 'LOCAL_AGENT_PROVIDER_QUOTA_EXHAUSTED') return 'provider'
|
||||
if (value === 'LOCAL_AGENT_TIMEOUT') return 'timeout'
|
||||
if (value === 'LOCAL_AGENT_EXECUTION_STOPPED') return 'cancellation'
|
||||
if (value === 'LOCAL_AGENT_REFUSED') return 'refusal'
|
||||
@@ -286,15 +324,32 @@ function requireTerminalAgentResult(result) {
|
||||
throw agentRuntimeError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
}
|
||||
if (normalized.outcome === 'failed') {
|
||||
const failure = normalized.failure?.code
|
||||
let failure = normalized.failure?.code
|
||||
? normalized.failure
|
||||
: agentRuntimeFailure('LOCAL_AGENT_PROCESS_FAILED')
|
||||
const diagnostic = String(normalized.diagnostic || normalized.text || '')
|
||||
// A structured failure whose diagnostic names an authentication problem is
|
||||
// an authentication failure even when the CLI reported a generic process
|
||||
// error, so the catalog can mark the Agent as needing a fresh login.
|
||||
if (failure.category !== 'authentication' && authConfigurationFailure(diagnostic)) {
|
||||
failure = agentRuntimeFailure('LOCAL_AGENT_AUTH_REQUIRED')
|
||||
} else if (failure.code === 'LOCAL_AGENT_PROCESS_FAILED'
|
||||
&& authFailureCertainty(normalized.diagnostic) === 'ambiguous') {
|
||||
failure = agentRuntimeFailure('LOCAL_AGENT_ACCESS_DENIED')
|
||||
}
|
||||
throw agentRuntimeError(
|
||||
failure.code,
|
||||
normalized.diagnostic || normalized.text,
|
||||
failure,
|
||||
)
|
||||
}
|
||||
const authenticationDiagnostic = terminalAuthenticationDiagnostic(normalized.text)
|
||||
if (authenticationDiagnostic) {
|
||||
throw agentRuntimeError(
|
||||
authFailureCode(authenticationDiagnostic) || 'LOCAL_AGENT_ACCESS_DENIED',
|
||||
authenticationDiagnostic,
|
||||
)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -321,6 +376,8 @@ module.exports = {
|
||||
agentRuntimeCapabilities,
|
||||
agentRuntimeError,
|
||||
agentRuntimeFailure,
|
||||
authConfigurationFailure,
|
||||
authFailureCode,
|
||||
isCodeReviewAgentKind,
|
||||
isReviewOnlyAgentKind,
|
||||
normalizeAgentResult,
|
||||
@@ -328,4 +385,5 @@ module.exports = {
|
||||
normalizeOutcome,
|
||||
outcomeForAcpStopReason,
|
||||
requireTerminalAgentResult,
|
||||
terminalAuthenticationDiagnostic,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
async function abortableHttpOperation(operation, signal) {
|
||||
const pending = Promise.resolve(operation)
|
||||
if (!signal) return pending
|
||||
if (signal.aborted) {
|
||||
pending.catch(() => {})
|
||||
throw signal.reason
|
||||
}
|
||||
let onAbort
|
||||
const aborted = new Promise((_resolve, reject) => {
|
||||
onAbort = () => reject(signal.reason)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
try { return await Promise.race([pending, aborted]) }
|
||||
finally { signal.removeEventListener('abort', onAbort) }
|
||||
}
|
||||
|
||||
function discardHttpResponse(response) {
|
||||
if (typeof response?.body?.cancel === 'function') {
|
||||
try { Promise.resolve(response.body.cancel()).catch(() => {}) } catch { /* already closed */ }
|
||||
}
|
||||
}
|
||||
|
||||
function discardLateHttpResponse(operation, signal) {
|
||||
Promise.resolve(operation).then(response => {
|
||||
if (signal?.aborted) discardHttpResponse(response)
|
||||
}, () => {})
|
||||
}
|
||||
|
||||
async function boundedHttpResponseText(response, limit, errorCode, signal) {
|
||||
const invalid = () => { throw Object.assign(new Error(errorCode), { code: errorCode }) }
|
||||
if (!response.body?.getReader) {
|
||||
const text = await response.text()
|
||||
if (typeof text !== 'string' || Buffer.byteLength(text) > limit) invalid()
|
||||
return text
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
let cancelled = false
|
||||
const cancelReader = () => {
|
||||
if (cancelled) return
|
||||
cancelled = true
|
||||
try { Promise.resolve(reader.cancel()).catch(() => {}) } catch { /* already closed */ }
|
||||
}
|
||||
signal?.addEventListener('abort', cancelReader, { once: true })
|
||||
if (signal?.aborted) cancelReader()
|
||||
const chunks = []
|
||||
let total = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await abortableHttpOperation(reader.read(), signal)
|
||||
if (done) break
|
||||
const chunk = Buffer.from(value)
|
||||
total += chunk.length
|
||||
if (total > limit) invalid()
|
||||
chunks.push(chunk)
|
||||
}
|
||||
} catch (error) {
|
||||
cancelReader()
|
||||
throw error
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancelReader)
|
||||
reader.releaseLock()
|
||||
}
|
||||
return Buffer.concat(chunks, total).toString('utf8')
|
||||
}
|
||||
|
||||
module.exports = { abortableHttpOperation, boundedHttpResponseText, discardHttpResponse, discardLateHttpResponse }
|
||||
@@ -1,6 +1,11 @@
|
||||
const { spawn } = require('node:child_process')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { createHash, randomBytes } = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { PassThrough, Readable, Writable } = require('node:stream')
|
||||
const { SECRET_ENV_KEYS, nativeSecretEnvKeys } = require('./native-secret-environment.cjs')
|
||||
const { mimoConfigurationFingerprint } = require('./mimo-native-config.cjs')
|
||||
const { deepseekConfigurationFingerprint } = require('./deepseek-native-config.cjs')
|
||||
const { AGENT_PROFILES, prepareCommand } = require('./cli-discovery.cjs')
|
||||
const {
|
||||
outcomeForAcpStopReason,
|
||||
@@ -18,13 +23,35 @@ const {
|
||||
agentExecutionError,
|
||||
childEnvironment,
|
||||
failedAgentProcessError,
|
||||
terminateWindowsProcessTree,
|
||||
} = require('./cli-process-support.cjs')
|
||||
const { createAcpOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
|
||||
const { redactSecrets } = require('../../security/secret-redaction.cjs')
|
||||
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
|
||||
const { createTurnCompletion } = require('./cli-turn-completion.cjs')
|
||||
const { terminalPlanRejection } = require('../agent-permission-policy.cjs')
|
||||
|
||||
const ACP_CANCEL_GRACE_MS = 250
|
||||
const ACP_MAX_LINE_BYTES = 1024 * 1024
|
||||
// After a stop or timeout the Agent gets this long to answer the cancelled
|
||||
// prompt. A clean cancellation keeps the long-lived process and its native
|
||||
// session; only an Agent that ignores cancel is torn down.
|
||||
const ACP_CANCEL_SETTLE_MS = 3000
|
||||
// MiMo can flush the last delta of a message just after its prompt response.
|
||||
const MIMO_TERMINAL_DRAIN_MS = 50
|
||||
const ACP_INITIALIZE_TIMEOUT_MS = 60 * 1000
|
||||
const ACP_STDERR_TAIL_BYTES = 64 * 1024
|
||||
// Process identity must not change when only volatile shell values change.
|
||||
// Version managers such as fnm put a per-shell directory on PATH, so a fresh
|
||||
// login-shell capture used to restart the ACP process and drop its session.
|
||||
const ACP_VOLATILE_ENV_KEYS = new Set([
|
||||
'PATH', 'Path', 'TERM', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'TERM_SESSION_ID',
|
||||
'SHLVL', 'PWD', 'OLDPWD', '_', 'TMPDIR', 'COLUMNS', 'LINES', 'SHELL',
|
||||
'FNM_MULTISHELL_PATH', 'FNM_DIR', 'FNM_VERSION_FILE_STRATEGY', 'FNM_LOGLEVEL',
|
||||
'FNM_NODE_DIST_MIRROR', 'FNM_ARCH', 'FNM_COREPACK_ENABLED', 'FNM_RESOLVE_ENGINES',
|
||||
])
|
||||
const ACP_MAX_LINE_BYTES = 4 * 1024 * 1024
|
||||
const ACP_MAX_INPUT_BYTES = 16 * 1024 * 1024
|
||||
const ACP_UNKNOWN_METHOD_NAME_LIMIT = 120
|
||||
const ACP_MAX_REPLY_BYTES = 10 * 1024 * 1024
|
||||
const ACP_PERSISTENT_RUNTIME_LIMIT = 16
|
||||
const ACP_PERMISSION_OPTION_LIMIT = 16
|
||||
@@ -88,6 +115,10 @@ function acpTransportError(diagnostic) {
|
||||
return agentExecutionError('LOCAL_AGENT_PROCESS_FAILED', diagnostic)
|
||||
}
|
||||
|
||||
// Returns the message to deliver, or null for traffic that is safe to ignore.
|
||||
// A newer Agent can send updates and notifications this client does not know;
|
||||
// failing the whole turn over them would break Meldwork on every Agent upgrade,
|
||||
// so only messages that decide what is executed or approved stay strict.
|
||||
function validateAcpInboundMessage(message, validators, replyState, protocolLabel) {
|
||||
if (!message || typeof message !== 'object' || Array.isArray(message)
|
||||
|| message.jsonrpc !== '2.0') {
|
||||
@@ -99,7 +130,8 @@ function validateAcpInboundMessage(message, validators, replyState, protocolLabe
|
||||
if (message.method === 'session/update' && !hasId) {
|
||||
const parsed = validators.zSessionNotification.safeParse(message.params)
|
||||
if (!parsed.success) {
|
||||
throw acpTransportError(`${protocolLabel} returned invalid session update parameters.`)
|
||||
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
|
||||
return null
|
||||
}
|
||||
const update = parsed.data.update
|
||||
if (replyState.collecting !== false
|
||||
@@ -119,7 +151,23 @@ function validateAcpInboundMessage(message, validators, replyState, protocolLabe
|
||||
}
|
||||
return message
|
||||
}
|
||||
throw acpTransportError(`${protocolLabel} requested an unsupported client method.`)
|
||||
// An unknown request still gets the connection's "method not found"
|
||||
// answer, so the Agent is not left waiting. The SDK logs every request it
|
||||
// rejects together with its parameters, so only the method name is
|
||||
// forwarded. An unknown notification needs no answer and is dropped
|
||||
// before it can reach the SDK's logging.
|
||||
const validId = typeof message.id === 'string'
|
||||
|| (Number.isInteger(message.id) && message.id >= 0)
|
||||
if (hasId && validId) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
method: message.method.slice(0, ACP_UNKNOWN_METHOD_NAME_LIMIT),
|
||||
params: {},
|
||||
}
|
||||
}
|
||||
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
|
||||
return null
|
||||
}
|
||||
|
||||
if (!Number.isInteger(message.id) || message.id < 0) {
|
||||
@@ -215,6 +263,12 @@ function boundedAcpStream(
|
||||
const parseLine = (line) => {
|
||||
const text = line.toString('utf8').trim()
|
||||
if (!text) return
|
||||
// Some CLIs print banners or warnings on stdout next to the protocol.
|
||||
// Anything that is not an object is not protocol traffic.
|
||||
if (!text.startsWith('{')) {
|
||||
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
|
||||
return
|
||||
}
|
||||
let message
|
||||
try {
|
||||
message = JSON.parse(text)
|
||||
@@ -224,6 +278,7 @@ function boundedAcpStream(
|
||||
const validated = validateAcpInboundMessage(
|
||||
message, validators, replyState, protocolLabel,
|
||||
)
|
||||
if (!validated) return
|
||||
if (message.method === 'session/request_permission') {
|
||||
replyState.pendingPermissionRequestIds.add(message.id)
|
||||
} else if (message.method === 'session/update' && message.params?.update) {
|
||||
@@ -309,7 +364,7 @@ function boundedAcpStream(
|
||||
}
|
||||
|
||||
function permissionRejection(options) {
|
||||
return (options || []).find(option => (
|
||||
return terminalPlanRejection(options || []) || (options || []).find(option => (
|
||||
['reject_once', 'reject_always'].includes(option.kind)
|
||||
|| /reject|deny/i.test(`${option.optionId || ''} ${option.name || ''}`)
|
||||
))
|
||||
@@ -423,6 +478,7 @@ function destroyChildPipes(child) {
|
||||
|
||||
async function terminateChild(child, platform, spawnFn) {
|
||||
if (child.exitCode != null || child.signalCode != null) return
|
||||
if (platform === 'win32') return terminateWindowsProcessTree(child, spawnFn)
|
||||
await new Promise((resolve) => {
|
||||
let settled = false
|
||||
let forceKillTimeout
|
||||
@@ -438,15 +494,6 @@ async function terminateChild(child, platform, spawnFn) {
|
||||
}
|
||||
child.once('close', finish)
|
||||
child.once('error', finish)
|
||||
if (platform === 'win32' && child.pid) {
|
||||
const killer = spawnFn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'ignore', windowsHide: true,
|
||||
})
|
||||
killer.on('error', () => {})
|
||||
killer.unref()
|
||||
forceSettleTimeout = setTimeout(finish, TERMINATE_GRACE_MS + KILL_SETTLE_MS)
|
||||
return
|
||||
}
|
||||
const signalTree = (signal) => {
|
||||
if (child.pid) {
|
||||
try {
|
||||
@@ -482,13 +529,14 @@ async function waitForChildExit(child, timeoutMs) {
|
||||
let timeout
|
||||
const finish = (exited) => {
|
||||
clearTimeout(timeout)
|
||||
child.removeListener('close', onExit)
|
||||
child.removeListener('error', onExit)
|
||||
child.removeListener('close', onClose)
|
||||
child.removeListener('error', onError)
|
||||
resolve(exited)
|
||||
}
|
||||
const onExit = () => finish(true)
|
||||
child.once('close', onExit)
|
||||
child.once('error', onExit)
|
||||
const onClose = () => finish(true)
|
||||
const onError = () => finish(false)
|
||||
child.once('close', onClose)
|
||||
child.once('error', onError)
|
||||
timeout = setTimeout(() => finish(false), timeoutMs)
|
||||
timeout.unref?.()
|
||||
})
|
||||
@@ -500,13 +548,16 @@ async function closeAcpChild(child, platform, spawnFn) {
|
||||
try { child.stdin.end() } catch { /* the process has already closed its input */ }
|
||||
}
|
||||
if (await waitForChildExit(child, TERMINATE_GRACE_MS)) return
|
||||
await terminateChild(child, platform, spawnFn)
|
||||
destroyChildPipes(child)
|
||||
try { await terminateChild(child, platform, spawnFn) } finally { destroyChildPipes(child) }
|
||||
}
|
||||
|
||||
function acpProtocolError(error, childEnv, sessionRef = '') {
|
||||
function acpProtocolError(error, childEnv, sessionRef = '', kind = '') {
|
||||
if (/^LOCAL_AGENT_[A-Z_]+$/.test(String(error?.message || ''))) return error
|
||||
const detail = redactChildSecrets(error?.diagnostic || error?.message || error, childEnv).trim()
|
||||
if (kind === 'deepseek'
|
||||
&& /^Internal error: turn failed: llm-pi-ai: no credential for provider route "zgci"/i.test(detail)) {
|
||||
return agentExecutionError('LOCAL_AGENT_AUTH_REQUIRED', 'Missing API key for the configured ZGCI provider.')
|
||||
}
|
||||
return detail
|
||||
? failedAgentProcessError(detail, { sessionRef })
|
||||
: agentExecutionError('LOCAL_AGENT_PROCESS_FAILED')
|
||||
@@ -529,6 +580,32 @@ function persistenceKey(agent, options) {
|
||||
return `${agent.kind}:${value}`
|
||||
}
|
||||
|
||||
const ACP_IMAGE_MIME_TYPES = new Map([
|
||||
['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'],
|
||||
['.gif', 'image/gif'], ['.webp', 'image/webp'],
|
||||
])
|
||||
const ACP_MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
// Images travel as ACP content blocks only when the Agent advertises image
|
||||
// prompts during initialize. Otherwise the turn fails before prompting so the
|
||||
// adapter can fall back to a transport that accepts image arguments.
|
||||
function acpImageBlocks(runtime, imageAttachments) {
|
||||
const images = Array.isArray(imageAttachments) ? imageAttachments : []
|
||||
if (!images.length) return []
|
||||
if (runtime.agentCapabilities?.promptCapabilities?.image !== true) {
|
||||
throw allowAcpSetupFallback(agentExecutionError('LOCAL_AGENT_IMAGE_UNSUPPORTED'))
|
||||
}
|
||||
return images.map((filename) => {
|
||||
const mimeType = ACP_IMAGE_MIME_TYPES.get(path.extname(filename).toLowerCase())
|
||||
if (!mimeType) throw agentExecutionError('LOCAL_AGENT_IMAGE_UNSUPPORTED')
|
||||
const stat = fs.statSync(filename)
|
||||
if (!stat.isFile() || stat.size > ACP_MAX_IMAGE_BYTES) {
|
||||
throw agentExecutionError('LOCAL_AGENT_IMAGE_LIMIT')
|
||||
}
|
||||
return { type: 'image', mimeType, data: fs.readFileSync(filename).toString('base64') }
|
||||
})
|
||||
}
|
||||
|
||||
function withAcpRuntimeLock(key, callback) {
|
||||
const previous = acpRuntimeLocks.get(key) || Promise.resolve()
|
||||
const current = previous.catch(() => {}).then(callback)
|
||||
@@ -540,16 +617,28 @@ function withAcpRuntimeLock(key, callback) {
|
||||
}
|
||||
|
||||
function runtimeSignature(agent, workdir, spec, profile, childEnv) {
|
||||
const environment = Object.keys(childEnv).sort().map(key => [key, childEnv[key]])
|
||||
const environment = Object.keys(childEnv).sort()
|
||||
.filter(key => !ACP_VOLATILE_ENV_KEYS.has(key))
|
||||
.map(key => [key, childEnv[key]])
|
||||
return createHash('sha256').update(JSON.stringify({
|
||||
kind: agent.kind,
|
||||
executable: agent.executable,
|
||||
workdir,
|
||||
command: spec.command,
|
||||
args: spec.args,
|
||||
acpMode: spec.acpMode || '',
|
||||
// The native session mode is applied on every turn
|
||||
// with session/set_mode, so it does not identify a process. Anything that
|
||||
// really needs a separate process (a permission policy in the environment)
|
||||
// is already part of the environment below.
|
||||
profileId: profile.profileId,
|
||||
nativeConfiguration: agent.kind === 'mimo'
|
||||
? mimoConfigurationFingerprint({ env: childEnv, home: childEnv.HOME, workdir }) || randomBytes(32).toString('hex')
|
||||
: agent.kind === 'deepseek'
|
||||
? deepseekConfigurationFingerprint({ env: childEnv, home: childEnv.HOME, workdir }) || randomBytes(32).toString('hex')
|
||||
: '',
|
||||
environment,
|
||||
secretEnvironmentKeys: nativeSecretEnvKeys(childEnv[SECRET_ENV_KEYS]),
|
||||
redactionSecrets: childEnv[Symbol.for('meldwork.project-secret-values')] || [],
|
||||
})).digest('hex')
|
||||
}
|
||||
|
||||
@@ -653,8 +742,14 @@ async function createAcpRuntime(input) {
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
try { runtime.activeTurn?.noteActivity?.() } catch { /* activity is best-effort */ }
|
||||
// Keep only recent stderr. A long-lived process used to keep the first
|
||||
// megabyte of its whole lifetime, so a new failure was classified from
|
||||
// stale early lines (for example an old auth warning).
|
||||
runtime.stderr.push(chunk)
|
||||
runtime.stderrBytes += chunk.length
|
||||
if (runtime.stderrBytes <= 1024 * 1024) runtime.stderr.push(chunk)
|
||||
while (runtime.stderrBytes > ACP_STDERR_TAIL_BYTES && runtime.stderr.length > 1) {
|
||||
runtime.stderrBytes -= runtime.stderr.shift().length
|
||||
}
|
||||
})
|
||||
runtime.observedStdout = createObservedAcpInput(
|
||||
child.stdout, runtime.replyState, protocolLabel,
|
||||
@@ -663,6 +758,16 @@ async function createAcpRuntime(input) {
|
||||
const client = {
|
||||
async requestPermission(params) {
|
||||
const turn = runtime.activeTurn
|
||||
if (turn?.completion?.completed) return { outcome: { outcome: 'cancelled' } }
|
||||
// DeepSeek escalation can escape its sandbox. Meldwork's selected
|
||||
// workspace mode is the entire grant for this process.
|
||||
if (runtime.agent.kind === 'deepseek') {
|
||||
return selectedPermissionOutcome(permissionRejection(params?.options))
|
||||
}
|
||||
if (runtime.agent.kind === 'mimo' && runtime.replyState.promptTerminalSequence != null) {
|
||||
runtime.replyState.permissionPendingAtPromptTerminal = true
|
||||
return { outcome: { outcome: 'cancelled' } }
|
||||
}
|
||||
const pending = permissionOutcome(params, turn?.options || {}, childEnv)
|
||||
turn?.pendingPermissionCallbacks.add(pending)
|
||||
pending.then(
|
||||
@@ -676,11 +781,40 @@ async function createAcpRuntime(input) {
|
||||
try { turn?.noteActivity?.() } catch { /* activity is best-effort */ }
|
||||
const sequence = runtime.replyState.pendingUpdateSequences.shift()
|
||||
if (!turn || !runtime.replyState.collecting) return
|
||||
if (params.sessionId !== runtime.protocolSessionRef) return
|
||||
if (runtime.replyState.promptTerminalSequence != null
|
||||
&& Number.isInteger(sequence)
|
||||
&& sequence > runtime.replyState.promptTerminalSequence) return
|
||||
turn.structuredOutput.ingest?.(params)
|
||||
for (const event of turn.profile.mapEvent(params.update, turn.runtimeState)) {
|
||||
&& sequence > runtime.replyState.promptTerminalSequence
|
||||
&& !(runtime.agent.kind === 'mimo'
|
||||
&& params.update?.sessionUpdate === 'agent_message_chunk'
|
||||
&& turn.lastMessageId
|
||||
&& params.update.messageId === turn.lastMessageId)) return
|
||||
if (params.update?.sessionUpdate === 'agent_message_chunk' && params.update.messageId) {
|
||||
turn.lastMessageId = params.update.messageId
|
||||
}
|
||||
let update = params.update
|
||||
if (['tool_call', 'tool_call_update'].includes(update?.sessionUpdate)) {
|
||||
if (['completed', 'failed'].includes(update.status)) turn.openTools.delete(update.toolCallId)
|
||||
else if (update.status || update.sessionUpdate === 'tool_call') turn.openTools.add(update.toolCallId)
|
||||
}
|
||||
if (turn.completion && update?.sessionUpdate === 'agent_message_chunk'
|
||||
&& update.content?.type === 'text') {
|
||||
const next = turn.completion.push(update.content.text,
|
||||
turn.openTools.size === 0 && turn.pendingPermissionCallbacks.size === 0)
|
||||
update = { ...update, content: { ...update.content, text: next.delta } }
|
||||
if (next.completed) turn.finishContribution()
|
||||
}
|
||||
if (turn.repetitionGuard?.(update)) {
|
||||
turn.stopRepeatedOutput()
|
||||
return
|
||||
}
|
||||
if (update?.sessionUpdate === 'agent_thought_chunk'
|
||||
&& update.content?.type === 'text' && typeof update.content.text === 'string'
|
||||
&& update.content.text.length > 0) {
|
||||
try { turn.options.onSubstantiveActivity?.() } catch { /* activity is best-effort */ }
|
||||
}
|
||||
turn.structuredOutput.ingest?.({ ...params, update })
|
||||
for (const event of turn.profile.mapEvent(update, turn.runtimeState)) {
|
||||
turn.runtimeEvents.emit(event)
|
||||
}
|
||||
},
|
||||
@@ -713,15 +847,42 @@ async function createAcpRuntime(input) {
|
||||
)
|
||||
runtime.connection = new ClientSideConnection(() => client, stream)
|
||||
acpInitializingRuntimes.add(runtime)
|
||||
const abortSignal = input.options?.signal
|
||||
const initializeTimeoutMs = Number.isFinite(input.options?.acpInitializeTimeoutMs)
|
||||
&& input.options.acpInitializeTimeoutMs > 0
|
||||
? input.options.acpInitializeTimeoutMs
|
||||
: ACP_INITIALIZE_TIMEOUT_MS
|
||||
let initializeTimer = null
|
||||
let abortListener = null
|
||||
try {
|
||||
await Promise.race([
|
||||
// initialize used to wait forever while holding the per-conversation
|
||||
// lock, leaving the Agent unusable in that conversation until restart.
|
||||
const initialized = await Promise.race([
|
||||
runtime.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }),
|
||||
runtime.failurePromise,
|
||||
new Promise((_, reject) => {
|
||||
initializeTimer = setTimeout(() => reject(agentExecutionError(
|
||||
'LOCAL_AGENT_PROTOCOL_UNAVAILABLE',
|
||||
`${protocolLabel} did not answer the ACP initialize request.`,
|
||||
)), initializeTimeoutMs)
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
abortListener = () => reject(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
if (abortSignal?.aborted) abortListener()
|
||||
else abortSignal?.addEventListener('abort', abortListener, { once: true })
|
||||
}),
|
||||
])
|
||||
runtime.agentCapabilities = initialized?.agentCapabilities
|
||||
&& typeof initialized.agentCapabilities === 'object'
|
||||
? initialized.agentCapabilities
|
||||
: {}
|
||||
} catch (error) {
|
||||
await closeAcpRuntime(runtime)
|
||||
if (abortSignal?.aborted) throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
throw allowAcpSetupFallback(acpProtocolError(error, childEnv))
|
||||
} finally {
|
||||
clearTimeout(initializeTimer)
|
||||
if (abortListener) abortSignal?.removeEventListener('abort', abortListener)
|
||||
acpInitializingRuntimes.delete(runtime)
|
||||
}
|
||||
return runtime
|
||||
@@ -745,7 +906,7 @@ function publicSessionRef(value, childEnv) {
|
||||
return safe.includes('[redacted]') ? '' : safe
|
||||
}
|
||||
|
||||
async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
if (runtime.closed || runtime.failureError) {
|
||||
throw runtime.failureError || agentExecutionError('LOCAL_AGENT_PROCESS_FAILED')
|
||||
}
|
||||
@@ -753,6 +914,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
try { options.onActivity?.() } catch { /* activity is best-effort */ }
|
||||
}
|
||||
const runtimeEvents = createRuntimeEventEmitter({ ...options, onActivity: noteActivity }, runtime.childEnv)
|
||||
const completion = options.turnCompletion === true ? createTurnCompletion() : null
|
||||
if (completion) prompt = `${prompt}\n\n${completion.instruction}`
|
||||
const capabilityWarning = connectorLimitedRuntimeEvent(runtime.agent.kind, profile)
|
||||
if (capabilityWarning) runtimeEvents.emit(capabilityWarning)
|
||||
const turn = {
|
||||
@@ -767,8 +930,13 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
noteActivity,
|
||||
outboundPayloadFailed: false,
|
||||
pendingPermissionCallbacks: new Set(),
|
||||
completion,
|
||||
openTools: new Set(),
|
||||
repetitionGuard: ['opencode', 'mimo', 'hermes'].includes(runtime.agent.kind) ? createOutputRepetitionGuard() : null,
|
||||
}
|
||||
runtime.activeTurn = turn
|
||||
runtime.stderr = []
|
||||
runtime.stderrBytes = 0
|
||||
runtime.replyState.bytes = 0
|
||||
runtime.replyState.inputBytes = 0
|
||||
runtime.replyState.observedInputBytes = 0
|
||||
@@ -785,12 +953,16 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
runtime.publicSessionRef = visibleSessionRef
|
||||
}
|
||||
let promptStarted = false
|
||||
let promptRequest = null
|
||||
let caughtError = null
|
||||
let abortRequested = false
|
||||
let timeoutRequested = false
|
||||
let cancelPromise = Promise.resolve()
|
||||
let rejectAbort
|
||||
let resolveContribution
|
||||
const contribution = new Promise(resolve => { resolveContribution = resolve })
|
||||
const abortPromise = new Promise((_, reject) => { rejectAbort = reject })
|
||||
const abort = () => {
|
||||
const abort = (error = agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')) => {
|
||||
if (abortRequested) return
|
||||
abortRequested = true
|
||||
if (protocolSessionRef) {
|
||||
@@ -800,7 +972,16 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
})).catch(() => {})
|
||||
} catch { /* the ACP stream has already closed */ }
|
||||
}
|
||||
rejectAbort(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
rejectAbort(error instanceof Error ? error : agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
}
|
||||
turn.stopRepeatedOutput = () => {
|
||||
runtime.replyState.collecting = false
|
||||
abort(agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT',
|
||||
'The Agent repeated the same response without tool progress; the native turn was cancelled.'))
|
||||
}
|
||||
turn.finishContribution = () => {
|
||||
runtime.replyState.collecting = false
|
||||
resolveContribution({ stopReason: 'end_turn' })
|
||||
}
|
||||
const operation = promise => Promise.race([promise, abortPromise, runtime.failurePromise])
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -812,26 +993,26 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
|
||||
try {
|
||||
if (!protocolSessionRef) {
|
||||
if (persistent && options.sessionRef) {
|
||||
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (spec.acpSessionStrategy !== 'new' && options.sessionRef) {
|
||||
protocolSessionRef = String(options.sessionRef)
|
||||
protocolSessionRef = runtime.agent.kind === 'deepseek'
|
||||
? String(options.sessionRef).slice(`dsh-${spec.deepSeekMode}:`.length)
|
||||
: String(options.sessionRef)
|
||||
await operation(runtime.connection.resumeSession({
|
||||
sessionId: protocolSessionRef,
|
||||
cwd: runtime.workdir,
|
||||
mcpServers: [],
|
||||
}))
|
||||
visibleSessionRef = protocolSessionRef
|
||||
visibleSessionRef = runtime.agent.kind === 'deepseek'
|
||||
? String(options.sessionRef) : protocolSessionRef
|
||||
} else {
|
||||
const session = await operation(runtime.connection.newSession({
|
||||
cwd: runtime.workdir,
|
||||
mcpServers: [],
|
||||
}))
|
||||
protocolSessionRef = String(session.sessionId || '')
|
||||
visibleSessionRef = String(
|
||||
spec.publicSessionRef || options.sessionRef || protocolSessionRef,
|
||||
)
|
||||
visibleSessionRef = runtime.agent.kind === 'deepseek'
|
||||
? `dsh-${spec.deepSeekMode}:${protocolSessionRef}`
|
||||
: String(spec.publicSessionRef || options.sessionRef || protocolSessionRef)
|
||||
}
|
||||
if (!protocolSessionRef) throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
runtime.protocolSessionRef = protocolSessionRef
|
||||
@@ -847,14 +1028,28 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
if (visibleSessionRef && typeof options.onSessionRef === 'function') {
|
||||
await operation(options.onSessionRef(visibleSessionRef, { transport: 'acp' }))
|
||||
}
|
||||
const imageBlocks = acpImageBlocks(runtime, spec.imageAttachments)
|
||||
runtime.replyState.collecting = true
|
||||
let promptResult
|
||||
try {
|
||||
promptStarted = true
|
||||
promptResult = await operation(runtime.connection.prompt({
|
||||
promptRequest = Promise.resolve(runtime.connection.prompt({
|
||||
sessionId: protocolSessionRef,
|
||||
prompt: [{ type: 'text', text: prompt }],
|
||||
prompt: [{ type: 'text', text: prompt }, ...imageBlocks],
|
||||
}))
|
||||
promptRequest.catch(() => {})
|
||||
promptResult = await operation(Promise.race([promptRequest, contribution]))
|
||||
if (completion?.completed) {
|
||||
await operation(settleWithin(Promise.resolve(runtime.connection.cancel({ sessionId: protocolSessionRef }))
|
||||
.catch(() => {}), ACP_CANCEL_GRACE_MS))
|
||||
let settled = false
|
||||
await operation(settleWithin(promptRequest.then(() => { settled = true }, () => { settled = true }),
|
||||
ACP_CANCEL_SETTLE_MS))
|
||||
if (!settled) runtime.contributionNeedsClose = true
|
||||
}
|
||||
if (runtime.agent.kind === 'mimo' && turn.lastMessageId) {
|
||||
await operation(new Promise(resolve => setTimeout(resolve, MIMO_TERMINAL_DRAIN_MS)))
|
||||
}
|
||||
} finally {
|
||||
runtime.replyState.collecting = false
|
||||
}
|
||||
@@ -883,15 +1078,21 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
runtimeEvents.finalize({ status: 'failed' })
|
||||
throw error
|
||||
}
|
||||
const normalized = acpProtocolError(error, runtime.childEnv, visibleSessionRef)
|
||||
const normalized = acpProtocolError(error, runtime.childEnv, visibleSessionRef, runtime.agent.kind)
|
||||
runtimeEvents.finalize({
|
||||
status: timeoutRequested
|
||||
? 'timeout'
|
||||
: normalized.failure?.category === 'cancellation' ? 'stopped' : 'failed',
|
||||
})
|
||||
if (promptStarted && options.sessionRef
|
||||
&& runtime.replyState.bytes === 0
|
||||
&& normalized.message === 'LOCAL_AGENT_REFUSED') {
|
||||
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (!promptStarted && normalized.message !== 'LOCAL_AGENT_SESSION_INVALID') {
|
||||
allowAcpSetupFallback(normalized)
|
||||
}
|
||||
caughtError = normalized
|
||||
throw normalized
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
@@ -904,7 +1105,26 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
turn.pendingPermissionCallbacks.clear()
|
||||
runtime.activeTurn = null
|
||||
runtime.lastUsedAt = Date.now()
|
||||
if (abortRequested) await settleWithin(cancelPromise, ACP_CANCEL_GRACE_MS)
|
||||
if (abortRequested) {
|
||||
await settleWithin(cancelPromise, ACP_CANCEL_GRACE_MS)
|
||||
// A user stop or watchdog timeout used to destroy the process and its
|
||||
// native session. Keep both when the Agent honours the cancellation.
|
||||
let promptSettled = false
|
||||
// Disposable runtimes are always closed after the turn; only a
|
||||
// persistent runtime is worth waiting for.
|
||||
if (promptRequest && !acpDisposableRuntimes.has(runtime)) {
|
||||
await settleWithin(
|
||||
promptRequest.then(() => { promptSettled = true }, () => { promptSettled = true }),
|
||||
ACP_CANCEL_SETTLE_MS,
|
||||
)
|
||||
}
|
||||
if (caughtError && caughtError.code !== 'LOCAL_AGENT_REPEATED_OUTPUT'
|
||||
&& promptSettled && !runtime.closed && !runtime.failureError) {
|
||||
Object.defineProperty(caughtError, 'acpRuntimeReusable', {
|
||||
value: true, enumerable: false, configurable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,9 +1175,6 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
runtime = null
|
||||
if (options.sessionRef) throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (!runtime && options.sessionRef) {
|
||||
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (!runtime) {
|
||||
if (shutdownGeneration !== acpShutdownGeneration) {
|
||||
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
@@ -969,13 +1186,19 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
}
|
||||
runtime.active = true
|
||||
try {
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile, true)
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile)
|
||||
} catch (error) {
|
||||
acpSessionRuntimes.delete(key)
|
||||
await closeAcpRuntime(runtime)
|
||||
if (error?.acpRuntimeReusable !== true) {
|
||||
acpSessionRuntimes.delete(key)
|
||||
await closeAcpRuntime(runtime)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
runtime.active = false
|
||||
if (runtime.contributionNeedsClose) {
|
||||
acpSessionRuntimes.delete(key)
|
||||
await closeAcpRuntime(runtime)
|
||||
}
|
||||
runtime.lastUsedAt = Date.now()
|
||||
await evictIdleAcpRuntimes()
|
||||
}
|
||||
@@ -1003,7 +1226,7 @@ async function runDisposableAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
}
|
||||
acpDisposableRuntimes.add(runtime)
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile, false)
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile)
|
||||
} finally {
|
||||
if (runtime) {
|
||||
acpDisposableRuntimes.delete(runtime)
|
||||
@@ -1013,6 +1236,10 @@ async function runDisposableAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
}
|
||||
|
||||
async function runAcpAgent(agent, prompt, workdir, options, spec, profile) {
|
||||
if (agent.kind === 'deepseek' && options.sessionRef
|
||||
&& !new RegExp(`^dsh-${spec.deepSeekMode}:[A-Za-z0-9][A-Za-z0-9._-]{0,200}$`).test(options.sessionRef)) {
|
||||
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (options.signal?.aborted
|
||||
|| options.acpShutdownGeneration != null
|
||||
&& options.acpShutdownGeneration !== acpShutdownGeneration) {
|
||||
|
||||
@@ -15,11 +15,13 @@ const {
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parseOpenCodeReviewOutput,
|
||||
parsePiOutput,
|
||||
parseWorkBuddyOutput,
|
||||
readHermesFinalResponse,
|
||||
readHermesMessageWatermark,
|
||||
redactChildSecrets,
|
||||
runtimeCommandSummary,
|
||||
stripExactMeldworkPromptEcho,
|
||||
structuredCliError,
|
||||
} = require('./cli-runtime-events.cjs')
|
||||
const {
|
||||
@@ -28,6 +30,7 @@ const {
|
||||
shutdownAcpSessionRuntime,
|
||||
} = require('./cli-acp-runner.cjs')
|
||||
const { withOpenClawGateway } = require('./cli-openclaw-gateway.cjs')
|
||||
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
|
||||
const { imageAttachmentLimit, invocation } = require('./cli-invocations.cjs')
|
||||
const {
|
||||
connectorLimitedRuntimeEvent,
|
||||
@@ -43,14 +46,27 @@ const {
|
||||
agentExecutionError,
|
||||
childEnvironment,
|
||||
failedAgentProcessError,
|
||||
terminateWindowsProcessTree,
|
||||
} = require('./cli-process-support.cjs')
|
||||
const { createLegacyOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
|
||||
const { authFailureCertainty } = require('../../runs/failure-signals.cjs')
|
||||
|
||||
const MAX_PROGRESS_STEPS = 8
|
||||
const MAX_STDOUT_CAPTURE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_STDERR_CAPTURE_BYTES = 1024 * 1024
|
||||
const OUTPUT_TRUNCATION_MARKER = Buffer.from('\n[output truncated]\n')
|
||||
|
||||
const BROKEN_INPUT_GRACE_MS = 5000
|
||||
|
||||
// A step that ends because the model asked for tools is followed by more
|
||||
// steps (the tools run, then the model continues); only the last step's finish
|
||||
// ends the run. Ending the process after the first one cut every tool-using
|
||||
// MiMo or OpenCode turn short.
|
||||
function stepFinishContinues(event) {
|
||||
const reason = String(event?.part?.reason || event?.reason || '').toLowerCase()
|
||||
return /^tool[-_ ]?(calls?|use)$/.test(reason)
|
||||
}
|
||||
|
||||
function createBoundedOutputCapture(maxBytes) {
|
||||
const headLimit = Math.floor(maxBytes / 2)
|
||||
const tailLimit = maxBytes - headLimit
|
||||
@@ -121,12 +137,16 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
? options.hermesAcpAvailable
|
||||
: agent.acpAvailable
|
||||
const spec = invocation(agent.kind, agent.executable, workdir, sessionRef, {
|
||||
platform,
|
||||
...(agent.kind === 'deepseek' ? { env: options.env } : {}),
|
||||
sandbox: options.sandbox,
|
||||
provider: options.provider,
|
||||
attachments: options.attachments,
|
||||
hermesAcpAvailable,
|
||||
hermesStreamJsonAvailable: agent.streamJsonAvailable,
|
||||
invocationTransport: options.invocationTransport,
|
||||
sessionTransport: options.sessionTransport,
|
||||
maxTurns: options.maxTurns,
|
||||
model: agent.kind === 'workbuddy' ? options.env?.CODEBUDDY_MODEL : undefined,
|
||||
})
|
||||
const profile = resolveConnectorEventProfile(agent.kind, {
|
||||
transport: spec.eventTransport,
|
||||
@@ -210,10 +230,12 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
}))
|
||||
}
|
||||
const childEnv = childEnvironment(agent, workdir, options, platform)
|
||||
const repetitionGuard = ['opencode', 'mimo', 'hermes'].includes(agent.kind)
|
||||
? createOutputRepetitionGuard() : null
|
||||
const runtimeEvents = createRuntimeEventEmitter({ ...options, onActivity: noteActivity }, childEnv)
|
||||
const capabilityWarning = connectorLimitedRuntimeEvent(agent.kind, profile)
|
||||
if (capabilityWarning) runtimeEvents.emit(capabilityWarning)
|
||||
const profileRunContext = profile.createRunContext(options)
|
||||
const profileRunContext = profile.createRunContext({ ...options, childEnv, workdir, platform })
|
||||
return await new Promise((resolve, reject) => {
|
||||
const rejectTerminal = (error, status = 'failed') => {
|
||||
runtimeEvents.finalize({ status })
|
||||
@@ -241,9 +263,14 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
let settled = false
|
||||
let stopRequested = false
|
||||
let timeoutRequested = false
|
||||
let inputError = null
|
||||
let brokenInputTimer = null
|
||||
let timeout
|
||||
let forceKillTimeout
|
||||
let forceSettleTimeout
|
||||
let terminalKillTimer
|
||||
let terminalForceKillTimer
|
||||
let windowsTermination = null
|
||||
const progress = []
|
||||
const progressIndexes = new Map()
|
||||
let anonymousProgressId = 0
|
||||
@@ -272,9 +299,16 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
&& typeof profile.createDecoder === 'function'
|
||||
? profile.createDecoder((event) => {
|
||||
structuredOutput.ingest?.(event)
|
||||
observeTerminalEvent(event)
|
||||
const progressEvent = profile.mapProgress(event)
|
||||
if (progressEvent) emitProgress(progressEvent)
|
||||
for (const runtimeEvent of profile.mapEvent(event, runtimeState)) {
|
||||
if (stopRequested || settled) return
|
||||
if (repetitionGuard?.(runtimeEvent)) {
|
||||
inputError = agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT')
|
||||
abort()
|
||||
return
|
||||
}
|
||||
runtimeEvents.emit(runtimeEvent)
|
||||
}
|
||||
})
|
||||
@@ -285,6 +319,9 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
clearTimeout(timeout)
|
||||
clearTimeout(forceKillTimeout)
|
||||
clearTimeout(forceSettleTimeout)
|
||||
clearTimeout(terminalKillTimer)
|
||||
clearTimeout(terminalForceKillTimer)
|
||||
clearTimeout(brokenInputTimer)
|
||||
options.signal?.removeEventListener('abort', abort)
|
||||
callback()
|
||||
}
|
||||
@@ -299,24 +336,46 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
child.kill(signal)
|
||||
} catch { /* the process has already exited */ }
|
||||
}
|
||||
let terminalObserved = false
|
||||
const observeTerminalEvent = (event) => {
|
||||
if (terminalObserved || !event || typeof event !== 'object') return
|
||||
const terminal = (
|
||||
(['workbuddy', 'claude', 'qwen'].includes(agent.kind) && event.type === 'result')
|
||||
|| (agent.kind === 'hermes' && spec.eventTransport === 'stream-json' && event.type === 'result')
|
||||
|| (agent.kind === 'pi' && (event.type === 'agent_settled'
|
||||
|| (event.type === 'agent_end'
|
||||
&& !Object.hasOwn(event, 'willRetry') && !Object.hasOwn(event, 'willContinue'))))
|
||||
|| (agent.kind === 'codex' && event.type === 'turn.completed')
|
||||
|| (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish'
|
||||
&& !stepFinishContinues(event))
|
||||
|| (agent.kind === 'gemini' && event.type === 'result')
|
||||
|| (agent.kind === 'kimi' && event.type === 'session.resume_hint')
|
||||
)
|
||||
if (!terminal) return
|
||||
terminalObserved = true
|
||||
terminalKillTimer = setTimeout(() => {
|
||||
if (settled) return
|
||||
signalProcessTree('SIGTERM')
|
||||
terminalForceKillTimer = setTimeout(() => {
|
||||
if (!settled) signalProcessTree('SIGKILL')
|
||||
}, 1500)
|
||||
}, 250)
|
||||
}
|
||||
const abort = () => {
|
||||
if (settled || stopRequested) return
|
||||
stopRequested = true
|
||||
if (platform === 'win32' && child.pid) {
|
||||
const killer = spawnFn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'ignore', windowsHide: true,
|
||||
})
|
||||
killer.on('error', () => {})
|
||||
killer.unref()
|
||||
forceSettleTimeout = setTimeout(() => finish(() => {
|
||||
const terminalError = inputError || new Error('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
if (platform === 'win32') {
|
||||
windowsTermination = terminateWindowsProcessTree(child, spawnFn)
|
||||
windowsTermination.then(() => finish(() => rejectTerminal(
|
||||
terminalError,
|
||||
inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped'),
|
||||
)), error => finish(() => {
|
||||
child.stdin.destroy()
|
||||
child.stdout.destroy()
|
||||
child.stderr.destroy()
|
||||
rejectTerminal(
|
||||
new Error('LOCAL_AGENT_EXECUTION_STOPPED'),
|
||||
timeoutRequested ? 'timeout' : 'stopped',
|
||||
)
|
||||
}), KILL_SETTLE_MS)
|
||||
rejectTerminal(error, 'failed')
|
||||
}))
|
||||
return
|
||||
}
|
||||
signalProcessTree('SIGTERM')
|
||||
@@ -328,8 +387,8 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
child.stdout.destroy()
|
||||
child.stderr.destroy()
|
||||
rejectTerminal(
|
||||
new Error('LOCAL_AGENT_EXECUTION_STOPPED'),
|
||||
timeoutRequested ? 'timeout' : 'stopped',
|
||||
terminalError,
|
||||
inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped'),
|
||||
)
|
||||
}), KILL_SETTLE_MS)
|
||||
}, TERMINATE_GRACE_MS)
|
||||
@@ -351,36 +410,51 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
stderr.push(chunk)
|
||||
noteActivity()
|
||||
})
|
||||
child.on('error', error => finish(() => rejectTerminal(
|
||||
stopRequested
|
||||
? agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
: agentExecutionError(
|
||||
'LOCAL_AGENT_SPAWN_FAILED',
|
||||
redactChildSecrets(error?.message || error, childEnv),
|
||||
),
|
||||
stopRequested ? (timeoutRequested ? 'timeout' : 'stopped') : 'failed',
|
||||
)))
|
||||
child.on('close', (code, signal) => finish(() => {
|
||||
child.stdin.on('error', (error) => {
|
||||
if (settled || inputError) return
|
||||
const sendFailure = () => agentExecutionError(
|
||||
'LOCAL_AGENT_SEND_FAILED',
|
||||
redactChildSecrets(error?.message || error, childEnv),
|
||||
)
|
||||
// A closed pipe usually means the CLI exited before it read the whole
|
||||
// prompt (expired sign-in, rejected flag). Its own output says why, so
|
||||
// let the process finish and report that. Only a process that stays
|
||||
// alive without reading its input is a send failure.
|
||||
if (['EPIPE', 'ERR_STREAM_DESTROYED', 'ECONNRESET'].includes(error?.code)) {
|
||||
brokenInputTimer ||= setTimeout(() => {
|
||||
if (settled || inputError) return
|
||||
inputError = sendFailure()
|
||||
abort()
|
||||
}, BROKEN_INPUT_GRACE_MS)
|
||||
return
|
||||
}
|
||||
inputError = sendFailure()
|
||||
abort()
|
||||
})
|
||||
child.on('error', error => {
|
||||
if (stopRequested && windowsTermination) return
|
||||
finish(() => rejectTerminal(
|
||||
inputError || (stopRequested
|
||||
? agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
: agentExecutionError(
|
||||
'LOCAL_AGENT_SPAWN_FAILED',
|
||||
redactChildSecrets(error?.message || error, childEnv),
|
||||
)),
|
||||
stopRequested ? (inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped')) : 'failed',
|
||||
))
|
||||
})
|
||||
child.on('close', (code, signal) => {
|
||||
if (stopRequested && windowsTermination) return
|
||||
finish(() => {
|
||||
void (async () => {
|
||||
runtimeStreamParser?.end()
|
||||
if (stopRequested || options.signal?.aborted) {
|
||||
rejectTerminal(
|
||||
new Error('LOCAL_AGENT_EXECUTION_STOPPED'),
|
||||
timeoutRequested ? 'timeout' : 'stopped',
|
||||
inputError || new Error('LOCAL_AGENT_EXECUTION_STOPPED'),
|
||||
inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped'),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (code !== 0) {
|
||||
const rawStdout = stdout.text()
|
||||
const rawStderr = stderr.text().trim()
|
||||
const structuredDetail = structuredCliError(rawStdout)
|
||||
const detail = redactChildSecrets(
|
||||
[rawStderr, structuredDetail].filter(Boolean).join('\n'),
|
||||
childEnv,
|
||||
)
|
||||
rejectTerminal(failedAgentProcessError(detail, { sessionRef: failureSessionRef }))
|
||||
return
|
||||
}
|
||||
const rawStderr = stderr.text()
|
||||
const nextSessionRef = profile.resolveSessionRef({
|
||||
stderr: rawStderr,
|
||||
@@ -389,11 +463,35 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
})
|
||||
let result = structuredOutput.end({
|
||||
sessionRef: nextSessionRef,
|
||||
prompt,
|
||||
})
|
||||
const rawStdout = stdout.text()
|
||||
const structuredDetail = structuredCliError(rawStdout)
|
||||
if (!String(result?.text || '').trim() && authFailureCertainty(rawStderr) === 'definitive') {
|
||||
rejectTerminal(failedAgentProcessError(redactChildSecrets(rawStderr, childEnv), {
|
||||
sessionRef: failureSessionRef, kind: agent.kind, model: options.env?.CODEBUDDY_MODEL,
|
||||
}))
|
||||
return
|
||||
}
|
||||
const usableNonZeroResult = code !== 0
|
||||
&& result
|
||||
&& typeof result.text === 'string'
|
||||
&& result.text.trim()
|
||||
&& ['completed', 'partial'].includes(result.outcome)
|
||||
if (code !== 0 && !usableNonZeroResult) {
|
||||
const detail = redactChildSecrets(
|
||||
[result?.error, rawStderr, structuredDetail].filter(Boolean).join('\n'),
|
||||
childEnv,
|
||||
)
|
||||
rejectTerminal(failedAgentProcessError(detail, {
|
||||
sessionRef: failureSessionRef, kind: agent.kind, model: options.env?.CODEBUDDY_MODEL,
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (result.error) {
|
||||
rejectTerminal(failedAgentProcessError(
|
||||
redactChildSecrets(result.error, childEnv),
|
||||
{ sessionRef: failureSessionRef },
|
||||
{ sessionRef: failureSessionRef, kind: agent.kind, model: options.env?.CODEBUDDY_MODEL },
|
||||
))
|
||||
return
|
||||
}
|
||||
@@ -407,6 +505,10 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
runContext: profileRunContext,
|
||||
options,
|
||||
})
|
||||
result = {
|
||||
...result,
|
||||
text: stripExactMeldworkPromptEcho(result.text, prompt),
|
||||
}
|
||||
if (progress.length) result.progress = progress
|
||||
const redactedText = redactChildSecrets(result.text, childEnv)
|
||||
let output
|
||||
@@ -436,7 +538,8 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
runtimeEvents.finalize({ text: redactedText, status: output.outcome })
|
||||
resolve(output)
|
||||
})().catch(error => rejectTerminal(error))
|
||||
}))
|
||||
})
|
||||
})
|
||||
if (spec.stdin) child.stdin.end(stdin)
|
||||
else child.stdin.end()
|
||||
})
|
||||
@@ -453,6 +556,7 @@ module.exports = {
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parseOpenCodeReviewOutput,
|
||||
parsePiOutput,
|
||||
parseWorkBuddyOutput,
|
||||
prepareCommand,
|
||||
readHermesFinalResponse,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const CONFIG_DIRECTORY_ENV_KEYS = Object.freeze([
|
||||
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME',
|
||||
'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR',
|
||||
])
|
||||
|
||||
function configDirectoryEnvironment(source = {}) {
|
||||
return Object.fromEntries(CONFIG_DIRECTORY_ENV_KEYS
|
||||
.filter(key => typeof source[key] === 'string')
|
||||
.map(key => [key, source[key]]))
|
||||
}
|
||||
|
||||
module.exports = { CONFIG_DIRECTORY_ENV_KEYS, configDirectoryEnvironment }
|
||||
@@ -1,11 +1,20 @@
|
||||
const { execFile } = require('node:child_process')
|
||||
const { createHmac, randomBytes } = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { promisify } = require('node:util')
|
||||
const { networkEnvironment } = require('./cli-network-environment.cjs')
|
||||
const { SECRET_ENV_KEYS, nativeSecretEnvKeys } = require('./native-secret-environment.cjs')
|
||||
const { NATIVE_CONFIG_DIRECTORIES, NATIVE_CONFIG_IDENTITY_DIRECTORY, NATIVE_AUTH_IDENTITY_FILES,
|
||||
nativeIdentityDirectory, nativeAuthIdentityFiles, nativeNetworkIdentityFiles } = require('../native-config-directory.cjs')
|
||||
const { nativeCredentialEnvironment } = require('../agent-native-environment.cjs')
|
||||
const { redactChildSecrets } = require('./cli-runtime-event-sanitizer.cjs')
|
||||
const { authFailureCertainty } = require('../../runs/failure-signals.cjs')
|
||||
const {
|
||||
assessAgentVersion,
|
||||
capabilityProbes,
|
||||
extractAgentVersion,
|
||||
identifyAgentVersion,
|
||||
} = require('../agent-compatibility.cjs')
|
||||
|
||||
@@ -15,8 +24,11 @@ const AGENT_PROFILES = {
|
||||
hermes: { label: 'Hermes', commands: ['hermes'] },
|
||||
openclaw: { label: 'OpenClaw', commands: ['openclaw'] },
|
||||
workbuddy: { label: 'WorkBuddy', commands: ['codebuddy'] },
|
||||
zcode: { label: 'Zcode', commands: ['zcode'] },
|
||||
pi: { label: 'Pi Agent', commands: ['pi', 'pi-agent', 'piagent'] },
|
||||
kimi: { label: 'Kimi', commands: ['kimi'] },
|
||||
mimo: { label: 'MiMo', commands: ['mimo'] },
|
||||
deepseek: { label: 'DeepSeek Harness', commands: ['dsh'] },
|
||||
claude: { label: 'Claude', commands: ['claude'] },
|
||||
gemini: { label: 'Gemini', commands: ['gemini'] },
|
||||
opencode: { label: 'OpenCode', commands: ['opencode'] },
|
||||
@@ -25,6 +37,10 @@ const AGENT_PROFILES = {
|
||||
}
|
||||
const ALLOWED_KINDS = Object.keys(AGENT_PROFILES)
|
||||
const DEFAULT_WINDOWS_PATHEXT = ['.COM', '.EXE', '.BAT', '.CMD']
|
||||
const CAPABILITY_CACHE_TTL_MS = 5 * 60 * 1000
|
||||
const capabilityCache = new Map()
|
||||
const activeCapabilityProbes = new Map()
|
||||
const capabilityEnvironmentKey = randomBytes(32)
|
||||
const SYSTEM_CHILD_ENV_KEYS = Object.freeze([
|
||||
'HOME', 'USER', 'LOGNAME', 'SHELL',
|
||||
'TMPDIR', 'TMP', 'TEMP',
|
||||
@@ -37,18 +53,24 @@ const SYSTEM_CHILD_ENV_KEYS = Object.freeze([
|
||||
'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
|
||||
'PROGRAMFILES', 'PROGRAMFILES(X86)', 'PROGRAMW6432',
|
||||
'OS', 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER', 'NUMBER_OF_PROCESSORS',
|
||||
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
|
||||
])
|
||||
|
||||
function envValue(env, name) {
|
||||
const match = Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase())
|
||||
function envValue(env, name, platform = process.platform) {
|
||||
const match = Object.hasOwn(env, name) ? name
|
||||
: platform === 'win32' ? Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase()) : undefined
|
||||
return match ? env[match] : ''
|
||||
}
|
||||
|
||||
function nativeConfigDirectoryEnvironment(kind, env, platform = process.platform) {
|
||||
const key = Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, kind) ? NATIVE_CONFIG_DIRECTORIES[kind].key : ''
|
||||
const keys = kind === 'deepseek' ? [key, 'DSH_AGENTS_HOME'] : [key]
|
||||
return Object.fromEntries(keys.filter(Boolean).map(name => [name, envValue(env, name, platform)])
|
||||
.filter(([, value]) => typeof value === 'string' && value))
|
||||
}
|
||||
|
||||
function systemChildEnvironment(sourceEnv = process.env, platform = process.platform) {
|
||||
const env = {}
|
||||
const env = networkEnvironment(sourceEnv, platform)
|
||||
for (const name of SYSTEM_CHILD_ENV_KEYS) {
|
||||
const value = envValue(sourceEnv, name)
|
||||
const value = envValue(sourceEnv, name, platform)
|
||||
if (typeof value === 'string' && value) env[name] = value
|
||||
}
|
||||
if (platform === 'win32') env.USERPROFILE ||= os.homedir()
|
||||
@@ -98,12 +120,16 @@ function versionManagerBins(root, suffix, options = {}) {
|
||||
.map(name => pathApi.join(root, name, ...suffix))
|
||||
}
|
||||
|
||||
const DETECTION_SEARCH_DIRECTORIES = Symbol('detectionSearchDirectories')
|
||||
|
||||
function searchDirectories(options = {}) {
|
||||
if (options[DETECTION_SEARCH_DIRECTORIES]) return options[DETECTION_SEARCH_DIRECTORIES]
|
||||
const { platform, env, home, pathApi } = runtimeOptions(options)
|
||||
const configured = String(envValue(env, 'PATH') || '').split(pathApi.delimiter)
|
||||
const pnpmHome = envValue(env, 'PNPM_HOME')
|
||||
const readEnv = name => envValue(env, name, platform)
|
||||
const configured = String(readEnv('PATH') || '').split(pathApi.delimiter)
|
||||
const pnpmHome = readEnv('PNPM_HOME')
|
||||
if (platform !== 'win32') {
|
||||
const fnmMultishellPath = envValue(env, 'FNM_MULTISHELL_PATH')
|
||||
const fnmMultishellPath = readEnv('FNM_MULTISHELL_PATH')
|
||||
const fnmDataRoots = [
|
||||
pathApi.join(home, '.local', 'share', 'fnm', 'node-versions'),
|
||||
platform === 'darwin'
|
||||
@@ -113,11 +139,11 @@ function searchDirectories(options = {}) {
|
||||
return uniquePaths([
|
||||
...configured,
|
||||
pnpmHome,
|
||||
envValue(env, 'NVM_BIN'),
|
||||
envValue(env, 'BUN_INSTALL') && pathApi.join(envValue(env, 'BUN_INSTALL'), 'bin'),
|
||||
envValue(env, 'ASDF_DATA_DIR') && pathApi.join(envValue(env, 'ASDF_DATA_DIR'), 'shims'),
|
||||
envValue(env, 'MISE_DATA_DIR') && pathApi.join(envValue(env, 'MISE_DATA_DIR'), 'shims'),
|
||||
envValue(env, 'NODENV_ROOT') && pathApi.join(envValue(env, 'NODENV_ROOT'), 'shims'),
|
||||
readEnv('NVM_BIN'),
|
||||
readEnv('BUN_INSTALL') && pathApi.join(readEnv('BUN_INSTALL'), 'bin'),
|
||||
readEnv('ASDF_DATA_DIR') && pathApi.join(readEnv('ASDF_DATA_DIR'), 'shims'),
|
||||
readEnv('MISE_DATA_DIR') && pathApi.join(readEnv('MISE_DATA_DIR'), 'shims'),
|
||||
readEnv('NODENV_ROOT') && pathApi.join(readEnv('NODENV_ROOT'), 'shims'),
|
||||
fnmMultishellPath && pathApi.join(fnmMultishellPath, 'bin'),
|
||||
pathApi.join(home, '.volta', 'bin'),
|
||||
platform === 'darwin' && pathApi.join(home, 'Library', 'pnpm'),
|
||||
@@ -142,15 +168,15 @@ function searchDirectories(options = {}) {
|
||||
], platform)
|
||||
}
|
||||
|
||||
const appData = envValue(env, 'APPDATA')
|
||||
const localAppData = envValue(env, 'LOCALAPPDATA')
|
||||
const programFiles = envValue(env, 'ProgramFiles')
|
||||
const programFilesX86 = envValue(env, 'ProgramFiles(x86)')
|
||||
const programData = envValue(env, 'ProgramData')
|
||||
const chocolatey = envValue(env, 'ChocolateyInstall')
|
||||
const scoop = envValue(env, 'SCOOP')
|
||||
const nvmHome = envValue(env, 'NVM_HOME')
|
||||
const nvmSymlink = envValue(env, 'NVM_SYMLINK')
|
||||
const appData = readEnv('APPDATA')
|
||||
const localAppData = readEnv('LOCALAPPDATA')
|
||||
const programFiles = readEnv('ProgramFiles')
|
||||
const programFilesX86 = readEnv('ProgramFiles(x86)')
|
||||
const programData = readEnv('ProgramData')
|
||||
const chocolatey = readEnv('ChocolateyInstall')
|
||||
const scoop = readEnv('SCOOP')
|
||||
const nvmHome = readEnv('NVM_HOME')
|
||||
const nvmSymlink = readEnv('NVM_SYMLINK')
|
||||
const workBuddyCli = base => base && pathApi.join(
|
||||
base, 'WorkBuddy', 'resources', 'app.asar.unpacked', 'cli', 'bin',
|
||||
)
|
||||
@@ -187,8 +213,33 @@ function searchPath(options = {}) {
|
||||
}
|
||||
|
||||
function agentCommands(kind, options = {}) {
|
||||
const { platform, home, pathApi } = runtimeOptions(options)
|
||||
const { platform, home, pathApi, env } = runtimeOptions(options)
|
||||
const commands = [...AGENT_PROFILES[kind].commands]
|
||||
if (kind === 'hermes' && platform !== 'win32') {
|
||||
const configuredHome = String(envValue(env, 'HERMES_HOME', platform) || '').replace(/^~(?=$|[\\/])/, home)
|
||||
const installDir = envValue(env, 'HERMES_INSTALL_DIR', platform)
|
||||
const suffix = envValue(env, 'HERMES_DATA_DIR_SUFFIX', platform) || ''
|
||||
const homes = [configuredHome, pathApi.join(home, `.hermes${suffix}`)]
|
||||
.filter(value => typeof value === 'string' && pathApi.isAbsolute(value))
|
||||
const roots = [installDir, ...homes.map(value => pathApi.join(value, 'hermes-agent')), ...homes]
|
||||
.filter(value => typeof value === 'string' && pathApi.isAbsolute(value))
|
||||
for (const root of roots) {
|
||||
commands.push(pathApi.join(root, '.hermes', 'bin', 'hermes'))
|
||||
commands.push(pathApi.join(root, 'venv', 'bin', 'hermes'))
|
||||
}
|
||||
}
|
||||
if (kind === 'hermes' && platform === 'win32') {
|
||||
const configuredHome = String(envValue(env, 'HERMES_HOME', platform) || '').replace(/^~(?=$|[\\/])/, home)
|
||||
const localAppData = envValue(env, 'LOCALAPPDATA', platform) || pathApi.join(home, 'AppData', 'Local')
|
||||
const suffix = envValue(env, 'HERMES_DATA_DIR_SUFFIX', platform) || ''
|
||||
const homes = [configuredHome, pathApi.join(localAppData, `hermes${suffix}`)]
|
||||
.filter(value => typeof value === 'string' && pathApi.isAbsolute(value))
|
||||
for (const root of homes) {
|
||||
commands.push(pathApi.join(root, 'bin', 'hermes'))
|
||||
commands.push(pathApi.join(root, 'hermes-agent', 'venv', 'Scripts', 'hermes'))
|
||||
commands.push(pathApi.join(root, 'venv', 'Scripts', 'hermes'))
|
||||
}
|
||||
}
|
||||
if (kind === 'codex' && platform === 'darwin') {
|
||||
commands.push('/Applications/ChatGPT.app/Contents/Resources/codex')
|
||||
commands.push(pathApi.join(
|
||||
@@ -205,10 +256,27 @@ function agentCommands(kind, options = {}) {
|
||||
return commands
|
||||
}
|
||||
|
||||
// The WorkBuddy app ships its CLI with only the lite bundle, which the
|
||||
// launcher loads solely when this variable is set. Without it every headless
|
||||
// call, `--help` included, exits with MODULE_NOT_FOUND. Installs that carry a
|
||||
// regular bundle launch exactly as before.
|
||||
function launchEnvironment(kind, executable, options = {}) {
|
||||
if (kind !== 'workbuddy' || !executable) return {}
|
||||
const { pathApi } = runtimeOptions(options)
|
||||
try {
|
||||
const dist = pathApi.join(pathApi.dirname(fs.realpathSync(executable)), '..', 'dist')
|
||||
const shipped = name => fs.existsSync(pathApi.join(dist, name))
|
||||
if (shipped('codebuddy-lite-wb.mjs') && !shipped('codebuddy-headless.js') && !shipped('codebuddy.js')) {
|
||||
return { CODEBUDDY_FORCE_LITE_WB_BUNDLE: '1' }
|
||||
}
|
||||
} catch { /* an unreadable layout keeps the default launch */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
function executableExtensions(options = {}) {
|
||||
const { platform, env } = runtimeOptions(options)
|
||||
if (platform !== 'win32') return ['']
|
||||
const configured = String(envValue(env, 'PATHEXT') || '')
|
||||
const configured = String(envValue(env, 'PATHEXT', platform) || '')
|
||||
.split(';')
|
||||
.map(extension => extension.trim())
|
||||
.filter(Boolean)
|
||||
@@ -242,6 +310,15 @@ async function* resolvedExecutableCandidates(kind, options = {}) {
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
try {
|
||||
if (kind === 'hermes' && platform !== 'win32' && pathApi.isAbsolute(command)
|
||||
&& candidate.endsWith('/venv/bin/hermes')) {
|
||||
const installRoot = pathApi.dirname(pathApi.dirname(pathApi.dirname(candidate)))
|
||||
try {
|
||||
if ((await (options.statFn || fs.promises.stat)(pathApi.join(installRoot, 'pm'))).isDirectory()) continue
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') continue
|
||||
}
|
||||
}
|
||||
await accessFn(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK)
|
||||
yield candidate
|
||||
} catch { /* keep searching */ }
|
||||
@@ -329,52 +406,152 @@ async function probeAgentCapabilities(kind, executable, options = {}) {
|
||||
const runtime = runtimeOptions(options)
|
||||
const { env, execFileFn, platform } = runtime
|
||||
const prepareCommandFn = options.prepareCommandFn || prepareCommand
|
||||
const childEnv = options.childEnv || {
|
||||
...systemChildEnvironment(env, platform),
|
||||
PATH: searchPath(options),
|
||||
const childEnv = {
|
||||
...(options.childEnv || {
|
||||
...systemChildEnvironment(env, platform),
|
||||
...nativeConfigDirectoryEnvironment(kind, env, platform),
|
||||
...(kind === 'openclaw' ? {} : nativeCredentialEnvironment(kind, env, options.nativeCredentialKeys, platform)),
|
||||
PATH: searchPath(options),
|
||||
}),
|
||||
...launchEnvironment(kind, executable, options),
|
||||
}
|
||||
const results = await Promise.all(capabilityProbes(kind).map(async (probe) => {
|
||||
try {
|
||||
const command = prepareCommandFn(executable, probe.args, options)
|
||||
const result = await execFileFn(command.command, command.args, {
|
||||
timeout: 8000,
|
||||
windowsHide: true,
|
||||
env: childEnv,
|
||||
})
|
||||
return {
|
||||
probe,
|
||||
output: `${result.stdout || ''}\n${result.stderr || ''}`,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
probe,
|
||||
error: true,
|
||||
}
|
||||
let identity = ''
|
||||
try {
|
||||
const stat = (options.statFn || fs.statSync)(executable)
|
||||
let shimTargetIdentity = null
|
||||
if (platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable)) {
|
||||
const target = npmShimTarget(executable, options)
|
||||
if (!target) throw new Error('LOCAL_CLI_WRAPPER_UNSUPPORTED')
|
||||
const targetStat = (options.statFn || fs.statSync)(target)
|
||||
shimTargetIdentity = [target, targetStat.dev, targetStat.ino, targetStat.size,
|
||||
targetStat.mtimeMs, targetStat.ctimeMs]
|
||||
}
|
||||
}))
|
||||
for (const result of results) {
|
||||
const { probe } = result
|
||||
if (result.error) {
|
||||
return {
|
||||
compatibilityState: 'incompatible',
|
||||
incompatibilityReason: probe.id.endsWith('-acp')
|
||||
? 'LOCAL_AGENT_PROTOCOL_UNAVAILABLE'
|
||||
: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
|
||||
incompatibilityProbe: probe.id,
|
||||
const environment = Object.keys(childEnv).sort().map(key => [key, childEnv[key]])
|
||||
const environmentFingerprint = createHmac('sha256', capabilityEnvironmentKey)
|
||||
.update(JSON.stringify(environment)).digest('hex')
|
||||
const nativeRoot = nativeIdentityDirectory(kind, childEnv, runtime.home, platform)
|
||||
if (!nativeRoot && platform === process.platform) throw new Error('native configuration unavailable')
|
||||
// Loaded lazily because verification identities also use the executable shim parser in this module.
|
||||
const { agentVerificationIdentity } = require('../../workspace/agent-verification-identity.cjs')
|
||||
const nativeAgent = { kind, [NATIVE_CONFIG_IDENTITY_DIRECTORY]: nativeRoot,
|
||||
[NATIVE_AUTH_IDENTITY_FILES]: [...nativeAuthIdentityFiles(kind, childEnv, runtime.home, platform),
|
||||
...nativeNetworkIdentityFiles(childEnv, platform)] }
|
||||
const nativeConfiguration = agentVerificationIdentity(nativeAgent, {})
|
||||
identity = JSON.stringify([
|
||||
kind, executable, options.version || '', stat.dev, stat.ino,
|
||||
stat.size, stat.mtimeMs, stat.ctimeMs, shimTargetIdentity, environmentFingerprint, nativeConfiguration,
|
||||
])
|
||||
} catch { /* caching requires a verifiable executable identity */ }
|
||||
const cacheKey = `${kind}:${executable}`
|
||||
let resolveCompletion
|
||||
const record = { identity, completion: new Promise(resolve => { resolveCompletion = resolve }) }
|
||||
const group = activeCapabilityProbes.get(cacheKey) || { latest: null, active: 0 }
|
||||
group.latest = record
|
||||
group.active += 1
|
||||
activeCapabilityProbes.set(cacheKey, group)
|
||||
try {
|
||||
const outcome = await (async () => {
|
||||
const prior = capabilityCache.get(cacheKey)
|
||||
const now = Date.now()
|
||||
const recentCompatible = identity && prior?.identity === identity
|
||||
&& now >= prior.checkedAt && now - prior.checkedAt < CAPABILITY_CACHE_TTL_MS
|
||||
const results = await Promise.all(capabilityProbes(kind).map(async (probe) => {
|
||||
for (const timeout of probe.timeout ? [probe.timeout] : [8000, 16000]) {
|
||||
try {
|
||||
const command = prepareCommandFn(executable, probe.args, options)
|
||||
const execution = execFileFn(command.command, command.args, {
|
||||
timeout,
|
||||
windowsHide: true,
|
||||
env: childEnv,
|
||||
})
|
||||
if (probe.id === 'pi-json') execution.child?.stdin?.end()
|
||||
const result = await execution
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`
|
||||
const missingCapabilities = probe.requiredText.some(value => !output.includes(value))
|
||||
|| (probe.validateOutput && !probe.validateOutput(output))
|
||||
return { probe, output, authRequired: missingCapabilities && authFailureCertainty(output) === 'definitive' }
|
||||
} catch (error) {
|
||||
const timedOut = error?.killed === true || error?.code === 'ETIMEDOUT'
|
||||
if (timedOut && timeout === 8000) continue
|
||||
const diagnostic = `${error?.stdout || ''}\n${error?.stderr || ''}\n${error?.message || ''}`
|
||||
return { probe, error: true, timedOut, systemError: typeof error?.code === 'string' && !timedOut,
|
||||
authRequired: authFailureCertainty(diagnostic) === 'definitive' }
|
||||
}
|
||||
}
|
||||
}))
|
||||
while (group.latest !== record) {
|
||||
const latest = group.latest
|
||||
const newer = await latest.completion
|
||||
if (group.latest !== latest) continue
|
||||
return identity && identity === latest.identity
|
||||
? newer : { compatibilityState: 'unknown', incompatibilityReason: '', incompatibilityProbe: '' }
|
||||
}
|
||||
}
|
||||
if (probe.requiredText.some(value => !result.output.includes(value))) {
|
||||
return {
|
||||
compatibilityState: 'incompatible',
|
||||
incompatibilityReason: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
|
||||
incompatibilityProbe: probe.id,
|
||||
const failed = results.find(result => !result.timedOut && !result.systemError && !result.authRequired && (result.error
|
||||
|| result.probe.requiredText.some(value => !result.output.includes(value))
|
||||
|| (result.probe.validateOutput && !result.probe.validateOutput(result.output))))
|
||||
if (group.latest === record
|
||||
&& (failed || results.some(result => result.systemError || result.authRequired))) capabilityCache.delete(cacheKey)
|
||||
for (const result of results) {
|
||||
const { probe } = result
|
||||
if (result.authRequired && !failed) {
|
||||
return {
|
||||
compatibilityState: 'unknown',
|
||||
incompatibilityReason: 'LOCAL_AGENT_AUTH_REQUIRED',
|
||||
incompatibilityProbe: probe.id,
|
||||
}
|
||||
}
|
||||
if (result.authRequired) continue
|
||||
if (result.timedOut && !failed) {
|
||||
if (recentCompatible) continue
|
||||
return {
|
||||
compatibilityState: 'unknown',
|
||||
incompatibilityReason: 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT',
|
||||
incompatibilityProbe: probe.id,
|
||||
}
|
||||
}
|
||||
if (result.timedOut) continue
|
||||
if (result.systemError && !failed) {
|
||||
return { compatibilityState: 'unknown', incompatibilityReason: 'LOCAL_AGENT_CAPABILITY_PROBE_FAILED',
|
||||
incompatibilityProbe: probe.id }
|
||||
}
|
||||
if (result.systemError) continue
|
||||
if (result.error) {
|
||||
return {
|
||||
compatibilityState: 'incompatible',
|
||||
incompatibilityReason: probe.id.endsWith('-acp')
|
||||
? 'LOCAL_AGENT_PROTOCOL_UNAVAILABLE'
|
||||
: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
|
||||
incompatibilityProbe: probe.id,
|
||||
}
|
||||
}
|
||||
if (probe.requiredText.some(value => !result.output.includes(value))
|
||||
|| (probe.validateOutput && !probe.validateOutput(result.output))) {
|
||||
return {
|
||||
compatibilityState: 'incompatible',
|
||||
incompatibilityReason: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
|
||||
incompatibilityProbe: probe.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
compatibilityState: 'compatible',
|
||||
incompatibilityReason: '',
|
||||
incompatibilityProbe: '',
|
||||
if (group.latest === record && identity && !results.some(result => result.timedOut)) {
|
||||
capabilityCache.set(cacheKey, { identity, checkedAt: Date.now() })
|
||||
}
|
||||
return {
|
||||
compatibilityState: 'compatible',
|
||||
incompatibilityReason: '',
|
||||
incompatibilityProbe: '',
|
||||
...(kind === 'hermes' && results.some(result => result.output?.includes('--format')
|
||||
&& result.output.includes('stream-json')) ? { streamJsonAvailable: true } : {}),
|
||||
}
|
||||
})()
|
||||
resolveCompletion(outcome)
|
||||
return outcome
|
||||
} catch (error) {
|
||||
resolveCompletion({ compatibilityState: 'unknown', incompatibilityReason: '', incompatibilityProbe: '' })
|
||||
throw error
|
||||
} finally {
|
||||
group.active -= 1
|
||||
if (!group.active && activeCapabilityProbes.get(cacheKey) === group) activeCapabilityProbes.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +564,12 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
|
||||
incompatibilityReason: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
|
||||
incompatibilityProbe: '',
|
||||
}
|
||||
const identifiedVersion = (stdout, stderr) => {
|
||||
const identified = identifyAgentVersion(kind, [stdout, stderr], { executable })
|
||||
const version = identified && extractAgentVersion(identified.line) !== identified.resolvedVersion
|
||||
? identified.resolvedVersion : identified?.line || ''
|
||||
return { succeeded: true, version: redactChildSecrets(version, childEnv), versionIdentified: Boolean(identified) }
|
||||
}
|
||||
const versionTask = (async () => {
|
||||
try {
|
||||
const versionCommand = prepareCommandFn(executable, ['--version'], options)
|
||||
@@ -395,24 +578,23 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
|
||||
windowsHide: true,
|
||||
env: childEnv,
|
||||
})
|
||||
const identified = identifyAgentVersion(kind, [result.stdout, result.stderr], { executable })
|
||||
return {
|
||||
succeeded: true,
|
||||
version: identified?.line || '',
|
||||
versionIdentified: Boolean(identified),
|
||||
return identifiedVersion(result.stdout, result.stderr)
|
||||
} catch (error) {
|
||||
if (Number.isInteger(error?.code) && !error.killed && !error.signal) {
|
||||
return identifiedVersion(error.stdout, error.stderr)
|
||||
}
|
||||
} catch { /* a broken shim is not a usable CLI */ }
|
||||
/* Capability probes can still establish whether this installation works. */
|
||||
}
|
||||
return { succeeded: false, version: '', versionIdentified: false }
|
||||
})()
|
||||
|
||||
const { succeeded: versionCommandSucceeded, version } = await versionTask
|
||||
if (!versionCommandSucceeded) return null
|
||||
const { version, versionIdentified } = await versionTask
|
||||
const versionCompatibility = assessAgentVersion(kind, version)
|
||||
const capabilityTask = Promise.resolve().then(async () => {
|
||||
const result = await (options.probeAgentCapabilitiesFn || probeAgentCapabilities)(
|
||||
kind,
|
||||
executable,
|
||||
{ ...options, childEnv },
|
||||
{ ...options, childEnv, version },
|
||||
)
|
||||
return result && typeof result === 'object' ? result : incompatibleCapabilities
|
||||
}).catch(() => incompatibleCapabilities)
|
||||
@@ -432,8 +614,10 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
|
||||
})()
|
||||
: Promise.resolve(undefined)
|
||||
const [capabilityCompatibility, acpAvailable] = await Promise.all([capabilityTask, acpTask])
|
||||
const compatibility = capabilityCompatibility.compatibilityState === 'incompatible'
|
||||
const compatibility = capabilityCompatibility.compatibilityState !== 'compatible'
|
||||
? { ...versionCompatibility, ...capabilityCompatibility }
|
||||
: kind === 'pi' && !version
|
||||
? { ...versionCompatibility, compatibilityState: 'unknown', incompatibilityProbe: '' }
|
||||
: {
|
||||
...versionCompatibility,
|
||||
compatibilityState: 'compatible',
|
||||
@@ -445,8 +629,12 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
|
||||
name: `${AGENT_PROFILES[kind].label} CLI`,
|
||||
executable,
|
||||
version,
|
||||
...(!versionIdentified ? { versionIdentified: false } : {}),
|
||||
...compatibility,
|
||||
...(kind === 'hermes' ? { acpAvailable } : {}),
|
||||
...(kind === 'hermes' ? {
|
||||
acpAvailable,
|
||||
...(capabilityCompatibility.streamJsonAvailable ? { streamJsonAvailable: true } : {}),
|
||||
} : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,27 +644,39 @@ async function detectAgentKind(kind, options, childEnv) {
|
||||
: null
|
||||
const candidates = typeof options.resolveExecutableFn === 'function'
|
||||
? (resolved ? [resolved] : [])
|
||||
: resolvedExecutableCandidates(kind, options)
|
||||
: resolvedExecutableCandidates(kind, kind === 'hermes'
|
||||
? { ...options, env: { ...options.env, HERMES_HOME: childEnv.HERMES_HOME,
|
||||
HERMES_INSTALL_DIR: childEnv.HERMES_INSTALL_DIR } } : options)
|
||||
let firstIncompatible = null
|
||||
for await (const executable of candidates) {
|
||||
const candidate = await inspectAgentCandidate(kind, executable, options, childEnv)
|
||||
if (!candidate) continue
|
||||
if (candidate.compatibilityState === 'compatible') return candidate
|
||||
if (candidate.compatibilityState === 'compatible' && candidate.versionIdentified !== false) return candidate
|
||||
firstIncompatible ||= candidate
|
||||
}
|
||||
return firstIncompatible
|
||||
}
|
||||
|
||||
async function detectAgents(options = {}) {
|
||||
const runtime = runtimeOptions(options)
|
||||
const detectionOptions = { ...options, [DETECTION_SEARCH_DIRECTORIES]: searchDirectories(options) }
|
||||
const runtime = runtimeOptions(detectionOptions)
|
||||
const { env, platform } = runtime
|
||||
const childEnv = {
|
||||
...systemChildEnvironment(env, platform),
|
||||
PATH: searchPath(options),
|
||||
PATH: searchPath(detectionOptions),
|
||||
}
|
||||
const detected = await Promise.all(ALLOWED_KINDS.map(kind => (
|
||||
detectAgentKind(kind, options, childEnv).catch(() => null)
|
||||
)))
|
||||
const detected = await Promise.all(ALLOWED_KINDS.map(kind => {
|
||||
const secretKeys = typeof options.nativeCredentialKeysForKind === 'function'
|
||||
? nativeSecretEnvKeys(options.nativeCredentialKeysForKind(kind)) : []
|
||||
const nativeEnv = typeof options.nativeEnvironmentForKind === 'function'
|
||||
? options.nativeEnvironmentForKind(kind) : {
|
||||
...nativeConfigDirectoryEnvironment(kind, env, platform),
|
||||
...(kind === 'openclaw' ? {} : nativeCredentialEnvironment(kind, env, secretKeys, platform)),
|
||||
}
|
||||
const kindEnv = { ...childEnv, ...nativeEnv }
|
||||
Object.defineProperty(kindEnv, SECRET_ENV_KEYS, { value: secretKeys })
|
||||
return detectAgentKind(kind, detectionOptions, kindEnv).catch(() => null)
|
||||
}))
|
||||
return detected.filter(Boolean)
|
||||
}
|
||||
|
||||
@@ -484,6 +684,8 @@ module.exports = {
|
||||
AGENT_PROFILES,
|
||||
ALLOWED_KINDS,
|
||||
detectAgents,
|
||||
launchEnvironment,
|
||||
npmShimTarget,
|
||||
prepareCommand,
|
||||
probeAgentCapabilities,
|
||||
resolveExecutable,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const { StringDecoder } = require('node:string_decoder')
|
||||
const { createHash } = require('node:crypto')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { agentRuntimeFailure } = require('../agent-runtime-contract.cjs')
|
||||
const {
|
||||
classifyCliOutcome,
|
||||
codexProgressEvent,
|
||||
@@ -14,6 +17,7 @@ const {
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parseOpenCodeReviewOutput,
|
||||
parsePiOutput,
|
||||
parseWorkBuddyOutput,
|
||||
readHermesFinalResponse,
|
||||
readHermesMessageWatermark,
|
||||
@@ -26,11 +30,13 @@ const {
|
||||
createAcpRuntimeState,
|
||||
createClaudeQwenRuntimeState,
|
||||
createGeminiRuntimeState,
|
||||
createPiRuntimeState,
|
||||
finalOnlyRuntimeEvents,
|
||||
geminiStreamJsonRuntimeEvents,
|
||||
kimiStreamJsonRuntimeEvents,
|
||||
mimoJsonRuntimeEvents,
|
||||
openCodeJsonRuntimeEvents,
|
||||
piJsonRuntimeEvents,
|
||||
} = require('./cli-runtime-event-mappers.cjs')
|
||||
|
||||
function createStatelessRuntimeState() {
|
||||
@@ -75,6 +81,7 @@ const FINAL_OUTPUT_PARSERS = Object.freeze({
|
||||
codex: parseCodexOutput,
|
||||
openclaw: stdout => ({ text: normalizeOpenClawOutput(stdout), sessionRef: '' }),
|
||||
workbuddy: parseWorkBuddyOutput,
|
||||
pi: parsePiOutput,
|
||||
kimi: parseKimiOutput,
|
||||
mimo: parseMimoOutput,
|
||||
claude: parseClaudeQwenOutput,
|
||||
@@ -178,19 +185,34 @@ function createHermesLegacyFinalOutputAccumulator(sessionRef = '') {
|
||||
|
||||
function createHermesLegacyRunContext(options = {}) {
|
||||
const watermarkFn = options.hermesMessageWatermarkFn || readHermesMessageWatermark
|
||||
const env = options.childEnv || {}
|
||||
const home = options.home || env.HOME || os.homedir()
|
||||
let hermesHome = String(env.HERMES_HOME || '').trim()
|
||||
if (hermesHome) {
|
||||
hermesHome = hermesHome.replace(/\$(\w+)|\$\{([^}]+)\}/g, (match, plain, braced) => env[plain || braced] ?? match)
|
||||
if (hermesHome === '~' || hermesHome.startsWith('~/')) hermesHome = path.join(home, hermesHome.slice(2))
|
||||
hermesHome = path.resolve(options.workdir || process.cwd(), hermesHome)
|
||||
} else {
|
||||
const suffix = env.HERMES_DATA_DIR_SUFFIX || ''
|
||||
hermesHome = options.platform === 'win32'
|
||||
? path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), `hermes${suffix}`)
|
||||
: path.join(home, `.hermes${suffix}`)
|
||||
}
|
||||
try {
|
||||
const messageWatermark = watermarkFn({
|
||||
home: options.home,
|
||||
hermesHome,
|
||||
existsFn: options.hermesStateExistsFn,
|
||||
queryFn: options.hermesStateQueryFn,
|
||||
})
|
||||
return {
|
||||
hermesHome,
|
||||
messageWatermark: Number.isSafeInteger(messageWatermark) && messageWatermark >= 0
|
||||
? messageWatermark
|
||||
: null,
|
||||
}
|
||||
} catch {
|
||||
return { messageWatermark: null }
|
||||
return { hermesHome, messageWatermark: null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,21 +225,30 @@ async function finalizeHermesLegacyResult(input = {}) {
|
||||
if (sessionRef && typeof options.onSessionRef === 'function') {
|
||||
await options.onSessionRef(sessionRef, { transport: 'legacy' })
|
||||
}
|
||||
if (!sessionRef || !Number.isSafeInteger(runContext?.messageWatermark)) return result
|
||||
const finalResponseFn = options.hermesFinalResponseFn || readHermesFinalResponse
|
||||
let finalResponse = ''
|
||||
try {
|
||||
finalResponse = finalResponseFn(sessionRef, {
|
||||
home: options.home,
|
||||
afterMessageId: runContext.messageWatermark,
|
||||
existsFn: options.hermesStateExistsFn,
|
||||
queryFn: options.hermesStateQueryFn,
|
||||
})
|
||||
} catch { /* fall back to the official --quiet stdout */ }
|
||||
if (sessionRef && Number.isSafeInteger(runContext?.messageWatermark)) {
|
||||
try {
|
||||
finalResponse = finalResponseFn(sessionRef, {
|
||||
home: options.home,
|
||||
hermesHome: runContext.hermesHome,
|
||||
afterMessageId: runContext.messageWatermark,
|
||||
existsFn: options.hermesStateExistsFn,
|
||||
queryFn: options.hermesStateQueryFn,
|
||||
})
|
||||
} catch { /* fall back to the official --quiet stdout */ }
|
||||
}
|
||||
const text = typeof finalResponse === 'string'
|
||||
? finalResponse.trim().slice(0, MAX_FINAL_TEXT_CHARS)
|
||||
: ''
|
||||
return text ? { ...result, text } : result
|
||||
const output = text ? { ...result, text } : result
|
||||
const notice = /^(?:Your request was not processed\. Send it again if you still want me to carry it out\.|This turn did not complete\. Some actions may already have run; verify their effects before resending\.)$/m
|
||||
const failureText = [result.text, text].map(value => String(value || '').replace(/\r/g, '').trim())
|
||||
.find(candidate => notice.test(candidate)
|
||||
&& (!candidate.includes('\n') || /^Provider said:\s*\S.*$/m.test(candidate)))
|
||||
return failureText
|
||||
? { ...output, outcome: 'failed', failure: agentRuntimeFailure('LOCAL_AGENT_PROCESS_FAILED'), diagnostic: failureText }
|
||||
: output
|
||||
}
|
||||
|
||||
function profile(input) {
|
||||
@@ -353,6 +384,21 @@ const OPENCODE_JSON_EVENTS_PROFILE = profile({
|
||||
createFinalOutputAccumulator: createFinalOutputAccumulator('opencode'),
|
||||
})
|
||||
|
||||
const PI_JSON_EVENTS_PROFILE = profile({
|
||||
profileId: 'pi-json-events-v2',
|
||||
protocol: 'pi-json',
|
||||
framing: 'jsonl',
|
||||
source: 'stdout',
|
||||
capabilities: {
|
||||
answerMode: 'delta', tools: FULL_TOOL_LIFECYCLE,
|
||||
plan: false, reasoning: true, session: true, terminal: true,
|
||||
},
|
||||
createDecoder: createJsonLineParser,
|
||||
createState: createPiRuntimeState,
|
||||
mapEvent: piJsonRuntimeEvents,
|
||||
createFinalOutputAccumulator: createFinalOutputAccumulator('pi'),
|
||||
})
|
||||
|
||||
const OPENCLAW_TERMINAL_DOCUMENT_PROFILE = profile({
|
||||
profileId: 'openclaw-terminal-document-v1',
|
||||
protocol: 'openclaw-terminal-document',
|
||||
@@ -431,13 +477,103 @@ const HERMES_FINAL_TEXT_PROFILE = profile({
|
||||
createFinalOutputAccumulator: createHermesLegacyFinalOutputAccumulator,
|
||||
})
|
||||
|
||||
const HERMES_STREAM_JSON_PROFILE = profile({
|
||||
profileId: 'hermes-stream-json-v1',
|
||||
protocol: 'hermes-stream-json',
|
||||
framing: 'jsonl',
|
||||
source: 'stdout',
|
||||
capabilities: {
|
||||
answerMode: 'delta', tools: START_RESULT_TOOL_LIFECYCLE,
|
||||
plan: false, reasoning: false, session: true, terminal: true,
|
||||
},
|
||||
createDecoder: createJsonLineParser,
|
||||
createState: createStatelessRuntimeState,
|
||||
mapEvent(event) {
|
||||
if (event.type === 'text' && typeof event.text === 'string') {
|
||||
return [{ type: 'answer_delta', status: 'running', delta: event.text }]
|
||||
}
|
||||
if (['tool_use', 'tool_result'].includes(event.type)) {
|
||||
return [{
|
||||
id: event.tool_call_id || event.name,
|
||||
type: event.type === 'tool_use' ? 'tool_start' : 'tool_result_summary',
|
||||
title: event.name || 'tool',
|
||||
status: event.type === 'tool_use' ? 'running' : event.is_error ? 'failed' : 'completed',
|
||||
}]
|
||||
}
|
||||
return []
|
||||
},
|
||||
async finalizeResult({ result, options }) {
|
||||
// Both native chat formats use the same resumable conversation IDs.
|
||||
if (result.sessionRef) await options.onSessionRef?.(result.sessionRef, { transport: 'legacy' })
|
||||
return result
|
||||
},
|
||||
createFinalOutputAccumulator(sessionRef = '') {
|
||||
const state = { text: '', sessionRef, outcome: 'partial' }
|
||||
const ingest = event => {
|
||||
if (typeof event.session_id === 'string' && event.session_id) state.sessionRef = event.session_id
|
||||
if (event.type === 'text' && typeof event.text === 'string') appendFinalText(state, event.text)
|
||||
if (event.type === 'result') {
|
||||
if (typeof event.text === 'string') state.text = event.text.slice(0, MAX_FINAL_TEXT_CHARS)
|
||||
state.outcome = event.exit_code === 0 && !event.error ? 'completed' : 'failed'
|
||||
if (event.error) state.error = String(event.error)
|
||||
}
|
||||
}
|
||||
const parser = createJsonLineParser(ingest)
|
||||
return {
|
||||
ingest,
|
||||
write: chunk => parser.write(chunk),
|
||||
end() {
|
||||
parser.end()
|
||||
return { ...state }
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const ZCODE_JSON_PROFILE = profile({
|
||||
profileId: 'zcode-prompt-json-v1',
|
||||
protocol: 'zcode-prompt-json',
|
||||
framing: 'document',
|
||||
source: 'stdout',
|
||||
capabilities: {
|
||||
answerMode: 'final', tools: NO_TOOL_LIFECYCLE,
|
||||
plan: false, reasoning: false, session: true, terminal: true,
|
||||
},
|
||||
createState: createStatelessRuntimeState,
|
||||
createDecoder: null,
|
||||
mapEvent: finalOnlyRuntimeEvents,
|
||||
createFinalOutputAccumulator() {
|
||||
const decoder = new StringDecoder('utf8')
|
||||
const raw = { text: '' }
|
||||
return {
|
||||
capture(chunk) { appendFinalText(raw, decoder.write(chunk)) },
|
||||
end() {
|
||||
appendFinalText(raw, decoder.end())
|
||||
let value
|
||||
try { value = JSON.parse(raw.text) } catch { return { text: '', outcome: 'failed' } }
|
||||
if (typeof value.response !== 'string' || typeof value.sessionId !== 'string') {
|
||||
return { text: '', outcome: 'failed' }
|
||||
}
|
||||
return {
|
||||
text: value.response,
|
||||
sessionRef: value.sessionId,
|
||||
outcome: ['idle', 'completed'].includes(value.projection?.status) ? 'completed' : 'partial',
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const CONNECTOR_EVENT_PROFILES = Object.freeze({
|
||||
codex: CODEX_APP_SERVER_PROFILE,
|
||||
hermes: ACP_JSONRPC_PROFILE,
|
||||
openclaw: ACP_JSONRPC_PROFILE,
|
||||
workbuddy: WORKBUDDY_TERMINAL_RESULT_PROFILE,
|
||||
zcode: ZCODE_JSON_PROFILE,
|
||||
pi: PI_JSON_EVENTS_PROFILE,
|
||||
kimi: ACP_JSONRPC_PROFILE,
|
||||
mimo: MIMO_JSON_EVENTS_PROFILE,
|
||||
deepseek: ACP_JSONRPC_PROFILE,
|
||||
claude: ANTHROPIC_STREAM_JSON_PROFILE,
|
||||
gemini: GEMINI_STREAM_JSON_PROFILE,
|
||||
opencode: OPENCODE_JSON_EVENTS_PROFILE,
|
||||
@@ -446,10 +582,11 @@ const CONNECTOR_EVENT_PROFILES = Object.freeze({
|
||||
})
|
||||
|
||||
const TRANSPORT_EVENT_PROFILES = Object.freeze({
|
||||
hermes: Object.freeze({ legacy: HERMES_FINAL_TEXT_PROFILE }),
|
||||
hermes: Object.freeze({ legacy: HERMES_FINAL_TEXT_PROFILE, 'stream-json': HERMES_STREAM_JSON_PROFILE }),
|
||||
openclaw: Object.freeze({ legacy: OPENCLAW_TERMINAL_DOCUMENT_PROFILE }),
|
||||
kimi: Object.freeze({ 'stream-json': KIMI_STREAM_JSON_PROFILE }),
|
||||
mimo: Object.freeze({ acp: ACP_JSONRPC_PROFILE }),
|
||||
pi: Object.freeze({ json: PI_JSON_EVENTS_PROFILE }),
|
||||
opencode: Object.freeze({ acp: ACP_JSONRPC_PROFILE }),
|
||||
workbuddy: Object.freeze({ 'stream-json': ANTHROPIC_STREAM_JSON_PROFILE }),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const path = require('node:path')
|
||||
const { deepseekNativeConfig } = require('./deepseek-native-config.cjs')
|
||||
|
||||
const CODEX_SANDBOXES = new Set(['read-only', 'workspace-write'])
|
||||
const IMAGE_ATTACHMENT_LIMITS = Object.freeze({
|
||||
@@ -6,6 +7,18 @@ const IMAGE_ATTACHMENT_LIMITS = Object.freeze({
|
||||
hermes: 1,
|
||||
opencode: 4,
|
||||
})
|
||||
const DEFAULT_INTERNAL_TURN_LIMITS = Object.freeze({
|
||||
// Preserve the default for independent replies. Automatic discussions pass
|
||||
// an explicit zero to let the native tool loop finish its work.
|
||||
workbuddy: 8,
|
||||
})
|
||||
|
||||
function internalTurnLimit(kind, requested) {
|
||||
if (requested === 0) return 0
|
||||
const value = Number(requested)
|
||||
if (Number.isInteger(value) && value >= 1 && value <= 50) return value
|
||||
return DEFAULT_INTERNAL_TURN_LIMITS[kind]
|
||||
}
|
||||
|
||||
function codexSandbox(requested) {
|
||||
if (requested != null) {
|
||||
@@ -62,9 +75,24 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
}
|
||||
}
|
||||
if (kind === 'hermes') {
|
||||
const legacySessionRef = options.sessionTransport === 'acp' ? '' : sessionRef
|
||||
if (options.hermesStreamJsonAvailable === true
|
||||
&& (options.hermesAcpAvailable === false || options.invocationTransport === 'stream-json'
|
||||
|| (sessionRef && options.sessionTransport !== 'acp'))
|
||||
&& (!sessionRef || options.sessionTransport !== 'acp')) {
|
||||
return {
|
||||
command: executable,
|
||||
args: ['chat', '--format', 'stream-json',
|
||||
...(options.sandbox === 'workspace-write' ? ['--yolo'] : []),
|
||||
...(sessionRef ? ['--resume', sessionRef] : []),
|
||||
...(attachments[0] ? ['--image', attachments[0]] : []), '--query'],
|
||||
promptArg: true,
|
||||
eventTransport: 'stream-json',
|
||||
}
|
||||
}
|
||||
// Hermes uses the same native conversation identifier across its ACP and
|
||||
// legacy transports. Keep it when an attachment requires the legacy path.
|
||||
const legacySessionRef = sessionRef
|
||||
const useAcp = options.invocationTransport !== 'legacy'
|
||||
&& !attachments.length
|
||||
&& options.hermesAcpAvailable !== false
|
||||
&& (!sessionRef || options.sessionTransport === 'acp')
|
||||
if (useAcp) {
|
||||
@@ -75,6 +103,9 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
eventTransport: 'acp',
|
||||
fallbackTransport: 'legacy',
|
||||
fallbackSessionPolicy: 'invalidate',
|
||||
// Hermes ACP advertises image prompts; send images as content blocks
|
||||
// instead of silently dropping them from a text-only prompt.
|
||||
...(attachments.length ? { imageAttachments: attachments } : {}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -83,8 +114,6 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
'chat',
|
||||
'--quiet',
|
||||
...(options.sandbox === 'workspace-write' ? ['--yolo'] : []),
|
||||
...(options.provider?.id ? ['--provider', options.provider.id] : []),
|
||||
...(options.provider?.model ? ['--model', options.provider.model] : []),
|
||||
...(legacySessionRef ? ['--resume', legacySessionRef] : []),
|
||||
...(attachments[0] ? ['--image', attachments[0]] : []),
|
||||
'--query',
|
||||
@@ -125,46 +154,63 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
}
|
||||
}
|
||||
if (kind === 'workbuddy') {
|
||||
const maxTurns = internalTurnLimit(kind, options.maxTurns)
|
||||
return {
|
||||
command: executable,
|
||||
args: [
|
||||
'--print',
|
||||
'--output-format', 'stream-json',
|
||||
'--include-partial-messages',
|
||||
// Workspace write access means accepting file edits in the working
|
||||
// directory. Skipping every permission check would also let the CLI
|
||||
// run any command anywhere on the machine without confirmation.
|
||||
'--permission-mode', options.sandbox === 'workspace-write' ? 'acceptEdits' : 'plan',
|
||||
'--max-turns', '20',
|
||||
...(maxTurns ? ['--max-turns', String(maxTurns)] : []),
|
||||
...(options.model ? ['--model', options.model] : []),
|
||||
...(sessionRef ? ['--resume', sessionRef] : []),
|
||||
],
|
||||
promptArg: true,
|
||||
eventTransport: 'stream-json',
|
||||
}
|
||||
}
|
||||
if (kind === 'kimi') {
|
||||
if (options.sandbox !== 'workspace-write') {
|
||||
return {
|
||||
command: executable,
|
||||
args: ['acp'],
|
||||
acpMode: 'plan',
|
||||
eventTransport: 'acp',
|
||||
}
|
||||
if (kind === 'zcode') {
|
||||
return {
|
||||
command: executable,
|
||||
args: ['--json', '--no-color', '--verbose', '--cwd', workdir,
|
||||
'--mode', options.sandbox === 'workspace-write' ? 'edit' : 'plan',
|
||||
...(sessionRef ? ['--resume', sessionRef] : []), '--prompt'],
|
||||
promptArg: true,
|
||||
}
|
||||
}
|
||||
if (kind === 'pi') {
|
||||
return {
|
||||
command: executable,
|
||||
args: [
|
||||
'--output-format', 'stream-json',
|
||||
'--auto',
|
||||
'--mode', 'json', '--print',
|
||||
...(options.sandbox === 'workspace-write'
|
||||
? ['--approve']
|
||||
: ['--no-approve', '--tools', 'read,grep,find,ls']),
|
||||
...(sessionRef ? ['--session', sessionRef] : []),
|
||||
'--prompt',
|
||||
],
|
||||
promptArg: true,
|
||||
eventTransport: 'stream-json',
|
||||
eventTransport: 'json',
|
||||
}
|
||||
}
|
||||
if (kind === 'kimi') {
|
||||
// Kimi rejects --auto together with --prompt. Use the permission-aware
|
||||
// ACP transport for both modes instead of bypassing approval with --yolo.
|
||||
return {
|
||||
command: executable,
|
||||
args: ['acp'],
|
||||
acpMode: 'default',
|
||||
eventTransport: 'acp',
|
||||
}
|
||||
}
|
||||
if (kind === 'mimo') {
|
||||
if (options.invocationTransport !== 'json') {
|
||||
if (!attachments.length && options.invocationTransport !== 'json') {
|
||||
return {
|
||||
command: executable,
|
||||
args: ['acp', '--pure', '--cwd', workdir],
|
||||
args: ['acp', '--cwd', workdir],
|
||||
acpMode: options.sandbox === 'workspace-write' ? 'build' : 'plan',
|
||||
eventTransport: 'acp',
|
||||
fallbackTransport: 'json',
|
||||
@@ -174,13 +220,26 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
return {
|
||||
command: executable,
|
||||
args: [
|
||||
'run', '--pure', '--agent', options.sandbox === 'workspace-write' ? 'build' : 'plan',
|
||||
'run', '--agent', options.sandbox === 'workspace-write' ? 'build' : 'plan',
|
||||
'--format', 'json', '--dir', workdir,
|
||||
...(sessionRef ? ['--session', sessionRef] : []),
|
||||
],
|
||||
promptArg: true,
|
||||
}
|
||||
}
|
||||
if (kind === 'deepseek') {
|
||||
const mode = options.sandbox === 'workspace-write' ? 'write' : 'read'
|
||||
// The external dsh process cannot read files inside Electron's ASAR.
|
||||
const policyDirectory = __dirname.replace(/app\.asar(?=[\\/])/, 'app.asar.unpacked')
|
||||
return {
|
||||
command: executable,
|
||||
args: ['--profile', 'acp', ...(deepseekNativeConfig({ env: options.env }).explicitModel
|
||||
? [] : ['--patch', path.join(policyDirectory, 'deepseek-zgci.yml')]),
|
||||
'--patch', path.join(policyDirectory, `deepseek-${mode}.yml`)],
|
||||
eventTransport: 'acp',
|
||||
deepSeekMode: mode,
|
||||
}
|
||||
}
|
||||
if (kind === 'claude') {
|
||||
return {
|
||||
command: executable,
|
||||
@@ -202,8 +261,6 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
'--output-format', 'stream-json',
|
||||
'--include-partial-messages',
|
||||
'--approval-mode', options.sandbox === 'workspace-write' ? 'auto-edit' : 'plan',
|
||||
...(options.provider?.id === 'openai' ? ['--auth-type', 'openai'] : []),
|
||||
...(options.provider?.model ? ['--model', options.provider.model] : []),
|
||||
...(sessionRef ? ['--resume', sessionRef] : []),
|
||||
],
|
||||
promptArg: true,
|
||||
@@ -225,7 +282,7 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
if (!attachments.length && options.invocationTransport !== 'json') {
|
||||
return {
|
||||
command: executable,
|
||||
args: ['acp', '--pure', '--cwd', workdir],
|
||||
args: ['acp', '--cwd', workdir],
|
||||
acpMode: options.sandbox === 'workspace-write' ? 'build' : 'plan',
|
||||
eventTransport: 'acp',
|
||||
fallbackTransport: 'json',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const NETWORK_ENV_KEYS = Object.freeze([
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
||||
'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
|
||||
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
|
||||
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'NODE_USE_ENV_PROXY',
|
||||
])
|
||||
|
||||
function networkEnvironment(source = {}, platform = process.platform) {
|
||||
const result = {}
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const allowed = platform === 'win32'
|
||||
? NETWORK_ENV_KEYS.find(name => name.toLowerCase() === key.toLowerCase())
|
||||
: NETWORK_ENV_KEYS.find(name => name === key)
|
||||
if (allowed && typeof value === 'string') result[allowed] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = { NETWORK_ENV_KEYS, networkEnvironment }
|
||||
@@ -4,10 +4,14 @@ const path = require('node:path')
|
||||
|
||||
const {
|
||||
KILL_SETTLE_MS,
|
||||
terminateWindowsProcessTree,
|
||||
TERMINATE_GRACE_MS,
|
||||
childEnvironment,
|
||||
} = require('./cli-process-support.cjs')
|
||||
const { configureOpenClawGatewayRuntime } = require('./openclaw-runtime.cjs')
|
||||
const {
|
||||
configureOpenClawGatewayRuntime,
|
||||
refreshOpenClawRuntimeAfterStartup,
|
||||
} = require('./openclaw-runtime.cjs')
|
||||
|
||||
const GATEWAY_READY_MARKER = '[gateway] ready'
|
||||
const GATEWAY_READY_TIMEOUT_MS = 15_000
|
||||
@@ -60,6 +64,14 @@ async function availableLoopbackPort() {
|
||||
|
||||
async function terminateGateway(child, platform = process.platform) {
|
||||
if (!child || child.exitCode != null || child.signalCode != null) return
|
||||
if (platform === 'win32') {
|
||||
try { await terminateWindowsProcessTree(child, spawn) } finally {
|
||||
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
||||
if (stream && !stream.destroyed) stream.destroy()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
let settled = false
|
||||
let forceKillTimeout
|
||||
@@ -75,15 +87,6 @@ async function terminateGateway(child, platform = process.platform) {
|
||||
}
|
||||
child.once('close', finish)
|
||||
child.once('error', finish)
|
||||
if (platform === 'win32' && child.pid) {
|
||||
const killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'ignore', windowsHide: true,
|
||||
})
|
||||
killer.on('error', () => {})
|
||||
killer.unref()
|
||||
forceSettleTimeout = setTimeout(finish, TERMINATE_GRACE_MS + KILL_SETTLE_MS)
|
||||
return
|
||||
}
|
||||
const signalTree = (signal) => {
|
||||
if (child.pid) {
|
||||
try {
|
||||
@@ -160,6 +163,8 @@ async function verifyGatewayHealth({
|
||||
gatewayPort,
|
||||
signal,
|
||||
workdir,
|
||||
spawnHealth = spawn,
|
||||
terminateHealth = terminateGateway,
|
||||
}) {
|
||||
if (!gatewayChildAlive(gatewayChild)) throw gatewayStartError()
|
||||
const args = [
|
||||
@@ -170,7 +175,7 @@ async function verifyGatewayHealth({
|
||||
]
|
||||
let healthChild
|
||||
try {
|
||||
healthChild = spawn(executable, args, {
|
||||
healthChild = spawnHealth(executable, args, {
|
||||
cwd: path.resolve(workdir),
|
||||
detached: process.platform !== 'win32',
|
||||
env,
|
||||
@@ -203,12 +208,12 @@ async function verifyGatewayHealth({
|
||||
settled = true
|
||||
cleanup()
|
||||
const stopHealth = error && gatewayChildAlive(healthChild)
|
||||
? terminateGateway(healthChild)
|
||||
? terminateHealth(healthChild)
|
||||
: Promise.resolve()
|
||||
stopHealth.catch(() => {}).then(() => {
|
||||
stopHealth.then(() => {
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
}, reject)
|
||||
}
|
||||
const onData = (chunk) => {
|
||||
stdoutBytes += chunk.length
|
||||
@@ -286,8 +291,11 @@ async function withOpenClawGateway(options, callback) {
|
||||
signal: options.signal,
|
||||
workdir: options.workdir,
|
||||
})
|
||||
runtimeOptions = refreshOpenClawRuntimeAfterStartup(runtimeOptions)
|
||||
} catch (error) {
|
||||
await terminateGateway(child)
|
||||
// Startup can migrate the config before a port conflict or health failure.
|
||||
runtimeOptions = refreshOpenClawRuntimeAfterStartup(runtimeOptions)
|
||||
if (error?.retryable && attempt + 1 < GATEWAY_SETUP_ATTEMPTS) continue
|
||||
throw attachRuntimeOptions(error, runtimeOptions)
|
||||
}
|
||||
@@ -308,4 +316,4 @@ async function withOpenClawGateway(options, callback) {
|
||||
throw gatewayStartError()
|
||||
}
|
||||
|
||||
module.exports = { withOpenClawGateway }
|
||||
module.exports = { withOpenClawGateway, verifyGatewayHealth }
|
||||
@@ -10,7 +10,7 @@ const MAX_RUNTIME_JSON_PENDING_CHARS = 1024 * 1024
|
||||
const MAX_STRUCTURED_JSON_BYTES = 64 * 1024 * 1024
|
||||
const MAX_STRUCTURED_TEXT_CHARS = 1024 * 1024
|
||||
const STRUCTURED_JSONL_KINDS = new Set([
|
||||
'codex', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'codex', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
])
|
||||
const STRUCTURED_DOCUMENT_KINDS = new Set(['openclaw', 'opencodereview'])
|
||||
function parseCodexOutput(stdout) {
|
||||
@@ -90,6 +90,65 @@ function appendStructuredText(state, value, separator = '\n') {
|
||||
state.text += `${prefix}${text}`.slice(0, remaining)
|
||||
}
|
||||
|
||||
function stripPiInternalPromptEcho(value) {
|
||||
let text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
const preamble = /^You are participating in a local multi-agent discussion\.[\s\S]*?^Answer the user naturally(?: in Markdown)?\.?\s*/im
|
||||
text = text.replace(preamble, '').trim()
|
||||
// Pi may resume from a compact continuation prompt and echo only the
|
||||
// protocol instructions or the selected Harness records. Remove those
|
||||
// internal lines while preserving any user-facing text on the same line.
|
||||
text = text.replace(/^Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template\.\s*/im, '')
|
||||
text = text.replace(/^Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission\.\s*/im, '')
|
||||
const lines = text.split(/\r?\n/)
|
||||
const cleaned = []
|
||||
let inRecords = false
|
||||
for (const line of lines) {
|
||||
const valueLine = line.trim()
|
||||
if (/^Selected records from completed phases:\s*$/i.test(valueLine)) {
|
||||
inRecords = true
|
||||
continue
|
||||
}
|
||||
if (inRecords) {
|
||||
if (/^MELDWORK_V4_COLLABORATION_PACKAGE_V1$/i.test(valueLine)
|
||||
|| /^\[index\]\s+\S+/i.test(valueLine)
|
||||
|| /^Artifact:\s+\S+/i.test(valueLine)
|
||||
|| /^Evidence:\s+\S+/i.test(valueLine)) continue
|
||||
const phasePrefix = /^(?:\[(?:challenge|proposal)\]\s+agent=\S+\s+Completed the (?:challenge|proposal) phase\.\s*)/i
|
||||
const remainder = line.replace(phasePrefix, '')
|
||||
if (remainder !== line) {
|
||||
if (remainder.trim()) cleaned.push(remainder)
|
||||
continue
|
||||
}
|
||||
inRecords = false
|
||||
}
|
||||
cleaned.push(line)
|
||||
}
|
||||
return cleaned.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
function stripPiPromptEcho(value, prompt) {
|
||||
const text = String(value || '').trim()
|
||||
const expected = String(prompt || '').trim()
|
||||
if (expected && text.startsWith(expected)) {
|
||||
return text.slice(expected.length).trim()
|
||||
}
|
||||
return stripPiInternalPromptEcho(text)
|
||||
}
|
||||
|
||||
function stripExactMeldworkPromptEcho(value, prompt) {
|
||||
const text = String(value || '')
|
||||
const expected = String(prompt || '').trim()
|
||||
if (!expected.includes('Current user task (authoritative):')
|
||||
|| !expected.startsWith('You are participating in the local ')) {
|
||||
return text.trim()
|
||||
}
|
||||
const candidate = text.trimStart()
|
||||
return candidate.startsWith(expected)
|
||||
? candidate.slice(expected.length).trimStart()
|
||||
: text.trim()
|
||||
}
|
||||
|
||||
function markStructuredFailure(state, code = 'LOCAL_AGENT_PROCESS_FAILED', diagnostic = '') {
|
||||
state.outcome = 'failed'
|
||||
state.failure = agentRuntimeFailure(code)
|
||||
@@ -113,9 +172,40 @@ function stepFinishOutcome(reason) {
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
function structuredResultDiagnostic(event) {
|
||||
const parts = []
|
||||
if (typeof event?.error === 'string' && event.error.trim()) parts.push(event.error.trim())
|
||||
else if (event?.error?.message) parts.push(String(event.error.message))
|
||||
if (typeof event?.result === 'string' && event.result.trim()) parts.push(event.result.trim())
|
||||
if (Array.isArray(event?.errors)) {
|
||||
parts.push(...event.errors.map(value => String(value || '').trim()).filter(Boolean))
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function ingestStructuredEvent(state, event) {
|
||||
if (!event || typeof event !== 'object' || Array.isArray(event)) return
|
||||
state.seen = true
|
||||
if (['workbuddy', 'claude', 'qwen'].includes(state.kind)
|
||||
&& event.type === 'result') {
|
||||
if (typeof event.session_id === 'string') state.sessionRef = event.session_id
|
||||
if (typeof event.result === 'string') {
|
||||
state.failure = null
|
||||
state.diagnostic = ''
|
||||
state.text = ''
|
||||
appendStructuredText(state, event.result, '')
|
||||
state.outcome = event.is_error === true ? 'partial' : 'completed'
|
||||
return
|
||||
}
|
||||
// An error result without a usable result string still carries the CLI's
|
||||
// error list; keep it as the failure diagnostic so authentication and
|
||||
// session problems are classified instead of surfacing as a bare
|
||||
// LOCAL_AGENT_PROCESS_FAILED.
|
||||
if (event.is_error === true || String(event.subtype || '').startsWith('error')) {
|
||||
markStructuredFailure(state, 'LOCAL_AGENT_PROCESS_FAILED', structuredResultDiagnostic(event))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (structuredEventFailed(event)) {
|
||||
markStructuredFailure(state)
|
||||
return
|
||||
@@ -133,15 +223,7 @@ function ingestStructuredEvent(state, event) {
|
||||
return
|
||||
}
|
||||
|
||||
if (['workbuddy', 'claude', 'qwen'].includes(state.kind)) {
|
||||
if (typeof event.session_id === 'string') state.sessionRef = event.session_id
|
||||
if (event.type === 'result' && typeof event.result === 'string') {
|
||||
state.text = ''
|
||||
appendStructuredText(state, event.result, '')
|
||||
state.outcome = 'completed'
|
||||
}
|
||||
return
|
||||
}
|
||||
if (['workbuddy', 'claude', 'qwen'].includes(state.kind)) return
|
||||
|
||||
if (state.kind === 'kimi') {
|
||||
if (event.role === 'assistant' && typeof event.content === 'string') {
|
||||
@@ -154,6 +236,41 @@ function ingestStructuredEvent(state, event) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.kind === 'pi') {
|
||||
if (event.type === 'session' && typeof event.id === 'string') state.sessionRef = event.id
|
||||
const update = event.assistantMessageEvent
|
||||
if (event.type === 'message_update' && update?.type === 'text_delta'
|
||||
&& typeof update.delta === 'string') {
|
||||
appendStructuredText(state, update.delta, '')
|
||||
}
|
||||
if (event.type === 'message_end' && event.message?.role === 'assistant'
|
||||
&& Array.isArray(event.message?.content)) {
|
||||
for (const block of event.message.content) {
|
||||
if (block?.type === 'text' && typeof block.text === 'string') {
|
||||
if (!state.text) appendStructuredText(state, block.text, '')
|
||||
}
|
||||
}
|
||||
const stopReason = event.message.stopReason
|
||||
if (stopReason) state.outcome = stepFinishOutcome(stopReason)
|
||||
}
|
||||
if (event.type === 'turn_end') {
|
||||
state.outcome = stepFinishOutcome(event.message?.stopReason || event.stopReason || 'stop')
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
state.piContinuing = event.willRetry === true || event.willContinue === true
|
||||
const lastAssistant = event.messages?.findLast(message => message?.role === 'assistant')
|
||||
if (lastAssistant?.stopReason) state.outcome = stepFinishOutcome(lastAssistant.stopReason)
|
||||
else if (!state.outcome) state.outcome = 'completed'
|
||||
if (state.outcome === 'completed' && !state.piContinuing) {
|
||||
state.failure = null
|
||||
state.diagnostic = ''
|
||||
}
|
||||
}
|
||||
if (event.type === 'agent_settled') state.piContinuing = false
|
||||
if (state.outcome === 'failed') markStructuredFailure(state)
|
||||
return
|
||||
}
|
||||
|
||||
if (['mimo', 'opencode'].includes(state.kind)) {
|
||||
if (typeof event.sessionID === 'string') state.sessionRef = event.sessionID
|
||||
if (event.type === 'text' && event.part?.type === 'text'
|
||||
@@ -273,7 +390,7 @@ function openCodeReviewTerminal(status, manifest, manifestPresent = manifest !=
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeStructuredState(state) {
|
||||
function finalizeStructuredState(state, context = {}) {
|
||||
if (state.parseError) {
|
||||
return {
|
||||
text: '',
|
||||
@@ -313,9 +430,12 @@ function finalizeStructuredState(state) {
|
||||
}
|
||||
|
||||
if (!state.seen && !state.text && !state.outcome) return null
|
||||
const outcome = state.outcome || (state.text ? 'partial' : 'failed')
|
||||
const outcome = state.kind === 'pi' && state.piContinuing && state.outcome === 'completed'
|
||||
? 'partial' : state.outcome || (state.text ? 'partial' : 'failed')
|
||||
const result = {
|
||||
text: state.text.trim(),
|
||||
text: state.kind === 'pi'
|
||||
? stripPiPromptEcho(state.text, context.prompt)
|
||||
: state.text.trim(),
|
||||
sessionRef: state.sessionRef,
|
||||
outcome,
|
||||
}
|
||||
@@ -348,7 +468,7 @@ function createStructuredOutputAccumulator(kind, sessionRef = '') {
|
||||
return {
|
||||
format: 'jsonl',
|
||||
ingest: event => ingestStructuredEvent(state, event),
|
||||
end: () => finalizeStructuredState(state),
|
||||
end: context => finalizeStructuredState(state, context),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +731,30 @@ function parseOpenCodeOutput(stdout) {
|
||||
}
|
||||
}
|
||||
|
||||
function parsePiOutput(stdout) {
|
||||
const events = parseJsonOutputEvents(stdout)
|
||||
let sessionRef = ''
|
||||
let text = ''
|
||||
for (const event of events) {
|
||||
if (typeof event.id === 'string' && event.type === 'session') sessionRef = event.id
|
||||
if (event.type === 'message_update'
|
||||
&& event.assistantMessageEvent?.type === 'text_delta'
|
||||
&& typeof event.assistantMessageEvent.delta === 'string') {
|
||||
text += event.assistantMessageEvent.delta
|
||||
}
|
||||
if (!text && event.type === 'message_end' && event.message?.role === 'assistant'
|
||||
&& Array.isArray(event.message?.content)) {
|
||||
text = event.message.content
|
||||
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
}
|
||||
const parsedText = text.trim()
|
||||
const answer = stripPiInternalPromptEcho(parsedText)
|
||||
return { text: parsedText ? answer : events.length ? '' : String(stdout || '').trim(), sessionRef }
|
||||
}
|
||||
|
||||
function openCodeReviewText(output) {
|
||||
const sections = []
|
||||
if (typeof output.message === 'string' && output.message.trim()) {
|
||||
@@ -686,6 +830,14 @@ function classifyCliOutcome(kind, stdout) {
|
||||
}
|
||||
|
||||
const events = parseJsonOutputEvents(stdout)
|
||||
if (['workbuddy', 'claude', 'qwen'].includes(kind)) {
|
||||
const result = events.findLast(event => event?.type === 'result' && typeof event.result === 'string')
|
||||
if (result) {
|
||||
return {
|
||||
outcome: result.is_error === true ? 'partial' : 'completed',
|
||||
}
|
||||
}
|
||||
}
|
||||
const failed = events.findLast(event => (
|
||||
event?.type === 'error'
|
||||
|| event?.status === 'failed'
|
||||
@@ -706,11 +858,6 @@ function classifyCliOutcome(kind, stdout) {
|
||||
: 'partial',
|
||||
}
|
||||
}
|
||||
if (['workbuddy', 'claude', 'qwen'].includes(kind)) {
|
||||
return {
|
||||
outcome: events.some(event => event?.type === 'result') ? 'completed' : 'partial',
|
||||
}
|
||||
}
|
||||
if (kind === 'kimi') {
|
||||
return {
|
||||
outcome: events.some(event => event?.type === 'session.resume_hint')
|
||||
@@ -718,6 +865,16 @@ function classifyCliOutcome(kind, stdout) {
|
||||
: 'partial',
|
||||
}
|
||||
}
|
||||
if (kind === 'pi') {
|
||||
const terminal = events.findLast(event => event?.type === 'turn_end' || event?.type === 'agent_end')
|
||||
if ((terminal?.willRetry === true || terminal?.willContinue === true)
|
||||
&& events.at(-1)?.type !== 'agent_settled') return { outcome: 'partial' }
|
||||
const lastAssistant = terminal?.messages?.findLast(message => message?.role === 'assistant')
|
||||
const previousTurn = events.findLast(event => event?.type === 'turn_end')
|
||||
const reason = terminal?.message?.stopReason || terminal?.stopReason || lastAssistant?.stopReason
|
||||
|| previousTurn?.message?.stopReason || previousTurn?.stopReason
|
||||
return { outcome: reason ? stepFinishOutcome(reason) : terminal ? 'completed' : 'partial' }
|
||||
}
|
||||
if (['mimo', 'opencode'].includes(kind)) {
|
||||
const finish = events.findLast(event => (
|
||||
event?.type === 'step_finish' || event?.part?.type === 'step-finish'
|
||||
@@ -754,7 +911,8 @@ function classifyCliOutcome(kind, stdout) {
|
||||
function structuredCliError(stdout) {
|
||||
const failed = parseJsonOutputEvents(stdout)
|
||||
.findLast(event => event?.is_error || event?.subtype?.startsWith('error'))
|
||||
return String(failed?.error?.message || failed?.result || '').trim()
|
||||
if (!failed) return ''
|
||||
return structuredResultDiagnostic(failed)
|
||||
}
|
||||
|
||||
function hermesSessionRef(stderr) {
|
||||
@@ -781,7 +939,7 @@ function queryHermesState(databasePath, sql, params, options = {}) {
|
||||
}
|
||||
|
||||
function readHermesMessageWatermark(options = {}) {
|
||||
const databasePath = path.join(options.home || os.homedir(), '.hermes', 'state.db')
|
||||
const databasePath = path.join(options.hermesHome || path.join(options.home || os.homedir(), '.hermes'), 'state.db')
|
||||
const existsFn = options.existsFn || fs.existsSync
|
||||
if (!existsFn(databasePath)) return 0
|
||||
try {
|
||||
@@ -799,7 +957,7 @@ function readHermesMessageWatermark(options = {}) {
|
||||
function readHermesFinalResponse(sessionRef, options = {}) {
|
||||
const afterMessageId = Number(options.afterMessageId)
|
||||
if (!sessionRef || !Number.isSafeInteger(afterMessageId) || afterMessageId < 0) return ''
|
||||
const databasePath = path.join(options.home || os.homedir(), '.hermes', 'state.db')
|
||||
const databasePath = path.join(options.hermesHome || path.join(options.home || os.homedir(), '.hermes'), 'state.db')
|
||||
const existsFn = options.existsFn || fs.existsSync
|
||||
if (!existsFn(databasePath)) return ''
|
||||
try {
|
||||
@@ -833,11 +991,13 @@ module.exports = {
|
||||
parseKimiOutput,
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parsePiOutput,
|
||||
parseOpenCodeReviewOutput,
|
||||
parseWorkBuddyOutput,
|
||||
readHermesFinalResponse,
|
||||
readHermesMessageWatermark,
|
||||
stripAnsi,
|
||||
stripExactMeldworkPromptEcho,
|
||||
structuredCliError,
|
||||
classifyCliOutcome,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Detect a runaway native turn, rather than deduplicating its visible answer.
|
||||
// Exact repetitions and sustained paraphrased closing acknowledgements count;
|
||||
// real tool activity resets the window.
|
||||
const CLOSING_ACKNOWLEDGEMENT = /^(?:(?:确认(?:收到)?|收到|本轮).{0,100}(?:收尾|结束|完成|定稿|后续轮次|按.{0,16}分工).{0,30}|确认|收到|确认收到|(?:confirmed|acknowledged|done)(?:[,:;].{0,100})?)$/iu
|
||||
|
||||
function createOutputRepetitionGuard() {
|
||||
const windows = { answer: { text: '', unchecked: 0 }, thought: { text: '', unchecked: 0 } }
|
||||
return update => {
|
||||
const type = update?.sessionUpdate || update?.type
|
||||
if (['tool_call', 'tool_call_update', 'tool_start', 'tool_result_summary'].includes(type)) {
|
||||
for (const window of Object.values(windows)) { window.text = ''; window.unchecked = 0 }
|
||||
return false
|
||||
}
|
||||
const window = type === 'agent_thought_chunk' ? windows.thought : windows.answer
|
||||
const value = type === 'answer_delta' ? update.delta
|
||||
: ['agent_message_chunk', 'agent_thought_chunk'].includes(type)
|
||||
&& update.content?.type === 'text' ? update.content.text : ''
|
||||
const chunk = String(value || '').replace(/\s/g, '')
|
||||
if (!chunk) return false
|
||||
window.text = (window.text + chunk).slice(-8192)
|
||||
window.unchecked += chunk.length
|
||||
const text = window.text
|
||||
if (window.unchecked < 32 || text.length < 64) return false
|
||||
window.unchecked = 0
|
||||
const clauses = text.split(/[。!?.!?]+/u).filter(Boolean).slice(-12)
|
||||
if (window === windows.answer
|
||||
&& clauses.filter(clause => CLOSING_ACKNOWLEDGEMENT.test(clause)).length >= 8) return true
|
||||
if (new Set(text).size < 6) return false
|
||||
const anchor = text.slice(-8)
|
||||
let previous = text.lastIndexOf(anchor, text.length - 16)
|
||||
while (previous >= 0) {
|
||||
const period = text.length - 8 - previous
|
||||
if (period > 2048) break
|
||||
const copies = window === windows.thought ? (period < 80 ? 16 : 8) : (period < 80 ? 8 : 4)
|
||||
if (period * copies <= text.length) {
|
||||
const block = text.slice(-period)
|
||||
if (new Set(block).size >= 6 && text.endsWith(block.repeat(copies))) return true
|
||||
}
|
||||
previous = previous > 0 ? text.lastIndexOf(anchor, previous - 1) : -1
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createOutputRepetitionGuard }
|
||||
@@ -1,7 +1,22 @@
|
||||
const path = require('node:path')
|
||||
const { searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
|
||||
const fs = require('node:fs')
|
||||
const JSON5 = require('json5')
|
||||
const { launchEnvironment, searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
|
||||
const { networkEnvironment } = require('./cli-network-environment.cjs')
|
||||
const { CONFIG_DIRECTORY_ENV_KEYS } = require('./cli-config-directories.cjs')
|
||||
const { SECRET_ENV_KEYS, nativeSecretEnvKeys, configSecrets } = require('./native-secret-environment.cjs')
|
||||
const { nativeCredentialEnvironment } = require('../agent-native-environment.cjs')
|
||||
const { mimoConfigSecrets } = require('./mimo-native-config.cjs')
|
||||
const { opencodeConfigLayers } = require('./opencode-native-config.cjs')
|
||||
const { qwenConfigSecrets } = require('./qwen-native-config.cjs')
|
||||
const { hermesConfigSecrets } = require('./hermes-config-secrets.cjs')
|
||||
const { deepseekNativeSecrets } = require('./deepseek-native-config.cjs')
|
||||
const { redactChildSecrets } = require('./cli-runtime-events.cjs')
|
||||
const { agentRuntimeError } = require('../agent-runtime-contract.cjs')
|
||||
const {
|
||||
agentRuntimeError,
|
||||
authFailureCode,
|
||||
} = require('../agent-runtime-contract.cjs')
|
||||
const { authFailureCertainty, isContextOverflow } = require('../../runs/failure-signals.cjs')
|
||||
const { validateOpenClawRuntimeGuard } = require('./openclaw-runtime.cjs')
|
||||
|
||||
const TERMINATE_GRACE_MS = 500
|
||||
@@ -21,36 +36,108 @@ const OPENCLAW_RUNTIME_PATH_KEYS = Object.freeze([
|
||||
'OPENCLAW_CONFIG_PATH',
|
||||
'OPENCLAW_WORKSPACE_DIR',
|
||||
])
|
||||
const TEST_RUNTIME_ENV = /^(?:CONNECTOR_TEST_|MELDWORK_TEST_)/
|
||||
const BLOCKED_RUNTIME_ENV = /^(?:NODE_OPTIONS|NODE_PATH|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_|ELECTRON_RUN_AS_NODE)$/
|
||||
|
||||
function agentExecutionError(code, diagnostic = '') {
|
||||
return agentRuntimeError(code, diagnostic)
|
||||
}
|
||||
|
||||
function authConfigurationFailure(detail) {
|
||||
return /api[ _-]?key|access[ _-]?token|refresh[ _-]?token|auth[ _-]?token|credential|auth(?:entication|orization)?|log(?:ged)?[ -]?in|sign(?:ed)?[ -]?in|unauthorized|forbidden|\b(?:401|403)\b|select an auth type|(?:provider|model).{0,80}(?:reject|configur|missing|invalid)|(?:reject|configur|missing|invalid).{0,80}(?:provider|model)|令牌|凭据|登录|认证|鉴权|(?:提供商|供应商|模型).{0,40}(?:配置|拒绝|缺失|无效)|(?:配置|拒绝|缺失|无效).{0,40}(?:提供商|供应商|模型)/i
|
||||
.test(String(detail || ''))
|
||||
function terminateWindowsProcessTree(child, spawnFn, timeoutMs = TERMINATE_GRACE_MS + KILL_SETTLE_MS) {
|
||||
if (child.exitCode != null || child.signalCode != null) return Promise.resolve()
|
||||
return new Promise((resolve, reject) => {
|
||||
let killer
|
||||
let childClosed = false
|
||||
let killerSucceeded = false
|
||||
let settled = false
|
||||
let timer
|
||||
const finish = (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
child.removeListener('close', onChildClose)
|
||||
killer?.removeListener('close', onKillerClose)
|
||||
if (error) {
|
||||
const failure = agentExecutionError('LOCAL_AGENT_TERMINATION_FAILED')
|
||||
if (child.pid && child.exitCode == null && child.signalCode == null) {
|
||||
const pending = new Promise(resolveExit => {
|
||||
child.once('exit', resolveExit)
|
||||
if (child.exitCode != null || child.signalCode != null) {
|
||||
child.removeListener('exit', resolveExit)
|
||||
resolveExit()
|
||||
}
|
||||
})
|
||||
Object.defineProperty(failure, Symbol.for('meldwork.pending-process-exit'), { value: pending })
|
||||
}
|
||||
reject(failure)
|
||||
} else resolve()
|
||||
}
|
||||
const onFailure = () => finish(true)
|
||||
const onChildClose = () => { childClosed = true; if (killerSucceeded) finish(false) }
|
||||
const onKillerClose = code => {
|
||||
if (code !== 0) return finish(true)
|
||||
killerSucceeded = true
|
||||
if (childClosed || child.exitCode != null || child.signalCode != null) finish(false)
|
||||
}
|
||||
child.once('close', onChildClose)
|
||||
child.once('error', onFailure)
|
||||
timer = setTimeout(onFailure, timeoutMs)
|
||||
try {
|
||||
if (!child.pid) return onFailure()
|
||||
killer = spawnFn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'ignore', windowsHide: true,
|
||||
})
|
||||
killer.once('close', onKillerClose)
|
||||
killer.once('error', onFailure)
|
||||
killer.unref?.()
|
||||
} catch { onFailure() }
|
||||
})
|
||||
}
|
||||
|
||||
function invalidSessionFailure(detail) {
|
||||
const text = String(detail || '').trim()
|
||||
if (!text || authConfigurationFailure(text)
|
||||
if (!text || authFailureCertainty(text)
|
||||
|| /\b(?:api|access|refresh|auth|session)[ _-]?token\b/i.test(text)) return false
|
||||
// A resumed native session that no longer fits the model context cannot be
|
||||
// continued; rebuilding it with compact continuation context is the fix.
|
||||
if (isContextOverflow(text)) return true
|
||||
if (/^(?:Error: )?Resume retains gateway session policy; creation overrides are unsupported on resume\./m.test(text)
|
||||
&& /--yolo differs from this session's launch/.test(text)) return true
|
||||
// Runtime crashes ("thread 'main' panicked ... invalid ...") and parser
|
||||
// errors mention threads or invalid input without saying anything about the
|
||||
// stored session. Discarding a healthy session for them loses context.
|
||||
if (/\bpanicked\b|\bSyntaxError\b|\binvalid JSON\b|\bUnexpected token\b|\bstack backtrace\b/i.test(text)) {
|
||||
return false
|
||||
}
|
||||
return /\b(?:no|unknown|invalid|expired|missing|stale)\s+(?:saved\s+)?(?:session|conversation|thread)\b|\b(?:session|conversation|thread)(?:\s+(?:id|key|reference))?\b[^\n]{0,100}\b(?:not found|does not exist|no longer exists|invalid|unknown|expired|has expired|was deleted|cannot be resumed)\b/i
|
||||
.test(text)
|
||||
}
|
||||
|
||||
function failedAgentProcessError(detail, options = {}) {
|
||||
if (!detail) return agentExecutionError('LOCAL_AGENT_EXITED')
|
||||
if (options.kind === 'zcode' && /\b1113\b|insufficient balance|no resource package/i.test(detail)) {
|
||||
return agentExecutionError('LOCAL_AGENT_PROVIDER_QUOTA_EXHAUSTED', 'The configured provider has insufficient balance or no active resource package.')
|
||||
}
|
||||
if (options.kind === 'zcode' && /\bModel provider is missing an API key:/i.test(detail)) {
|
||||
return agentExecutionError('LOCAL_AGENT_AUTH_REQUIRED', 'Missing API key for the configured Zcode provider.')
|
||||
}
|
||||
if (options.kind === 'workbuddy' && options.model && authFailureCode(detail)) {
|
||||
return agentExecutionError('LOCAL_WORKBUDDY_PROVIDER_AUTH', detail)
|
||||
}
|
||||
return agentExecutionError(
|
||||
options.sessionRef && invalidSessionFailure(detail)
|
||||
? 'LOCAL_AGENT_SESSION_INVALID'
|
||||
: authConfigurationFailure(detail)
|
||||
? 'LOCAL_AGENT_AUTH_REQUIRED'
|
||||
: 'LOCAL_AGENT_PROCESS_FAILED',
|
||||
: authFailureCode(detail) || 'LOCAL_AGENT_PROCESS_FAILED',
|
||||
detail,
|
||||
)
|
||||
}
|
||||
|
||||
function inheritedEnvironment(options, platform) {
|
||||
const env = systemChildEnvironment(process.env, platform)
|
||||
for (const key of options.nativeUnsetKeys || []) delete env[key]
|
||||
return env
|
||||
}
|
||||
|
||||
function openClawChildEnvironment(workdir, options, platform) {
|
||||
const source = options.env || {}
|
||||
const guard = options.openClawRuntimeGuard
|
||||
@@ -60,7 +147,10 @@ function openClawChildEnvironment(workdir, options, platform) {
|
||||
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
}
|
||||
|
||||
const env = systemChildEnvironment(process.env, platform)
|
||||
const env = {
|
||||
...inheritedEnvironment(options, platform),
|
||||
...networkEnvironment(source, platform),
|
||||
}
|
||||
for (const key of [
|
||||
'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH',
|
||||
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME',
|
||||
@@ -98,18 +188,96 @@ function childEnvironment(agent, workdir, options, platform) {
|
||||
? { HERMES_EXEC_ASK: '', HERMES_YOLO_MODE: '1' }
|
||||
: { HERMES_EXEC_ASK: '1', HERMES_YOLO_MODE: '' }
|
||||
: {}
|
||||
const openCodeSafetyEnv = agent.kind === 'opencode'
|
||||
&& options.sandbox !== 'workspace-write'
|
||||
? { OPENCODE_PERMISSION: OPENCODE_READ_ONLY_PERMISSION }
|
||||
: {}
|
||||
const openCodeSafetyEnv = {}
|
||||
// ACP has no terminal UI for question/plan transitions. Match the native
|
||||
// headless runner: return the answer to Meldwork instead of waiting forever.
|
||||
const headlessPermissions = { question: 'deny', plan_enter: 'deny', plan_exit: 'deny' }
|
||||
if (agent.kind === 'opencode') {
|
||||
openCodeSafetyEnv.OPENCODE_PERMISSION = JSON.stringify({
|
||||
...(options.sandbox !== 'workspace-write' ? JSON.parse(OPENCODE_READ_ONLY_PERMISSION) : {}),
|
||||
...headlessPermissions,
|
||||
})
|
||||
}
|
||||
if (agent.kind === 'mimo') {
|
||||
// MiMo applies read/write permissions with ACP session/set_mode. Keep
|
||||
// process-wide permissions stable when the discussion changes phase.
|
||||
openCodeSafetyEnv.MIMOCODE_PERMISSION = JSON.stringify(headlessPermissions)
|
||||
}
|
||||
const source = { ...process.env, ...options.env }
|
||||
return {
|
||||
...systemChildEnvironment(process.env, platform),
|
||||
...options.env,
|
||||
for (const key of options.nativeUnsetKeys || []) {
|
||||
if (!Object.hasOwn(options.env || {}, key)) delete source[key]
|
||||
}
|
||||
const nativeKeys = nativeSecretEnvKeys(options.nativeCredentialKeys)
|
||||
const nativeEnvironment = nativeCredentialEnvironment(agent.kind, options.env || {}, nativeKeys, platform)
|
||||
const controlled = {
|
||||
...Object.fromEntries(Object.entries(nativeEnvironment).filter(([key]) => !BLOCKED_RUNTIME_ENV.test(key))),
|
||||
...networkEnvironment(options.env || {}, platform),
|
||||
...Object.fromEntries(Object.entries(options.env || {}).filter(([key, value]) => (
|
||||
typeof value === 'string' && (TEST_RUNTIME_ENV.test(key)
|
||||
|| CONFIG_DIRECTORY_ENV_KEYS.includes(key)) && !BLOCKED_RUNTIME_ENV.test(key)
|
||||
))),
|
||||
}
|
||||
const childEnv = {
|
||||
...inheritedEnvironment(options, platform),
|
||||
...controlled,
|
||||
...hermesSafetyEnv,
|
||||
...openCodeSafetyEnv,
|
||||
...launchEnvironment(agent.kind, agent.executable, { platform }),
|
||||
PATH: searchPath({ platform, env: source }),
|
||||
}
|
||||
Object.defineProperty(childEnv, SECRET_ENV_KEYS, { value: nativeKeys })
|
||||
if (agent.kind === 'hermes') {
|
||||
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
|
||||
const secrets = hermesConfigSecrets({ env: childEnv, home, platform, declaredKeys: nativeKeys })
|
||||
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: secrets })
|
||||
}
|
||||
if (agent.kind === 'deepseek') {
|
||||
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
|
||||
const secrets = deepseekNativeSecrets({ env: childEnv, home, workdir })
|
||||
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: secrets })
|
||||
}
|
||||
if (agent.kind === 'qwen') {
|
||||
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
|
||||
const secrets = qwenConfigSecrets({ home, env: childEnv, workdir, platform })
|
||||
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: secrets })
|
||||
}
|
||||
if (agent.kind === 'mimo') {
|
||||
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
|
||||
const secrets = mimoConfigSecrets({ home, env: childEnv, workdir })
|
||||
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: secrets })
|
||||
}
|
||||
if (agent.kind === 'opencode') {
|
||||
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
|
||||
const { configs } = opencodeConfigLayers({ home, env: childEnv, workdir })
|
||||
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: configSecrets(configs) })
|
||||
}
|
||||
if (agent.kind === 'claude' && typeof workdir === 'string' && path.isAbsolute(workdir)) {
|
||||
const secrets = new Set()
|
||||
const home = platform === 'win32' ? (childEnv.USERPROFILE || childEnv.HOME) : childEnv.HOME
|
||||
const userRoot = childEnv.CLAUDE_CONFIG_DIR || (home && path.join(home, '.claude'))
|
||||
const files = [
|
||||
...(typeof userRoot === 'string' && path.isAbsolute(userRoot) ? [path.join(userRoot, 'settings.json')] : []),
|
||||
...['settings.json', 'settings.local.json'].map(name => path.join(workdir, '.claude', name)),
|
||||
]
|
||||
for (const file of files) {
|
||||
try {
|
||||
const stat = fs.statSync(file)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) continue
|
||||
const env = JSON5.parse(fs.readFileSync(file, 'utf8'))?.env
|
||||
if (!env || typeof env !== 'object' || Array.isArray(env)) continue
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (/(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(key)
|
||||
&& typeof value === 'string' && value && value.length <= 8192) {
|
||||
secrets.add(value)
|
||||
const bearer = value.match(/^Bearer\s+(\S+)$/i)
|
||||
if (bearer) secrets.add(bearer[1])
|
||||
}
|
||||
}
|
||||
} catch { /* The CLI reports invalid or unreadable native settings. */ }
|
||||
}
|
||||
Object.defineProperty(childEnv, Symbol.for('meldwork.project-secret-values'), { value: [...secrets] })
|
||||
}
|
||||
return childEnv
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -119,4 +287,5 @@ module.exports = {
|
||||
childEnvironment,
|
||||
failedAgentProcessError,
|
||||
invalidSessionFailure,
|
||||
terminateWindowsProcessTree,
|
||||
}
|
||||
@@ -345,6 +345,70 @@ function openCodeJsonRuntimeEvents(event) {
|
||||
return []
|
||||
}
|
||||
|
||||
function createPiRuntimeState() {
|
||||
return {
|
||||
reasoning: new Map(),
|
||||
tools: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function piJsonRuntimeEvents(event, state) {
|
||||
if (!event || typeof event !== 'object') return []
|
||||
const update = event.assistantMessageEvent
|
||||
if (event.type === 'message_update' && update && typeof update === 'object') {
|
||||
const index = Number.isInteger(update.contentIndex) ? update.contentIndex : 0
|
||||
const id = `content:${index}`
|
||||
if (update.type === 'thinking_start') {
|
||||
state.reasoning.set(id, '')
|
||||
return [{ id, type: 'reasoning_summary', title: 'reasoning', status: 'running' }]
|
||||
}
|
||||
if (update.type === 'thinking_delta' && typeof update.delta === 'string') {
|
||||
const summary = `${state.reasoning.get(id) || ''}${update.delta}`.slice(-2000)
|
||||
state.reasoning.set(id, summary)
|
||||
return [{ id, type: 'reasoning_summary', title: 'reasoning', status: 'running', summary }]
|
||||
}
|
||||
if (update.type === 'thinking_end') {
|
||||
const summary = String(update.content || state.reasoning.get(id) || '').trim()
|
||||
state.reasoning.set(id, summary)
|
||||
return [{ id, type: 'reasoning_summary', title: 'reasoning', status: 'completed', ...(summary ? { summary } : {}) }]
|
||||
}
|
||||
if (update.type === 'text_delta' && typeof update.delta === 'string') {
|
||||
return [{ type: 'answer_delta', status: 'running', delta: update.delta }]
|
||||
}
|
||||
if (['toolcall_start', 'tool_call_start'].includes(update.type)) {
|
||||
const toolId = String(update.toolCallId || update.id || `tool:${index}`)
|
||||
const title = String(update.toolName || update.name || 'tool')
|
||||
state.tools.set(toolId, title)
|
||||
return [{ id: toolId, type: 'tool_start', title, status: 'running' }]
|
||||
}
|
||||
if (['toolcall_end', 'tool_call_end'].includes(update.type)) {
|
||||
const toolId = String(update.toolCallId || update.id || `tool:${index}`)
|
||||
return [{
|
||||
id: toolId,
|
||||
type: 'tool_result_summary',
|
||||
title: state.tools.get(toolId) || String(update.toolName || update.name || 'tool'),
|
||||
status: update.isError === true || update.error ? 'failed' : 'completed',
|
||||
}]
|
||||
}
|
||||
}
|
||||
if (event.type === 'tool_execution_start' || event.type === 'tool_start') {
|
||||
const id = String(event.toolCallId || event.id || 'tool')
|
||||
const title = String(event.toolName || event.name || 'tool')
|
||||
state.tools.set(id, title)
|
||||
return [{ id, type: 'tool_start', title, status: 'running' }]
|
||||
}
|
||||
if (event.type === 'tool_execution_end' || event.type === 'tool_end') {
|
||||
const id = String(event.toolCallId || event.id || 'tool')
|
||||
return [{
|
||||
id,
|
||||
type: 'tool_result_summary',
|
||||
title: state.tools.get(id) || String(event.toolName || event.name || 'tool'),
|
||||
status: event.isError === true || event.error ? 'failed' : 'completed',
|
||||
}]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function finalOnlyRuntimeEvents() {
|
||||
return []
|
||||
}
|
||||
@@ -361,13 +425,16 @@ function acpPlanSummary(update) {
|
||||
}
|
||||
|
||||
function createAcpRuntimeState() {
|
||||
return { tools: new Map() }
|
||||
return { tools: new Map(), toolKinds: new Map(), reasoning: new Map() }
|
||||
}
|
||||
|
||||
const ACP_TOOL_KINDS = new Set([
|
||||
'read', 'edit', 'delete', 'move', 'search',
|
||||
'execute', 'think', 'fetch', 'switch_mode', 'other',
|
||||
])
|
||||
const ACP_TOOL_TITLES = Object.freeze({ read: 'read_file', edit: 'write_file', delete: 'delete',
|
||||
move: 'move', search: 'search', execute: 'Bash', think: 'reasoning', fetch: 'fetch',
|
||||
switch_mode: 'switch_mode', other: 'tool' })
|
||||
|
||||
function acpToolOperation(update) {
|
||||
const name = ACP_TOOL_KINDS.has(update.kind) ? update.kind : 'tool'
|
||||
@@ -383,15 +450,29 @@ function acpToolOperation(update) {
|
||||
function acpRuntimeEvents(update, state) {
|
||||
if (!update || typeof update !== 'object') return []
|
||||
if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') {
|
||||
return [{ type: 'answer_delta', status: 'running', delta: update.content.text }]
|
||||
// Close an open reasoning phase once the visible answer starts.
|
||||
const closed = state?.reasoning?.get('reasoning:acp')?.startsWith('running:')
|
||||
? reasoningLifecycleEvent(state, 'reasoning:acp', 'completed')
|
||||
: null
|
||||
return [
|
||||
...(closed ? [closed] : []),
|
||||
{ type: 'answer_delta', status: 'running', delta: update.content.text },
|
||||
]
|
||||
}
|
||||
if (update.sessionUpdate === 'agent_thought_chunk' && update.content?.type === 'text') {
|
||||
return []
|
||||
// Thinking is real progress: report a text-free reasoning status so the
|
||||
// watchdog and the trace know the Agent is working. The private thought
|
||||
// text itself never leaves the main process.
|
||||
if (!state?.reasoning) return []
|
||||
const event = reasoningLifecycleEvent(state, 'reasoning:acp', 'running')
|
||||
return event ? [event] : []
|
||||
}
|
||||
if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
|
||||
const status = runtimeEventStatus(update.status)
|
||||
const completed = ['completed', 'failed', 'stopped', 'timeout'].includes(status)
|
||||
const id = String(update.toolCallId || '')
|
||||
if (id && ACP_TOOL_KINDS.has(update.kind)) state?.toolKinds?.set(id, update.kind)
|
||||
const kind = ACP_TOOL_KINDS.has(update.kind) ? update.kind : state?.toolKinds?.get(id)
|
||||
const operation = acpToolOperation(update)
|
||||
if (id && operation && update.sessionUpdate === 'tool_call') state?.tools.set(id, operation)
|
||||
const summary = state?.tools.get(id) || operation
|
||||
@@ -401,7 +482,7 @@ function acpRuntimeEvents(update, state) {
|
||||
type: completed
|
||||
? 'tool_result_summary'
|
||||
: update.sessionUpdate === 'tool_call' ? 'tool_start' : 'tool_update',
|
||||
title: runtimeToolTitle(update),
|
||||
title: ACP_TOOL_TITLES[kind] || 'tool',
|
||||
status: status || (update.sessionUpdate === 'tool_call' ? 'running' : 'waiting'),
|
||||
...(summary ? { summary } : {}),
|
||||
...(detail ? { detail } : {}),
|
||||
@@ -432,4 +513,6 @@ module.exports = {
|
||||
kimiStreamJsonRuntimeEvents,
|
||||
mimoJsonRuntimeEvents,
|
||||
openCodeJsonRuntimeEvents,
|
||||
piJsonRuntimeEvents,
|
||||
createPiRuntimeState,
|
||||
}
|
||||
@@ -1,4 +1,39 @@
|
||||
const { stripAnsi } = require('./cli-output-parsers.cjs')
|
||||
const { SECRET_ENV_KEYS } = require('./native-secret-environment.cjs')
|
||||
const JSON5 = require('json5')
|
||||
|
||||
const inlineSecretCache = new WeakMap()
|
||||
const INLINE_SECRET_FIELD = /(?:api[_-]?key|access[_-]?key|token|secret|password|authorization|private[_-]?key)$/i
|
||||
|
||||
function inlineConfigSecrets(env) {
|
||||
if (!env || typeof env !== 'object') return []
|
||||
const values = [env.MIMOCODE_CONFIG_CONTENT, env.OPENCODE_CONFIG_CONTENT]
|
||||
const cached = inlineSecretCache.get(env)
|
||||
if (cached && values.every((value, index) => value === cached.values[index])) return cached.secrets
|
||||
const secrets = new Set()
|
||||
function collect(value) {
|
||||
const pending = [value]
|
||||
while (pending.length) {
|
||||
const current = pending.pop()
|
||||
if (!current || typeof current !== 'object') continue
|
||||
for (const [key, item] of Object.entries(current)) {
|
||||
if (INLINE_SECRET_FIELD.test(key) && typeof item === 'string' && item
|
||||
&& item.length <= 8192 && !/^\{(?:env|file):[^}]+\}$/.test(item)) {
|
||||
secrets.add(item)
|
||||
const bearer = item.match(/^Bearer\s+(\S+)$/i)
|
||||
if (bearer) secrets.add(bearer[1])
|
||||
} else if (item && typeof item === 'object') pending.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string' || !value || Buffer.byteLength(value) > 256 * 1024) continue
|
||||
try { collect(JSON5.parse(value)) } catch { /* native CLI reports invalid configuration */ }
|
||||
}
|
||||
const result = [...secrets]
|
||||
inlineSecretCache.set(env, { values, secrets: result })
|
||||
return result
|
||||
}
|
||||
|
||||
const RUNTIME_EVENT_TYPES = new Set([
|
||||
'status',
|
||||
@@ -28,21 +63,44 @@ function utf8ByteLength(value) {
|
||||
|
||||
function redactChildSecrets(value, env) {
|
||||
let result = String(value || '')
|
||||
for (const [name, secret] of Object.entries(env)) {
|
||||
if (!/(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
|
||||
|| typeof secret !== 'string' || utf8ByteLength(secret) < 8) continue
|
||||
for (const secret of childSecrets(env)) {
|
||||
result = result.split(secret).join('[redacted]')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function childSecrets(env) {
|
||||
return Object.entries(env || {})
|
||||
.filter(([name, secret]) => (
|
||||
/(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
|
||||
&& typeof secret === 'string' && utf8ByteLength(secret) >= 8
|
||||
))
|
||||
.map(([, secret]) => secret)
|
||||
const secrets = new Set(inlineConfigSecrets(env))
|
||||
for (const value of env?.[Symbol.for('meldwork.project-secret-values')] || []) {
|
||||
if (typeof value === 'string' && value) secrets.add(value)
|
||||
}
|
||||
for (const [name, value] of Object.entries(env || {})) {
|
||||
if (typeof value !== 'string' || !value) continue
|
||||
if (env?.[SECRET_ENV_KEYS]?.includes(name)
|
||||
|| /(?:^|_)(?:API_KEY|ACCESS_KEY_ID|SECRET_ACCESS_KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)$/i.test(name)
|
||||
|| /^AWS_BEARER_TOKEN_BEDROCK$/i.test(name)
|
||||
|| (/(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
|
||||
&& utf8ByteLength(value) >= 8)) secrets.add(value)
|
||||
if (!/^(?:https?|all)_proxy$/i.test(name)) continue
|
||||
if (value.includes('@')) secrets.add(value)
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (!url.username && !url.password) continue
|
||||
secrets.add(value)
|
||||
secrets.add(url.href)
|
||||
const userinfo = `${url.username}:${url.password}`
|
||||
secrets.add(userinfo)
|
||||
const decoded = decodeURIComponent(userinfo)
|
||||
secrets.add(decoded)
|
||||
secrets.add(Buffer.from(decoded).toString('base64'))
|
||||
for (const part of [url.username, url.password]) {
|
||||
if (utf8ByteLength(part) >= 8) secrets.add(part)
|
||||
const literal = decodeURIComponent(part)
|
||||
if (utf8ByteLength(literal) >= 8) secrets.add(literal)
|
||||
}
|
||||
} catch { /* malformed proxy URLs are diagnosed by the native runtime */ }
|
||||
}
|
||||
return [...secrets].sort((left, right) => right.length - left.length)
|
||||
}
|
||||
|
||||
function assignmentNameVariants(parts) {
|
||||
@@ -82,7 +140,7 @@ function pathSuffixLength(value) {
|
||||
const matches = [
|
||||
value.match(/file:\/\/\/[^\s"'`<>|,;)}\]]+$/i),
|
||||
value.match(/(?<![A-Za-z0-9:])(?:[A-Za-z]:[\\/]|\\\\)[^\s"'`<>|,;)}\]]+$/),
|
||||
value.match(/(?<![A-Za-z0-9:/])\/+[^\s"'`<>|,;)}\]]+$/),
|
||||
value.match(/(?<![\p{L}\p{N}:/])\/+[^\s"'`<>|,;)}\]]+$/u),
|
||||
]
|
||||
return Math.max(...matches.map(match => match?.[0]?.length || 0))
|
||||
}
|
||||
@@ -90,6 +148,9 @@ function pathSuffixLength(value) {
|
||||
function boundarySensitiveSuffixLength(value, secrets) {
|
||||
let retained = 0
|
||||
const retain = (length) => { retained = Math.max(retained, length || 0) }
|
||||
// Keep a word boundary across chunks: a slash in the next chunk may be
|
||||
// prose (云/端), not the start of an absolute filesystem path.
|
||||
retain(value.match(/(?=[^\x00-\x7f])[\p{L}\p{N}]$/u)?.[0]?.length)
|
||||
for (const secret of secrets) {
|
||||
const limit = Math.min(value.length, secret.length - 1)
|
||||
for (let length = limit; length > retained; length -= 1) {
|
||||
@@ -106,7 +167,7 @@ function boundarySensitiveSuffixLength(value, secrets) {
|
||||
}
|
||||
const pathLength = pathSuffixLength(value)
|
||||
if (pathLength) retain(pathLength)
|
||||
retain(value.match(/(?<![A-Za-z0-9:/])\/+$/)?.[0]?.length)
|
||||
retain(value.match(/(?<![\p{L}\p{N}:/])\/+$/u)?.[0]?.length)
|
||||
|
||||
const matches = [
|
||||
value.match(/\bAKIA[0-9A-Z]{0,15}$/),
|
||||
@@ -189,7 +250,7 @@ function sanitizeRuntimeEventText(value, childEnv, limit, singleLine = false, tr
|
||||
.replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/gi, '[redacted private key]')
|
||||
.replace(/file:\/\/\/[^\s"'`<>|,;)}\]]+/gi, '[path]')
|
||||
.replace(/(?<![A-Za-z0-9:])(?:[A-Za-z]:[\\/]|\\\\)[^\s"'`<>|,;)}\]]+/g, '[path]')
|
||||
.replace(/(?<![A-Za-z0-9:/])\/+[^\s"'`<>|,;)}\]]+/g, '[path]')
|
||||
.replace(/(?<![\p{L}\p{N}:/])\/+[^\s"'`<>|,;)}\]]+/gu, '[path]')
|
||||
.replace(/(["']?)(?:[A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|secret[_-]?access[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|database[_-]?url|connection[_-]?string|dsn|token|secret|password|credential|authorization|private[_-]?key)[A-Za-z0-9_.-]*)\1\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|\[[^\]\r\n]*\]|[^\s,;}\]]+)/gi, 'credential=[redacted]')
|
||||
.replace(/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]')
|
||||
.replace(/\bbearer\s+[A-Za-z0-9._~+\/-]+=*/gi, 'Bearer [redacted]')
|
||||
|
||||
@@ -13,10 +13,12 @@ const {
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parseOpenCodeReviewOutput,
|
||||
parsePiOutput,
|
||||
parseWorkBuddyOutput,
|
||||
readHermesFinalResponse,
|
||||
readHermesMessageWatermark,
|
||||
stripAnsi,
|
||||
stripExactMeldworkPromptEcho,
|
||||
structuredCliError,
|
||||
} = require('./cli-output-parsers.cjs')
|
||||
const {
|
||||
@@ -53,11 +55,13 @@ module.exports = {
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parseOpenCodeReviewOutput,
|
||||
parsePiOutput,
|
||||
parseWorkBuddyOutput,
|
||||
readHermesFinalResponse,
|
||||
readHermesMessageWatermark,
|
||||
redactChildSecrets,
|
||||
runtimeCommandSummary,
|
||||
stripAnsi,
|
||||
stripExactMeldworkPromptEcho,
|
||||
structuredCliError,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const { randomUUID } = require('node:crypto')
|
||||
|
||||
// A contribution ends only on an explicit, invocation-specific signal from
|
||||
// the assistant. Tool output and acknowledgements cannot finish a turn.
|
||||
function createTurnCompletion() {
|
||||
const marker = `[[MELDWORK_TURN_DONE:${randomUUID()}]]`
|
||||
let text = ''
|
||||
let delivered = 0
|
||||
let completed = false
|
||||
return {
|
||||
instruction: `This is one scheduled contribution. Other participants cannot run until you return. Complete only the contribution requested for this invocation; do not poll or wait for their approvals. After your answer and any required collaboration receipt, output this exact signal on its own final line: ${marker}\nThe signal ends only your invocation, never the group task. Do not quote or explain it, and do not output it before all your tools have finished.`,
|
||||
push(delta, toolsFinished = true) {
|
||||
if (completed) return { delta: '', completed: true }
|
||||
text += delta
|
||||
const index = text.lastIndexOf(marker)
|
||||
if (toolsFinished && index > 0 && text[index - 1] === '\n'
|
||||
&& /^(?:[ \t]*\r?\n|[ \t]*$)/.test(text.slice(index + marker.length))) {
|
||||
const before = text.slice(0, index)
|
||||
const fences = before.match(/^\s*(?:```|~~~)/gm) || []
|
||||
if (fences.length % 2 === 0 && before.trim()) {
|
||||
completed = true
|
||||
const visible = before.slice(delivered)
|
||||
text = before
|
||||
delivered = text.length
|
||||
return { delta: visible, completed: true }
|
||||
}
|
||||
}
|
||||
let held = 0
|
||||
for (let length = Math.min(marker.length, text.length); length > 0; length--) {
|
||||
if (text.endsWith(marker.slice(0, length))) { held = length; break }
|
||||
}
|
||||
const end = text.length - held
|
||||
const visible = text.slice(delivered, end)
|
||||
delivered = end
|
||||
return { delta: visible, completed: false }
|
||||
},
|
||||
get completed() { return completed },
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createTurnCompletion }
|
||||
@@ -0,0 +1,167 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { parseEnv } = require('node:util')
|
||||
const { createHash } = require('node:crypto')
|
||||
const yaml = require('js-yaml')
|
||||
const { nativeSecretEnvKeys, configSecrets } = require('./native-secret-environment.cjs')
|
||||
// DSH supports JavaScript-tagged scalar expressions; inspect them as inert text.
|
||||
const patchSchema = yaml.DEFAULT_SCHEMA.extend([new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar', construct: value => value,
|
||||
})])
|
||||
|
||||
function deepseekConfigurationFingerprint(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const home = options.home || os.homedir()
|
||||
const root = typeof env.DSH_HOME === 'string' && env.DSH_HOME ? env.DSH_HOME.replace(/^~(?=$|[\\/])/, home) : path.join(home, '.dsh')
|
||||
if (!path.isAbsolute(root)) return null
|
||||
const files = ['cordis.patch.yml', '.credentials.yaml', 'profiles/acp/cordis.patch.yml',
|
||||
'profiles/acp/.credentials.yaml', '.env'].map(file => [file, path.join(root, file)])
|
||||
if (typeof options.workdir === 'string' && path.isAbsolute(options.workdir)) {
|
||||
files.push(['project/.env', path.join(options.workdir, '.env')])
|
||||
}
|
||||
const entries = []
|
||||
for (const [file, filename] of files) {
|
||||
try {
|
||||
const stat = fs.statSync(filename)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return null
|
||||
entries.push([file, createHash('sha256').update(fs.readFileSync(filename)).digest('hex')])
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') return null
|
||||
entries.push([file, null])
|
||||
}
|
||||
}
|
||||
return createHash('sha256').update(JSON.stringify([root, entries])).digest('hex')
|
||||
}
|
||||
|
||||
function deepseekNativeSecrets(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const home = options.home || os.homedir()
|
||||
const root = typeof env.DSH_HOME === 'string' && env.DSH_HOME
|
||||
? env.DSH_HOME.replace(/^~(?=$|[\\/])/, home) : path.join(home, '.dsh')
|
||||
if (!path.isAbsolute(root)) return []
|
||||
const secrets = new Set()
|
||||
const add = value => {
|
||||
if (typeof value === 'string' && value && value.length <= 8192) secrets.add(value)
|
||||
}
|
||||
for (const file of ['.credentials.yaml', 'profiles/acp/.credentials.yaml',
|
||||
'cordis.patch.yml', 'profiles/acp/cordis.patch.yml']) {
|
||||
try {
|
||||
const filename = path.join(root, file)
|
||||
const stat = fs.statSync(filename)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) continue
|
||||
const document = yaml.load(fs.readFileSync(filename, 'utf8'), { schema: patchSchema })
|
||||
for (const value of configSecrets([document])) add(value)
|
||||
if (!file.endsWith('.credentials.yaml') || !document || typeof document !== 'object') continue
|
||||
if (document.refs && typeof document.refs === 'object' && !Array.isArray(document.refs)) {
|
||||
for (const value of Object.values(document.refs)) add(value)
|
||||
}
|
||||
if (document.records && typeof document.records === 'object' && !Array.isArray(document.records)) {
|
||||
for (const record of Object.values(document.records)) {
|
||||
if (record?.kind === 'api-key') add(record.key)
|
||||
}
|
||||
}
|
||||
} catch { /* The CLI handles unreadable or invalid native configuration. */ }
|
||||
}
|
||||
const declared = new Set(['ZGCI_API_KEY', ...deepseekNativeConfig({ env, home }).credentialKeys])
|
||||
const dotenvFiles = [path.join(root, '.env')]
|
||||
if (typeof options.workdir === 'string' && path.isAbsolute(options.workdir)) {
|
||||
dotenvFiles.push(path.join(options.workdir, '.env'))
|
||||
}
|
||||
for (const file of dotenvFiles) {
|
||||
try {
|
||||
const stat = fs.statSync(file)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) continue
|
||||
for (const [key, value] of Object.entries(parseEnv(fs.readFileSync(file, 'utf8')))) {
|
||||
if (declared.has(key) || /(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(key)) add(value)
|
||||
}
|
||||
} catch { /* The CLI handles unreadable or invalid native environment files. */ }
|
||||
}
|
||||
return [...secrets]
|
||||
}
|
||||
|
||||
function deepseekDefaultCredentialConfigured(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const home = options.home || os.homedir()
|
||||
const root = typeof env.DSH_HOME === 'string' && env.DSH_HOME
|
||||
? env.DSH_HOME.replace(/^~(?=$|[\\/])/, home) : path.join(home, '.dsh')
|
||||
if (!path.isAbsolute(root)) return false
|
||||
const mapping = value => value && typeof value === 'object' && !Array.isArray(value)
|
||||
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
|
||||
let remaining = 8192
|
||||
const jsonValue = (value, seen = new Set(), depth = 0) => {
|
||||
if (--remaining < 0) return false
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
|
||||
if (typeof value === 'number') return Number.isFinite(value)
|
||||
if (depth > 32 || (!Array.isArray(value) && !mapping(value)) || seen.has(value)) return false
|
||||
seen.add(value)
|
||||
const valid = Object.values(value).every(item => jsonValue(item, seen, depth + 1))
|
||||
seen.delete(value)
|
||||
return valid
|
||||
}
|
||||
const validEnv = value => mapping(value) && Object.entries(value).every(([key, item]) => (
|
||||
/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && typeof item === 'string' && item.length > 0
|
||||
))
|
||||
try {
|
||||
const filename = path.join(root, '.credentials.yaml')
|
||||
const stat = fs.statSync(filename)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024
|
||||
|| ((options.platform || process.platform) !== 'win32' && (stat.mode & 0o077))) return false
|
||||
const document = yaml.load(fs.readFileSync(filename, 'utf8'))
|
||||
if (!mapping(document) || document.version !== 1
|
||||
|| (document.refs !== undefined && !validEnv(document.refs))
|
||||
|| (document.records !== undefined && !mapping(document.records))) return false
|
||||
for (const [key, record] of Object.entries(document.records || {})) {
|
||||
if (!/^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(key) || !mapping(record)) return false
|
||||
if (record.kind === 'api-key') {
|
||||
if (Object.keys(record).some(field => !['kind', 'key', 'env'].includes(field))
|
||||
|| (record.key !== undefined && (typeof record.key !== 'string' || !record.key.length))
|
||||
|| (record.env !== undefined && !validEnv(record.env))) return false
|
||||
} else if (record.kind === 'grant') {
|
||||
if (Object.keys(record).some(field => !['kind', 'payload'].includes(field))
|
||||
|| !Object.hasOwn(record, 'payload') || !jsonValue(record.payload)) return false
|
||||
} else return false
|
||||
}
|
||||
const credential = document.refs?.ZGCI_API_KEY
|
||||
return typeof credential === 'string' && /^[\x21-\x7e]+$/.test(credential)
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
function deepseekNativeConfig(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const home = options.home || os.homedir()
|
||||
const root = typeof env.DSH_HOME === 'string' && env.DSH_HOME ? env.DSH_HOME.replace(/^~(?=$|[\\/])/, home) : path.join(home, '.dsh')
|
||||
if (!path.isAbsolute(root)) return { explicitModel: true, credentialKeys: [] }
|
||||
const files = [path.join(root, 'profiles', 'acp', 'cordis.patch.yml'), path.join(root, 'cordis.patch.yml')]
|
||||
const keys = []
|
||||
let explicitModel = false
|
||||
let provider = ''
|
||||
for (const filename of files) {
|
||||
let document
|
||||
try {
|
||||
const stat = fs.statSync(filename)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return { explicitModel: true, credentialKeys: [] }
|
||||
document = yaml.load(fs.readFileSync(filename, 'utf8'), { schema: patchSchema })
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') continue
|
||||
return { explicitModel: true, credentialKeys: [] }
|
||||
}
|
||||
const pending = [document]
|
||||
const seen = new Set()
|
||||
while (pending.length) {
|
||||
const node = pending.pop()
|
||||
if (!node || typeof node !== 'object' || seen.has(node)) continue
|
||||
seen.add(node)
|
||||
if (node.id === 'acp' && node.config && typeof node.config === 'object') {
|
||||
if (['provider', 'model'].some(key => typeof node.config[key] === 'string' && node.config[key].trim())) explicitModel = true
|
||||
if (typeof node.config.provider === 'string' && node.config.provider.trim()) provider = node.config.provider.trim()
|
||||
}
|
||||
if (typeof node.apiKeyEnv === 'string') keys.push(node.apiKeyEnv)
|
||||
pending.push(...Object.values(node).reverse())
|
||||
}
|
||||
}
|
||||
if (explicitModel && (!provider || provider === 'deepseek-official')) keys.push('DEEPSEEK_API_KEY')
|
||||
return { explicitModel, credentialKeys: nativeSecretEnvKeys(keys) }
|
||||
}
|
||||
|
||||
module.exports = { deepseekNativeConfig, deepseekConfigurationFingerprint, deepseekDefaultCredentialConfigured, deepseekNativeSecrets }
|
||||
@@ -0,0 +1,11 @@
|
||||
- id: sandbox-policy
|
||||
disabled: false
|
||||
config:
|
||||
mode: read-only
|
||||
workspaceRoot: !!js process.cwd()
|
||||
- id: approval
|
||||
disabled: false
|
||||
config:
|
||||
policy: never
|
||||
- id: permission
|
||||
disabled: true
|
||||
@@ -0,0 +1,11 @@
|
||||
- id: sandbox-policy
|
||||
disabled: false
|
||||
config:
|
||||
mode: workspace-write
|
||||
workspaceRoot: !!js process.cwd()
|
||||
- id: approval
|
||||
disabled: false
|
||||
config:
|
||||
policy: never
|
||||
- id: permission
|
||||
disabled: true
|
||||
@@ -0,0 +1,15 @@
|
||||
- id: llm-pi-ai
|
||||
config:
|
||||
providers:
|
||||
zgci:
|
||||
displayName: ZGCI
|
||||
apiKeyEnv: ZGCI_API_KEY
|
||||
api: openai-completions
|
||||
baseURL: https://hub.zgci.org/v1
|
||||
models:
|
||||
- id: smart-router-v2
|
||||
name: smart-router-v2
|
||||
- id: acp
|
||||
config:
|
||||
provider: zgci
|
||||
model: smart-router-v2
|
||||
@@ -0,0 +1,107 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const JSON5 = require('json5')
|
||||
|
||||
function readSettings(file) {
|
||||
try {
|
||||
const stat = fs.statSync(file)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error('invalid settings')
|
||||
const value = JSON5.parse(fs.readFileSync(file, 'utf8'))
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid settings')
|
||||
return value
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return {}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function readSystemSettings(file, platform) {
|
||||
try {
|
||||
fs.lstatSync(file)
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return {}
|
||||
throw error
|
||||
}
|
||||
// Gemini requires root-owned, non-writable system paths on POSIX. Windows
|
||||
// uses ACL checks that cannot be inferred from Node's mode bits.
|
||||
if (platform === 'win32') throw new Error('unverified system settings')
|
||||
let current = path.resolve(file)
|
||||
for (;;) {
|
||||
const stat = fs.lstatSync(current)
|
||||
if (stat.isSymbolicLink() || stat.uid !== 0 || (stat.mode & 0o022) !== 0) {
|
||||
throw new Error('unverified system settings')
|
||||
}
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return readSettings(file)
|
||||
}
|
||||
|
||||
function geminiNativeAuth(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const platform = options.platform || process.platform
|
||||
const home = options.home || os.homedir()
|
||||
const base = env.GEMINI_CLI_HOME || home
|
||||
const system = env.GEMINI_CLI_SYSTEM_SETTINGS_PATH || (platform === 'darwin'
|
||||
? '/Library/Application Support/GeminiCli/settings.json' : platform === 'win32'
|
||||
? 'C:\\ProgramData\\gemini-cli\\settings.json' : '/etc/gemini-cli/settings.json')
|
||||
const defaults = env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH || path.join(path.dirname(system), 'system-defaults.json')
|
||||
if (![base, system, defaults].every(value => typeof value === 'string' && path.isAbsolute(value))) return { uncertain: true }
|
||||
const root = path.join(base, '.gemini')
|
||||
try {
|
||||
const layers = [readSystemSettings(defaults, platform), readSettings(path.join(root, 'settings.json'))]
|
||||
const systemSettings = readSystemSettings(system, platform)
|
||||
const validOverrides = ['oauth-personal', 'gemini-api-key', 'vertex-ai', 'cloud-shell', 'compute-default-credentials', 'gateway']
|
||||
const override = validOverrides.includes(env.GEMINI_CLI_AUTH_OVERRIDE) ? env.GEMINI_CLI_AUTH_OVERRIDE : ''
|
||||
let authType = override || (layers[1].security?.auth?.selectedType ?? layers[0].security?.auth?.selectedType ?? '')
|
||||
let enforcedType = layers[1].security?.auth?.enforcedType ?? layers[0].security?.auth?.enforcedType ?? ''
|
||||
const workdir = typeof options.workdir === 'string' && path.isAbsolute(options.workdir) ? options.workdir : ''
|
||||
if (workdir && path.resolve(workdir) !== path.resolve(base)) {
|
||||
let trusted = env.GEMINI_CLI_TRUST_WORKSPACE === 'true'
|
||||
if (env.GEMINI_RESTRICTED_MODE === 'true' || env.GEMINI_CLI_TRUST_WORKSPACE === 'false') trusted = false
|
||||
else if (!trusted) {
|
||||
const enabled = systemSettings.security?.folderTrust?.enabled
|
||||
?? layers[1].security?.folderTrust?.enabled ?? layers[0].security?.folderTrust?.enabled ?? true
|
||||
if (enabled === false) trusted = true
|
||||
else {
|
||||
const trustFile = env.GEMINI_CLI_TRUSTED_FOLDERS_PATH || path.join(root, 'trustedFolders.json')
|
||||
if (!path.isAbsolute(trustFile)) return { uncertain: true }
|
||||
const rules = readSettings(trustFile)
|
||||
const real = fs.realpathSync(workdir)
|
||||
let longest = -1
|
||||
for (const [folder, level] of Object.entries(rules)) {
|
||||
if (!path.isAbsolute(folder) || !['TRUST_FOLDER', 'TRUST_PARENT', 'DO_NOT_TRUST'].includes(level)) continue
|
||||
const candidate = level === 'TRUST_PARENT' ? path.dirname(folder) : folder
|
||||
const rootPath = fs.existsSync(candidate) ? fs.realpathSync(candidate) : path.resolve(candidate)
|
||||
const relative = path.relative(rootPath, real)
|
||||
if ((relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)))
|
||||
&& rootPath.length > longest) {
|
||||
longest = rootPath.length
|
||||
trusted = level !== 'DO_NOT_TRUST'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trusted) {
|
||||
const workspaceAuth = readSettings(path.join(workdir, '.gemini', 'settings.json')).security?.auth
|
||||
authType = workspaceAuth?.selectedType ?? authType
|
||||
enforcedType = workspaceAuth?.enforcedType ?? enforcedType
|
||||
}
|
||||
}
|
||||
authType = systemSettings.security?.auth?.selectedType ?? authType
|
||||
enforcedType = systemSettings.security?.auth?.enforcedType ?? enforcedType
|
||||
if (!authType) {
|
||||
if (env.GOOGLE_GENAI_USE_GCA === 'true') authType = 'oauth-personal'
|
||||
else if (env.GOOGLE_GENAI_USE_VERTEXAI === 'true') authType = 'vertex-ai'
|
||||
else if (env.GOOGLE_GEMINI_BASE_URL) authType = 'gateway'
|
||||
else if (env.GEMINI_API_KEY) authType = 'gemini-api-key'
|
||||
else if (env.CLOUD_SHELL === 'true' || env.GEMINI_CLI_USE_COMPUTE_ADC === 'true') authType = 'compute-default-credentials'
|
||||
}
|
||||
if (enforcedType && enforcedType !== authType) return { uncertain: true }
|
||||
return { authType: typeof authType === 'string' ? authType : '', root }
|
||||
} catch { return { uncertain: true } }
|
||||
}
|
||||
|
||||
module.exports = { geminiNativeAuth }
|
||||
@@ -0,0 +1,32 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const yaml = require('js-yaml')
|
||||
const { nativeIdentityDirectory } = require('../native-config-directory.cjs')
|
||||
const { hermesDotenvAssignments } = require('../hermes-dotenv.cjs')
|
||||
const { configSecrets, nativeSecretEnvKeys } = require('./native-secret-environment.cjs')
|
||||
|
||||
function hermesConfigSecrets({ env, home, platform, declaredKeys }) {
|
||||
const root = nativeIdentityDirectory('hermes', env, home, platform)
|
||||
if (!root) return []
|
||||
const read = name => {
|
||||
const file = path.join(root, name)
|
||||
const stat = fs.statSync(file)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return ''
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
}
|
||||
const configs = []
|
||||
for (const [name, parse] of [['config.yaml', yaml.load], ['auth.json', JSON.parse]]) {
|
||||
try { const content = read(name); if (content) configs.push(parse(content)) } catch { /* CLI reports invalid settings. */ }
|
||||
}
|
||||
const secrets = new Set(configSecrets(configs))
|
||||
const declared = new Set(nativeSecretEnvKeys(declaredKeys))
|
||||
try {
|
||||
for (const [key, value] of hermesDotenvAssignments(read('.env'), env)) {
|
||||
if ((declared.has(key) || /(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(key))
|
||||
&& value && value.length <= 8192) secrets.add(value)
|
||||
}
|
||||
} catch { /* Missing native environment is normal. */ }
|
||||
return [...secrets]
|
||||
}
|
||||
|
||||
module.exports = { hermesConfigSecrets }
|
||||
@@ -0,0 +1,163 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const { createHash } = require('node:crypto')
|
||||
const JSON5 = require('json5')
|
||||
const { nativeSecretEnvKeys, configSecrets } = require('./native-secret-environment.cjs')
|
||||
|
||||
function projectDirectories(workdir) {
|
||||
if (typeof workdir !== 'string' || !path.isAbsolute(workdir)) return []
|
||||
const directories = []
|
||||
let current = path.resolve(workdir)
|
||||
for (let depth = 0; depth < 64; depth += 1) {
|
||||
directories.push(current)
|
||||
if (fs.existsSync(path.join(current, '.git'))) return directories.reverse()
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return directories.reverse()
|
||||
}
|
||||
|
||||
function mimoConfigLayers(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const home = options.home || os.homedir()
|
||||
const root = env.XDG_CONFIG_HOME || path.join(home, '.config')
|
||||
try {
|
||||
const resolveNativePath = value => {
|
||||
if (!value || path.isAbsolute(value)) return value
|
||||
return typeof options.workdir === 'string' && path.isAbsolute(options.workdir)
|
||||
? path.resolve(options.workdir, value) : null
|
||||
}
|
||||
const explicitFile = resolveNativePath(env.MIMOCODE_CONFIG)
|
||||
const explicitDirectory = resolveNativePath(env.MIMOCODE_CONFIG_DIR)
|
||||
if (explicitFile === null || explicitDirectory === null) return null
|
||||
const projects = /^(?:1|true)$/i.test(env.MIMOCODE_DISABLE_PROJECT_CONFIG || '')
|
||||
? [] : projectDirectories(options.workdir)
|
||||
const files = [path.join(root, 'mimocode', 'mimocode.json'), path.join(root, 'mimocode', 'mimocode.jsonc'),
|
||||
explicitFile, ...projects.flatMap(directory => [path.join(directory, 'mimocode.json'), path.join(directory, 'mimocode.jsonc')]),
|
||||
...[...projects].reverse().flatMap(directory => [path.join(directory, '.mimocode', 'mimocode.json'), path.join(directory, '.mimocode', 'mimocode.jsonc')]),
|
||||
path.join(home, '.mimocode', 'mimocode.json'), path.join(home, '.mimocode', 'mimocode.jsonc'),
|
||||
explicitDirectory && path.join(explicitDirectory, 'mimocode.json'),
|
||||
explicitDirectory && path.join(explicitDirectory, 'mimocode.jsonc')].filter(Boolean)
|
||||
const configs = []
|
||||
for (const file of files) {
|
||||
let stat
|
||||
try { stat = fs.statSync(file) } catch (error) {
|
||||
if (error.code === 'ENOENT') continue
|
||||
throw error
|
||||
}
|
||||
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return null
|
||||
configs.push(JSON5.parse(fs.readFileSync(file, 'utf8')))
|
||||
}
|
||||
if (env.MIMOCODE_CONFIG_CONTENT) {
|
||||
if (Buffer.byteLength(env.MIMOCODE_CONFIG_CONTENT) > 2 * 1024 * 1024) return null
|
||||
configs.push(JSON5.parse(env.MIMOCODE_CONFIG_CONTENT))
|
||||
}
|
||||
return configs
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function mimoConfigurationFingerprint(options = {}) {
|
||||
const configs = mimoConfigLayers(options)
|
||||
if (configs === null) return null
|
||||
const recent = mimoModel(configs) ? null : mimoRecentModels(options.env || process.env, options.home || os.homedir())
|
||||
return createHash('sha256').update(JSON.stringify([configs, recent])).digest('hex')
|
||||
}
|
||||
|
||||
function mimoModel(configs) {
|
||||
return configs.reduce((model, config) => typeof config?.model === 'string' ? config.model : model, '')
|
||||
}
|
||||
|
||||
function mimoRecentModels(env, home) {
|
||||
const stateRoot = env.XDG_STATE_HOME || path.join(home, '.local', 'state')
|
||||
try {
|
||||
const filename = path.join(stateRoot, 'mimocode', 'model.json')
|
||||
const stat = fs.statSync(filename)
|
||||
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return []
|
||||
const recent = JSON.parse(fs.readFileSync(filename, 'utf8'))?.recent
|
||||
return (Array.isArray(recent) ? recent.slice(0, 32) : [])
|
||||
.filter(item => typeof item?.providerID === 'string' && /^[A-Za-z0-9._-]+$/.test(item.providerID)
|
||||
&& typeof item.modelID === 'string' && item.modelID)
|
||||
.map(item => ({ providerID: item.providerID, modelID: item.modelID }))
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function mimoProviderIds(configs, env, home) {
|
||||
const configuredModel = mimoModel(configs)
|
||||
if (!configuredModel) return [...new Set(mimoRecentModels(env, home).map(item => item.providerID))]
|
||||
const model = configuredModel.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,
|
||||
(_match, name) => nativeSecretEnvKeys([name]).length ? env[name] || '' : '')
|
||||
return model.includes('/') && model.split('/')[0] ? [model.split('/')[0]] : []
|
||||
}
|
||||
|
||||
function mimoProviderKeys(options = {}, configs = mimoConfigLayers(options)) {
|
||||
if (!configs) return []
|
||||
try {
|
||||
const result = []
|
||||
for (const providerId of mimoProviderIds(configs, options.env || process.env, options.home || os.homedir())) {
|
||||
let key
|
||||
for (const config of configs) {
|
||||
const options = config?.provider?.[providerId]?.options
|
||||
if (options && Object.hasOwn(options, 'apiKey')) key = options.apiKey
|
||||
}
|
||||
if (typeof key === 'string' && key.trim()) result.push(key)
|
||||
}
|
||||
return result
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function mimoDeclaredCredentialKeys(options = {}) {
|
||||
const configs = mimoConfigLayers(options)
|
||||
if (!configs) return []
|
||||
const keys = []
|
||||
const visit = (value, depth = 0) => {
|
||||
if (depth > 32 || keys.length >= 128) return
|
||||
if (typeof value === 'string') {
|
||||
for (const match of value.matchAll(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g)) keys.push(match[1])
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const child of Object.values(value)) visit(child, depth + 1)
|
||||
}
|
||||
}
|
||||
visit(mimoModel(configs))
|
||||
const providerIds = mimoProviderIds(configs, options.env || process.env, options.home || os.homedir())
|
||||
if (providerIds.length) {
|
||||
const merge = (current, incoming, depth = 0) => {
|
||||
if (depth > 32 || !incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return incoming
|
||||
const result = current && typeof current === 'object' && !Array.isArray(current)
|
||||
? current : Object.create(null)
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
result[key] = merge(result[key], value, depth + 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
for (const providerId of providerIds) {
|
||||
let provider = null
|
||||
for (const config of configs) {
|
||||
if (Object.hasOwn(config?.provider || {}, providerId)) provider = merge(provider, config.provider[providerId])
|
||||
}
|
||||
visit(provider)
|
||||
}
|
||||
}
|
||||
return nativeSecretEnvKeys(keys)
|
||||
}
|
||||
|
||||
function mimoConfiguredProvider(options = {}) {
|
||||
const env = options.env || process.env
|
||||
return mimoProviderKeys(options).some(key => {
|
||||
let missing = false
|
||||
key = key.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
|
||||
const value = nativeSecretEnvKeys([name]).length ? env[name] : ''
|
||||
if (typeof value !== 'string' || !value.trim()) missing = true
|
||||
return value || ''
|
||||
})
|
||||
return !missing && !/^\{.*\}$/.test(key) && Boolean(key.trim())
|
||||
})
|
||||
}
|
||||
|
||||
function mimoConfigSecrets(options = {}) {
|
||||
const configs = mimoConfigLayers(options)
|
||||
return configs ? configSecrets(configs) : []
|
||||
}
|
||||
|
||||
module.exports = { mimoConfigurationFingerprint, mimoConfiguredProvider, mimoDeclaredCredentialKeys, mimoConfigSecrets }
|
||||
@@ -0,0 +1,42 @@
|
||||
const SECRET_ENV_KEYS = Symbol.for('meldwork.native-secret-env-keys')
|
||||
const UNSAFE = /^(?:(?:PATH|HOME|USER|LOGNAME|SHELL|ENV|IFS|CDPATH|GLOBIGNORE|TMPDIR|TMP|TEMP|TERM|COMSPEC|SYSTEMROOT|WINDIR|USERPROFILE|PATHEXT|APPDATA|LOCALAPPDATA|HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY|HERMES_EXEC_ASK|HERMES_YOLO_MODE)$|(?:BASH_|ZSH_|FISH_|NODE_|ELECTRON_|LD_|DYLD_|PYTHON|RUBY|PERL|JAVA_|GIT_|SSH_))/i
|
||||
|
||||
function nativeSecretEnvKeys(input) {
|
||||
if (!Array.isArray(input)) return []
|
||||
return [...new Set(input.filter(key => typeof key === 'string'
|
||||
&& /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key) && !UNSAFE.test(key)))].slice(0, 128).sort()
|
||||
}
|
||||
|
||||
function credentialLiteral(value) {
|
||||
if (typeof value !== 'string') return false
|
||||
const candidate = value.trim()
|
||||
if (!candidate) return false
|
||||
if (/^(?:null|none|undefined|true|false|~)$/i.test(candidate)) return false
|
||||
if (/^\$(?:[A-Z_][A-Z0-9_]*|\{[A-Z_][A-Z0-9_]*\})$/i.test(candidate)) return false
|
||||
if (/^(?:env|secretref-env|__env__):[A-Z_][A-Z0-9_]*$/i.test(candidate)) return false
|
||||
return !/^[A-Z_][A-Z0-9_]*(?:API_KEY|TOKEN|CREDENTIAL)$/i.test(candidate)
|
||||
}
|
||||
|
||||
function configSecrets(configs) {
|
||||
const secrets = new Set()
|
||||
const pending = configs.map(value => [value, 0])
|
||||
const visited = new WeakMap()
|
||||
while (pending.length && secrets.size < 128) {
|
||||
const [value, depth] = pending.pop()
|
||||
if (!value || typeof value !== 'object' || depth > 32) continue
|
||||
if (visited.has(value) && visited.get(value) <= depth) continue
|
||||
visited.set(value, depth)
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if ((/(?:api[_-]?key|access[_-]?key|token|secret|password|authorization)$/i.test(key)
|
||||
|| (key === 'key' && value.type === 'api_key'))
|
||||
&& credentialLiteral(item) && item.length <= 8192 && !/^\{(?:env|file):[^}]+\}$/.test(item)) {
|
||||
secrets.add(item)
|
||||
const bearer = item.match(/^Bearer\s+(\S+)$/i)
|
||||
if (bearer) secrets.add(bearer[1])
|
||||
} else if (item && typeof item === 'object') pending.push([item, depth + 1])
|
||||
}
|
||||
}
|
||||
return [...secrets]
|
||||
}
|
||||
|
||||
module.exports = { SECRET_ENV_KEYS, nativeSecretEnvKeys, credentialLiteral, configSecrets }
|
||||
@@ -0,0 +1,49 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
// Native OCR v1.13 provider EnvVar fields; this selects existing shell values,
|
||||
// it does not set a provider, endpoint, or credential.
|
||||
const OCR_PROVIDER_KEYS = Object.freeze({
|
||||
anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', 'openai-responses': 'OPENAI_RESPONSES_API_KEY',
|
||||
openrouter: 'OPENROUTER_API_KEY', edenai: 'EDENAI_API_KEY', gemini: 'GEMINI_API_KEY',
|
||||
dashscope: 'DASHSCOPE_API_KEY', 'dashscope-tokenplan': 'DASHSCOPE_TOKENPLAN_KEY',
|
||||
volcengine: 'ARK_API_KEY', deepseek: 'DEEPSEEK_API_KEY', 'tencent-tokenhub': 'TENCENT_TOKENHUB_API_KEY',
|
||||
'hy-tokenplan': 'TENCENT_HUNYUAN_TOKENPLAN_KEY', iflytek: 'SPARK_API_KEY', kimi: 'MOONSHOT_API_KEY',
|
||||
'kimi-global': 'MOONSHOT_GLOBAL_API_KEY', 'z-ai': 'Z_AI_API_KEY', 'z-ai-coding': 'Z_AI_CODING_API_KEY',
|
||||
mimo: 'MIMO_API_KEY', minimax: 'MINIMAX_GLOBAL_API_KEY', 'minimax-cn': 'MINIMAX_API_KEY',
|
||||
'baidu-qianfan': 'QIANFAN_API_KEY', 'ollama-cloud': 'OLLAMA_API_KEY', novita: 'NOVITA_API_KEY',
|
||||
xai: 'XAI_API_KEY', litellm: 'LITELLM_API_KEY', siliconflow: 'SILICONFLOW_GLOBAL_API_KEY',
|
||||
'siliconflow-cn': 'SILICONFLOW_API_KEY', mistral: 'MISTRAL_API_KEY',
|
||||
})
|
||||
|
||||
function ocrConfig(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const platform = options.platform || process.platform
|
||||
const configured = platform === 'win32' ? env.USERPROFILE : env.HOME
|
||||
const home = typeof configured === 'string' && configured.trim() ? configured.trim() : (options.home || os.homedir())
|
||||
try {
|
||||
const file = path.join(home, '.opencodereview', 'config.json')
|
||||
const stat = fs.statSync(file)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return null
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function ocrSelectedProvider(options = {}) {
|
||||
const provider = ocrConfig(options)?.provider
|
||||
return typeof provider === 'string' ? provider.trim() : ''
|
||||
}
|
||||
|
||||
function ocrDeclaredCredentialKeys(options = {}) {
|
||||
const config = ocrConfig(options)
|
||||
const provider = typeof config?.provider === 'string' ? config.provider.trim() : ''
|
||||
const key = Object.hasOwn(OCR_PROVIDER_KEYS, provider) ? OCR_PROVIDER_KEYS[provider] : ''
|
||||
const entry = config?.providers?.[provider]
|
||||
if (!key || !entry || typeof entry !== 'object' || Array.isArray(entry)) return []
|
||||
if (typeof entry.api_key === 'string' && entry.api_key.trim()) return []
|
||||
if (typeof entry.api_key_cmd === 'string' && entry.api_key_cmd.trim()) return []
|
||||
return [key]
|
||||
}
|
||||
|
||||
module.exports = { OCR_PROVIDER_KEYS, ocrDeclaredCredentialKeys, ocrSelectedProvider }
|
||||
@@ -1,10 +1,10 @@
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { isDeepStrictEqual } = require('node:util')
|
||||
|
||||
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
|
||||
|
||||
const MANAGED_OPENCLAW_PROVIDER_ID = 'meldwork-desktop'
|
||||
const READ_ONLY_TOOLS = [
|
||||
'read', 'web_search', 'web_fetch', 'memory_search', 'memory_get', 'session_status',
|
||||
]
|
||||
@@ -36,12 +36,12 @@ const OPENCLAW_RUNTIME_PATH_ENV_KEYS = Object.freeze([
|
||||
'OPENCLAW_WORKSPACE_DIR',
|
||||
])
|
||||
const OPENCLAW_RUNTIME_CREDENTIAL_KEYS = new Set([
|
||||
'MELDWORK_OPENCLAW_API_KEY',
|
||||
'MELDWORK_OPENCLAW_NATIVE_API_KEY',
|
||||
'OPENCLAW_GATEWAY_TOKEN',
|
||||
])
|
||||
const issuedOpenClawRuntimeGuards = new WeakSet()
|
||||
const openClawRuntimeCredentialDigests = new WeakMap()
|
||||
const openClawRuntimeConfigs = new WeakMap()
|
||||
const OPENCLAW_ENV_SECRET_NAME = /^[A-Z][A-Z0-9_]{0,127}$/
|
||||
const OPENCLAW_SECRET_MARKERS = new Set([
|
||||
'secretref-managed',
|
||||
@@ -89,7 +89,7 @@ function directoryIdentity(filename) {
|
||||
})
|
||||
}
|
||||
|
||||
function fileIdentity(filename) {
|
||||
function readRuntimeConfigFile(filename) {
|
||||
const resolved = path.resolve(filename)
|
||||
const entry = fs.lstatSync(resolved)
|
||||
if (!entry.isFile() || entry.isSymbolicLink() || fs.realpathSync(resolved) !== resolved) {
|
||||
@@ -99,14 +99,22 @@ function fileIdentity(filename) {
|
||||
const descriptor = fs.openSync(resolved, fs.constants.O_RDONLY | noFollow)
|
||||
try {
|
||||
const before = fs.fstatSync(descriptor)
|
||||
if (!before.isFile() || !sameFilesystemIdentity(before, {
|
||||
if (!before.isFile() || before.size > 256 * 1024 || !sameFilesystemIdentity(before, {
|
||||
dev: identityValue(entry.dev),
|
||||
ino: identityValue(entry.ino),
|
||||
birthtime: identityValue(entry.birthtimeMs),
|
||||
})) {
|
||||
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
}
|
||||
const contents = fs.readFileSync(descriptor)
|
||||
const buffer = Buffer.alloc(256 * 1024 + 1)
|
||||
let length = 0
|
||||
while (length < buffer.length) {
|
||||
const count = fs.readSync(descriptor, buffer, length, buffer.length - length, null)
|
||||
if (!count) break
|
||||
length += count
|
||||
}
|
||||
if (length === buffer.length) throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
const contents = buffer.subarray(0, length)
|
||||
const after = fs.fstatSync(descriptor)
|
||||
const current = fs.lstatSync(resolved)
|
||||
if (!sameFilesystemIdentity(after, {
|
||||
@@ -121,7 +129,7 @@ function fileIdentity(filename) {
|
||||
}) || fs.realpathSync(resolved) !== resolved) {
|
||||
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
}
|
||||
return Object.freeze({
|
||||
const identity = Object.freeze({
|
||||
path: resolved,
|
||||
dev: identityValue(before.dev),
|
||||
ino: identityValue(before.ino),
|
||||
@@ -130,11 +138,16 @@ function fileIdentity(filename) {
|
||||
size: contents.length,
|
||||
digest: crypto.createHash('sha256').update(contents).digest('hex'),
|
||||
})
|
||||
return { identity, contents: contents.toString('utf8') }
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
function fileIdentity(filename) {
|
||||
return readRuntimeConfigFile(filename).identity
|
||||
}
|
||||
|
||||
function validateDirectoryIdentities(identities) {
|
||||
for (const expected of identities) {
|
||||
const current = directoryIdentity(expected.path)
|
||||
@@ -158,26 +171,7 @@ function validateFileIdentity(expected) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProvider(input) {
|
||||
const apiKey = typeof input?.OPENAI_API_KEY === 'string' ? input.OPENAI_API_KEY.trim() : ''
|
||||
const baseUrl = String(input?.OPENAI_BASE_URL || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/chat\/completions$/i, '')
|
||||
const model = String(input?.OPENAI_MODEL || '').trim()
|
||||
let parsed
|
||||
try { parsed = new URL(baseUrl) } catch { throw new Error('OPENCLAW_PROVIDER_INVALID') }
|
||||
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
|
||||
if (!apiKey || apiKey.length > 8192 || isOpenClawSecretReference(apiKey)
|
||||
|| !model || model.length > 120
|
||||
|| (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback))
|
||||
|| parsed.username || parsed.password || parsed.search || parsed.hash) {
|
||||
throw new Error('OPENCLAW_PROVIDER_INVALID')
|
||||
}
|
||||
return { apiKey, baseUrl, model }
|
||||
}
|
||||
|
||||
function runtimePaths({ storageRoot, workdir, sessionRef, configName }) {
|
||||
function runtimePaths({ storageRoot, workdir, sessionRef, configName, runtimeVersion }) {
|
||||
if (!path.isAbsolute(storageRoot) || !path.isAbsolute(workdir)) {
|
||||
throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE')
|
||||
}
|
||||
@@ -189,8 +183,15 @@ function runtimePaths({ storageRoot, workdir, sessionRef, configName }) {
|
||||
throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE')
|
||||
}
|
||||
const resolvedWorkdir = path.resolve(workdir)
|
||||
// The managed scope is keyed by session, workspace, and CLI version. OpenClaw
|
||||
// refuses to start on state written by an older CLI ("Legacy workspace setup
|
||||
// state requires migration"), so a version-scoped directory gives an upgraded
|
||||
// CLI a fresh isolated scope instead of a guaranteed startup failure.
|
||||
const versionSegment = String(runtimeVersion || '').trim()
|
||||
const scope = crypto.createHash('sha256')
|
||||
.update(`${sessionRef || 'configure'}\0${resolvedWorkdir}`)
|
||||
.update(versionSegment
|
||||
? `${sessionRef || 'configure'}\0${resolvedWorkdir}\0${versionSegment}`
|
||||
: `${sessionRef || 'configure'}\0${resolvedWorkdir}`)
|
||||
.digest('hex')
|
||||
.slice(0, 24)
|
||||
const runtimeRoot = path.join(resolvedStorageRoot, 'openclaw-managed', scope)
|
||||
@@ -268,6 +269,7 @@ function writeRuntimeConfig(runtime, config, credentials, credentialKey) {
|
||||
credentialKeys: Object.freeze(credentialEntries.map(([key]) => key)),
|
||||
})
|
||||
issuedOpenClawRuntimeGuards.add(guard)
|
||||
openClawRuntimeConfigs.set(guard, contents)
|
||||
openClawRuntimeCredentialDigests.set(guard, Object.freeze(Object.fromEntries(
|
||||
credentialEntries.map(([key, value]) => [
|
||||
key,
|
||||
@@ -277,7 +279,7 @@ function writeRuntimeConfig(runtime, config, credentials, credentialKey) {
|
||||
return guard
|
||||
}
|
||||
|
||||
function validateOpenClawRuntimeGuard(guard, env = {}) {
|
||||
function validateRuntimeScope(guard, env = {}) {
|
||||
const credentialDigests = guard && typeof guard === 'object'
|
||||
? openClawRuntimeCredentialDigests.get(guard)
|
||||
: undefined
|
||||
@@ -301,10 +303,57 @@ function validateOpenClawRuntimeGuard(guard, env = {}) {
|
||||
}
|
||||
}
|
||||
validateDirectoryIdentities(guard.directories)
|
||||
}
|
||||
|
||||
function validateOpenClawRuntimeGuard(guard, env = {}) {
|
||||
validateRuntimeScope(guard, env)
|
||||
validateFileIdentity(guard.config)
|
||||
return true
|
||||
}
|
||||
|
||||
function comparableMigratedConfig(config) {
|
||||
// OpenClaw materializes the implicit main agent and stamps migrations at startup.
|
||||
const result = JSON.parse(JSON.stringify(config))
|
||||
const meta = result.meta
|
||||
if (meta && typeof meta === 'object' && !Array.isArray(meta)
|
||||
&& Object.keys(meta).every(key => ['migrations', 'lastTouchedVersion', 'lastTouchedAt'].includes(key))
|
||||
&& (meta.lastTouchedVersion === undefined || /^\d{4}\.\d{1,2}\.\d{1,2}(?:[-+.][\w.-]+)?$/.test(meta.lastTouchedVersion))
|
||||
&& (meta.lastTouchedAt === undefined || (typeof meta.lastTouchedAt === 'string' && Number.isFinite(Date.parse(meta.lastTouchedAt))))
|
||||
&& (meta.migrations === undefined || isDeepStrictEqual(meta.migrations, { modelPolicyAllowlist: true }))) {
|
||||
delete result.meta
|
||||
}
|
||||
if (isDeepStrictEqual(result.agents?.entries, { main: {} })) delete result.agents.entries
|
||||
if (isDeepStrictEqual(result.discovery?.wideArea, { enabled: false })) {
|
||||
result.discovery.wideArea = {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function refreshOpenClawRuntimeAfterStartup(options) {
|
||||
const guard = options?.openClawRuntimeGuard
|
||||
const env = options?.env || {}
|
||||
validateRuntimeScope(guard, env)
|
||||
const { identity: config, contents } = readRuntimeConfigFile(guard.config.path)
|
||||
if (process.platform !== 'win32' && (config.mode & 0o777) !== 0o600) {
|
||||
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
}
|
||||
validateDirectoryIdentities(guard.directories)
|
||||
validateFileIdentity(config)
|
||||
let equivalent = false
|
||||
try {
|
||||
equivalent = isDeepStrictEqual(
|
||||
comparableMigratedConfig(JSON.parse(openClawRuntimeConfigs.get(guard))),
|
||||
comparableMigratedConfig(JSON.parse(contents)),
|
||||
)
|
||||
} catch { /* malformed config is rejected below */ }
|
||||
if (!equivalent) throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
|
||||
const refreshed = Object.freeze({ ...guard, config })
|
||||
issuedOpenClawRuntimeGuards.add(refreshed)
|
||||
openClawRuntimeConfigs.set(refreshed, contents)
|
||||
openClawRuntimeCredentialDigests.set(refreshed, openClawRuntimeCredentialDigests.get(guard))
|
||||
return { ...options, openClawRuntimeGuard: refreshed }
|
||||
}
|
||||
|
||||
function configureOpenClawGatewayRuntime(options, port) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('OPENCLAW_GATEWAY_PORT_INVALID')
|
||||
@@ -313,7 +362,7 @@ function configureOpenClawGatewayRuntime(options, port) {
|
||||
const env = options?.env || {}
|
||||
validateOpenClawRuntimeGuard(guard, env)
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(guard.config.path, 'utf8'))
|
||||
const config = JSON.parse(openClawRuntimeConfigs.get(guard))
|
||||
const gatewayCredentialKey = 'OPENCLAW_GATEWAY_TOKEN'
|
||||
const nextConfig = {
|
||||
...config,
|
||||
@@ -395,74 +444,8 @@ function normalizedNativeModel(input) {
|
||||
return model
|
||||
}
|
||||
|
||||
function managedOpenClawOptions({
|
||||
storageRoot, workdir, sessionRef = '', allowWrite = false, provider,
|
||||
}) {
|
||||
const normalized = normalizeProvider(provider)
|
||||
const configName = allowWrite
|
||||
? 'openclaw.provider.workspace-write.json'
|
||||
: 'openclaw.provider.read-only.json'
|
||||
const runtime = runtimePaths({
|
||||
storageRoot, workdir, sessionRef, configName,
|
||||
})
|
||||
const { resolvedWorkdir, home, state, configPath } = runtime
|
||||
|
||||
const modelRef = `${MANAGED_OPENCLAW_PROVIDER_ID}/${normalized.model}`
|
||||
const gatewayCredentialKey = 'OPENCLAW_GATEWAY_TOKEN'
|
||||
const gatewayToken = crypto.randomBytes(32).toString('base64url')
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: resolvedWorkdir,
|
||||
model: { primary: modelRef },
|
||||
skipBootstrap: true,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: 'replace',
|
||||
providers: {
|
||||
[MANAGED_OPENCLAW_PROVIDER_ID]: {
|
||||
baseUrl: normalized.baseUrl,
|
||||
apiKey: {
|
||||
source: 'env', provider: 'default', id: 'MELDWORK_OPENCLAW_API_KEY',
|
||||
},
|
||||
api: 'openai-completions',
|
||||
models: [{ id: normalized.model, name: normalized.model, input: ['text'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: toolPolicy(allowWrite),
|
||||
gateway: {
|
||||
mode: 'local',
|
||||
bind: 'loopback',
|
||||
auth: {
|
||||
mode: 'token',
|
||||
token: { source: 'env', provider: 'default', id: gatewayCredentialKey },
|
||||
},
|
||||
controlUi: { enabled: false },
|
||||
},
|
||||
}
|
||||
const credentialKey = 'MELDWORK_OPENCLAW_API_KEY'
|
||||
const openClawRuntimeGuard = writeRuntimeConfig(runtime, config, {
|
||||
[credentialKey]: normalized.apiKey,
|
||||
[gatewayCredentialKey]: gatewayToken,
|
||||
}, credentialKey)
|
||||
|
||||
return {
|
||||
openClawRuntimeGuard,
|
||||
env: {
|
||||
OPENCLAW_HOME: home,
|
||||
OPENCLAW_STATE_DIR: state,
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_WORKSPACE_DIR: resolvedWorkdir,
|
||||
[credentialKey]: normalized.apiKey,
|
||||
[gatewayCredentialKey]: gatewayToken,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function nativeOpenClawOptions({
|
||||
storageRoot, workdir, sessionRef = '', allowWrite = false, runtime,
|
||||
storageRoot, workdir, sessionRef = '', allowWrite = false, runtime, openclawVersion,
|
||||
}) {
|
||||
const modelRef = String(runtime?.model || '').trim()
|
||||
const provider = runtime?.provider
|
||||
@@ -494,7 +477,7 @@ function nativeOpenClawOptions({
|
||||
? 'openclaw.native.workspace-write.json'
|
||||
: 'openclaw.native.read-only.json'
|
||||
const runtimePathsResult = runtimePaths({
|
||||
storageRoot, workdir, sessionRef, configName,
|
||||
storageRoot, workdir, sessionRef, configName, runtimeVersion: openclawVersion,
|
||||
})
|
||||
const { resolvedWorkdir, home, state, configPath } = runtimePathsResult
|
||||
const modelConfig = { primary: modelRef }
|
||||
@@ -555,7 +538,7 @@ function nativeOpenClawOptions({
|
||||
module.exports = {
|
||||
configureOpenClawGatewayRuntime,
|
||||
isOpenClawSecretReference,
|
||||
managedOpenClawOptions,
|
||||
nativeOpenClawOptions,
|
||||
refreshOpenClawRuntimeAfterStartup,
|
||||
validateOpenClawRuntimeGuard,
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const JSON5 = require('json5')
|
||||
const { nativeSecretEnvKeys } = require('./native-secret-environment.cjs')
|
||||
|
||||
function opencodeConfigLayers(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const home = options.home || os.homedir()
|
||||
const workdir = typeof options.workdir === 'string' && path.isAbsolute(options.workdir)
|
||||
? options.workdir : ''
|
||||
const resolvePath = value => {
|
||||
if (typeof value !== 'string' || !value) return ''
|
||||
const expanded = value.replace(/^~(?=$|[\\/])/, home)
|
||||
return path.isAbsolute(expanded) ? expanded : workdir ? path.resolve(workdir, expanded) : ''
|
||||
}
|
||||
const configRoot = env.XDG_CONFIG_HOME || path.join(home, '.config')
|
||||
const globalRoot = path.join(configRoot, 'opencode')
|
||||
const files = ['config.json', 'opencode.json', 'opencode.jsonc'].map(name => path.join(globalRoot, name))
|
||||
const explicit = resolvePath(env.OPENCODE_CONFIG)
|
||||
if (explicit) files.push(explicit)
|
||||
const projectDisabled = /^(?:1|true)$/i.test(String(env.OPENCODE_DISABLE_PROJECT_CONFIG || ''))
|
||||
if (workdir && !projectDisabled) {
|
||||
let current = path.resolve(workdir)
|
||||
const directories = []
|
||||
for (let depth = 0; depth < 64; depth += 1) {
|
||||
directories.push(current)
|
||||
if (fs.existsSync(path.join(current, '.git'))) break
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
for (const directory of [...directories].reverse()) {
|
||||
files.push(...['opencode.json', 'opencode.jsonc'].map(name => path.join(directory, name)))
|
||||
}
|
||||
for (const directory of directories) {
|
||||
files.push(...['opencode.json', 'opencode.jsonc'].map(name => path.join(directory, '.opencode', name)))
|
||||
}
|
||||
}
|
||||
const roots = [path.join(home, '.opencode'), resolvePath(env.OPENCODE_CONFIG_DIR)].filter(Boolean)
|
||||
files.push(...roots.flatMap(root => ['opencode.json', 'opencode.jsonc'].map(name => path.join(root, name))))
|
||||
const configs = []
|
||||
for (const file of new Set(files)) {
|
||||
try {
|
||||
const stat = fs.statSync(file)
|
||||
if (stat.isFile() && stat.size <= 2 * 1024 * 1024) configs.push(JSON5.parse(fs.readFileSync(file, 'utf8')))
|
||||
} catch { /* Invalid or missing native configs are diagnosed by OpenCode. */ }
|
||||
}
|
||||
if (typeof env.OPENCODE_CONFIG_CONTENT === 'string'
|
||||
&& Buffer.byteLength(env.OPENCODE_CONFIG_CONTENT) <= 2 * 1024 * 1024) {
|
||||
try { configs.push(JSON5.parse(env.OPENCODE_CONFIG_CONTENT)) } catch { /* Native CLI reports invalid content. */ }
|
||||
}
|
||||
return { configs, env, home }
|
||||
}
|
||||
|
||||
function opencodeDeclaredCredentialKeys(options = {}) {
|
||||
const { configs, env, home } = opencodeConfigLayers(options)
|
||||
const effectiveProviderList = name => {
|
||||
const lists = configs.filter(config => Array.isArray(config?.[name])).map(config => config[name])
|
||||
const selected = lists.at(-1)
|
||||
if (!selected || !selected.every(value => typeof value === 'string')) return null
|
||||
const resolved = selected.map(value => value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,
|
||||
(_match, key) => env[key] || ''))
|
||||
return resolved.every(Boolean) ? new Set(resolved) : null
|
||||
}
|
||||
const enabled = effectiveProviderList('enabled_providers')
|
||||
const disabled = effectiveProviderList('disabled_providers')
|
||||
const providerAllowed = provider => (!enabled || enabled.has(provider)) && !disabled?.has(provider)
|
||||
const keys = []
|
||||
const visit = (value, depth = 0) => {
|
||||
if (depth > 32) return
|
||||
if (typeof value === 'string') {
|
||||
for (const match of value.matchAll(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g)) keys.push(match[1])
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const child of Object.values(value)) visit(child, depth + 1)
|
||||
}
|
||||
}
|
||||
for (const config of configs) {
|
||||
for (const collection of [config?.provider, config?.providers]) {
|
||||
if (Array.isArray(collection)) visit(collection)
|
||||
else if (collection && typeof collection === 'object') {
|
||||
for (const [provider, value] of Object.entries(collection)) if (providerAllowed(provider)) visit(value)
|
||||
}
|
||||
}
|
||||
visit(config?.model)
|
||||
visit(config?.small_model)
|
||||
visit(config?.enabled_providers)
|
||||
visit(config?.disabled_providers)
|
||||
}
|
||||
const resolveModel = model => typeof model === 'string'
|
||||
? model.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => env[name] || '') : model
|
||||
const effectiveModel = configs.findLast(config => typeof config?.model === 'string')?.model || ''
|
||||
const effectiveSmallModel = configs.findLast(config => typeof config?.small_model === 'string')?.small_model || ''
|
||||
const providers = new Set([resolveModel(effectiveModel), resolveModel(effectiveSmallModel)]
|
||||
.filter(model => typeof model === 'string' && /^[A-Za-z0-9._-]+\/.+/.test(model))
|
||||
.map(model => model.slice(0, model.indexOf('/'))))
|
||||
const hasConfiguredModel = Boolean(effectiveModel)
|
||||
if (!hasConfiguredModel) {
|
||||
const stateRoot = env.XDG_STATE_HOME || path.join(home, '.local', 'state')
|
||||
const filename = path.join(stateRoot, 'opencode', 'model.json')
|
||||
try {
|
||||
const stat = fs.statSync(filename)
|
||||
if (stat.isFile() && stat.size <= 2 * 1024 * 1024) {
|
||||
const recent = JSON.parse(fs.readFileSync(filename, 'utf8'))?.recent
|
||||
if (Array.isArray(recent)) {
|
||||
for (const item of recent.slice(0, 32)) {
|
||||
if (typeof item?.providerID === 'string' && /^[A-Za-z0-9._-]+$/.test(item.providerID)
|
||||
&& typeof item.modelID === 'string' && item.modelID) providers.add(item.providerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* No usable recent native model is available. */ }
|
||||
}
|
||||
if (providers.size || !hasConfiguredModel) {
|
||||
const cacheRoot = env.XDG_CACHE_HOME || path.join(home, '.cache')
|
||||
const filename = path.join(cacheRoot, 'opencode', 'models.json')
|
||||
try {
|
||||
const stat = fs.statSync(filename)
|
||||
if (stat.isFile() && stat.size <= 16 * 1024 * 1024) {
|
||||
const metadata = JSON.parse(fs.readFileSync(filename, 'utf8'))
|
||||
const candidates = hasConfiguredModel ? [...providers] : Object.keys(metadata).slice(0, 1024)
|
||||
for (const provider of candidates) {
|
||||
if (!providerAllowed(provider)) continue
|
||||
const declared = Object.hasOwn(metadata, provider) ? metadata[provider]?.env : null
|
||||
if (Array.isArray(declared)) keys.push(...declared.slice(0, 128))
|
||||
if (keys.length >= 1024) break
|
||||
}
|
||||
}
|
||||
} catch { /* Missing or invalid native metadata is handled by OpenCode. */ }
|
||||
}
|
||||
const safeKeys = []
|
||||
for (let index = 0; index < Math.min(keys.length, 1024); index += 128) {
|
||||
safeKeys.push(...nativeSecretEnvKeys(keys.slice(index, index + 128)))
|
||||
}
|
||||
return [...new Set(safeKeys)].sort()
|
||||
}
|
||||
|
||||
module.exports = { opencodeDeclaredCredentialKeys, opencodeConfigLayers }
|
||||
@@ -0,0 +1,29 @@
|
||||
// Environment names used by Pi's built-in providers. Custom providers are
|
||||
// discovered separately from the user's native models.json.
|
||||
const PI_PROVIDER_CREDENTIAL_KEYS = Object.freeze({
|
||||
'ant-ling': 'ANT_LING_API_KEY', 'qwen-token-plan': 'QWEN_TOKEN_PLAN_API_KEY',
|
||||
'qwen-token-plan-cn': 'QWEN_TOKEN_PLAN_CN_API_KEY',
|
||||
'qwen-token-plan-individual': 'QWEN_TOKEN_PLAN_API_KEY',
|
||||
openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', 'github-copilot': 'COPILOT_GITHUB_TOKEN',
|
||||
'azure-openai-responses': 'AZURE_OPENAI_API_KEY', nvidia: 'NVIDIA_API_KEY',
|
||||
deepseek: 'DEEPSEEK_API_KEY', google: 'GEMINI_API_KEY',
|
||||
'google-vertex': 'GOOGLE_CLOUD_API_KEY', groq: 'GROQ_API_KEY',
|
||||
cerebras: 'CEREBRAS_API_KEY', xai: 'XAI_API_KEY', radius: 'RADIUS_API_KEY',
|
||||
openrouter: 'OPENROUTER_API_KEY', 'vercel-ai-gateway': 'AI_GATEWAY_API_KEY',
|
||||
zai: 'ZAI_API_KEY', 'zai-coding-cn': 'ZAI_CODING_CN_API_KEY',
|
||||
mistral: 'MISTRAL_API_KEY', minimax: 'MINIMAX_API_KEY', 'minimax-cn': 'MINIMAX_CN_API_KEY',
|
||||
moonshotai: 'MOONSHOT_API_KEY', 'moonshotai-cn': 'MOONSHOT_API_KEY',
|
||||
huggingface: 'HF_TOKEN', fireworks: 'FIREWORKS_API_KEY', together: 'TOGETHER_API_KEY',
|
||||
baseten: 'BASETEN_API_KEY', opencode: 'OPENCODE_API_KEY', 'opencode-go': 'OPENCODE_API_KEY',
|
||||
'kimi-coding': 'KIMI_API_KEY', meta: 'META_API_KEY',
|
||||
'cloudflare-workers-ai': 'CLOUDFLARE_API_KEY', 'cloudflare-ai-gateway': 'CLOUDFLARE_API_KEY',
|
||||
xiaomi: 'XIAOMI_API_KEY', 'xiaomi-token-plan-cn': 'XIAOMI_TOKEN_PLAN_CN_API_KEY',
|
||||
'xiaomi-token-plan-ams': 'XIAOMI_TOKEN_PLAN_AMS_API_KEY',
|
||||
'xiaomi-token-plan-sgp': 'XIAOMI_TOKEN_PLAN_SGP_API_KEY',
|
||||
})
|
||||
const PI_BUILTIN_CREDENTIAL_KEYS = Object.freeze([...new Set([
|
||||
...Object.values(PI_PROVIDER_CREDENTIAL_KEYS), 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN',
|
||||
])])
|
||||
const PI_BUILTIN_CONFIG_KEYS = Object.freeze(['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID'])
|
||||
|
||||
module.exports = { PI_PROVIDER_CREDENTIAL_KEYS, PI_BUILTIN_CREDENTIAL_KEYS, PI_BUILTIN_CONFIG_KEYS }
|
||||
@@ -0,0 +1,119 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const JSON5 = require('json5')
|
||||
const { nativeSecretEnvKeys, configSecrets } = require('./native-secret-environment.cjs')
|
||||
|
||||
function qwenNativeSelection(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const platform = options.platform || process.platform
|
||||
const configuredHome = platform === 'win32' ? env.USERPROFILE : env.HOME
|
||||
const home = typeof configuredHome === 'string' && configuredHome.trim() ? configuredHome.trim() : (options.home || os.homedir())
|
||||
const system = env.QWEN_CODE_SYSTEM_SETTINGS_PATH || (platform === 'darwin'
|
||||
? '/Library/Application Support/QwenCode/settings.json' : platform === 'win32'
|
||||
? 'C:\\ProgramData\\qwen-code\\settings.json' : '/etc/qwen-code/settings.json')
|
||||
const defaults = env.QWEN_CODE_SYSTEM_DEFAULTS_PATH || path.join(path.dirname(system), 'system-defaults.json')
|
||||
const resolveFile = file => path.isAbsolute(file) ? file
|
||||
: typeof options.workdir === 'string' && path.isAbsolute(options.workdir) ? path.resolve(options.workdir, file) : ''
|
||||
const user = path.join(home, '.qwen', 'settings.json')
|
||||
const project = options.workdir && path.isAbsolute(options.workdir) && path.resolve(options.workdir) !== path.resolve(home)
|
||||
? path.join(options.workdir, '.qwen', 'settings.json') : ''
|
||||
let model = ''
|
||||
let auth = ''
|
||||
let inlineCredential = ''
|
||||
const providers = new Map()
|
||||
try {
|
||||
const configs = new Map()
|
||||
for (const entry of [defaults, user, project, system].filter(Boolean)) {
|
||||
const file = resolveFile(entry)
|
||||
if (!file) return { keys: [], unsupported: true }
|
||||
let stat
|
||||
try { stat = fs.statSync(file) } catch (error) { if (error.code === 'ENOENT') continue; throw error }
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return { keys: [], unsupported: true }
|
||||
const settings = JSON5.parse(fs.readFileSync(file, 'utf8'))
|
||||
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return { keys: [], unsupported: true }
|
||||
configs.set(entry, settings)
|
||||
}
|
||||
if (Array.isArray(options.redactionSecrets)) {
|
||||
options.redactionSecrets.push(...configSecrets([...configs.values()]))
|
||||
}
|
||||
const trustEnabled = configs.get(system)?.security?.folderTrust?.enabled
|
||||
?? configs.get(user)?.security?.folderTrust?.enabled ?? false
|
||||
let includeProject = trustEnabled === false
|
||||
if (project && trustEnabled === true) {
|
||||
const trustFile = resolveFile(env.GEMINI_CLI_TRUSTED_FOLDERS_PATH || path.join(home, '.qwen', 'trustedFolders.json'))
|
||||
try {
|
||||
const stat = fs.statSync(trustFile)
|
||||
const rules = stat.isFile() && stat.size <= 1024 * 1024 ? JSON5.parse(fs.readFileSync(trustFile, 'utf8')) : null
|
||||
const target = path.resolve(options.workdir)
|
||||
if (!rules || typeof rules !== 'object' || Array.isArray(rules)) return { keys: [], unsupported: true }
|
||||
const entries = Object.entries(rules)
|
||||
const trusted = entries.some(([folder, level]) => {
|
||||
if (!path.isAbsolute(folder) || !['TRUST_FOLDER', 'TRUST_PARENT'].includes(level)) return false
|
||||
const root = path.resolve(level === 'TRUST_PARENT' ? path.dirname(folder) : folder)
|
||||
const relative = path.relative(root, target)
|
||||
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
|
||||
})
|
||||
const denied = entries.some(([folder, level]) => level === 'DO_NOT_TRUST'
|
||||
&& path.isAbsolute(folder) && path.normalize(folder) === path.normalize(target))
|
||||
includeProject = trusted || !denied
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') return { keys: [], unsupported: true }
|
||||
includeProject = true
|
||||
}
|
||||
}
|
||||
for (const entry of [defaults, user, ...(includeProject && project ? [project] : []), system]) {
|
||||
const settings = configs.get(entry)
|
||||
if (!settings) continue
|
||||
if (typeof settings.model?.name === 'string') model = settings.model.name
|
||||
if (typeof settings.security?.auth?.selectedType === 'string') auth = settings.security.auth.selectedType
|
||||
if (Object.hasOwn(settings.security?.auth || {}, 'apiKey')) inlineCredential = settings.security.auth.apiKey
|
||||
if (settings.modelProviders && typeof settings.modelProviders === 'object' && !Array.isArray(settings.modelProviders)) {
|
||||
providers.clear()
|
||||
for (const [type, entries] of Object.entries(settings.modelProviders)) {
|
||||
if (Array.isArray(entries)) providers.set(type, entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!auth) {
|
||||
if (env.QWEN_OAUTH) auth = 'qwen-oauth'
|
||||
else if (env.OPENAI_API_KEY && env.OPENAI_MODEL && env.OPENAI_BASE_URL) auth = 'openai'
|
||||
else if (env.GEMINI_API_KEY && env.GEMINI_MODEL) auth = 'gemini'
|
||||
else if (env.GOOGLE_API_KEY && env.GOOGLE_MODEL) auth = 'vertex-ai'
|
||||
else if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_MODEL && env.ANTHROPIC_BASE_URL) auth = 'anthropic'
|
||||
}
|
||||
auth ||= typeof env.QWEN_DEFAULT_AUTH_TYPE === 'string' ? env.QWEN_DEFAULT_AUTH_TYPE : ''
|
||||
if (!auth) return { keys: [], unsupported: false, authType: '' }
|
||||
const selected = model ? (providers.get(auth) || []).filter(entry => entry?.id === model) : []
|
||||
if (selected.length > 1) return { keys: [], unsupported: true, authType: auth }
|
||||
if (selected[0]?.envKey) {
|
||||
const keys = nativeSecretEnvKeys([selected[0].envKey])
|
||||
return { keys, unsupported: keys.length === 0, authType: auth }
|
||||
}
|
||||
if (typeof inlineCredential === 'string' && inlineCredential.trim()) return { keys: [], unsupported: false, authType: auth }
|
||||
const defaultKeys = { openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY',
|
||||
gemini: 'GEMINI_API_KEY', 'vertex-ai': 'GOOGLE_API_KEY' }
|
||||
return { keys: nativeSecretEnvKeys([defaultKeys[auth]]), unsupported: false, authType: auth }
|
||||
} catch { return { keys: [], unsupported: true } }
|
||||
}
|
||||
|
||||
function qwenCredentialSelection(options = {}) {
|
||||
const { keys, unsupported } = qwenNativeSelection(options)
|
||||
return { keys, unsupported }
|
||||
}
|
||||
|
||||
function qwenConfiguredAuth(options = {}) {
|
||||
return qwenNativeSelection(options).authType || ''
|
||||
}
|
||||
|
||||
function qwenDeclaredCredentialKeys(options = {}) {
|
||||
return qwenCredentialSelection(options).keys
|
||||
}
|
||||
|
||||
function qwenConfigSecrets(options = {}) {
|
||||
const redactionSecrets = []
|
||||
qwenNativeSelection({ ...options, redactionSecrets })
|
||||
return redactionSecrets
|
||||
}
|
||||
|
||||
module.exports = { qwenCredentialSelection, qwenDeclaredCredentialKeys, qwenConfiguredAuth, qwenConfigSecrets }
|
||||
@@ -0,0 +1,89 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { launchEnvironment } = require('./cli-discovery.cjs')
|
||||
|
||||
const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/
|
||||
|
||||
function workBuddyModels(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const root = env.CODEBUDDY_CONFIG_DIR || path.join(options.home || os.homedir(), '.workbuddy')
|
||||
try {
|
||||
const filename = path.join(root, 'models.json')
|
||||
const stat = fs.statSync(filename)
|
||||
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return []
|
||||
const value = JSON.parse(fs.readFileSync(filename, 'utf8'))
|
||||
const models = Array.isArray(value) ? value : value.models
|
||||
return (Array.isArray(models) ? models : []).filter(model => (
|
||||
typeof model?.id === 'string' && MODEL_ID.test(model.id)
|
||||
&& typeof model.apiKey === 'string' && model.apiKey.trim()
|
||||
&& model.supportsToolCall === true && validUrl(model.url)
|
||||
)).slice(0, 64)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function validUrl(value) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
function workBuddySettings(options = {}) {
|
||||
return {
|
||||
modelId: options.modelId || '',
|
||||
models: workBuddyModels(options).map(model => ({
|
||||
id: model.id, name: String(model.name || model.id).slice(0, 120),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function workBuddyEnvironment(options = {}) {
|
||||
if (!options.modelId) return {}
|
||||
const model = workBuddyModels(options).find(model => model.id === options.modelId)
|
||||
if (!model) throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
|
||||
// Use the CLI's supported provider settings, without copying desktop account
|
||||
// tokens or attempting to unlock its host-managed encrypted credential store.
|
||||
return {
|
||||
CODEBUDDY_CONFIG_DIR: (options.env || process.env).CODEBUDDY_CONFIG_DIR
|
||||
|| path.join(options.home || os.homedir(), '.workbuddy'),
|
||||
CODEBUDDY_MODEL: `custom-local:${model.id}`,
|
||||
CODEBUDDY_API_KEY: model.apiKey,
|
||||
CODEBUDDY_BASE_URL: model.url,
|
||||
}
|
||||
}
|
||||
|
||||
function workBuddyCredentialState(options = {}) {
|
||||
if (options.modelId) {
|
||||
try {
|
||||
workBuddyEnvironment(options)
|
||||
return { state: 'ready', source: 'native-credential' }
|
||||
} catch { return { state: 'unknown', source: 'workbuddy-model-unavailable' } }
|
||||
}
|
||||
const env = options.env || process.env
|
||||
if (env.CODEBUDDY_API_KEY?.trim() || env.CODEBUDDY_AUTH_TOKEN?.trim()) {
|
||||
return { state: 'ready', source: 'native-credential' }
|
||||
}
|
||||
// A models.json entry is not a selected provider. The desktop app's shared
|
||||
// encrypted login also cannot be decoded by its standalone lite CLI.
|
||||
if (launchEnvironment('workbuddy', options.executable, options).CODEBUDDY_FORCE_LITE_WB_BUNDLE !== '1') {
|
||||
return { state: 'unknown', source: 'unverified' }
|
||||
}
|
||||
const home = options.home || os.homedir()
|
||||
const platform = options.platform || process.platform
|
||||
const root = platform === 'darwin' ? path.join(home, 'Library', 'Application Support')
|
||||
: platform === 'win32' ? path.join(home, 'AppData', 'Local')
|
||||
: path.join(home, '.local', 'share')
|
||||
try {
|
||||
const filename = path.join(root, 'CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info')
|
||||
if (fs.statSync(filename).size <= 2 * 1024 * 1024) {
|
||||
const auth = JSON.parse(fs.readFileSync(filename, 'utf8')).auth
|
||||
if (auth?.accessToken && typeof auth.accessToken === 'object') {
|
||||
return { state: 'unknown', source: 'workbuddy-desktop-locked' }
|
||||
}
|
||||
}
|
||||
} catch { /* native CLI may still have an independent login */ }
|
||||
return { state: 'unknown', source: 'unverified' }
|
||||
}
|
||||
|
||||
module.exports = { workBuddyCredentialState, workBuddyEnvironment, workBuddySettings }
|
||||
@@ -0,0 +1,420 @@
|
||||
const fs = require('node:fs')
|
||||
const http = require('node:http')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { StringDecoder } = require('node:string_decoder')
|
||||
const { spawn, spawnSync } = require('node:child_process')
|
||||
|
||||
const requestedPort = Number(process.env.MELDWORK_AGENT_BRIDGE_PORT || 8765)
|
||||
const PORT = Number.isInteger(requestedPort) && requestedPort >= 1024 && requestedPort <= 65535
|
||||
? requestedPort
|
||||
: 8765
|
||||
const MAX_REQUEST_BYTES = 4 * 1024 * 1024
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
const MAX_SKILLS = 64
|
||||
const PUBLIC_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/
|
||||
|
||||
const AGENT_DEFINITIONS = Object.freeze([
|
||||
{
|
||||
id: 'codex', label: 'Codex',
|
||||
description: 'Codex CLI exposed through an SSH-local Bridge.',
|
||||
auth(command) { return command('codex', ['login', 'status']).status === 0 },
|
||||
},
|
||||
{
|
||||
id: 'hermes', label: 'Hermes',
|
||||
description: 'Hermes Agent exposed through an SSH-local Bridge.',
|
||||
auth() { return true },
|
||||
},
|
||||
{
|
||||
id: 'claude', label: 'Claude Code',
|
||||
description: 'Claude Code exposed through an SSH-local Bridge.',
|
||||
auth(command) {
|
||||
const result = command('claude', ['auth', 'status'])
|
||||
if (result.status !== 0) return false
|
||||
try { return JSON.parse(String(result.stdout || '')).loggedIn === true } catch { return false }
|
||||
},
|
||||
},
|
||||
{ id: 'openclaw', label: 'OpenClaw' },
|
||||
{ id: 'workbuddy', label: 'WorkBuddy', commands: ['codebuddy'] },
|
||||
{ id: 'zcode', label: 'Zcode' },
|
||||
{ id: 'deepseek', label: 'DeepSeek Harness', commands: ['dsh'] },
|
||||
{ id: 'pi', label: 'Pi Agent', commands: ['pi', 'pi-agent', 'piagent'] },
|
||||
{ id: 'kimi', label: 'Kimi Code' },
|
||||
{ id: 'mimo', label: 'MiMo Code' },
|
||||
{ id: 'gemini', label: 'Gemini CLI' },
|
||||
{
|
||||
id: 'opencode', label: 'OpenCode',
|
||||
description: 'OpenCode exposed through an SSH-local Bridge.',
|
||||
auth() { return null },
|
||||
},
|
||||
{ id: 'qwen', label: 'Qwen Code' },
|
||||
{ id: 'opencodereview', label: 'OpenCodeReview', commands: ['ocr'] },
|
||||
])
|
||||
|
||||
function json(res, status, body) {
|
||||
const payload = JSON.stringify(body)
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) })
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
function versionFrom(value) {
|
||||
return String(value || '').match(/(?:^|[^0-9])v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1] || ''
|
||||
}
|
||||
|
||||
function commandResult(spawnSyncFn, command, args) {
|
||||
const result = spawnSyncFn(command, args, { encoding: 'utf8', timeout: 5000 }) || {}
|
||||
return {
|
||||
status: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: String(result.stdout || ''),
|
||||
}
|
||||
}
|
||||
|
||||
function listSkills(kind, options = {}) {
|
||||
const readdirSync = options.readdirSync || fs.readdirSync
|
||||
const home = options.home || os.homedir()
|
||||
const env = options.env || process.env
|
||||
const nativeRoot = (key, fallback) => {
|
||||
const configured = typeof env[key] === 'string' ? env[key] : ''
|
||||
if (!configured.trim()) return fallback
|
||||
if (configured === '~') return home
|
||||
if (configured.startsWith('~/')) return path.join(home, configured.slice(2))
|
||||
return path.resolve(home, configured)
|
||||
}
|
||||
const roots = {
|
||||
codex: [path.join(nativeRoot('CODEX_HOME', path.join(home, '.codex')), 'skills'), path.join(home, '.agents', 'skills')],
|
||||
hermes: [path.join(nativeRoot('HERMES_HOME', path.join(home, '.hermes')), 'skills'), path.join(home, '.agents', 'skills')],
|
||||
claude: [path.join(nativeRoot('CLAUDE_CONFIG_DIR', path.join(home, '.claude')), 'skills'), path.join(home, '.agents', 'skills')],
|
||||
opencode: [path.join(nativeRoot('XDG_CONFIG_HOME', path.join(home, '.config')), 'opencode', 'skills'),
|
||||
path.join(nativeRoot('XDG_CONFIG_HOME', path.join(home, '.config')), 'opencode', 'skill')],
|
||||
}[kind] || []
|
||||
const names = new Set()
|
||||
for (const root of roots) {
|
||||
let entries = []
|
||||
try { entries = readdirSync(root, { withFileTypes: true }) } catch { continue }
|
||||
for (const entry of entries) {
|
||||
if (names.size >= MAX_SKILLS) break
|
||||
if ((entry.isDirectory?.() || entry.isSymbolicLink?.()) && PUBLIC_NAME.test(entry.name)) names.add(entry.name)
|
||||
}
|
||||
}
|
||||
return [...names].sort().map(name => ({ namespace: kind, slug: name, name }))
|
||||
}
|
||||
|
||||
function discoverAgents(options = {}) {
|
||||
const spawnSyncFn = options.spawnSync || spawnSync
|
||||
const listSkillsFn = options.listSkills || (kind => listSkills(kind))
|
||||
const command = (name, args) => commandResult(spawnSyncFn, name, args)
|
||||
const agents = []
|
||||
for (const definition of AGENT_DEFINITIONS) {
|
||||
const version = (definition.commands || [definition.id]).map(name => command(name, ['--version']))
|
||||
.map(result => result.status === 0 ? versionFrom(result.stdout) : '').find(Boolean) || ''
|
||||
if (!version) continue
|
||||
const auth = definition.auth ? definition.auth(command) : null
|
||||
const available = Boolean(definition.auth) && auth !== false
|
||||
agents.push({
|
||||
id: definition.id,
|
||||
sourceKind: definition.id,
|
||||
label: definition.label,
|
||||
version,
|
||||
description: definition.description || 'Detected on the server; this CLI is not yet available through the SSH Bridge.',
|
||||
domains: ['general'],
|
||||
inputTypes: ['text'],
|
||||
permissionModes: ['read-only'],
|
||||
available,
|
||||
executionSupported: Boolean(definition.auth),
|
||||
credentialState: auth === true ? 'ready' : auth === false ? 'missing' : 'unknown',
|
||||
skills: listSkillsFn(definition.id),
|
||||
session: {
|
||||
supported: Boolean(definition.auth),
|
||||
resume: Boolean(definition.auth),
|
||||
cancel: true,
|
||||
checkpoint: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
return agents
|
||||
}
|
||||
|
||||
function invocationFor(agentId, prompt, sessionRef = '') {
|
||||
if (agentId === 'codex') {
|
||||
return {
|
||||
command: 'codex',
|
||||
args: sessionRef
|
||||
? ['exec', 'resume', '--json', '--skip-git-repo-check', '--sandbox', 'read-only', sessionRef, '-']
|
||||
: ['exec', '--json', '--skip-git-repo-check', '--sandbox', 'read-only', '-'],
|
||||
input: prompt,
|
||||
}
|
||||
}
|
||||
if (agentId === 'hermes') {
|
||||
return {
|
||||
command: 'hermes',
|
||||
args: ['chat', '--quiet', ...(sessionRef ? ['--resume', sessionRef] : []), '--query', prompt],
|
||||
input: '',
|
||||
}
|
||||
}
|
||||
if (agentId === 'claude') {
|
||||
return {
|
||||
command: 'claude',
|
||||
args: [
|
||||
'--print', '--output-format', 'json', '--permission-mode', 'plan',
|
||||
...(sessionRef ? ['--resume', sessionRef] : []), prompt,
|
||||
],
|
||||
input: '',
|
||||
}
|
||||
}
|
||||
if (agentId === 'opencode') {
|
||||
return {
|
||||
command: 'opencode',
|
||||
args: ['run', '--format', 'json', '--agent', 'plan',
|
||||
...(sessionRef ? ['--session', sessionRef] : []), prompt],
|
||||
input: '',
|
||||
}
|
||||
}
|
||||
throw new Error('REMOTE_AGENT_UNSUPPORTED')
|
||||
}
|
||||
|
||||
function parseAgentOutput(agentId, stdout) {
|
||||
if (agentId === 'opencode') {
|
||||
let sessionRef = ''
|
||||
let text = ''
|
||||
let completed = false
|
||||
for (const line of String(stdout || '').split(/\r?\n/)) {
|
||||
let event
|
||||
try { event = JSON.parse(line) } catch { continue }
|
||||
if (typeof event.sessionID === 'string') sessionRef = event.sessionID
|
||||
if (event.type === 'text' && event.part?.type === 'text' && typeof event.part.text === 'string') text += event.part.text
|
||||
if (event.type === 'error') return null
|
||||
if (event.type === 'step_finish' || event.part?.type === 'step-finish') {
|
||||
completed = ['stop', 'end_turn'].includes(String(event.part?.reason || event.reason || '').toLowerCase())
|
||||
}
|
||||
}
|
||||
return completed && text.trim() ? { text: text.trim(), sessionRef, outcome: 'completed' } : null
|
||||
}
|
||||
if (agentId === 'codex') {
|
||||
const messages = []
|
||||
let sessionRef = ''
|
||||
let completed = false
|
||||
for (const line of String(stdout || '').split(/\r?\n/)) {
|
||||
try {
|
||||
const event = JSON.parse(line)
|
||||
if (event.type === 'turn.failed' || event.type === 'error') return null
|
||||
if (event.type === 'turn.completed') completed = true
|
||||
if (event.type === 'thread.started' && typeof event.thread_id === 'string') sessionRef = event.thread_id
|
||||
if (event.type === 'item.completed' && event.item?.type === 'agent_message'
|
||||
&& typeof event.item.text === 'string') messages.push(event.item.text)
|
||||
} catch { /* ignore non-JSON diagnostics */ }
|
||||
}
|
||||
const text = messages.join('\n').trim()
|
||||
return completed && text ? { text, sessionRef, outcome: 'completed' } : null
|
||||
}
|
||||
if (agentId === 'hermes') {
|
||||
const lines = String(stdout || '').split(/\r?\n/)
|
||||
let sessionRef = ''
|
||||
const text = lines.filter((line) => {
|
||||
const match = line.match(/^session_id:\s*(\S+)\s*$/)
|
||||
if (match) {
|
||||
sessionRef = match[1]
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}).join('\n').trim()
|
||||
if (hermesFailedTurn(text)) return null
|
||||
return text ? { text, sessionRef, outcome: 'completed' } : null
|
||||
}
|
||||
if (agentId === 'claude') {
|
||||
let result
|
||||
try { result = JSON.parse(String(stdout || '')) } catch { return null }
|
||||
if (result?.is_error === true || typeof result?.result !== 'string' || !result.result.trim()) return null
|
||||
return { text: result.result.trim(), sessionRef: String(result.session_id || ''), outcome: 'completed' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function hermesFailedTurn(text) {
|
||||
return /^Provider said:\s*\S.*$/m.test(text)
|
||||
&& /^(?:Your request was not processed\. Send it again if you still want me to carry it out\.|This turn did not complete\. Some actions may already have run; verify their effects before resending\.)$/m.test(text)
|
||||
}
|
||||
|
||||
function hasOpenCodeError(stdout) {
|
||||
return String(stdout || '').split(/\r?\n/).some(line => {
|
||||
try { return JSON.parse(line)?.type === 'error' } catch { return false }
|
||||
})
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = ''
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString('utf8')
|
||||
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) reject(new Error('request too large'))
|
||||
})
|
||||
req.once('end', () => resolve(body))
|
||||
req.once('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function parentSessionDetached(parentPid = process.ppid, readFileSync = fs.readFileSync) {
|
||||
if (!Number.isInteger(parentPid) || parentPid <= 1 || process.ppid === 1) return true
|
||||
try {
|
||||
const stat = String(readFileSync(`/proc/${parentPid}/stat`, 'utf8'))
|
||||
const match = stat.match(/^\d+\s+\(.+\)\s+\S+\s+(\d+)\s/)
|
||||
return match ? Number(match[1]) === 1 : false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function runAgent(agentId, prompt, sessionRef = '', signal = null) {
|
||||
return new Promise(resolve => {
|
||||
if (signal?.aborted) return resolve({ outcome: 'cancelled' })
|
||||
let invocation
|
||||
try { invocation = invocationFor(agentId, prompt, sessionRef) } catch {
|
||||
resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_UNSUPPORTED' } })
|
||||
return
|
||||
}
|
||||
const child = spawn(invocation.command, invocation.args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'], cwd: process.env.HOME || undefined,
|
||||
detached: process.platform !== 'win32',
|
||||
...(agentId === 'opencode' ? { env: { ...process.env, OPENCODE_PERMISSION: JSON.stringify({
|
||||
'*': 'deny', read: 'allow', glob: 'allow', grep: 'allow', list: 'allow',
|
||||
webfetch: 'allow', websearch: 'allow', question: 'deny', plan_enter: 'deny', plan_exit: 'deny',
|
||||
}) } } : {}),
|
||||
})
|
||||
let stdout = ''
|
||||
let stdoutBytes = 0
|
||||
const stdoutDecoder = new StringDecoder('utf8')
|
||||
let terminalResult = null
|
||||
let terminalTimer = null
|
||||
let terminalStopRequested = false
|
||||
let forceTimer = null
|
||||
let cancelled = false
|
||||
let invalidOutput = false
|
||||
let pendingOutputLine = ''
|
||||
const sendSignal = value => {
|
||||
if (process.platform !== 'win32' && child.pid) {
|
||||
try { process.kill(-child.pid, value); return } catch { /* use direct child */ }
|
||||
}
|
||||
try { child.kill(value) } catch { /* already exited */ }
|
||||
}
|
||||
const cancel = () => {
|
||||
cancelled = true
|
||||
sendSignal('SIGTERM')
|
||||
if (!forceTimer) forceTimer = setTimeout(() => sendSignal('SIGKILL'), 1500)
|
||||
}
|
||||
signal?.addEventListener('abort', cancel, { once: true })
|
||||
if (signal?.aborted) cancel()
|
||||
child.stdout.on('data', chunk => {
|
||||
if (invalidOutput) return
|
||||
stdoutBytes += chunk.length
|
||||
const text = stdoutBytes <= MAX_RESPONSE_BYTES ? stdoutDecoder.write(chunk) : ''
|
||||
stdout += text
|
||||
const lines = `${pendingOutputLine}${text}`.split(/\r?\n/)
|
||||
pendingOutputLine = lines.pop()
|
||||
if (stdoutBytes > MAX_RESPONSE_BYTES || (agentId === 'opencode' && hasOpenCodeError(lines.join('\n')))) {
|
||||
invalidOutput = true
|
||||
terminalResult = null
|
||||
if (terminalTimer) clearTimeout(terminalTimer)
|
||||
sendSignal('SIGTERM')
|
||||
if (!forceTimer) forceTimer = setTimeout(() => sendSignal('SIGKILL'), 1500)
|
||||
return
|
||||
}
|
||||
if (agentId === 'opencode' && !terminalResult && !cancelled) {
|
||||
terminalResult = parseAgentOutput(agentId, stdout)
|
||||
if (terminalResult) terminalTimer = setTimeout(() => {
|
||||
terminalStopRequested = true
|
||||
child.kill('SIGTERM')
|
||||
forceTimer = setTimeout(() => child.kill('SIGKILL'), 1500)
|
||||
}, 1500)
|
||||
}
|
||||
})
|
||||
child.stderr.resume()
|
||||
child.once('error', () => {
|
||||
signal?.removeEventListener('abort', cancel)
|
||||
if (forceTimer) clearTimeout(forceTimer)
|
||||
resolve(cancelled ? { outcome: 'cancelled' }
|
||||
: { outcome: 'failed', failure: { code: 'REMOTE_AGENT_UNAVAILABLE' } })
|
||||
})
|
||||
child.once('close', (code, signalName) => {
|
||||
if (terminalTimer) clearTimeout(terminalTimer)
|
||||
if (forceTimer) clearTimeout(forceTimer)
|
||||
signal?.removeEventListener('abort', cancel)
|
||||
if (cancelled) return resolve({ outcome: 'cancelled' })
|
||||
if (!invalidOutput) {
|
||||
const tail = stdoutDecoder.end()
|
||||
stdout += tail
|
||||
pendingOutputLine += tail
|
||||
}
|
||||
if (invalidOutput || (agentId === 'opencode' && hasOpenCodeError(pendingOutputLine))) {
|
||||
return resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_RESPONSE_INVALID' } })
|
||||
}
|
||||
if (terminalResult && (code === 0 || (terminalStopRequested
|
||||
&& ['SIGTERM', 'SIGKILL'].includes(signalName)))) return resolve(terminalResult)
|
||||
if (code !== 0) return resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_FAILED' } })
|
||||
if (agentId === 'hermes' && hermesFailedTurn(stdout)) {
|
||||
return resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_FAILED' } })
|
||||
}
|
||||
resolve(parseAgentOutput(agentId, stdout)
|
||||
|| { outcome: 'failed', failure: { code: 'REMOTE_AGENT_RESPONSE_INVALID' } })
|
||||
})
|
||||
child.stdin.end(invocation.input)
|
||||
})
|
||||
}
|
||||
|
||||
function createServer() {
|
||||
return http.createServer(async (req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/.well-known/meldwork-agent-bridge') {
|
||||
return json(res, 200, {
|
||||
protocol: 'meldwork-agent-bridge', version: 1,
|
||||
server: { id: 'ssh-session', label: 'SSH Agent Server' },
|
||||
agents: discoverAgents(),
|
||||
})
|
||||
}
|
||||
const match = req.method === 'POST' && req.url?.match(/^\/v1\/agents\/([A-Za-z0-9._-]+)\/runs$/)
|
||||
if (!match) return json(res, 404, { error: 'NOT_FOUND' })
|
||||
try {
|
||||
const body = JSON.parse(await readBody(req))
|
||||
const agent = discoverAgents().find(item => item.id === match[1] && item.available)
|
||||
if (!agent || typeof body.prompt !== 'string' || body.permissionMode !== 'read-only') {
|
||||
return json(res, 400, { error: 'REQUEST_INVALID' })
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const disconnect = () => { if (!res.writableFinished) controller.abort() }
|
||||
res.once('close', disconnect)
|
||||
if (res.destroyed) controller.abort()
|
||||
const result = await runAgent(agent.id, body.prompt, body.sessionRef || '', controller.signal)
|
||||
res.removeListener('close', disconnect)
|
||||
if (!res.destroyed) return json(res, 200, result)
|
||||
} catch {
|
||||
return json(res, 400, { error: 'REQUEST_INVALID' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
const server = createServer()
|
||||
const parentPid = process.ppid
|
||||
const parentTimer = setInterval(() => {
|
||||
if (!parentSessionDetached(parentPid)) return
|
||||
server.close(() => process.exit(0))
|
||||
}, 1000)
|
||||
server.once('close', () => clearInterval(parentTimer))
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
process.stdout.write(`MELDWORK_AGENT_BRIDGE_READY ${PORT}\n`)
|
||||
})
|
||||
process.once('SIGTERM', () => server.close(() => process.exit(0)))
|
||||
process.once('SIGINT', () => server.close(() => process.exit(0)))
|
||||
return server
|
||||
}
|
||||
|
||||
if (require.main === module || !require.main) startServer()
|
||||
|
||||
module.exports = {
|
||||
AGENT_DEFINITIONS,
|
||||
createServer,
|
||||
discoverAgents,
|
||||
invocationFor,
|
||||
listSkills,
|
||||
parentSessionDetached,
|
||||
parseAgentOutput,
|
||||
runAgent,
|
||||
startServer,
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
const crypto = require('node:crypto')
|
||||
const { spawn } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const net = require('node:net')
|
||||
const { abortableHttpOperation, boundedHttpResponseText, discardHttpResponse, discardLateHttpResponse } = require('../bounded-http-response.cjs')
|
||||
|
||||
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
|
||||
const { createHttpOutboundPayload, createSshOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
|
||||
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
|
||||
const { createAgentConnectorManifest } = require('../connectors/agent-connector-manifest.cjs')
|
||||
|
||||
const BRIDGE_PROTOCOL = 'meldwork-agent-bridge'
|
||||
const BRIDGE_VERSION = 1
|
||||
const BRIDGE_RECIPE_ID = 'external.cloud-agent-bridge'
|
||||
const SSH_RECIPE_ID = 'external.cloud-agent-ssh'
|
||||
const BRIDGE_UPSTREAM_ID = 'meldwork-cloud'
|
||||
const BRIDGE_UPSTREAM_VERSION = '1.0.0'
|
||||
const BRIDGE_ID = /^cloud-bridge-[a-f0-9]{24}$/
|
||||
const PUBLIC_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
const MAX_AGENTS = 32
|
||||
const MAX_SKILLS = 64
|
||||
const STORE_VERSION = 1
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 3 * 60 * 1000
|
||||
const DEFAULT_RUN_TIMEOUT_MS = 10 * 60 * 1000
|
||||
const MAX_REQUEST_TIMEOUT_MS = 15 * 60 * 1000
|
||||
const { AGENT_PROFILES } = require('../cli/cli-discovery.cjs')
|
||||
const SSH_AGENT_DEFINITIONS = Object.entries(AGENT_PROFILES).map(([id, profile]) => ({ id, ...profile }))
|
||||
const SSH_DISCOVERY_COMMAND = SSH_AGENT_DEFINITIONS.flatMap(definition =>
|
||||
(definition.commands || [definition.id]).map(command =>
|
||||
`if command -v ${command} >/dev/null 2>&1; then meldwork_version="$(${command} --version 2>/dev/null | head -n 1)"; if [ -n "$meldwork_version" ]; then printf '__MELDWORK_CLI__${definition.id}\\t%s\\n' "$meldwork_version"; fi; fi`),
|
||||
).join('; ')
|
||||
const SSH_CODEX_COMMAND = 'exec codex exec --json --skip-git-repo-check --sandbox read-only -'
|
||||
const SSH_TUNNEL_READY = 'MELDWORK_AGENT_BRIDGE_READY'
|
||||
const SSH_BRIDGE_SCRIPT = fs.readFileSync(require.resolve('./cloud-agent-bridge-server.cjs'), 'utf8')
|
||||
|
||||
function loginShellCommand(command) {
|
||||
const escaped = String(command).replace(/'/g, `'"'"'`)
|
||||
return `bash -lc '${escaped}'`
|
||||
}
|
||||
|
||||
function bridgeError(code) {
|
||||
const error = new Error(code)
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
function fail(code) {
|
||||
throw bridgeError(code)
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(canonicalJson(value))
|
||||
}
|
||||
|
||||
function cleanText(value, max = 240) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text || text.length > max || /[\u0000-\u001f\u007f]/.test(text)) return ''
|
||||
return text
|
||||
}
|
||||
|
||||
function normalizeSkill(input) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return null
|
||||
const namespace = cleanText(input.namespace, 80)
|
||||
const slug = cleanText(input.slug, 120)
|
||||
const name = cleanText(input.name || input.slug, 120)
|
||||
if (!PUBLIC_ID.test(namespace) || !PUBLIC_ID.test(slug) || !name) return null
|
||||
return { namespace, slug, name }
|
||||
}
|
||||
|
||||
function normalizeAddress(value) {
|
||||
const raw = String(value || '').trim().replace(/\/$/, '')
|
||||
const address = /^[a-z][a-z\d+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`
|
||||
let parsed
|
||||
try { parsed = new URL(address) } catch { fail('CLOUD_AGENT_BRIDGE_ADDRESS_INVALID') }
|
||||
if (!['https:'].includes(parsed.protocol)
|
||||
|| parsed.username || parsed.password || parsed.pathname !== '/'
|
||||
|| parsed.search || parsed.hash || !parsed.hostname
|
||||
|| net.isIP(parsed.hostname) === 0) {
|
||||
fail('CLOUD_AGENT_BRIDGE_ADDRESS_INVALID')
|
||||
}
|
||||
return parsed.origin
|
||||
}
|
||||
|
||||
function normalizeSshAddress(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw || /^[a-z][a-z\d+.-]*:\/\//i.test(raw) || /[\s@/?#]/.test(raw)) {
|
||||
fail('CLOUD_AGENT_SSH_ADDRESS_INVALID')
|
||||
}
|
||||
const match = raw.match(/^([^:]+)(?::(\d{1,5}))?$/)
|
||||
if (!match || net.isIP(match[1]) === 0) fail('CLOUD_AGENT_SSH_ADDRESS_INVALID')
|
||||
const port = match[2] ? Number(match[2]) : 22
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) fail('CLOUD_AGENT_SSH_ADDRESS_INVALID')
|
||||
return { host: match[1], port, address: port === 22 ? match[1] : `${match[1]}:${port}` }
|
||||
}
|
||||
|
||||
function normalizeTunnelEndpoint(value) {
|
||||
let parsed
|
||||
try { parsed = new URL(String(value || '')) } catch { fail('CLOUD_AGENT_SSH_TUNNEL_INVALID') }
|
||||
if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1'
|
||||
|| parsed.pathname !== '/' || parsed.search || parsed.hash
|
||||
|| parsed.username || parsed.password || !parsed.port) {
|
||||
fail('CLOUD_AGENT_SSH_TUNNEL_INVALID')
|
||||
}
|
||||
return parsed.origin
|
||||
}
|
||||
|
||||
function normalizeAgent(input) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
|
||||
}
|
||||
const id = cleanText(input.id, 80)
|
||||
const sourceKind = cleanText(input.sourceKind || input.id, 80)
|
||||
const label = cleanText(input.label || input.name, 120)
|
||||
const version = cleanText(input.version, 80)
|
||||
if (!PUBLIC_ID.test(id) || !PUBLIC_ID.test(sourceKind) || !label || !version) {
|
||||
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
|
||||
}
|
||||
const domains = Array.isArray(input.domains) && input.domains.length
|
||||
? input.domains.map(item => cleanText(item, 80)).filter(item => PUBLIC_ID.test(item))
|
||||
: ['general']
|
||||
const inputTypes = Array.isArray(input.inputTypes) && input.inputTypes.length
|
||||
? input.inputTypes.filter(item => ['text', 'image', 'audio', 'video', 'file', 'structured-data'].includes(item))
|
||||
: ['text']
|
||||
const permissionModes = Array.isArray(input.permissionModes) && input.permissionModes.length
|
||||
? input.permissionModes.filter(item => ['read-only', 'workspace-write'].includes(item))
|
||||
: ['read-only']
|
||||
if (!domains.length || !inputTypes.includes('text') || !permissionModes.length) {
|
||||
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
|
||||
}
|
||||
const credentialState = ['ready', 'missing', 'unknown'].includes(input.credentialState)
|
||||
? input.credentialState
|
||||
: (input.available === false ? 'unknown' : 'ready')
|
||||
const skills = (Array.isArray(input.skills) ? input.skills : [])
|
||||
.map(normalizeSkill)
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_SKILLS)
|
||||
return {
|
||||
id, sourceKind, label, version,
|
||||
description: cleanText(input.description, 400),
|
||||
executionSupported: input.executionSupported !== false && permissionModes.includes('read-only'),
|
||||
available: input.available !== false && input.executionSupported !== false
|
||||
&& permissionModes.includes('read-only'),
|
||||
credentialState,
|
||||
skills,
|
||||
domains: [...new Set(domains)].slice(0, 16),
|
||||
inputTypes: [...new Set(inputTypes)].slice(0, 16),
|
||||
permissionModes: [...new Set(permissionModes)],
|
||||
session: {
|
||||
supported: input.session?.supported === true,
|
||||
resume: input.session?.supported === true && input.session?.resume === true,
|
||||
cancel: input.session?.cancel === true,
|
||||
checkpoint: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProbe(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)
|
||||
|| value.protocol !== BRIDGE_PROTOCOL || value.version !== BRIDGE_VERSION
|
||||
|| !Array.isArray(value.agents) || !value.agents.length || value.agents.length > MAX_AGENTS) {
|
||||
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
|
||||
}
|
||||
const serverLabel = cleanText(value.server?.label || value.server?.name, 120)
|
||||
return {
|
||||
serverId: cleanText(value.server?.id, 120),
|
||||
serverLabel: serverLabel || 'Cloud Agent Server',
|
||||
agents: value.agents.map(normalizeAgent),
|
||||
}
|
||||
}
|
||||
|
||||
function bridgeIdFor(address) {
|
||||
return `cloud-bridge-${crypto.createHash('sha256').update(address).digest('hex').slice(0, 24)}`
|
||||
}
|
||||
|
||||
function instanceIdFor(bridgeId, agentId) {
|
||||
return `cloud-${crypto.createHash('sha256').update(`${bridgeId}:${agentId}`).digest('hex').slice(0, 24)}`
|
||||
}
|
||||
|
||||
function manifestFor(record, agent) {
|
||||
const ssh = record.transport === 'ssh'
|
||||
const tunnel = record.transport === 'ssh-tunnel'
|
||||
return createAgentConnectorManifest({
|
||||
connectorId: instanceIdFor(record.bridgeId, agent.id),
|
||||
connectorVersion: '1.0.0',
|
||||
kind: 'agent',
|
||||
label: `${agent.label} @ ${record.label}`,
|
||||
description: agent.description || `Agent CLI exposed by ${record.label}.`,
|
||||
transport: ssh ? { type: 'cli', protocol: 'jsonl' } : { type: 'http', protocol: 'json' },
|
||||
upstream: ssh
|
||||
? { id: agent.id, minVersion: BRIDGE_UPSTREAM_VERSION, maxVersion: BRIDGE_UPSTREAM_VERSION }
|
||||
: { id: BRIDGE_UPSTREAM_ID, minVersion: BRIDGE_UPSTREAM_VERSION, maxVersion: BRIDGE_UPSTREAM_VERSION },
|
||||
invocation: { recipeId: ssh ? SSH_RECIPE_ID : BRIDGE_RECIPE_ID, idempotencyMode: 'durable' },
|
||||
domains: agent.domains,
|
||||
session: agent.session,
|
||||
// The current bridge request carries text only, regardless of what the
|
||||
// remote Agent itself says it can accept.
|
||||
inputTypes: ['text'],
|
||||
permissionModes: ['read-only'],
|
||||
eventProtocolVersion: 1,
|
||||
eventTypes: ['Completed', 'Failed', 'Cancelled'],
|
||||
usage: {
|
||||
inputTokens: false, outputTokens: false, costMicros: false,
|
||||
toolCalls: false, outboundBytes: !ssh && !tunnel, elapsedMs: true,
|
||||
},
|
||||
outboundDestinations: ssh || tunnel ? [] : [record.address],
|
||||
credentials: { mode: 'none', slots: [] },
|
||||
license: 'LicenseRef-Meldwork-NC',
|
||||
})
|
||||
}
|
||||
|
||||
function instanceFor(record, agent, manifest) {
|
||||
return {
|
||||
instanceId: manifest.connectorId,
|
||||
connectorId: manifest.connectorId,
|
||||
connectorVersion: manifest.connectorVersion,
|
||||
upstreamVersion: BRIDGE_UPSTREAM_VERSION,
|
||||
label: manifest.label,
|
||||
credentialRef: null,
|
||||
manifestId: manifest.manifestId,
|
||||
bridgeId: record.bridgeId,
|
||||
agentId: agent.id,
|
||||
}
|
||||
}
|
||||
|
||||
class CloudAgentBridgeStore {
|
||||
constructor(options = {}) {
|
||||
if (!options || typeof options.storagePath !== 'string' || !options.storagePath
|
||||
|| typeof options.fetch !== 'function') {
|
||||
fail('CLOUD_AGENT_BRIDGE_OPTIONS_INVALID')
|
||||
}
|
||||
this.storagePath = options.storagePath
|
||||
this.fetch = options.fetch
|
||||
this.spawn = options.spawn || spawn
|
||||
this.sshExecute = typeof options.sshExecute === 'function' ? options.sshExecute : null
|
||||
this.sshTunnelStart = typeof options.sshTunnelStart === 'function' ? options.sshTunnelStart : null
|
||||
this.tunnels = new Map()
|
||||
this.activeRuns = new Set()
|
||||
this.timeoutMs = Number.isFinite(options.timeoutMs)
|
||||
? Math.max(1000, Math.min(MAX_REQUEST_TIMEOUT_MS, Math.floor(options.timeoutMs)))
|
||||
: DEFAULT_REQUEST_TIMEOUT_MS
|
||||
this.runTimeoutMs = Number.isFinite(options.runTimeoutMs)
|
||||
? Math.max(1000, Math.min(MAX_REQUEST_TIMEOUT_MS, Math.floor(options.runTimeoutMs)))
|
||||
: DEFAULT_RUN_TIMEOUT_MS
|
||||
this.records = this.read().records
|
||||
}
|
||||
|
||||
read() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(this.storagePath, 'utf8'))
|
||||
if (parsed?.version !== STORE_VERSION || !Array.isArray(parsed.records)) throw new Error('invalid')
|
||||
return { version: STORE_VERSION, records: parsed.records.map(value => this.normalizeRecord(value)) }
|
||||
} catch {
|
||||
return { version: STORE_VERSION, records: [] }
|
||||
}
|
||||
}
|
||||
|
||||
normalizeRecord(value) {
|
||||
const transport = ['ssh', 'ssh-tunnel'].includes(value?.transport) ? value.transport : 'bridge'
|
||||
const sshAddress = ['ssh', 'ssh-tunnel'].includes(transport) ? normalizeSshAddress(value?.address) : null
|
||||
const address = sshAddress?.address || normalizeAddress(value?.address)
|
||||
const bridgeId = String(value?.bridgeId || bridgeIdFor(`${transport}:${address}`))
|
||||
if (!BRIDGE_ID.test(bridgeId)) fail('CLOUD_AGENT_BRIDGE_STORE_INVALID')
|
||||
const agents = Array.isArray(value?.agents) ? value.agents.map(normalizeAgent).slice(0, MAX_AGENTS) : []
|
||||
return {
|
||||
bridgeId,
|
||||
transport,
|
||||
address,
|
||||
endpoint: transport === 'ssh-tunnel' ? normalizeTunnelEndpoint(value?.endpoint) : '',
|
||||
label: cleanText(value?.label, 120) || (transport === 'ssh' ? address : 'Cloud Agent Server'),
|
||||
serverId: cleanText(value?.serverId, 120),
|
||||
agents,
|
||||
available: transport === 'ssh-tunnel' ? false : value?.available === true,
|
||||
lastError: cleanText(value?.lastError, 120),
|
||||
checkedAt: cleanText(value?.checkedAt, 40),
|
||||
}
|
||||
}
|
||||
|
||||
persist() {
|
||||
atomicWritePrivateFile(this.storagePath, `${JSON.stringify({ version: STORE_VERSION, records: this.records })}\n`)
|
||||
}
|
||||
|
||||
async request(pathname, options = {}) {
|
||||
const controller = new AbortController()
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs)
|
||||
? Math.max(1000, Math.min(MAX_REQUEST_TIMEOUT_MS, Math.floor(options.timeoutMs)))
|
||||
: this.timeoutMs
|
||||
let abortCause = ''
|
||||
const abort = cause => {
|
||||
if (abortCause) return
|
||||
abortCause = cause
|
||||
controller.abort()
|
||||
}
|
||||
const timer = setTimeout(() => abort('timeout'), timeoutMs)
|
||||
const activityIntervalMs = Math.min(1000, Math.max(10, Math.floor(timeoutMs / 4)))
|
||||
const reportActivity = () => {
|
||||
try { options.onActivity?.() } catch { /* activity reporting must not fail the request */ }
|
||||
}
|
||||
reportActivity()
|
||||
const activityTimer = options.onActivity
|
||||
? setInterval(reportActivity, activityIntervalMs)
|
||||
: null
|
||||
const abortFromCaller = () => abort('caller')
|
||||
options.signal?.addEventListener?.('abort', abortFromCaller, { once: true })
|
||||
if (options.signal?.aborted) abortFromCaller()
|
||||
try {
|
||||
const pendingResponse = this.fetch(`${options.address}${pathname}`, {
|
||||
method: options.method || 'GET',
|
||||
redirect: 'error',
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) },
|
||||
...(options.body ? { body: options.bodyText ?? JSON.stringify(options.body) } : {}),
|
||||
})
|
||||
discardLateHttpResponse(pendingResponse, controller.signal)
|
||||
const response = await abortableHttpOperation(pendingResponse, controller.signal)
|
||||
controller.signal.throwIfAborted()
|
||||
if (!response?.ok) {
|
||||
discardHttpResponse(response)
|
||||
throw bridgeError(`CLOUD_AGENT_BRIDGE_HTTP_${response?.status || 'FAILED'}`)
|
||||
}
|
||||
const text = await abortableHttpOperation(
|
||||
boundedHttpResponseText(response, MAX_RESPONSE_BYTES, 'CLOUD_AGENT_BRIDGE_RESPONSE_TOO_LARGE', controller.signal),
|
||||
controller.signal,
|
||||
)
|
||||
controller.signal.throwIfAborted()
|
||||
try { return JSON.parse(text) } catch { fail('CLOUD_AGENT_BRIDGE_RESPONSE_INVALID') }
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
fail(abortCause === 'caller' ? 'LOCAL_AGENT_EXECUTION_STOPPED' : 'CLOUD_AGENT_BRIDGE_TIMEOUT')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
if (activityTimer) clearInterval(activityTimer)
|
||||
options.signal?.removeEventListener?.('abort', abortFromCaller)
|
||||
}
|
||||
}
|
||||
|
||||
async probe(address) {
|
||||
const normalizedAddress = normalizeAddress(address)
|
||||
return normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', { address: normalizedAddress }))
|
||||
}
|
||||
|
||||
async sshCommand(address, command, input = '', signal) {
|
||||
const parsed = normalizeSshAddress(address)
|
||||
if (signal?.aborted) fail('CLOUD_AGENT_SSH_CANCELLED')
|
||||
if (this.sshExecute) return this.sshExecute({
|
||||
address: parsed.address, command, input, signal,
|
||||
})
|
||||
const args = [
|
||||
'-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=yes',
|
||||
...(parsed.port === 22 ? [] : ['-p', String(parsed.port)]),
|
||||
parsed.host, command,
|
||||
]
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
const child = this.spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
const finish = (error, value) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
signal?.removeEventListener?.('abort', abort)
|
||||
if (error) reject(error)
|
||||
else resolve(value)
|
||||
}
|
||||
const append = (target, chunk) => {
|
||||
const next = `${target}${Buffer.from(chunk).toString('utf8')}`
|
||||
if (Buffer.byteLength(next, 'utf8') > MAX_RESPONSE_BYTES) {
|
||||
child.kill('SIGTERM')
|
||||
finish(bridgeError('CLOUD_AGENT_SSH_RESPONSE_TOO_LARGE'))
|
||||
return target
|
||||
}
|
||||
return next
|
||||
}
|
||||
const abort = () => {
|
||||
child.kill('SIGTERM')
|
||||
finish(bridgeError('CLOUD_AGENT_SSH_CANCELLED'))
|
||||
}
|
||||
child.once('error', () => finish(bridgeError('CLOUD_AGENT_SSH_UNAVAILABLE')))
|
||||
child.stdout.on('data', chunk => { stdout = append(stdout, chunk) })
|
||||
child.stderr.on('data', chunk => { stderr = append(stderr, chunk) })
|
||||
child.once('close', code => {
|
||||
if (signal?.aborted) return finish(bridgeError('CLOUD_AGENT_SSH_CANCELLED'))
|
||||
if (code !== 0) return finish(bridgeError('CLOUD_AGENT_SSH_UNAVAILABLE'))
|
||||
finish(null, { stdout, stderr })
|
||||
})
|
||||
signal?.addEventListener?.('abort', abort, { once: true })
|
||||
child.stdin.end(input)
|
||||
})
|
||||
}
|
||||
|
||||
async allocateLocalPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address()?.port
|
||||
server.close(error => error ? reject(error) : resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async startSshTunnel(address) {
|
||||
const parsed = normalizeSshAddress(address)
|
||||
if (this.sshTunnelStart) return this.sshTunnelStart({ address: parsed.address })
|
||||
const localPort = await this.allocateLocalPort()
|
||||
const remotePort = localPort
|
||||
const args = [
|
||||
'-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=yes',
|
||||
'-L', `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`,
|
||||
...(parsed.port === 22 ? [] : ['-p', String(parsed.port)]),
|
||||
parsed.host, loginShellCommand(`MELDWORK_AGENT_BRIDGE_PORT=${remotePort} exec node -`),
|
||||
]
|
||||
const child = this.spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
child.stderr.resume()
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = ''
|
||||
let settled = false
|
||||
const timer = setTimeout(() => finish(bridgeError('CLOUD_AGENT_SSH_TUNNEL_TIMEOUT')), this.timeoutMs)
|
||||
const finish = error => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (error) {
|
||||
child.kill('SIGTERM')
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve({ child, endpoint: `http://127.0.0.1:${localPort}` })
|
||||
}
|
||||
child.once('error', () => finish(bridgeError('CLOUD_AGENT_SSH_TUNNEL_UNAVAILABLE')))
|
||||
child.stdout.on('data', chunk => {
|
||||
output += Buffer.from(chunk).toString('utf8')
|
||||
if (Buffer.byteLength(output, 'utf8') > 16 * 1024) {
|
||||
finish(bridgeError('CLOUD_AGENT_SSH_TUNNEL_PROTOCOL'))
|
||||
return
|
||||
}
|
||||
if (new RegExp(`${SSH_TUNNEL_READY}\\s+${remotePort}\\b`).test(output)) finish()
|
||||
})
|
||||
child.once('close', code => {
|
||||
if (!settled) finish(bridgeError(code === 0
|
||||
? 'CLOUD_AGENT_SSH_TUNNEL_UNAVAILABLE'
|
||||
: 'CLOUD_AGENT_SSH_TUNNEL_FAILED'))
|
||||
})
|
||||
child.stdin.end(SSH_BRIDGE_SCRIPT)
|
||||
})
|
||||
}
|
||||
|
||||
stopTunnel(bridgeId) {
|
||||
const tunnel = this.tunnels.get(bridgeId)
|
||||
if (!tunnel) return
|
||||
this.tunnels.delete(bridgeId)
|
||||
try { tunnel.child?.kill('SIGTERM') } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
async ensureSshTunnel(record) {
|
||||
const existing = this.tunnels.get(record.bridgeId)
|
||||
if (existing?.child && !existing.child.killed) return existing.endpoint
|
||||
const tunnel = await this.startSshTunnel(record.address)
|
||||
this.tunnels.set(record.bridgeId, tunnel)
|
||||
tunnel.child?.once?.('close', () => {
|
||||
if (this.tunnels.get(record.bridgeId) === tunnel) this.tunnels.delete(record.bridgeId)
|
||||
})
|
||||
return tunnel.endpoint
|
||||
}
|
||||
|
||||
async probeSsh(address) {
|
||||
const result = await this.sshCommand(address, loginShellCommand(SSH_DISCOVERY_COMMAND))
|
||||
const output = String(result.stdout || '')
|
||||
const found = new Map()
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const match = line.match(/^__MELDWORK_CLI__([a-z]+)\t.*?\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/)
|
||||
if (match && !found.has(match[1])) found.set(match[1], match[2])
|
||||
}
|
||||
// Older SSH integrations returned only Codex's unprefixed version.
|
||||
if (!found.size) {
|
||||
const version = output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/)?.[1]
|
||||
if (version) found.set('codex', version)
|
||||
}
|
||||
const agents = SSH_AGENT_DEFINITIONS.filter(definition => found.has(definition.id)).map(definition => ({
|
||||
id: definition.id, label: definition.id === 'codex' ? 'Codex CLI' : definition.label, version: found.get(definition.id),
|
||||
description: definition.id === 'codex' ? '' : 'Detected on the server; this CLI is not yet available through the SSH Bridge.',
|
||||
available: definition.id === 'codex', executionSupported: definition.id === 'codex',
|
||||
credentialState: definition.id === 'codex' ? 'ready' : 'unknown', domains: ['general'],
|
||||
inputTypes: ['text'], permissionModes: ['read-only'],
|
||||
session: { supported: false, resume: false, cancel: true, checkpoint: false },
|
||||
}))
|
||||
if (!agents.length) fail('CLOUD_AGENT_SSH_NO_AGENT')
|
||||
return {
|
||||
serverId: '',
|
||||
serverLabel: normalizeSshAddress(address).address,
|
||||
agents,
|
||||
}
|
||||
}
|
||||
|
||||
async connect(input = {}) {
|
||||
const rawAddress = String(input.address || '').trim()
|
||||
let sshError = null
|
||||
if (!/^[a-z][a-z\d+.-]*:\/\//i.test(rawAddress)) {
|
||||
try { return await this.connectSshTunnel(input) } catch (error) { sshError = error }
|
||||
try { return await this.connectSsh(input) } catch (error) { sshError = error }
|
||||
}
|
||||
try {
|
||||
const address = normalizeAddress(input.address)
|
||||
const probe = await this.probe(address)
|
||||
const bridgeId = bridgeIdFor(address)
|
||||
const record = this.normalizeRecord({
|
||||
bridgeId,
|
||||
address,
|
||||
label: cleanText(input.label, 120) || probe.serverLabel,
|
||||
serverId: probe.serverId,
|
||||
agents: probe.agents,
|
||||
available: true,
|
||||
lastError: '',
|
||||
checkedAt: new Date().toISOString(),
|
||||
})
|
||||
const index = this.records.findIndex(item => item.bridgeId === bridgeId)
|
||||
if (index >= 0) this.records[index] = record
|
||||
else this.records.push(record)
|
||||
this.records.sort((left, right) => left.label.localeCompare(right.label) || left.bridgeId.localeCompare(right.bridgeId))
|
||||
this.persist()
|
||||
return this.publicRecord(record)
|
||||
} catch (error) {
|
||||
throw sshError || error
|
||||
}
|
||||
}
|
||||
|
||||
async connectSsh(input = {}) {
|
||||
const sshAddress = normalizeSshAddress(input.address)
|
||||
const probe = await this.probeSsh(sshAddress.address)
|
||||
const bridgeId = bridgeIdFor(`ssh:${sshAddress.address}`)
|
||||
const record = this.normalizeRecord({
|
||||
bridgeId,
|
||||
transport: 'ssh',
|
||||
address: sshAddress.address,
|
||||
label: cleanText(input.label, 120) || probe.serverLabel,
|
||||
serverId: probe.serverId,
|
||||
agents: probe.agents,
|
||||
available: true,
|
||||
lastError: '',
|
||||
checkedAt: new Date().toISOString(),
|
||||
})
|
||||
const index = this.records.findIndex(item => item.bridgeId === bridgeId)
|
||||
if (index >= 0) this.records[index] = record
|
||||
else this.records.push(record)
|
||||
this.records.sort((left, right) => left.label.localeCompare(right.label) || left.bridgeId.localeCompare(right.bridgeId))
|
||||
this.persist()
|
||||
return this.publicRecord(record)
|
||||
}
|
||||
|
||||
async connectSshTunnel(input = {}) {
|
||||
const sshAddress = normalizeSshAddress(input.address)
|
||||
const existing = this.records.find(record => (
|
||||
record.address === sshAddress.address && ['ssh', 'ssh-tunnel'].includes(record.transport)
|
||||
))
|
||||
const bridgeId = existing?.bridgeId || bridgeIdFor(`ssh-tunnel:${sshAddress.address}`)
|
||||
const endpoint = await this.ensureSshTunnel({ bridgeId, address: sshAddress.address })
|
||||
try {
|
||||
const probe = normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', { address: endpoint }))
|
||||
const record = this.normalizeRecord({
|
||||
bridgeId,
|
||||
transport: 'ssh-tunnel',
|
||||
address: sshAddress.address,
|
||||
endpoint,
|
||||
label: cleanText(input.label, 120) || probe.serverLabel,
|
||||
serverId: probe.serverId,
|
||||
agents: probe.agents,
|
||||
available: true,
|
||||
lastError: '',
|
||||
checkedAt: new Date().toISOString(),
|
||||
})
|
||||
record.available = true
|
||||
const index = this.records.findIndex(item => item.bridgeId === bridgeId)
|
||||
if (index >= 0) this.records[index] = record
|
||||
else this.records.push(record)
|
||||
this.records = this.records.filter(item => (
|
||||
item.bridgeId === bridgeId || item.address !== sshAddress.address
|
||||
))
|
||||
this.records.sort((left, right) => left.label.localeCompare(right.label) || left.bridgeId.localeCompare(right.bridgeId))
|
||||
this.persist()
|
||||
return this.publicRecord(record)
|
||||
} catch (error) {
|
||||
this.stopTunnel(bridgeId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
for (const current of this.records) {
|
||||
try {
|
||||
let probe
|
||||
if (current.transport === 'ssh-tunnel') {
|
||||
current.endpoint = await this.ensureSshTunnel(current)
|
||||
probe = normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', {
|
||||
address: current.endpoint,
|
||||
}))
|
||||
current.available = true
|
||||
} else if (current.transport === 'ssh') {
|
||||
try {
|
||||
current.endpoint = await this.ensureSshTunnel(current)
|
||||
probe = normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', {
|
||||
address: current.endpoint,
|
||||
}))
|
||||
current.transport = 'ssh-tunnel'
|
||||
current.available = true
|
||||
} catch {
|
||||
probe = await this.probeSsh(current.address)
|
||||
}
|
||||
} else {
|
||||
probe = await this.probe(current.address)
|
||||
}
|
||||
Object.assign(current, {
|
||||
serverId: probe.serverId,
|
||||
agents: probe.agents.map(normalizeAgent),
|
||||
available: true,
|
||||
lastError: '', checkedAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
if (current.transport === 'ssh-tunnel') this.stopTunnel(current.bridgeId)
|
||||
current.available = false
|
||||
current.lastError = String(error?.code || 'CLOUD_AGENT_BRIDGE_PROBE_FAILED').slice(0, 120)
|
||||
}
|
||||
}
|
||||
this.persist()
|
||||
return this.list()
|
||||
}
|
||||
|
||||
publicRecord(record) {
|
||||
return clone({
|
||||
bridgeId: record.bridgeId, address: record.address, label: record.label,
|
||||
transport: record.transport,
|
||||
serverId: record.serverId, available: record.available,
|
||||
lastError: record.lastError, checkedAt: record.checkedAt,
|
||||
agents: record.agents.map(agent => ({
|
||||
id: agent.id, sourceKind: agent.sourceKind, label: agent.label, version: agent.version,
|
||||
description: agent.description, domains: agent.domains,
|
||||
available: agent.available,
|
||||
credentialState: agent.credentialState,
|
||||
skills: agent.skills,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.records.map(record => this.publicRecord(record))
|
||||
}
|
||||
|
||||
remove(bridgeId) {
|
||||
const id = String(bridgeId || '')
|
||||
const index = this.records.findIndex(record => record.bridgeId === id)
|
||||
if (index < 0) fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
|
||||
this.stopTunnel(id)
|
||||
this.records.splice(index, 1)
|
||||
for (const active of this.activeRuns) {
|
||||
if (active.bridgeId === id) {
|
||||
active.removed = true
|
||||
active.controller.abort()
|
||||
}
|
||||
}
|
||||
this.persist()
|
||||
return { deleted: true, bridgeId: id }
|
||||
}
|
||||
|
||||
connectorEntries() {
|
||||
return this.records.filter(record => record.available).flatMap(record => record.agents
|
||||
.filter(agent => agent.available !== false)
|
||||
.map(agent => {
|
||||
const manifest = manifestFor(record, agent)
|
||||
return { record: clone(record), agent: clone(agent), manifest, instance: instanceFor(record, agent, manifest) }
|
||||
}))
|
||||
}
|
||||
|
||||
catalogEntries() {
|
||||
return this.records.flatMap(record => record.agents.map(agent => ({
|
||||
kind: instanceIdFor(record.bridgeId, agent.id),
|
||||
sourceKind: agent.sourceKind,
|
||||
label: `${agent.label} @ ${record.label}`,
|
||||
name: `${agent.label} @ ${record.label}`,
|
||||
description: agent.description,
|
||||
version: agent.version,
|
||||
installed: true,
|
||||
installSupported: false,
|
||||
installErrorCode: '',
|
||||
imageAttachmentLimit: 0,
|
||||
custom: false,
|
||||
connector: true,
|
||||
cloud: true,
|
||||
available: record.available && agent.available !== false,
|
||||
credentialState: agent.credentialState,
|
||||
availabilitySource: agent.executionSupported === false ? 'cloud-bridge-unsupported' : undefined,
|
||||
serverLabel: record.label,
|
||||
})))
|
||||
}
|
||||
|
||||
skillsForInstance(instanceId) {
|
||||
const id = String(instanceId || '')
|
||||
for (const record of this.records) {
|
||||
const agent = record.agents.find(item => instanceIdFor(record.bridgeId, item.id) === id)
|
||||
if (agent) return clone(agent.skills)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async run(input) {
|
||||
const bridgeId = String(input?.bridgeId || '')
|
||||
const agentId = String(input?.agentId || '')
|
||||
const record = this.records.find(item => item.bridgeId === bridgeId && item.available)
|
||||
if (!record || !PUBLIC_ID.test(agentId)) fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
|
||||
const agent = record.agents.find(item => item.id === agentId)
|
||||
if (!agent) fail('CLOUD_AGENT_BRIDGE_AGENT_NOT_FOUND')
|
||||
if (agent.available === false) fail('CLOUD_AGENT_BRIDGE_AGENT_UNAVAILABLE')
|
||||
const target = {
|
||||
transport: record.transport, address: record.address, endpoint: record.endpoint,
|
||||
serverId: record.serverId,
|
||||
manifestId: manifestFor(record, agent).manifestId,
|
||||
}
|
||||
if (target.transport === 'ssh') return this.runSsh(record, agent, input, target)
|
||||
const body = {
|
||||
prompt: input.prompt,
|
||||
sessionRef: input.sessionRef || null,
|
||||
permissionMode: input.permissionMode,
|
||||
operationId: input.operationId,
|
||||
resume: input.resume || null,
|
||||
}
|
||||
const serializedBody = JSON.stringify(body)
|
||||
await input.onOutboundPayload?.(createHttpOutboundPayload({
|
||||
prompt: input.prompt,
|
||||
destination: target.transport === 'ssh-tunnel' ? '' : target.address,
|
||||
tunneled: target.transport === 'ssh-tunnel',
|
||||
wireBytes: Buffer.from(serializedBody, 'utf8'),
|
||||
}))
|
||||
this.assertRunTarget(record, agent.id, target, input.signal)
|
||||
return this.runTransport(record, agentId, target, input.signal, signal => this.request(`/v1/agents/${encodeURIComponent(agentId)}/runs`, {
|
||||
address: target.transport === 'ssh-tunnel' ? target.endpoint : target.address,
|
||||
method: 'POST',
|
||||
signal,
|
||||
timeoutMs: this.runTimeoutMs,
|
||||
onActivity: input.onActivity,
|
||||
body,
|
||||
bodyText: serializedBody,
|
||||
}))
|
||||
}
|
||||
|
||||
async runTransport(record, agentId, target, callerSignal, execute) {
|
||||
const active = { bridgeId: record.bridgeId, controller: new AbortController(), removed: false }
|
||||
const abort = () => active.controller.abort()
|
||||
callerSignal?.addEventListener('abort', abort, { once: true })
|
||||
if (callerSignal?.aborted) abort()
|
||||
this.activeRuns.add(active)
|
||||
try {
|
||||
const result = await execute(active.controller.signal)
|
||||
if (active.removed) throw Object.assign(bridgeError('CLOUD_AGENT_BRIDGE_REMOVED'), { category: 'unknown' })
|
||||
if (!this.runTargetMatches(record.bridgeId, agentId, target)) {
|
||||
throw Object.assign(bridgeError('CLOUD_AGENT_BRIDGE_CHANGED'), { category: 'unknown' })
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (active.removed) throw Object.assign(bridgeError('CLOUD_AGENT_BRIDGE_REMOVED'), { category: 'unknown' })
|
||||
throw error
|
||||
} finally {
|
||||
this.activeRuns.delete(active)
|
||||
callerSignal?.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
|
||||
runTargetMatches(bridgeId, agentId, target) {
|
||||
const current = this.records.find(item => item.bridgeId === bridgeId)
|
||||
const agent = current?.agents.find(item => item.id === agentId)
|
||||
if (!current || !current.available || !agent || agent.available === false
|
||||
|| current.transport !== target.transport || current.address !== target.address
|
||||
|| current.endpoint !== target.endpoint || current.serverId !== target.serverId) return false
|
||||
try { return manifestFor(current, agent).manifestId === target.manifestId } catch { return false }
|
||||
}
|
||||
|
||||
assertRunTarget(record, agentId, target, signal) {
|
||||
if (signal?.aborted) fail('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
const current = this.records.find(item => item.bridgeId === record.bridgeId)
|
||||
if (current !== record || !this.runTargetMatches(record.bridgeId, agentId, target)) {
|
||||
fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
|
||||
}
|
||||
}
|
||||
|
||||
async runSsh(record, agent, input, target) {
|
||||
if (agent.id !== 'codex' || input.permissionMode !== 'read-only') {
|
||||
fail('CLOUD_AGENT_SSH_PERMISSION_UNSUPPORTED')
|
||||
}
|
||||
await input.onOutboundPayload?.(createSshOutboundPayload({ prompt: input.prompt }))
|
||||
this.assertRunTarget(record, agent.id, target, input.signal)
|
||||
const result = await this.runTransport(record, agent.id, target, input.signal,
|
||||
signal => this.sshCommand(target.address, SSH_CODEX_COMMAND, input.prompt, signal))
|
||||
const messages = []
|
||||
let sessionRef = ''
|
||||
for (const line of String(result.stdout || '').split('\n')) {
|
||||
try {
|
||||
const event = JSON.parse(line)
|
||||
if (event.type === 'thread.started' && typeof event.thread_id === 'string') sessionRef = event.thread_id
|
||||
if (event.type === 'item.completed' && event.item?.type === 'agent_message'
|
||||
&& typeof event.item.text === 'string') messages.push(event.item.text)
|
||||
} catch { /* SSH transport permits only Codex JSONL output */ }
|
||||
}
|
||||
const text = messages.join('\n').trim()
|
||||
if (!text) fail('CLOUD_AGENT_SSH_RESPONSE_INVALID')
|
||||
return { text, sessionRef: '', outcome: 'completed' }
|
||||
}
|
||||
|
||||
bridgeForInstance(instanceId) {
|
||||
for (const entry of this.connectorEntries()) {
|
||||
if (entry.instance.instanceId === instanceId) return entry
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const bridgeId of this.tunnels.keys()) this.stopTunnel(bridgeId)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BRIDGE_RECIPE_ID,
|
||||
SSH_RECIPE_ID,
|
||||
CloudAgentBridgeStore,
|
||||
loginShellCommand,
|
||||
manifestFor,
|
||||
normalizeAddress,
|
||||
normalizeProbe,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const toml = require('@iarna/toml')
|
||||
const { redactSecrets } = require('../security/secret-redaction.cjs')
|
||||
|
||||
function codexConfiguredProvider(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const home = options.home || os.homedir()
|
||||
const root = env.CODEX_HOME ? String(env.CODEX_HOME).replace(/^~(?=$|[\\/])/, home) : path.join(home, '.codex')
|
||||
if (!path.isAbsolute(root)) return ''
|
||||
try {
|
||||
const file = path.join(root, 'config.toml')
|
||||
const stat = fs.statSync(file)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return ''
|
||||
const config = toml.parse(fs.readFileSync(file, 'utf8'))
|
||||
const profile = typeof config.profile === 'string' ? config.profiles?.[config.profile] : null
|
||||
const value = profile?.model_provider || config.model_provider
|
||||
if (typeof value !== 'string') return ''
|
||||
const provider = value.trim()
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(provider) && redactSecrets(provider) === provider ? provider : ''
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
module.exports = { codexConfiguredProvider }
|
||||
@@ -4,7 +4,7 @@ const path = require('node:path')
|
||||
|
||||
const { isSemanticVersion } = require('./agent-connector-manifest.cjs')
|
||||
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
|
||||
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
|
||||
const { atomicWritePrivateFile, ensurePrivateDirectory } = require('../../security/private-file.cjs')
|
||||
const { redactSecrets } = require('../../security/secret-redaction.cjs')
|
||||
|
||||
const STORE_VERSION = 1
|
||||
@@ -153,6 +153,66 @@ class AgentConnectorInstanceStore {
|
||||
}
|
||||
}
|
||||
|
||||
recordExecution(input) {
|
||||
const fields = ['runId', 'agentRunId', 'connectorId', 'connectorVersion', 'manifestId',
|
||||
'manifestSchemaVersion', 'instanceId', 'recipeId', 'upstreamId', 'upstreamVersion', 'credentialRefId']
|
||||
if (!exactKeys(input, fields)
|
||||
|| ['runId', 'agentRunId', 'connectorId', 'instanceId', 'recipeId', 'upstreamId']
|
||||
.some(key => typeof input[key] !== 'string' || !PUBLIC_ID.test(input[key]))
|
||||
|| !MANIFEST_ID.test(input.manifestId)
|
||||
|| !isSemanticVersion(input.connectorVersion) || !isSemanticVersion(input.upstreamVersion)
|
||||
|| !Number.isSafeInteger(input.manifestSchemaVersion) || input.manifestSchemaVersion < 1
|
||||
|| (input.credentialRefId !== null && !CREDENTIAL_REF.test(input.credentialRefId))) {
|
||||
fail('AGENT_CONNECTOR_EXECUTION_RECORD_INVALID')
|
||||
}
|
||||
const directory = path.join(path.dirname(this.credentialStoragePath), 'agent-connector-executions')
|
||||
const id = crypto.createHash('sha256').update(canonicalJson([input.runId, input.agentRunId])).digest('hex')
|
||||
const filename = path.join(directory, `${id}.json`)
|
||||
const temporary = path.join(directory, `.${id}.${crypto.randomBytes(8).toString('hex')}.tmp`)
|
||||
const content = canonicalJson({ version: 1, ...input })
|
||||
let descriptor
|
||||
try {
|
||||
ensurePrivateDirectory(path.dirname(this.credentialStoragePath))
|
||||
ensurePrivateDirectory(directory)
|
||||
descriptor = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL
|
||||
| (fs.constants.O_NOFOLLOW || 0), 0o600)
|
||||
fs.writeFileSync(descriptor, content, 'utf8')
|
||||
fs.fsyncSync(descriptor)
|
||||
fs.closeSync(descriptor)
|
||||
descriptor = undefined
|
||||
try {
|
||||
fs.linkSync(temporary, filename)
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error
|
||||
descriptor = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0))
|
||||
const stat = fs.fstatSync(descriptor)
|
||||
if (!stat.isFile() || stat.size > 8192 || fs.readFileSync(descriptor, 'utf8') !== content) {
|
||||
fail('AGENT_CONNECTOR_EXECUTION_RECORD_CONFLICT')
|
||||
}
|
||||
return
|
||||
}
|
||||
let directoryDescriptor
|
||||
try {
|
||||
directoryDescriptor = fs.openSync(directory, 'r')
|
||||
fs.fsyncSync(directoryDescriptor)
|
||||
} catch (error) {
|
||||
const unsupported = ['EINVAL', 'ENOTSUP', 'EOPNOTSUPP'].includes(error.code)
|
||||
|| (process.platform === 'win32' && ['EPERM', 'EISDIR'].includes(error.code))
|
||||
if (!unsupported) throw error
|
||||
} finally {
|
||||
if (directoryDescriptor !== undefined) fs.closeSync(directoryDescriptor)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === 'AGENT_CONNECTOR_EXECUTION_RECORD_CONFLICT') throw error
|
||||
fail('AGENT_CONNECTOR_EXECUTION_RECORD_UNAVAILABLE')
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor)
|
||||
try { fs.unlinkSync(temporary) } catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
create(input) {
|
||||
const normalized = normalizeCreateInput(input)
|
||||
const instanceDocument = this.readInstanceDocument()
|
||||
@@ -219,6 +279,25 @@ class AgentConnectorInstanceStore {
|
||||
return this.readInstanceDocument().instances.map(frozenClone)
|
||||
}
|
||||
|
||||
migrateManifest(previous, next) {
|
||||
if (!previous || !next || !MANIFEST_ID.test(previous.manifestId || '')
|
||||
|| !MANIFEST_ID.test(next.manifestId || '') || previous.connectorId !== next.connectorId
|
||||
|| !PUBLIC_ID.test(next.connectorId || '') || !isSemanticVersion(next.connectorVersion)) {
|
||||
fail('AGENT_CONNECTOR_INSTANCE_INVALID')
|
||||
}
|
||||
const document = this.readInstanceDocument()
|
||||
let changed = false
|
||||
for (const instance of document.instances) {
|
||||
if (instance.manifestId !== previous.manifestId) continue
|
||||
if (instance.connectorId !== previous.connectorId
|
||||
|| instance.connectorVersion !== previous.connectorVersion) fail('AGENT_CONNECTOR_INSTANCE_STORE_UNAVAILABLE')
|
||||
instance.manifestId = next.manifestId
|
||||
instance.connectorVersion = next.connectorVersion
|
||||
changed = true
|
||||
}
|
||||
if (changed) this.writeInstanceDocument(document)
|
||||
}
|
||||
|
||||
resolveCredential(credentialRef) {
|
||||
const normalizedRef = String(credentialRef || '')
|
||||
if (!CREDENTIAL_REF.test(normalizedRef)) fail('AGENT_CONNECTOR_CREDENTIAL_UNAVAILABLE')
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { abortableHttpOperation, boundedHttpResponseText, discardHttpResponse, discardLateHttpResponse } = require('../bounded-http-response.cjs')
|
||||
const { createHttpOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
|
||||
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
|
||||
|
||||
const {
|
||||
createAgentConnectorManifest,
|
||||
isSemanticVersion,
|
||||
isUpstreamVersionCompatible,
|
||||
serializeAgentConnectorManifest,
|
||||
} = require('./agent-connector-manifest.cjs')
|
||||
const { AgentConnectorRegistry } = require('./agent-connector-registry.cjs')
|
||||
@@ -17,9 +21,13 @@ const {
|
||||
MAX_PACKAGE_BYTES,
|
||||
createAgentConnectorPackage,
|
||||
} = require('./agent-connector-package-store.cjs')
|
||||
const { BRIDGE_RECIPE_ID, SSH_RECIPE_ID } = require('../cloud/cloud-agent-bridge.cjs')
|
||||
|
||||
const LOCAL_DELEGATE_RECIPE_ID = 'external.local-agent.delegate'
|
||||
const MAX_LOCAL_MANIFESTS = 64
|
||||
const MAX_HTTP_RESPONSE_BYTES = 4 * 1024 * 1024
|
||||
const DEFAULT_HTTP_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
const SAMPLE_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest({
|
||||
connectorId: 'external.codex-sample',
|
||||
connectorVersion: '1.0.0',
|
||||
@@ -48,7 +56,7 @@ const SAMPLE_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest({
|
||||
},
|
||||
outboundDestinations: [],
|
||||
credentials: { mode: 'none', slots: [] },
|
||||
license: 'LicenseRef-Meldwork-NC',
|
||||
license: 'Apache-2.0',
|
||||
})
|
||||
const SAMPLE_CREDENTIAL_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest({
|
||||
connectorId: 'external.codex-provider-sample',
|
||||
@@ -81,7 +89,7 @@ const SAMPLE_CREDENTIAL_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest(
|
||||
mode: 'credential-ref',
|
||||
slots: [{ slotId: 'openai-api-key', type: 'api-key', required: true }],
|
||||
},
|
||||
license: 'LicenseRef-Meldwork-NC',
|
||||
license: 'Apache-2.0',
|
||||
})
|
||||
const APPROVED_AGENT_CONNECTOR_MANIFESTS = Object.freeze([
|
||||
SAMPLE_AGENT_CONNECTOR_MANIFEST,
|
||||
@@ -116,7 +124,7 @@ const SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest(
|
||||
},
|
||||
outboundDestinations: [],
|
||||
credentials: { mode: 'none', slots: [] },
|
||||
license: 'LicenseRef-Meldwork-NC',
|
||||
license: 'Apache-2.0',
|
||||
})
|
||||
const SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_PACKAGE = createAgentConnectorPackage({
|
||||
publisher: { id: 'meldwork', name: 'Meldwork' },
|
||||
@@ -137,7 +145,7 @@ function fail(code) {
|
||||
function exactOptions(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
&& Object.keys(value).every(key => [
|
||||
'fetch', 'instanceStore', 'manifestDirectory', 'packageStore', 'runAgent', 'seedSample',
|
||||
'cloudBridges', 'fetch', 'httpTimeoutMs', 'instanceStore', 'manifestDirectory', 'packageStore', 'runAgent', 'seedSample',
|
||||
].includes(key))
|
||||
}
|
||||
|
||||
@@ -184,7 +192,7 @@ function delegatedCredentialEnvironment(manifest, values) {
|
||||
fail('AGENT_CONNECTOR_CREDENTIAL_SCHEMA_UNSUPPORTED')
|
||||
}
|
||||
return values['openai-api-key']
|
||||
? { OPENAI_API_KEY: values['openai-api-key'] }
|
||||
? { CODEX_API_KEY: values['openai-api-key'] }
|
||||
: {}
|
||||
}
|
||||
|
||||
@@ -222,11 +230,14 @@ class LocalAgentConnectors {
|
||||
|| typeof options.instanceStore.list !== 'function'
|
||||
|| typeof options.instanceStore.listRecords !== 'function'
|
||||
|| typeof options.instanceStore.resolveCredential !== 'function'
|
||||
|| typeof options.instanceStore.recordExecution !== 'function'
|
||||
|| typeof options.runAgent !== 'function'
|
||||
|| (options.packageStore !== undefined
|
||||
&& (typeof options.packageStore.installedPackages !== 'function'
|
||||
|| typeof options.packageStore.packageForManifest !== 'function'))
|
||||
|| (options.fetch !== undefined && typeof options.fetch !== 'function')) {
|
||||
|| (options.fetch !== undefined && typeof options.fetch !== 'function')
|
||||
|| (options.httpTimeoutMs !== undefined && (!Number.isInteger(options.httpTimeoutMs)
|
||||
|| options.httpTimeoutMs < 1 || options.httpTimeoutMs > 20 * 60 * 1000))) {
|
||||
fail('LOCAL_AGENT_CONNECTORS_OPTIONS_INVALID')
|
||||
}
|
||||
this.manifestDirectory = path.normalize(options.manifestDirectory)
|
||||
@@ -234,12 +245,15 @@ class LocalAgentConnectors {
|
||||
this.packageStore = options.packageStore || null
|
||||
this.runAgent = options.runAgent
|
||||
this.fetch = options.fetch || globalThis.fetch
|
||||
this.httpTimeoutMs = options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS
|
||||
this.httpRequests = new Set()
|
||||
this.seedSample = options.seedSample === true
|
||||
this.registry = this.createRegistry()
|
||||
this.runtime = this.createRuntime(this.registry)
|
||||
this.upstreams = new Map()
|
||||
this.detectedUpstreams = []
|
||||
this.lastDiagnostics = []
|
||||
this.cloudBridges = options.cloudBridges || null
|
||||
}
|
||||
|
||||
createRegistry() {
|
||||
@@ -247,7 +261,7 @@ class LocalAgentConnectors {
|
||||
? this.packageStore.installedPackages().map(item => item.manifest)
|
||||
: []
|
||||
return new AgentConnectorRegistry({
|
||||
approvedRecipeIds: [LOCAL_DELEGATE_RECIPE_ID, ...SDK_RECIPE_IDS],
|
||||
approvedRecipeIds: [LOCAL_DELEGATE_RECIPE_ID, BRIDGE_RECIPE_ID, SSH_RECIPE_ID, ...SDK_RECIPE_IDS],
|
||||
approvedExternalManifestIds: [
|
||||
...APPROVED_AGENT_CONNECTOR_MANIFESTS.map(item => item.manifestId),
|
||||
...installedPackageManifests.map(item => item.manifestId),
|
||||
@@ -258,10 +272,14 @@ class LocalAgentConnectors {
|
||||
createRuntime(registry) {
|
||||
return new AgentConnectorRuntime({
|
||||
registry,
|
||||
recordExecution: record => this.instanceStore.recordExecution(record),
|
||||
trustedLegacyEventRecipes: [LOCAL_DELEGATE_RECIPE_ID],
|
||||
recipes: {
|
||||
[LOCAL_DELEGATE_RECIPE_ID]: input => this.runDelegated(input),
|
||||
[SDK_LOCAL_ECHO_RECIPE_ID]: input => this.runLocalEcho(input),
|
||||
[SDK_HTTP_JSON_RECIPE_ID]: input => this.runHttpJson(input),
|
||||
[BRIDGE_RECIPE_ID]: input => this.runCloudBridge(input),
|
||||
[SSH_RECIPE_ID]: input => this.runCloudBridge(input),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -332,6 +350,25 @@ class LocalAgentConnectors {
|
||||
}
|
||||
|
||||
refresh(detectedAgents = []) {
|
||||
if (this.packageStore && typeof this.packageStore.audit === 'function'
|
||||
&& typeof this.packageStore.inspect === 'function'
|
||||
&& typeof this.instanceStore.migrateManifest === 'function') {
|
||||
for (const installed of this.packageStore.installedPackages()) {
|
||||
const events = this.packageStore.audit(installed.packageId)
|
||||
let upgrade = null
|
||||
for (const event of [...events].reverse()) {
|
||||
if (event.action === 'imported') break
|
||||
if (event.action === 'upgraded') { upgrade = event; break }
|
||||
}
|
||||
if (!upgrade) continue
|
||||
try {
|
||||
const previous = this.packageStore.inspect(upgrade.details.fromPackageId)
|
||||
if (previous.state === 'disabled' && this.compatibleInstanceUpgrade(previous, installed)) {
|
||||
this.instanceStore.migrateManifest(previous.manifest, installed.manifest)
|
||||
}
|
||||
} catch { /* Unmigrated instances remain unavailable and can be retried. */ }
|
||||
}
|
||||
}
|
||||
const registry = this.createRegistry()
|
||||
let diagnostics = []
|
||||
try {
|
||||
@@ -350,6 +387,29 @@ class LocalAgentConnectors {
|
||||
diagnostics.push({ name: 'instances', code: safeFailureCode(error) })
|
||||
}
|
||||
const manifests = registry.listManifests()
|
||||
for (const entry of this.cloudBridges?.connectorEntries?.() || []) {
|
||||
try {
|
||||
const manifest = registry.registerBuiltin(entry.manifest)
|
||||
const instance = registry.registerInstance({
|
||||
instanceId: entry.instance.instanceId,
|
||||
connectorId: manifest.connectorId,
|
||||
connectorVersion: manifest.connectorVersion,
|
||||
upstreamVersion: entry.instance.upstreamVersion,
|
||||
label: entry.instance.label,
|
||||
credentialRef: null,
|
||||
})
|
||||
upstreams.set(instance.instanceId, Object.freeze({
|
||||
kind: instance.instanceId,
|
||||
label: instance.label,
|
||||
name: instance.label,
|
||||
version: instance.upstreamVersion,
|
||||
compatibilityState: 'compatible',
|
||||
cloud: true,
|
||||
}))
|
||||
} catch (error) {
|
||||
diagnostics.push({ name: entry.instance.instanceId, code: safeFailureCode(error) })
|
||||
}
|
||||
}
|
||||
for (const stored of storedInstances) {
|
||||
const manifest = manifests.find(item => (
|
||||
item.manifestId === stored.manifestId
|
||||
@@ -405,9 +465,10 @@ class LocalAgentConnectors {
|
||||
|
||||
detectAgents() {
|
||||
return this.runtime.detectAgents().map((agent) => {
|
||||
const { instance } = this.registry.resolveInstance(agent.connectorInstanceId)
|
||||
const { instance, manifest } = this.registry.resolveInstance(agent.connectorInstanceId)
|
||||
return Object.freeze({
|
||||
...agent,
|
||||
cloud: [BRIDGE_RECIPE_ID, SSH_RECIPE_ID].includes(manifest.invocation.recipeId),
|
||||
credentialConfigured: Boolean(instance.credentialRef),
|
||||
})
|
||||
}).sort((left, right) => (
|
||||
@@ -447,6 +508,10 @@ class LocalAgentConnectors {
|
||||
&& semanticVersionFrom(agent.version)
|
||||
))
|
||||
if (!upstream) fail('AGENT_CONNECTOR_UPSTREAM_UNAVAILABLE')
|
||||
const { trust: _trust, ...versionedManifest } = manifest
|
||||
if (!isUpstreamVersionCompatible(versionedManifest, semanticVersionFrom(upstream.version))) {
|
||||
fail('AGENT_CONNECTOR_UPSTREAM_INCOMPATIBLE')
|
||||
}
|
||||
const created = this.instanceStore.create({
|
||||
manifestId: manifest.manifestId,
|
||||
connectorId: manifest.connectorId,
|
||||
@@ -459,7 +524,14 @@ class LocalAgentConnectors {
|
||||
}
|
||||
|
||||
delete(instanceId) {
|
||||
const result = this.instanceStore.delete(String(instanceId || ''))
|
||||
const normalizedId = String(instanceId || '')
|
||||
const result = this.instanceStore.delete(normalizedId)
|
||||
for (const request of this.httpRequests) {
|
||||
if (request.instanceId === normalizedId) {
|
||||
request.trustChanged = true
|
||||
request.controller.abort()
|
||||
}
|
||||
}
|
||||
this.refresh(this.detectedUpstreams)
|
||||
return result
|
||||
}
|
||||
@@ -506,9 +578,17 @@ class LocalAgentConnectors {
|
||||
return this.requirePackageStore().audit(String(packageId || ''))
|
||||
}
|
||||
|
||||
compatibleInstanceUpgrade(previous, target) {
|
||||
return previous.publisher.id === target.publisher.id
|
||||
&& previous.manifest.connectorId === target.manifest.connectorId
|
||||
&& previous.provider.id === target.provider.id
|
||||
&& canonicalJson(previous.manifest.credentials) === canonicalJson(target.manifest.credentials)
|
||||
}
|
||||
|
||||
packageTransition(action, packageId) {
|
||||
const store = this.requirePackageStore()
|
||||
const normalizedId = String(packageId || '')
|
||||
let replacedManifestId = ''
|
||||
if (action === 'upgrade') {
|
||||
const target = store.inspect(normalizedId)
|
||||
const replaced = store.list().find(item => (
|
||||
@@ -516,9 +596,18 @@ class LocalAgentConnectors {
|
||||
&& item.connectorId === target.manifest.connectorId
|
||||
&& item.packageId !== normalizedId
|
||||
))
|
||||
if (replaced && this.instanceStore.listRecords().some(item => (
|
||||
item.manifestId === replaced.manifestId
|
||||
))) fail('AGENT_CONNECTOR_PACKAGE_IN_USE')
|
||||
if (replaced && [...this.httpRequests].some(request => request.packageId === replaced.packageId)) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_IN_USE')
|
||||
}
|
||||
const instances = this.instanceStore.listRecords()
|
||||
if (replaced && instances.some(item => item.connectorId === target.manifest.connectorId
|
||||
&& item.manifestId !== replaced.manifestId)) fail('AGENT_CONNECTOR_PACKAGE_IN_USE')
|
||||
if (replaced && instances.some(item => item.manifestId === replaced.manifestId)
|
||||
&& (typeof this.instanceStore.migrateManifest !== 'function'
|
||||
|| !this.compatibleInstanceUpgrade(store.inspect(replaced.packageId), target))) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_IN_USE')
|
||||
}
|
||||
replacedManifestId = replaced?.manifestId || ''
|
||||
}
|
||||
if (action === 'remove') {
|
||||
const manifestId = store.inspect(normalizedId).manifest.manifestId
|
||||
@@ -527,7 +616,18 @@ class LocalAgentConnectors {
|
||||
}
|
||||
}
|
||||
const result = store[action](normalizedId)
|
||||
if (action === 'disable' || action === 'revoke') {
|
||||
for (const request of this.httpRequests) {
|
||||
if (request.packageId === normalizedId) {
|
||||
request.trustChanged = true
|
||||
request.controller.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
this.refresh(this.detectedUpstreams)
|
||||
if (replacedManifestId && this.instanceStore.listRecords().some(item => item.manifestId === replacedManifestId)) {
|
||||
fail('AGENT_CONNECTOR_INSTANCE_STORE_UNAVAILABLE')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -563,20 +663,24 @@ class LocalAgentConnectors {
|
||||
catalog() {
|
||||
return this.detectAgents().map((agent) => {
|
||||
const { manifest } = this.registry.resolveInstance(agent.connectorInstanceId)
|
||||
const cloud = [BRIDGE_RECIPE_ID, SSH_RECIPE_ID].includes(manifest.invocation.recipeId)
|
||||
const cloudEntry = cloud ? this.cloudBridges?.bridgeForInstance?.(agent.kind) : null
|
||||
return Object.freeze({
|
||||
kind: agent.kind,
|
||||
sourceKind: cloudEntry?.agent?.sourceKind || '',
|
||||
label: agent.label,
|
||||
name: agent.name,
|
||||
description: manifest.description,
|
||||
version: agent.version,
|
||||
version: cloudEntry?.agent?.version || agent.version,
|
||||
installed: true,
|
||||
installSupported: false,
|
||||
installErrorCode: '',
|
||||
providerCompatible: false,
|
||||
providerMode: 'connector',
|
||||
imageAttachmentLimit: manifest.inputTypes.includes('image') ? 32 : 0,
|
||||
custom: true,
|
||||
custom: !cloud,
|
||||
connector: true,
|
||||
cloud,
|
||||
credentialState: cloudEntry?.agent?.credentialState || 'unknown',
|
||||
serverLabel: cloudEntry?.record?.label || '',
|
||||
connectorId: manifest.connectorId,
|
||||
connectorVersion: manifest.connectorVersion,
|
||||
upstreamVersion: agent.upstreamVersion,
|
||||
@@ -601,6 +705,7 @@ class LocalAgentConnectors {
|
||||
audio: snapshot.capabilities.inputTypes.includes('audio') ? 32 : 0,
|
||||
video: snapshot.capabilities.inputTypes.includes('video') ? 32 : 0,
|
||||
file: snapshot.capabilities.inputTypes.includes('file') ? 32 : 0,
|
||||
fileFallback: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,10 +744,11 @@ class LocalAgentConnectors {
|
||||
operationId: input.operationId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
connectorResume: input.resume,
|
||||
...(input.credentialRefId ? { connectorCredentialIsolation: true } : {}),
|
||||
...(input.credentialRefId ? { connectorCredentialIsolation: true,
|
||||
connectorInstanceId: input.connector.instanceId } : {}),
|
||||
...(Object.keys(credentialEnv).length ? { env: credentialEnv } : {}),
|
||||
})
|
||||
const outcome = String(result?.outcome || 'completed')
|
||||
const outcome = String(result?.outcome || '')
|
||||
if (outcome === 'completed' || outcome === 'partial') {
|
||||
input.emit({ ...terminal, type: 'Completed', outcome })
|
||||
} else if (outcome === 'waiting_input') {
|
||||
@@ -705,7 +811,8 @@ class LocalAgentConnectors {
|
||||
}
|
||||
const text = input.resume?.type === 'input' ? input.resume.response : input.prompt
|
||||
input.emit({ ...terminal, type: 'Completed', outcome: 'completed' })
|
||||
return { text, outcome: 'completed', sessionRef: input.sessionRef || 'local-echo-session' }
|
||||
return { text, outcome: 'completed', sessionRef: input.connector.capabilities.session.supported
|
||||
? input.sessionRef || 'local-echo-session' : '' }
|
||||
}
|
||||
|
||||
async runHttpJson(input) {
|
||||
@@ -713,57 +820,184 @@ class LocalAgentConnectors {
|
||||
if (!packageRecord || packageRecord.provider.id !== SDK_HTTP_JSON_RECIPE_ID) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_NOT_INSTALLED')
|
||||
}
|
||||
const packageRevision = this.packageStore.audit(packageRecord.packageId).at(-1)?.eventId
|
||||
const endpoint = packageRecord.provider.config.endpoint
|
||||
const terminal = {
|
||||
eventId: `${input.agentRunId}:terminal`,
|
||||
cursor: `${input.agentRunId}:terminal`,
|
||||
sequence: 1,
|
||||
}
|
||||
let credential = ''
|
||||
let timedOut = false
|
||||
let requestSent = false
|
||||
const controller = new AbortController()
|
||||
const activeRequest = { packageId: packageRecord.packageId, instanceId: input.connector.instanceId,
|
||||
controller, trustChanged: false }
|
||||
const abortTransport = () => controller.abort()
|
||||
input.transportSignal?.addEventListener('abort', abortTransport, { once: true })
|
||||
if (input.transportSignal?.aborted) abortTransport()
|
||||
this.httpRequests.add(activeRequest)
|
||||
let timer
|
||||
try {
|
||||
input.onOutboundPayload({ destination: endpoint, transport: 'http' })
|
||||
const body = JSON.stringify({
|
||||
prompt: input.prompt,
|
||||
sessionRef: input.sessionRef || null,
|
||||
resume: input.resume,
|
||||
permissionMode: input.permissionMode,
|
||||
operationId: input.operationId,
|
||||
})
|
||||
await abortableHttpOperation(input.onOutboundPayload(createHttpOutboundPayload({
|
||||
prompt: input.prompt, destination: endpoint, wireBytes: Buffer.from(body, 'utf8'),
|
||||
})), controller.signal)
|
||||
const currentPackage = this.packageStore.packageForManifest(input.connector.manifestId)
|
||||
if (!currentPackage || currentPackage.packageId !== packageRecord.packageId
|
||||
|| this.packageStore.audit(packageRecord.packageId).at(-1)?.eventId !== packageRevision) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_NOT_INSTALLED')
|
||||
}
|
||||
const currentExecution = this.registry.resolveExecution(input.connector.instanceId)
|
||||
if (currentExecution.provenance.manifestId !== input.connector.manifestId
|
||||
|| currentExecution.credentialRef !== input.credentialRefId) {
|
||||
fail('AGENT_CONNECTOR_INSTANCE_NOT_FOUND')
|
||||
}
|
||||
const headers = { 'content-type': 'application/json' }
|
||||
const authSlotId = packageRecord.provider.config.authSlotId
|
||||
if (authSlotId) {
|
||||
const credentials = this.instanceStore.resolveCredential(input.credentialRefId)
|
||||
headers.authorization = `Bearer ${credentials[authSlotId]}`
|
||||
credential = credentials[authSlotId]
|
||||
headers.authorization = `Bearer ${credential}`
|
||||
}
|
||||
const response = await this.fetch(endpoint, {
|
||||
controller.signal.throwIfAborted()
|
||||
timer = setTimeout(() => { timedOut = true; controller.abort() }, this.httpTimeoutMs)
|
||||
requestSent = true
|
||||
const pendingResponse = this.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers,
|
||||
signal: input.signal,
|
||||
body: JSON.stringify({
|
||||
prompt: input.prompt,
|
||||
sessionRef: input.sessionRef || null,
|
||||
resume: input.resume,
|
||||
permissionMode: input.permissionMode,
|
||||
operationId: input.operationId,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
body,
|
||||
})
|
||||
if (!response?.ok) throw Object.assign(new Error('AGENT_CONNECTOR_HTTP_FAILED'), {
|
||||
code: 'AGENT_CONNECTOR_HTTP_FAILED', retryable: response?.status >= 500,
|
||||
})
|
||||
const text = await response.text()
|
||||
if (typeof text !== 'string' || Buffer.byteLength(text) > 4 * 1024 * 1024) {
|
||||
fail('AGENT_CONNECTOR_HTTP_RESPONSE_INVALID')
|
||||
discardLateHttpResponse(pendingResponse, controller.signal)
|
||||
const response = await abortableHttpOperation(pendingResponse, controller.signal)
|
||||
controller.signal.throwIfAborted()
|
||||
if (!response?.ok) {
|
||||
discardHttpResponse(response)
|
||||
throw Object.assign(new Error('AGENT_CONNECTOR_HTTP_FAILED'), {
|
||||
code: 'AGENT_CONNECTOR_HTTP_FAILED', retryable: response?.status >= 500,
|
||||
})
|
||||
}
|
||||
const text = await abortableHttpOperation(
|
||||
boundedHttpResponseText(response, MAX_HTTP_RESPONSE_BYTES, 'AGENT_CONNECTOR_HTTP_RESPONSE_INVALID', controller.signal),
|
||||
controller.signal,
|
||||
)
|
||||
controller.signal.throwIfAborted()
|
||||
let parsed
|
||||
try { parsed = JSON.parse(text) } catch { fail('AGENT_CONNECTOR_HTTP_RESPONSE_INVALID') }
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)
|
||||
|| typeof parsed.text !== 'string' || parsed.text.includes('\u0000')
|
||||
|| (parsed.outcome !== undefined && !['completed', 'partial', 'failed', 'cancelled'].includes(parsed.outcome))
|
||||
|| (parsed.sessionRef !== undefined
|
||||
&& (typeof parsed.sessionRef !== 'string' || parsed.sessionRef.length > 8192
|
||||
|| /[\u0000-\u001f\u007f]/.test(parsed.sessionRef)))) {
|
||||
|| /[\u0000-\u001f\u007f]/.test(parsed.sessionRef)))
|
||||
|| (credential && (parsed.text.includes(credential)
|
||||
|| parsed.sessionRef?.includes(credential)))) {
|
||||
fail('AGENT_CONNECTOR_HTTP_RESPONSE_INVALID')
|
||||
}
|
||||
input.emit({ ...terminal, type: 'Completed', outcome: 'completed' })
|
||||
return { text: parsed.text, sessionRef: parsed.sessionRef || '', outcome: 'completed' }
|
||||
const outcome = parsed.outcome || 'completed'
|
||||
if (outcome === 'failed' || parsed.success === false || parsed.error) {
|
||||
const failure = { code: 'AGENT_CONNECTOR_HTTP_REMOTE_FAILED', category: 'execution', retryable: false }
|
||||
input.emit({ ...terminal, type: 'Failed', ...failure })
|
||||
return { outcome: 'failed', failure }
|
||||
}
|
||||
if (outcome === 'cancelled') {
|
||||
input.emit({ ...terminal, type: 'Cancelled', reason: 'other' })
|
||||
return { outcome: 'cancelled', sessionRef: parsed.sessionRef || '' }
|
||||
}
|
||||
input.emit({ ...terminal, type: 'Completed', outcome })
|
||||
return { text: parsed.text, sessionRef: parsed.sessionRef || '', outcome }
|
||||
} catch (error) {
|
||||
if (input.signal?.aborted || error?.name === 'AbortError') {
|
||||
if (activeRequest.trustChanged) {
|
||||
const failure = { code: 'AGENT_CONNECTOR_HTTP_TRUST_CHANGED', category: requestSent ? 'unknown' : 'execution', retryable: false }
|
||||
input.emit({ ...terminal, type: 'Failed', ...failure })
|
||||
return { outcome: 'failed', failure }
|
||||
}
|
||||
if (timedOut && !input.transportSignal?.aborted) {
|
||||
const failure = { code: 'AGENT_CONNECTOR_HTTP_TIMEOUT', category: 'timeout', retryable: false }
|
||||
input.emit({ ...terminal, type: 'Failed', ...failure })
|
||||
return { outcome: 'failed', failure }
|
||||
}
|
||||
if (input.transportSignal?.aborted && (requestSent || !input.connector.capabilities.session.cancel)) {
|
||||
const failure = { code: 'AGENT_CONNECTOR_HTTP_TRANSPORT_ABORTED', category: requestSent ? 'unknown' : 'execution', retryable: false }
|
||||
input.emit({ ...terminal, type: 'Failed', ...failure })
|
||||
return { outcome: 'failed', failure }
|
||||
}
|
||||
if (input.signal?.aborted) {
|
||||
input.emit({ ...terminal, type: 'Cancelled', reason: 'user' })
|
||||
return { outcome: 'cancelled', sessionRef: input.sessionRef || '' }
|
||||
}
|
||||
const failure = failureDetails(error)
|
||||
if (credential && failure.code.includes(credential)) failure.code = 'AGENT_CONNECTOR_HTTP_FAILED'
|
||||
input.emit({ ...terminal, type: 'Failed', ...failure })
|
||||
return { outcome: 'failed', failure }
|
||||
} finally {
|
||||
this.httpRequests.delete(activeRequest)
|
||||
clearTimeout(timer)
|
||||
input.transportSignal?.removeEventListener('abort', abortTransport)
|
||||
}
|
||||
}
|
||||
|
||||
async runCloudBridge(input) {
|
||||
const entry = this.cloudBridges?.bridgeForInstance?.(input.connector.instanceId)
|
||||
if (!entry) fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
|
||||
const terminal = {
|
||||
eventId: `${input.agentRunId}:terminal`,
|
||||
cursor: `${input.agentRunId}:terminal`,
|
||||
sequence: 1,
|
||||
}
|
||||
try {
|
||||
const result = await this.cloudBridges.run({
|
||||
bridgeId: entry.record.bridgeId,
|
||||
agentId: entry.agent.id,
|
||||
prompt: input.prompt,
|
||||
sessionRef: input.sessionRef,
|
||||
// Cloud bridge tasks never receive local workspace write authority.
|
||||
permissionMode: 'read-only',
|
||||
operationId: input.operationId,
|
||||
resume: input.resume,
|
||||
signal: input.transportSignal,
|
||||
onActivity: input.onActivity,
|
||||
onOutboundPayload: input.onOutboundPayload,
|
||||
})
|
||||
if (input.transportSignal?.aborted) fail('CLOUD_AGENT_BRIDGE_TRANSPORT_ABORTED')
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result)
|
||||
|| typeof result.text !== 'string' || result.text.includes('\u0000')
|
||||
|| (result.sessionRef !== undefined
|
||||
&& (typeof result.sessionRef !== 'string' || result.sessionRef.length > 8192
|
||||
|| /[\u0000-\u001f\u007f]/.test(result.sessionRef)))
|
||||
|| !['completed', 'partial', 'failed', 'cancelled'].includes(result.outcome || 'completed')) {
|
||||
fail('CLOUD_AGENT_BRIDGE_RESPONSE_INVALID')
|
||||
}
|
||||
if (result.outcome === 'failed') {
|
||||
const failure = failureDetails(result.failure || result)
|
||||
input.emit({ ...terminal, type: 'Failed', ...failure })
|
||||
return { outcome: 'failed', failure }
|
||||
}
|
||||
if (result.outcome === 'cancelled') {
|
||||
input.emit({ ...terminal, type: 'Cancelled', reason: 'user' })
|
||||
return { outcome: 'cancelled', sessionRef: result.sessionRef || input.sessionRef || '' }
|
||||
}
|
||||
input.emit({ ...terminal, type: 'Completed', outcome: result.outcome || 'completed' })
|
||||
return {
|
||||
text: result.text,
|
||||
sessionRef: typeof result.sessionRef === 'string' ? result.sessionRef : input.sessionRef || '',
|
||||
outcome: result.outcome || 'completed',
|
||||
}
|
||||
} catch (error) {
|
||||
if (input.transportSignal?.aborted || error?.name === 'AbortError') {
|
||||
const failure = { code: 'CLOUD_AGENT_BRIDGE_TRANSPORT_ABORTED', category: 'unknown', retryable: false }
|
||||
input.emit({ ...terminal, type: 'Failed', ...failure })
|
||||
return { outcome: 'failed', failure }
|
||||
}
|
||||
const failure = failureDetails(error)
|
||||
input.emit({ ...terminal, type: 'Failed', ...failure })
|
||||
return { outcome: 'failed', failure }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const {
|
||||
parseAgentConnectorManifest,
|
||||
} = require('./agent-connector-manifest.cjs')
|
||||
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
|
||||
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
|
||||
const { atomicWritePrivateFile, ensurePrivateDirectory } = require('../../security/private-file.cjs')
|
||||
const { redactSecrets } = require('../../security/secret-redaction.cjs')
|
||||
|
||||
const AGENT_CONNECTOR_PACKAGE_VERSION = 1
|
||||
@@ -61,6 +61,15 @@ const httpsEndpointSchema = z.string().min(9).max(2048).superRefine((value, cont
|
||||
}
|
||||
})
|
||||
|
||||
function assertSafePackageEndpoint(provider) {
|
||||
if (provider.id !== SDK_HTTP_JSON_RECIPE_ID) return
|
||||
try {
|
||||
const pathname = decodeURIComponent(new URL(provider.config.endpoint).pathname)
|
||||
if (redactSecrets(pathname) === pathname) return
|
||||
} catch { /* Invalid encoding cannot be safely checked. */ }
|
||||
fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
|
||||
}
|
||||
|
||||
const publisherSchema = z.strictObject({
|
||||
id: z.string().min(1).max(120).regex(PUBLIC_ID),
|
||||
name: safeTextSchema(120),
|
||||
@@ -108,7 +117,11 @@ const packageContentSchema = z.strictObject({
|
||||
}
|
||||
return
|
||||
}
|
||||
const endpoint = new URL(value.provider.config.endpoint)
|
||||
let endpoint
|
||||
try { endpoint = new URL(value.provider.config.endpoint) } catch {
|
||||
context.addIssue({ code: 'custom', message: 'HTTP JSON endpoint invalid' })
|
||||
return
|
||||
}
|
||||
const slotId = value.provider.config.authSlotId
|
||||
const authSlot = manifest.credentials.slots.find(slot => slot.slotId === slotId)
|
||||
if (manifest.transport.type !== 'http' || manifest.transport.protocol !== 'json'
|
||||
@@ -124,6 +137,13 @@ const packageContentSchema = z.strictObject({
|
||||
}
|
||||
})
|
||||
|
||||
function supportsSdkCapabilities(manifest) {
|
||||
return !manifest.session.checkpoint
|
||||
&& manifest.invocation.idempotencyMode !== 'durable'
|
||||
&& manifest.eventTypes.every(type => ['Completed', 'Failed', 'Cancelled'].includes(type))
|
||||
&& Object.values(manifest.usage).every(value => value === false)
|
||||
}
|
||||
|
||||
const packageRecordSchema = z.strictObject({
|
||||
packageId: z.string().regex(PACKAGE_ID),
|
||||
schemaVersion: z.literal(AGENT_CONNECTOR_PACKAGE_VERSION),
|
||||
@@ -148,6 +168,10 @@ function canonicalPackage(record) {
|
||||
function createAgentConnectorPackage(input) {
|
||||
const result = packageContentSchema.safeParse(input)
|
||||
if (!result.success) fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
|
||||
assertSafePackageEndpoint(result.data.provider)
|
||||
if (!supportsSdkCapabilities(parseAgentConnectorManifest(result.data.manifest))) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
|
||||
}
|
||||
const content = {
|
||||
...result.data,
|
||||
manifest: parseAgentConnectorManifest(result.data.manifest),
|
||||
@@ -259,7 +283,7 @@ function recordState(events) {
|
||||
for (const event of events) {
|
||||
const current = states.get(event.packageId)
|
||||
if (event.action === 'imported') {
|
||||
if (current || !exactKeys(event.details, ['originFilename', 'originSha256'])
|
||||
if ((current && current.state !== 'removed') || !exactKeys(event.details, ['originFilename', 'originSha256'])
|
||||
|| typeof event.details.originFilename !== 'string'
|
||||
|| event.details.originFilename !== path.basename(event.details.originFilename)
|
||||
|| !/^[a-f0-9]{64}$/.test(String(event.details.originSha256 || ''))) {
|
||||
@@ -290,6 +314,13 @@ function recordState(events) {
|
||||
|| !PACKAGE_ID.test(String(event.details.fromPackageId || ''))) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_AUDIT_INVALID')
|
||||
}
|
||||
const previous = states.get(event.details.fromPackageId)
|
||||
if (!previous || !['installed', 'disabled'].includes(previous.state)
|
||||
|| event.details.fromPackageId === event.packageId) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_AUDIT_INVALID')
|
||||
}
|
||||
previous.state = 'disabled'
|
||||
previous.updatedAt = event.timestamp
|
||||
current.state = 'installed'
|
||||
} else {
|
||||
if (!exactKeys(event.details, [])) fail('AGENT_CONNECTOR_PACKAGE_AUDIT_INVALID')
|
||||
@@ -333,14 +364,8 @@ class AgentConnectorPackageStore {
|
||||
}
|
||||
|
||||
ensureDirectories() {
|
||||
fs.mkdirSync(this.packageDirectory, { recursive: true, mode: 0o700 })
|
||||
for (const directory of [this.rootPath, this.packageDirectory]) {
|
||||
const stat = fs.lstatSync(directory)
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_STORE_UNAVAILABLE')
|
||||
}
|
||||
try { fs.chmodSync(directory, 0o700) } catch { /* best effort */ }
|
||||
}
|
||||
ensurePrivateDirectory(this.rootPath)
|
||||
ensurePrivateDirectory(this.packageDirectory)
|
||||
}
|
||||
|
||||
loadAudit() {
|
||||
@@ -379,11 +404,13 @@ class AgentConnectorPackageStore {
|
||||
this.requireAvailable()
|
||||
const filename = this.packagePath(packageId)
|
||||
try {
|
||||
return parseAgentConnectorPackage(readRegularNoFollow(
|
||||
const packageRecord = parseAgentConnectorPackage(readRegularNoFollow(
|
||||
filename,
|
||||
MAX_PACKAGE_BYTES,
|
||||
'AGENT_CONNECTOR_PACKAGE_UNAVAILABLE',
|
||||
))
|
||||
if (packageRecord.packageId !== packageId) fail('AGENT_CONNECTOR_PACKAGE_ID_MISMATCH')
|
||||
return packageRecord
|
||||
} catch (error) {
|
||||
if (error?.code?.startsWith('AGENT_CONNECTOR_')) throw error
|
||||
fail('AGENT_CONNECTOR_PACKAGE_UNAVAILABLE')
|
||||
@@ -410,6 +437,7 @@ class AgentConnectorPackageStore {
|
||||
body.previousEventId,
|
||||
)
|
||||
let descriptor
|
||||
let writeStarted = false
|
||||
try {
|
||||
this.ensureDirectories()
|
||||
descriptor = fs.openSync(
|
||||
@@ -423,13 +451,28 @@ class AgentConnectorPackageStore {
|
||||
if (!stat.isFile() || stat.size + Buffer.byteLength(line) > MAX_AUDIT_BYTES) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_AUDIT_LIMIT')
|
||||
}
|
||||
writeStarted = true
|
||||
fs.writeFileSync(descriptor, line, 'utf8')
|
||||
fs.fsyncSync(descriptor)
|
||||
fs.fchmodSync(descriptor, 0o600)
|
||||
fs.closeSync(descriptor)
|
||||
descriptor = undefined
|
||||
if (stat.size === 0) {
|
||||
let directoryDescriptor
|
||||
try {
|
||||
directoryDescriptor = fs.openSync(this.rootPath, 'r')
|
||||
fs.fsyncSync(directoryDescriptor)
|
||||
} catch (error) {
|
||||
const unsupported = ['EINVAL', 'ENOTSUP', 'EOPNOTSUPP'].includes(error.code)
|
||||
|| (process.platform === 'win32' && ['EPERM', 'EISDIR'].includes(error.code))
|
||||
if (!unsupported) throw error
|
||||
} finally {
|
||||
if (directoryDescriptor !== undefined) fs.closeSync(directoryDescriptor)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (descriptor !== undefined) try { fs.closeSync(descriptor) } catch {}
|
||||
if (writeStarted) this.unavailableCode = 'AGENT_CONNECTOR_PACKAGE_AUDIT_UNAVAILABLE'
|
||||
if (error?.code?.startsWith('AGENT_CONNECTOR_')) throw error
|
||||
fail('AGENT_CONNECTOR_PACKAGE_AUDIT_UNAVAILABLE')
|
||||
}
|
||||
@@ -441,9 +484,11 @@ class AgentConnectorPackageStore {
|
||||
import(input, originFilename = 'connector-package.json') {
|
||||
this.requireAvailable()
|
||||
const packageRecord = parseAgentConnectorPackage(input)
|
||||
assertSafePackageEndpoint(packageRecord.provider)
|
||||
if (!supportsSdkCapabilities(packageRecord.manifest)) fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
|
||||
const existing = this.states.get(packageRecord.packageId)
|
||||
if (existing) return this.inspect(packageRecord.packageId)
|
||||
if (this.states.size >= MAX_PACKAGES) fail('AGENT_CONNECTOR_PACKAGE_LIMIT')
|
||||
if (existing && existing.state !== 'removed') return this.inspect(packageRecord.packageId)
|
||||
if (!existing && this.states.size >= MAX_PACKAGES) fail('AGENT_CONNECTOR_PACKAGE_LIMIT')
|
||||
const filename = path.basename(String(originFilename || 'connector-package.json'))
|
||||
if (!filename || filename.length > 255 || /[\u0000-\u001f\u007f]/.test(filename)) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_ORIGIN_INVALID')
|
||||
@@ -480,7 +525,14 @@ class AgentConnectorPackageStore {
|
||||
if (!allowed[action]?.includes(current.state)) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_TRANSITION_INVALID')
|
||||
}
|
||||
this.readPackage(normalizedId)
|
||||
const target = this.readPackage(normalizedId)
|
||||
if (action === 'installed' && !supportsSdkCapabilities(target.manifest)) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
|
||||
}
|
||||
if (action === 'installed' && this.list().some(item => item.state === 'installed'
|
||||
&& item.packageId !== normalizedId && item.connectorId === target.manifest.connectorId)) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_UPGRADE_INVALID')
|
||||
}
|
||||
this.append(action, normalizedId)
|
||||
return this.inspect(normalizedId)
|
||||
}
|
||||
@@ -500,17 +552,19 @@ class AgentConnectorPackageStore {
|
||||
const current = this.stateFor(normalizedId)
|
||||
if (current.state !== 'approved') fail('AGENT_CONNECTOR_PACKAGE_TRANSITION_INVALID')
|
||||
const target = this.readPackage(normalizedId)
|
||||
if (!supportsSdkCapabilities(target.manifest)) fail('AGENT_CONNECTOR_PACKAGE_SCHEMA_INVALID')
|
||||
const installed = this.list().filter(item => (
|
||||
item.state === 'installed'
|
||||
&& item.connectorId === target.manifest.connectorId
|
||||
&& item.packageId !== normalizedId
|
||||
))
|
||||
if (installed.length !== 1) fail('AGENT_CONNECTOR_PACKAGE_UPGRADE_INVALID')
|
||||
if (installed.length !== 1 || installed[0].publisher.id !== target.publisher.id) {
|
||||
fail('AGENT_CONNECTOR_PACKAGE_UPGRADE_INVALID')
|
||||
}
|
||||
if (compareSemanticVersions(
|
||||
target.manifest.connectorVersion,
|
||||
installed[0].connectorVersion,
|
||||
) <= 0) fail('AGENT_CONNECTOR_PACKAGE_UPGRADE_INVALID')
|
||||
this.append('disabled', installed[0].packageId)
|
||||
this.append('upgraded', normalizedId, { fromPackageId: installed[0].packageId })
|
||||
return this.inspect(normalizedId)
|
||||
}
|
||||
@@ -544,6 +598,7 @@ class AgentConnectorPackageStore {
|
||||
connectorVersion: item.manifest.connectorVersion,
|
||||
manifestId: item.manifest.manifestId,
|
||||
label: item.manifest.label,
|
||||
supported: supportsSdkCapabilities(item.manifest),
|
||||
origin: item.origin,
|
||||
updatedAt: item.updatedAt,
|
||||
})).sort((left, right) => (
|
||||
@@ -555,9 +610,13 @@ class AgentConnectorPackageStore {
|
||||
installedPackages() {
|
||||
if (this.unavailableCode) return []
|
||||
try {
|
||||
return [...this.states.entries()]
|
||||
const installed = [...this.states.entries()]
|
||||
.filter(([, state]) => state.state === 'installed')
|
||||
.map(([packageId]) => this.inspect(packageId))
|
||||
.filter(item => supportsSdkCapabilities(item.manifest))
|
||||
const counts = new Map()
|
||||
for (const item of installed) counts.set(item.manifest.connectorId, (counts.get(item.manifest.connectorId) || 0) + 1)
|
||||
return installed.filter(item => counts.get(item.manifest.connectorId) === 1)
|
||||
} catch (error) {
|
||||
this.unavailableCode = error?.code || 'AGENT_CONNECTOR_PACKAGE_STORE_UNAVAILABLE'
|
||||
return []
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { detectAttachmentType } = require('../../attachments/attachment-records.cjs')
|
||||
const { automaticPermissionResponse, READ_ONLY_SAFE_TOOL_KINDS } = require('../agent-permission-policy.cjs')
|
||||
const { SDK_HTTP_JSON_RECIPE_ID } = require('./agent-connector-package-store.cjs')
|
||||
const { normalizeSessionRef } = require('../../runs/session-reference.cjs')
|
||||
|
||||
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
|
||||
const {
|
||||
@@ -11,8 +17,12 @@ const PUBLIC_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
|
||||
const SHA256 = /^[a-f0-9]{64}$/
|
||||
const MAX_PROMPT_BYTES = 4 * 1024 * 1024
|
||||
const MAX_INPUT_RESPONSE_BYTES = 128 * 1024
|
||||
const MAX_ATTACHMENTS = 16
|
||||
// Keep this aligned with the workspace message attachment limit.
|
||||
const MAX_ATTACHMENTS = 32
|
||||
const OUTBOUND_TRANSPORTS = new Set(['http', 'a2a'])
|
||||
const ABORTABLE_TRANSPORT_RECIPES = new Set([
|
||||
SDK_HTTP_JSON_RECIPE_ID, 'external.cloud-agent-bridge', 'external.cloud-agent-ssh',
|
||||
])
|
||||
|
||||
function runtimeError(code) {
|
||||
const error = new Error(code)
|
||||
@@ -28,18 +38,39 @@ function clone(value) {
|
||||
return JSON.parse(canonicalJson(value))
|
||||
}
|
||||
|
||||
function attachmentFileType(filename) {
|
||||
if (typeof filename !== 'string' || filename.length > 4096 || !path.isAbsolute(filename)
|
||||
|| /[\u0000-\u001f\u007f]/.test(filename)) return null
|
||||
let fd
|
||||
try {
|
||||
const initial = fs.lstatSync(filename)
|
||||
if (initial.isSymbolicLink() || !initial.isFile()) return null
|
||||
fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0))
|
||||
const stat = fs.fstatSync(fd)
|
||||
if (!stat.isFile() || stat.dev !== initial.dev || stat.ino !== initial.ino || stat.size > 128 * 1024 * 1024) return null
|
||||
const bytes = Buffer.alloc(Math.min(stat.size, 512))
|
||||
const size = fs.readSync(fd, bytes, 0, bytes.length, 0)
|
||||
return { size: stat.size, detected: detectAttachmentType(bytes.subarray(0, size)) }
|
||||
} catch { return null } finally { if (fd !== undefined) fs.closeSync(fd) }
|
||||
}
|
||||
|
||||
function attachmentInputType(attachment) {
|
||||
if (typeof attachment === 'string') return attachment ? 'image' : ''
|
||||
if (!attachment || typeof attachment !== 'object' || Array.isArray(attachment)) return ''
|
||||
const declared = String(attachment.inputType || attachment.type || '')
|
||||
if (['image', 'audio', 'video', 'file', 'structured-data'].includes(declared)) {
|
||||
return declared
|
||||
if (typeof attachment === 'string') {
|
||||
const file = attachmentFileType(attachment)
|
||||
return file && file.size <= 8 * 1024 * 1024 && file.detected?.mimeType.startsWith('image/') ? 'image' : ''
|
||||
}
|
||||
if (!attachment || typeof attachment !== 'object' || Array.isArray(attachment)) return ''
|
||||
const mimeType = String(attachment.mimeType || attachment.mediaType || '').toLowerCase()
|
||||
if (mimeType.startsWith('image/')) return 'image'
|
||||
if (mimeType.startsWith('audio/')) return 'audio'
|
||||
if (mimeType.startsWith('video/')) return 'video'
|
||||
return mimeType ? 'file' : ''
|
||||
if (mimeType) return 'file'
|
||||
if (Object.hasOwn(attachment, 'path')) return ''
|
||||
const declared = String(attachment.inputType || attachment.type || '')
|
||||
if (['image', 'audio', 'video', 'file', 'structured-data'].includes(declared)) {
|
||||
return declared
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function validatedAttachments(value, capabilities) {
|
||||
@@ -49,11 +80,25 @@ function validatedAttachments(value, capabilities) {
|
||||
}
|
||||
const attachments = [...value]
|
||||
for (const attachment of attachments) {
|
||||
if (typeof attachment === 'string' && !capabilities.inputTypes.includes('image')) {
|
||||
fail('AGENT_CONNECTOR_ATTACHMENT_UNSUPPORTED')
|
||||
}
|
||||
const inputType = attachmentInputType(attachment)
|
||||
if (!inputType) fail('AGENT_CONNECTOR_ATTACHMENTS_INVALID')
|
||||
if (!capabilities.inputTypes.includes(inputType)) {
|
||||
fail('AGENT_CONNECTOR_ATTACHMENT_UNSUPPORTED')
|
||||
}
|
||||
if (attachment && typeof attachment === 'object' && inputType !== 'structured-data'
|
||||
&& !Object.hasOwn(attachment, 'path')) fail('AGENT_CONNECTOR_ATTACHMENTS_INVALID')
|
||||
if (attachment && typeof attachment === 'object' && Object.hasOwn(attachment, 'path')) {
|
||||
const file = attachmentFileType(attachment.path)
|
||||
const declared = String(attachment.mimeType || attachment.mediaType || '').toLowerCase()
|
||||
if (!file || (['image', 'audio', 'video'].includes(inputType)
|
||||
&& (file.detected?.mimeType !== declared || file.size > file.detected.maxBytes))
|
||||
|| (inputType === 'file' && /^(?:image|audio|video)\//.test(file.detected?.mimeType || ''))) {
|
||||
fail('AGENT_CONNECTOR_ATTACHMENTS_INVALID')
|
||||
}
|
||||
}
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
@@ -68,10 +113,13 @@ function outboundOrigin(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertOutboundDestination(connector, value) {
|
||||
function assertOutboundDestination(connector, value, outbound = {}) {
|
||||
const destinations = connector.capabilities.outboundDestinations
|
||||
const destination = String(value || '')
|
||||
if (!destination) {
|
||||
if (connector.recipeId === 'external.cloud-agent-bridge'
|
||||
&& connector.upstreamId === 'meldwork-cloud'
|
||||
&& outbound.tunneled === true && destinations.length === 0) return ''
|
||||
if (destinations.length || OUTBOUND_TRANSPORTS.has(connector.transport.type)) {
|
||||
fail('AGENT_CONNECTOR_OUTBOUND_DESTINATION_REQUIRED')
|
||||
}
|
||||
@@ -84,7 +132,7 @@ function assertOutboundDestination(connector, value) {
|
||||
return origin
|
||||
}
|
||||
|
||||
function assertEventCapabilities(connector, input) {
|
||||
function assertEventCapabilities(connector, input, permissionMode) {
|
||||
const type = String(input?.type || '')
|
||||
if (!connector.capabilities.eventTypes.includes(type)) {
|
||||
fail('AGENT_CONNECTOR_EVENT_UNDECLARED')
|
||||
@@ -92,6 +140,10 @@ function assertEventCapabilities(connector, input) {
|
||||
if (type === 'Cancelled' && !connector.capabilities.session.cancel) {
|
||||
fail('AGENT_CONNECTOR_CANCEL_UNSUPPORTED')
|
||||
}
|
||||
if (type === 'Permission' && input.permission === 'workspace-write'
|
||||
&& permissionMode !== 'workspace-write') {
|
||||
fail('AGENT_CONNECTOR_PERMISSION_MODE_UNSUPPORTED')
|
||||
}
|
||||
if (type === 'Usage') {
|
||||
for (const field of Object.keys(input?.usage || {})) {
|
||||
if (connector.capabilities.usage[field] !== true) {
|
||||
@@ -101,13 +153,53 @@ function assertEventCapabilities(connector, input) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertResultCapabilities(connector, result) {
|
||||
if (String(result?.sessionRef || '') && !connector.capabilities.session.supported) {
|
||||
function validatedSessionRef(value) {
|
||||
if (value == null) return ''
|
||||
if (typeof value !== 'string' || (value !== '' && normalizeSessionRef(value) !== value)) {
|
||||
fail('AGENT_CONNECTOR_SESSION_INVALID')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function assertResultCapabilities(connector, result, state) {
|
||||
if (validatedSessionRef(result?.sessionRef) && !connector.capabilities.session.supported) {
|
||||
fail('AGENT_CONNECTOR_SESSION_UNSUPPORTED')
|
||||
}
|
||||
if (result?.outcome === 'cancelled' && !connector.capabilities.session.cancel) {
|
||||
fail('AGENT_CONNECTOR_CANCEL_UNSUPPORTED')
|
||||
}
|
||||
if (result.usage != null) {
|
||||
if (typeof result.usage !== 'object' || Array.isArray(result.usage)) fail('AGENT_CONNECTOR_USAGE_UNDECLARED')
|
||||
for (const [field, value] of Object.entries(result.usage)) {
|
||||
if (connector.capabilities.usage[field] !== true) fail('AGENT_CONNECTOR_USAGE_UNDECLARED')
|
||||
if (!state.events.some(event => event.type === 'Usage' && Object.hasOwn(event.usage, field))
|
||||
|| value !== state.usage[field]) fail('AGENT_CONNECTOR_USAGE_UNREPORTED')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function observedUsage(state) {
|
||||
const fields = new Set(state.events.filter(event => event.type === 'Usage')
|
||||
.flatMap(event => Object.keys(event.usage)))
|
||||
return Object.fromEntries([...fields].map(field => [field, state.usage[field]]))
|
||||
}
|
||||
|
||||
function validatedOutcomeRefs(result, state) {
|
||||
const refs = result?.outcomeRefs
|
||||
if (refs != null) {
|
||||
if (typeof refs !== 'object' || Array.isArray(refs)
|
||||
|| Object.keys(refs).some(key => !['artifactIds', 'evidenceIds'].includes(key))) {
|
||||
fail('AGENT_CONNECTOR_OUTCOME_UNDECLARED')
|
||||
}
|
||||
for (const field of ['artifactIds', 'evidenceIds']) {
|
||||
if (refs[field] == null) continue
|
||||
if (!Array.isArray(refs[field]) || refs[field].some(id => !state[field].includes(id))) {
|
||||
fail('AGENT_CONNECTOR_OUTCOME_UNDECLARED')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!refs && !state.artifactIds.length && !state.evidenceIds.length) return undefined
|
||||
return { artifactIds: [...state.artifactIds], evidenceIds: [...state.evidenceIds] }
|
||||
}
|
||||
|
||||
function normalizeResume(input) {
|
||||
@@ -126,8 +218,8 @@ function normalizeResume(input) {
|
||||
fail('AGENT_CONNECTOR_RESUME_INVALID')
|
||||
}
|
||||
if (type === 'input') {
|
||||
const response = String(input.response || '').trim()
|
||||
if (!response || Buffer.byteLength(response) > MAX_INPUT_RESPONSE_BYTES) {
|
||||
const response = typeof input.response === 'string' ? input.response : ''
|
||||
if (!response.trim() || Buffer.byteLength(response) > MAX_INPUT_RESPONSE_BYTES) {
|
||||
fail('AGENT_CONNECTOR_RESUME_INVALID')
|
||||
}
|
||||
return Object.freeze({
|
||||
@@ -136,11 +228,16 @@ function normalizeResume(input) {
|
||||
}
|
||||
const status = String(input.status || '')
|
||||
const optionId = String(input.optionId || '')
|
||||
const permission = input.permission == null ? '' : String(input.permission)
|
||||
if (!['approved', 'rejected'].includes(status) || !PUBLIC_ID.test(optionId)) {
|
||||
fail('AGENT_CONNECTOR_RESUME_INVALID')
|
||||
}
|
||||
if (permission && !['workspace-read', 'workspace-write', 'network', 'browser', 'external-message'].includes(permission)) {
|
||||
fail('AGENT_CONNECTOR_RESUME_INVALID')
|
||||
}
|
||||
return Object.freeze({
|
||||
type, requestId, requestHash, sessionRefHash, sessionProvenanceHash, status, optionId,
|
||||
...(permission ? { permission } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -255,7 +352,9 @@ class AgentConnectorRuntime {
|
||||
fail('AGENT_CONNECTOR_RUNTIME_REGISTRY_REQUIRED')
|
||||
}
|
||||
this.registry = options.registry
|
||||
this.recordExecution = options.recordExecution || (() => {})
|
||||
this.recipes = new Map()
|
||||
this.trustedLegacyEventRecipes = new Set(options.trustedLegacyEventRecipes || [])
|
||||
for (const [recipeId, handler] of recipeEntries(options.recipes)) {
|
||||
this.registerRecipe(recipeId, handler)
|
||||
}
|
||||
@@ -362,7 +461,7 @@ class AgentConnectorRuntime {
|
||||
if (!connector.capabilities.inputTypes.includes('text')) {
|
||||
fail('AGENT_CONNECTOR_INPUT_UNSUPPORTED')
|
||||
}
|
||||
const sessionRef = String(options.sessionRef || '')
|
||||
const sessionRef = validatedSessionRef(options.sessionRef)
|
||||
if (sessionRef && !connector.capabilities.session.supported) {
|
||||
fail('AGENT_CONNECTOR_SESSION_UNSUPPORTED')
|
||||
}
|
||||
@@ -374,12 +473,24 @@ class AgentConnectorRuntime {
|
||||
|| !connector.capabilities.session.resume)) {
|
||||
fail('AGENT_CONNECTOR_RESUME_UNSUPPORTED')
|
||||
}
|
||||
if (resume && !connector.capabilities.eventTypes.includes(resume.type === 'input' ? 'WaitingInput' : 'Permission')) {
|
||||
fail('AGENT_CONNECTOR_RESUME_UNSUPPORTED')
|
||||
}
|
||||
if (resume && crypto.createHash('sha256').update(sessionRef).digest('hex') !== resume.sessionRefHash) {
|
||||
fail('AGENT_CONNECTOR_SESSION_MISMATCH')
|
||||
}
|
||||
if (options.signal?.aborted && !connector.capabilities.session.cancel) {
|
||||
fail('AGENT_CONNECTOR_CANCEL_UNSUPPORTED')
|
||||
}
|
||||
const attachments = validatedAttachments(options.attachments, connector.capabilities)
|
||||
this.recordExecution({ ...execution.runtimeProvenance, runId, agentRunId })
|
||||
const emit = (input) => {
|
||||
assertEventCapabilities(connector, input)
|
||||
assertEventCapabilities(connector, input, permissionMode)
|
||||
if (input?.type === 'Permission' && input.decision !== 'requested'
|
||||
&& (resume?.type !== 'permission' || resume.requestId !== input.requestId
|
||||
|| resume.status !== input.decision || resume.permission !== input.permission)) {
|
||||
fail('AGENT_CONNECTOR_PERMISSION_DECISION_UNBOUND')
|
||||
}
|
||||
const event = parseConnectorRunEvent(input, provenance)
|
||||
const duplicate = state.events.find(existing => existing.eventId === event.eventId)
|
||||
if (duplicate && canonicalJson(duplicate) === canonicalJson(event)) return event
|
||||
@@ -390,9 +501,19 @@ class AgentConnectorRuntime {
|
||||
return event
|
||||
}
|
||||
const onOutboundPayload = (outbound = {}) => {
|
||||
assertOutboundDestination(connector, outbound.destination)
|
||||
assertOutboundDestination(connector, outbound.destination, outbound)
|
||||
return options.onOutboundPayload?.(outbound)
|
||||
}
|
||||
const onPermissionRequest = connector.capabilities.eventTypes.includes('Permission')
|
||||
&& typeof options.onPermissionRequest === 'function'
|
||||
? (request, context) => {
|
||||
if (permissionMode === 'read-only' && (['workspace-write', 'write'].includes(request?.permission)
|
||||
|| (request?.toolCall && !READ_ONLY_SAFE_TOOL_KINDS.has(request.toolCall.kind)))) {
|
||||
return automaticPermissionResponse(request, { allowWrite: false })
|
||||
}
|
||||
return options.onPermissionRequest(request, context)
|
||||
}
|
||||
: undefined
|
||||
|
||||
const result = await handler(Object.freeze({
|
||||
agent: Object.freeze({
|
||||
@@ -412,26 +533,35 @@ class AgentConnectorRuntime {
|
||||
sessionRef,
|
||||
attachments,
|
||||
signal: connector.capabilities.session.cancel ? options.signal : undefined,
|
||||
transportSignal: ABORTABLE_TRANSPORT_RECIPES.has(execution.recipeId) ? options.signal : undefined,
|
||||
onActivity: options.onActivity,
|
||||
emit,
|
||||
onProgress: options.onProgress,
|
||||
onRuntimeEvent: options.onEvent,
|
||||
onProgress: this.trustedLegacyEventRecipes.has(execution.recipeId) ? options.onProgress : undefined,
|
||||
onRuntimeEvent: this.trustedLegacyEventRecipes.has(execution.recipeId) ? options.onEvent : undefined,
|
||||
onOutboundPayload,
|
||||
assertOutboundDestination: destination => assertOutboundDestination(
|
||||
connector, destination,
|
||||
),
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onPermissionRequest,
|
||||
resume,
|
||||
}))
|
||||
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
||||
fail('AGENT_CONNECTOR_RESULT_INVALID')
|
||||
}
|
||||
assertResultCapabilities(connector, result)
|
||||
if (result.progress != null && !this.trustedLegacyEventRecipes.has(execution.recipeId)) {
|
||||
fail('AGENT_CONNECTOR_EVENT_UNDECLARED')
|
||||
}
|
||||
assertResultCapabilities(connector, result, state)
|
||||
const usage = observedUsage(state)
|
||||
const outcomeRefs = validatedOutcomeRefs(result, state)
|
||||
if (result.outcome != null && result.outcome !== state.status) {
|
||||
fail('AGENT_CONNECTOR_RESULT_MISMATCH')
|
||||
}
|
||||
if (['waiting_input', 'waiting_permission'].includes(state.status)) {
|
||||
if (!connector.capabilities.session.supported || !connector.capabilities.session.resume) {
|
||||
fail('AGENT_CONNECTOR_RESUME_UNSUPPORTED')
|
||||
}
|
||||
const waiting = state.events.at(-1)
|
||||
const validWaiting = state.status === 'waiting_input'
|
||||
? waiting?.type === 'WaitingInput'
|
||||
@@ -439,8 +569,9 @@ class AgentConnectorRuntime {
|
||||
if (!validWaiting) fail('AGENT_CONNECTOR_WAITING_EVENT_REQUIRED')
|
||||
return {
|
||||
...result,
|
||||
...(outcomeRefs ? { outcomeRefs } : {}),
|
||||
outcome: state.status,
|
||||
usage: state.usage,
|
||||
usage,
|
||||
waitingRequest: clone(waiting),
|
||||
connector,
|
||||
connectorEventState: clone(state),
|
||||
@@ -460,7 +591,7 @@ class AgentConnectorRuntime {
|
||||
category: terminal?.category || 'protocol',
|
||||
retryable: terminal?.retryable === true,
|
||||
},
|
||||
usage: state.usage,
|
||||
usage,
|
||||
connector,
|
||||
connectorEventState: clone(state),
|
||||
}
|
||||
@@ -470,15 +601,16 @@ class AgentConnectorRuntime {
|
||||
text: terminal?.summary || '',
|
||||
sessionRef: '',
|
||||
outcome: 'cancelled',
|
||||
usage: state.usage,
|
||||
usage,
|
||||
connector,
|
||||
connectorEventState: clone(state),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
...(outcomeRefs ? { outcomeRefs } : {}),
|
||||
outcome: state.status,
|
||||
usage: state.usage,
|
||||
usage,
|
||||
connector,
|
||||
connectorEventState: clone(state),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { agentRuntimeError } = require('./agent-runtime-contract.cjs')
|
||||
const { networkEnvironment } = require('./cli/cli-network-environment.cjs')
|
||||
|
||||
const CUSTOM_AGENT_KIND = /^custom-[a-f0-9]{16}$/
|
||||
const PROMPT_MODES = new Set(['stdin', 'argument'])
|
||||
@@ -108,10 +109,11 @@ function executablePath(filename, platform = process.platform) {
|
||||
return path.normalize(resolved)
|
||||
}
|
||||
|
||||
function childEnvironment(source = process.env) {
|
||||
const env = {}
|
||||
function childEnvironment(source = process.env, platform = process.platform) {
|
||||
const env = networkEnvironment(source, platform)
|
||||
for (const name of CHILD_ENV_KEYS) {
|
||||
const key = Object.keys(source).find(candidate => candidate.toLowerCase() === name.toLowerCase())
|
||||
const key = Object.hasOwn(source, name) ? name
|
||||
: Object.keys(source).find(candidate => candidate.toLowerCase() === name.toLowerCase())
|
||||
const value = key ? source[key] : ''
|
||||
if (typeof value === 'string' && value) env[name] = value
|
||||
}
|
||||
@@ -127,9 +129,6 @@ function publicProfile(definition, version = '') {
|
||||
promptMode: definition.promptMode,
|
||||
custom: true,
|
||||
recommended: false,
|
||||
providerCompatible: false,
|
||||
providerSupport: 'custom-cli',
|
||||
providerMode: 'custom',
|
||||
installed: true,
|
||||
installSupported: false,
|
||||
installErrorCode: '',
|
||||
|
||||
@@ -3,6 +3,7 @@ const { randomUUID } = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { promisify } = require('node:util')
|
||||
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
|
||||
const {
|
||||
CUSTOM_AGENT_KIND,
|
||||
KILL_SETTLE_MS,
|
||||
@@ -34,6 +35,7 @@ class CustomAgentStore {
|
||||
storagePath,
|
||||
platform = process.platform,
|
||||
execFileFn = execFileAsync,
|
||||
environment = async () => ({}),
|
||||
createId = randomUUID,
|
||||
now = () => new Date().toISOString(),
|
||||
}) {
|
||||
@@ -41,6 +43,7 @@ class CustomAgentStore {
|
||||
this.storagePath = path.resolve(storagePath)
|
||||
this.platform = platform
|
||||
this.execFileFn = execFileFn
|
||||
this.environment = environment
|
||||
this.createId = createId
|
||||
this.now = now
|
||||
this.definitions = this.load()
|
||||
@@ -59,13 +62,12 @@ class CustomAgentStore {
|
||||
}
|
||||
|
||||
save() {
|
||||
fs.mkdirSync(path.dirname(this.storagePath), { recursive: true })
|
||||
const tempPath = `${this.storagePath}.tmp`
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify({ version: 1, agents: this.definitions }, null, 2)}\n`, {
|
||||
mode: 0o600,
|
||||
})
|
||||
fs.renameSync(tempPath, this.storagePath)
|
||||
try { fs.chmodSync(this.storagePath, 0o600) } catch { /* Windows ignores POSIX file modes. */ }
|
||||
try {
|
||||
atomicWritePrivateFile(this.storagePath, `${JSON.stringify({ version: 1, agents: this.definitions }, null, 2)}\n`)
|
||||
} catch (error) {
|
||||
this.definitions = this.load()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
has(kind) {
|
||||
@@ -82,10 +84,11 @@ class CustomAgentStore {
|
||||
|
||||
async probeVersion(definition) {
|
||||
try {
|
||||
const source = { ...process.env, ...await this.environment() }
|
||||
const result = await this.execFileFn(definition.executable, ['--version'], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: childEnvironment(),
|
||||
env: childEnvironment(source, this.platform),
|
||||
})
|
||||
return [result?.stdout, result?.stderr]
|
||||
.flatMap(output => String(output || '').split(/\r?\n/))
|
||||
@@ -206,7 +209,7 @@ class CustomAgentStore {
|
||||
promptMode: definition.promptMode,
|
||||
}))
|
||||
}
|
||||
const env = childEnvironment(options.env || process.env)
|
||||
const env = childEnvironment({ ...process.env, ...await this.environment(), ...options.env }, this.platform)
|
||||
return await new Promise((resolve, reject) => {
|
||||
let child
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const yaml = require('js-yaml')
|
||||
const { redactSecrets } = require('../security/secret-redaction.cjs')
|
||||
const { hermesDotenvAssignments } = require('./hermes-dotenv.cjs')
|
||||
|
||||
function hermesProviderFromConfig(config, env = {}) {
|
||||
const model = config?.model && typeof config.model === 'object' && !Array.isArray(config.model) ? config.model : {}
|
||||
const providerText = value => typeof value === 'string' ? value.trim()
|
||||
: typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : ''
|
||||
let provider = providerText(model.provider)
|
||||
for (const key of ['default', 'model', 'name']) {
|
||||
const nested = model[key]
|
||||
if (nested && typeof nested === 'object' && !Array.isArray(nested)
|
||||
&& nested.provider && providerText(nested.provider) && (!provider || provider === 'auto')) provider = providerText(nested.provider)
|
||||
}
|
||||
if (!provider && config?.provider) provider = providerText(config.provider)
|
||||
return provider || (typeof env.HERMES_INFERENCE_PROVIDER === 'string' ? env.HERMES_INFERENCE_PROVIDER.trim() : '')
|
||||
}
|
||||
|
||||
function hermesProviderEnvironment(root, env = {}) {
|
||||
try {
|
||||
const file = path.join(root, '.env')
|
||||
const stat = fs.statSync(file)
|
||||
if (stat.isFile() && stat.size <= 1024 * 1024) {
|
||||
let provider
|
||||
for (const [key, value] of hermesDotenvAssignments(fs.readFileSync(file, 'utf8'), env)) {
|
||||
if (key === 'HERMES_INFERENCE_PROVIDER') provider = value ?? ''
|
||||
}
|
||||
if (provider !== undefined) return { ...env, HERMES_INFERENCE_PROVIDER: provider }
|
||||
}
|
||||
} catch { /* No readable native environment file. */ }
|
||||
return env
|
||||
}
|
||||
|
||||
function hermesConfiguredProvider(options = {}) {
|
||||
const env = options.env || process.env
|
||||
const home = options.home || os.homedir()
|
||||
const root = env.HERMES_HOME ? String(env.HERMES_HOME).replace(/^~(?=$|[\\/])/, home)
|
||||
: (options.platform || process.platform) === 'win32'
|
||||
? path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), `hermes${env.HERMES_DATA_DIR_SUFFIX || ''}`)
|
||||
: path.join(home, `.hermes${env.HERMES_DATA_DIR_SUFFIX || ''}`)
|
||||
if (!path.isAbsolute(root)) return ''
|
||||
let config = {}
|
||||
try {
|
||||
const file = path.join(root, 'config.yaml')
|
||||
const stat = fs.statSync(file)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return ''
|
||||
config = yaml.load(fs.readFileSync(file, 'utf8')) || {}
|
||||
if (typeof config !== 'object' || Array.isArray(config)) return ''
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') return ''
|
||||
}
|
||||
const value = hermesProviderFromConfig(config, hermesProviderEnvironment(root, env))
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(value) && redactSecrets(value) === value ? value : ''
|
||||
}
|
||||
|
||||
module.exports = { hermesConfiguredProvider, hermesProviderFromConfig, hermesProviderEnvironment }
|
||||
@@ -0,0 +1,24 @@
|
||||
function hermesDotenvAssignments(content, env = {}) {
|
||||
const values = { ...env }
|
||||
const assignments = []
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/)
|
||||
if (!match) continue
|
||||
const [, key, raw] = match
|
||||
const quoted = raw.match(/^(['"])(.*?)\1\s*(?:#.*)?$/)
|
||||
if (/^['"]/.test(raw) && !quoted) {
|
||||
assignments.push([key, null])
|
||||
continue
|
||||
}
|
||||
let value = quoted ? quoted[2] : raw.replace(/\s+#.*$/, '').trim()
|
||||
if (quoted?.[1] === '"') value = value.replace(/\\([nr"\\])/g, (_, c) => ({ n: '\n', r: '\r', '"': '"', '\\': '\\' })[c])
|
||||
if (quoted?.[1] === "'") value = value.replace(/\\(['\\])/g, '$1')
|
||||
value = value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g,
|
||||
(_, name, fallback) => Object.hasOwn(values, name) ? values[name] : fallback || '')
|
||||
values[key] = value
|
||||
assignments.push([key, value])
|
||||
}
|
||||
return assignments
|
||||
}
|
||||
|
||||
module.exports = { hermesDotenvAssignments }
|
||||
@@ -4,6 +4,17 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { promisify } = require('node:util')
|
||||
const { searchPath } = require('../cli/cli-adapters.cjs')
|
||||
const { compareReleaseVersions, extractAgentVersion } = require('../agent-compatibility.cjs')
|
||||
|
||||
// True when the installed CLI is the recipe's version or newer. A substring
|
||||
// test treated "11.2.3" as "1.2.3" and offered downgrades for newer CLIs.
|
||||
function installedVersionCurrent(installedVersion, targetVersion) {
|
||||
const installed = extractAgentVersion(installedVersion).split('-')[0]
|
||||
const target = extractAgentVersion(targetVersion).split('-')[0]
|
||||
const comparison = installed && target ? compareReleaseVersions(installed, target) : null
|
||||
if (comparison != null) return comparison >= 0
|
||||
return Boolean(targetVersion) && String(installedVersion || '').includes(targetVersion)
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const DETECTION_CACHE_TTL_MS = 30000
|
||||
@@ -53,6 +64,12 @@ const NPM_RELEASES = Object.freeze({
|
||||
version: '2026.7.1-2',
|
||||
integrity: 'sha512-ycF3yPcbjN6bUPeaUx6Mh6vze1hQWoD3CT/wWcmD7a8xaHHHRUaAlaq+lFxMHf1ssEgODVAwjlzYqp2twkYZ7g==',
|
||||
}),
|
||||
pi: Object.freeze({
|
||||
packageName: '@earendil-works/pi-coding-agent',
|
||||
version: '0.84.2',
|
||||
integrity: 'sha512-l4E+B7hgXKWddRo8bC/eSue2aWZjEgJ9xIpf5p0Og+lq8a2TArCwJ0HCoCPCgaBP/tN4zbYH/wOwvx9pJpeLCA==',
|
||||
ignoreScripts: true,
|
||||
}),
|
||||
qwen: Object.freeze({
|
||||
packageName: '@qwen-code/qwen-code',
|
||||
version: '0.21.5',
|
||||
@@ -84,6 +101,11 @@ const NPM_RELEASES = Object.freeze({
|
||||
detectedVersion: '0.1.0',
|
||||
integrity: 'sha512-YFqiotp1sHDmj2BOiw2AbgCY2zm+c7Z36lh5JNL6KACEvYgerB5kqqldqcy/xI4Erry501DsTN1YPxo2mw6fAQ==',
|
||||
}),
|
||||
deepseek: Object.freeze({
|
||||
packageName: '@deepseek-ai/dsh',
|
||||
version: '0.2.1-rc.2',
|
||||
integrity: 'sha512-tvY+yhfKRifthbqPe/OK0AYxM5ZkvqfYq/jMYgJcEUxiRRL1Vwra3Ti5TB6AXgSWQdiPHYh/Vp6SJa9yHwMNuw==',
|
||||
}),
|
||||
opencodereview: Object.freeze({
|
||||
packageName: '@alibaba-group/open-code-review',
|
||||
version: '1.8.6',
|
||||
@@ -93,50 +115,18 @@ const NPM_RELEASES = Object.freeze({
|
||||
const ALLOWED_SCRIPT_URLS = new Set(Object.values(HERMES_RECIPES).map(recipe => recipe.url))
|
||||
|
||||
const AGENT_CATALOG = Object.freeze([
|
||||
{
|
||||
kind: 'hermes', label: 'Hermes', recommended: true, providerCompatible: true,
|
||||
providerSupport: 'supported',
|
||||
},
|
||||
{
|
||||
kind: 'openclaw', label: 'OpenClaw', recommended: true, providerCompatible: true,
|
||||
providerSupport: 'supported',
|
||||
},
|
||||
{
|
||||
kind: 'workbuddy', label: 'WorkBuddy', recommended: true, providerCompatible: true,
|
||||
providerSupport: 'experimental',
|
||||
},
|
||||
{
|
||||
kind: 'kimi', label: 'Kimi Code', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'native-config',
|
||||
},
|
||||
{
|
||||
kind: 'mimo', label: 'MiMo Code', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'native-config',
|
||||
},
|
||||
{
|
||||
kind: 'codex', label: 'Codex', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'responses-required',
|
||||
},
|
||||
{
|
||||
kind: 'claude', label: 'Claude Code', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'anthropic-required',
|
||||
},
|
||||
{
|
||||
kind: 'gemini', label: 'Gemini CLI', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'native-config',
|
||||
},
|
||||
{
|
||||
kind: 'opencode', label: 'OpenCode', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'native-config',
|
||||
},
|
||||
{
|
||||
kind: 'qwen', label: 'Qwen Code', recommended: false, providerCompatible: true,
|
||||
providerSupport: 'supported',
|
||||
},
|
||||
{
|
||||
kind: 'opencodereview', label: 'OpenCodeReview', recommended: false, providerCompatible: true,
|
||||
providerSupport: 'supported',
|
||||
},
|
||||
{ kind: 'codex', label: 'Codex', recommended: false },
|
||||
{ kind: 'claude', label: 'Claude Code', recommended: false },
|
||||
{ kind: 'hermes', label: 'Hermes', recommended: true },
|
||||
{ kind: 'workbuddy', label: 'WorkBuddy', recommended: true },
|
||||
{ kind: 'openclaw', label: 'OpenClaw', recommended: true },
|
||||
{ kind: 'pi', label: 'Pi Agent', recommended: false },
|
||||
{ kind: 'opencode', label: 'OpenCode', recommended: false },
|
||||
{ kind: 'opencodereview', label: 'OpenCodeReview', recommended: false },
|
||||
{ kind: 'kimi', label: 'Kimi Code', recommended: false },
|
||||
{ kind: 'mimo', label: 'MiMo Code', recommended: false },
|
||||
{ kind: 'deepseek', label: 'DeepSeek Harness', recommended: false },
|
||||
{ kind: 'qwen', label: 'Qwen Code', recommended: false },
|
||||
])
|
||||
|
||||
function installRecipe(kind, platform) {
|
||||
@@ -366,6 +356,7 @@ function verifiedRecipeAgent(agent, recipe) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
installedVersionCurrent,
|
||||
AGENT_CATALOG,
|
||||
DETECTION_CACHE_TTL_MS,
|
||||
abortError,
|
||||
|
||||
@@ -3,6 +3,7 @@ const fs = require('node:fs')
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { DISPLAY_LIMIT, listLocalAgentSkills } = require('../../skills/local-skill-catalog.cjs')
|
||||
const {
|
||||
installedVersionCurrent,
|
||||
AGENT_CATALOG,
|
||||
DETECTION_CACHE_TTL_MS,
|
||||
abortError,
|
||||
@@ -32,6 +33,11 @@ const COMPATIBILITY_REASONS = new Set([
|
||||
'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
|
||||
'LOCAL_AGENT_PROTOCOL_UNAVAILABLE',
|
||||
])
|
||||
const INCONCLUSIVE_REASONS = new Set([
|
||||
'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT',
|
||||
'LOCAL_AGENT_CAPABILITY_PROBE_FAILED',
|
||||
'LOCAL_AGENT_AUTH_REQUIRED',
|
||||
])
|
||||
|
||||
function publicCompatibility(agent) {
|
||||
if (!agent) {
|
||||
@@ -60,12 +66,15 @@ function publicCompatibility(agent) {
|
||||
? agent.versionIdentified
|
||||
: Boolean(resolvedVersion)
|
||||
const compatible = compatibilityState === 'compatible'
|
||||
const incompatibilityReason = compatibilityState === 'incompatible'
|
||||
const incompatibilityReason = compatibilityState === 'unknown'
|
||||
&& INCONCLUSIVE_REASONS.has(agent.incompatibilityReason)
|
||||
? agent.incompatibilityReason
|
||||
: compatibilityState === 'incompatible'
|
||||
? (COMPATIBILITY_REASONS.has(agent.incompatibilityReason)
|
||||
? agent.incompatibilityReason
|
||||
: 'LOCAL_AGENT_VERSION_UNSUPPORTED')
|
||||
: ''
|
||||
const incompatibilityProbe = compatibilityState === 'incompatible'
|
||||
const incompatibilityProbe = Boolean(incompatibilityReason)
|
||||
&& /^[a-z0-9-]{1,80}$/.test(String(agent.incompatibilityProbe || ''))
|
||||
? agent.incompatibilityProbe
|
||||
: ''
|
||||
@@ -164,6 +173,10 @@ class AgentInstaller extends EventEmitter {
|
||||
agents: AGENT_CATALOG.map(profile => {
|
||||
const agent = installed.get(profile.kind)
|
||||
const recipe = installRecipe(profile.kind, this.platform)
|
||||
const targetVersion = recipe?.detectedVersion || recipe?.version || ''
|
||||
const currentVersion = String(agent?.version || '')
|
||||
const upToDate = Boolean(agent && targetVersion
|
||||
&& installedVersionCurrent(currentVersion, targetVersion))
|
||||
let installSupported = Boolean(recipe)
|
||||
let installErrorCode = ''
|
||||
if (!recipe) {
|
||||
@@ -177,6 +190,8 @@ class AgentInstaller extends EventEmitter {
|
||||
...profile,
|
||||
installed: Boolean(agent),
|
||||
version: agent?.version || '',
|
||||
targetVersion,
|
||||
installAction: agent ? (upToDate ? 'current' : 'update') : 'install',
|
||||
...publicCompatibility(agent),
|
||||
installSupported,
|
||||
installErrorCode,
|
||||
@@ -230,7 +245,10 @@ class AgentInstaller extends EventEmitter {
|
||||
this.invalidateDetectionCache()
|
||||
const installed = await abortable(this.detectedAgents(), signal)
|
||||
if (signal.aborted) throw abortError()
|
||||
if (installed.some(agent => agent.kind === profile.kind)) {
|
||||
const current = installed.find(agent => agent.kind === profile.kind)
|
||||
const targetVersion = recipe.detectedVersion || recipe.version
|
||||
if (current && (current.compatibilityState !== 'compatible'
|
||||
|| installedVersionCurrent(current.version, targetVersion))) {
|
||||
throw installerError('INSTALL_AGENT_ALREADY_INSTALLED')
|
||||
}
|
||||
let command = recipe.interpreter || ''
|
||||
@@ -295,7 +313,9 @@ class AgentInstaller extends EventEmitter {
|
||||
throw installerError('INSTALL_AGENT_INTEGRITY_FAILED')
|
||||
}
|
||||
args = [
|
||||
'install', '--global', npmPackageSpec(recipe),
|
||||
'install', '--global',
|
||||
...(recipe.ignoreScripts ? ['--ignore-scripts'] : []),
|
||||
npmPackageSpec(recipe),
|
||||
'--registry', NPM_REGISTRY,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { createHmac, randomBytes } = require('node:crypto')
|
||||
const { networkEnvironment } = require('./cli/cli-network-environment.cjs')
|
||||
const NATIVE_CONFIG_IDENTITY_DIRECTORY = Symbol('nativeConfigIdentityDirectory')
|
||||
const NATIVE_ENVIRONMENT_IDENTITY = Symbol('nativeEnvironmentIdentity')
|
||||
const NATIVE_AUTH_IDENTITY_FILES = Symbol('nativeAuthIdentityFiles')
|
||||
const NATIVE_PROJECT_CONFIG_PATHS = Symbol('nativeProjectConfigPaths')
|
||||
const environmentIdentityKey = randomBytes(32)
|
||||
const NETWORK_CERTIFICATE_KEYS = ['SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE']
|
||||
|
||||
function nativeNetworkIdentityFiles(env = {}, platform = process.platform) {
|
||||
const source = networkEnvironment(env, platform)
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
return NETWORK_CERTIFICATE_KEYS.map(key => source[key])
|
||||
.filter(filename => typeof filename === 'string' && paths.isAbsolute(filename))
|
||||
}
|
||||
|
||||
function cloudIdentityPathKeys(kind) {
|
||||
return ['AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE', 'AWS_WEB_IDENTITY_TOKEN_FILE', 'AWS_CA_BUNDLE',
|
||||
'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE', 'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
...(kind === 'hermes' ? ['VERTEX_CREDENTIALS_PATH'] : []),
|
||||
...(kind === 'claude' ? ['AZURE_CLIENT_CERTIFICATE_PATH', 'AZURE_FEDERATED_TOKEN_FILE'] : [])]
|
||||
}
|
||||
|
||||
function cloudIdentityFiles(kind, env, home, platform) {
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
const configuredHome = platform === 'win32' ? env?.USERPROFILE : env?.HOME
|
||||
const root = typeof configuredHome === 'string' && paths.isAbsolute(configuredHome) ? configuredHome : home
|
||||
const defaults = typeof root === 'string' && paths.isAbsolute(root)
|
||||
? { AWS_CONFIG_FILE: paths.join(root, '.aws', 'config'),
|
||||
AWS_SHARED_CREDENTIALS_FILE: paths.join(root, '.aws', 'credentials') } : {}
|
||||
const cloudRoot = env?.CLOUDSDK_CONFIG || (platform === 'win32'
|
||||
? env?.APPDATA && paths.join(env.APPDATA, 'gcloud')
|
||||
: root && paths.join(env?.XDG_CONFIG_HOME || paths.join(root, '.config'), 'gcloud'))
|
||||
return [...cloudIdentityPathKeys(kind).map(key => env?.[key] || defaults[key]),
|
||||
...(['hermes', 'pi', 'opencode'].includes(kind) && typeof cloudRoot === 'string' && paths.isAbsolute(cloudRoot)
|
||||
? [paths.join(cloudRoot, 'application_default_credentials.json')] : [])]
|
||||
.filter(filename => typeof filename === 'string' && paths.isAbsolute(filename))
|
||||
}
|
||||
|
||||
function nativeProjectConfigPaths(kind, env = {}) {
|
||||
let entries = kind === 'opencode' ? [[env.OPENCODE_CONFIG, false], [env.OPENCODE_CONFIG_DIR, true]]
|
||||
: kind === 'mimo' ? [[env.MIMOCODE_CONFIG, false], [env.MIMOCODE_CONFIG_DIR, true]] : []
|
||||
if (kind === 'zcode') entries = [[env.ZCODE_AGENT_CA_CERT, false]]
|
||||
if (kind === 'gemini' || kind === 'qwen') {
|
||||
const settings = env[kind === 'gemini' ? 'GEMINI_CLI_SYSTEM_SETTINGS_PATH' : 'QWEN_CODE_SYSTEM_SETTINGS_PATH']
|
||||
const defaults = env[kind === 'gemini' ? 'GEMINI_CLI_SYSTEM_DEFAULTS_PATH' : 'QWEN_CODE_SYSTEM_DEFAULTS_PATH']
|
||||
|| (typeof settings === 'string' && settings ? path.join(path.dirname(settings), 'system-defaults.json') : '')
|
||||
entries = [[settings, false], [defaults, false], ...(kind === 'gemini' ? [[env.GOOGLE_APPLICATION_CREDENTIALS, false],
|
||||
[env.GEMINI_CLI_TRUSTED_FOLDERS_PATH, false]]
|
||||
: [[env.GEMINI_CLI_TRUSTED_FOLDERS_PATH, false]])]
|
||||
}
|
||||
if (kind === 'pi' || kind === 'claude' || kind === 'hermes') {
|
||||
entries = cloudIdentityPathKeys(kind).map(key => [env[key], false])
|
||||
}
|
||||
if (kind === 'opencode') entries.push(...cloudIdentityPathKeys(kind).map(key => [env[key], false]))
|
||||
if (['gemini', 'hermes', 'pi', 'opencode'].includes(kind)
|
||||
&& typeof env.CLOUDSDK_CONFIG === 'string' && env.CLOUDSDK_CONFIG) {
|
||||
entries.push([path.join(env.CLOUDSDK_CONFIG, 'application_default_credentials.json'), false])
|
||||
}
|
||||
const network = networkEnvironment(env)
|
||||
entries.push(...NETWORK_CERTIFICATE_KEYS.map(key => [network[key], false]))
|
||||
return entries.flatMap(([value, directory]) => {
|
||||
return typeof value === 'string' && value && !path.isAbsolute(value)
|
||||
&& !(kind === 'opencode' && /^~(?=$|[\\/])/.test(value))
|
||||
? [{ value, directory }] : []
|
||||
})
|
||||
}
|
||||
|
||||
function nativeEnvironmentIdentity(env) {
|
||||
const entries = Object.entries(env).filter(([, value]) => typeof value === 'string')
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
return createHmac('sha256', environmentIdentityKey).update(JSON.stringify(entries)).digest('hex')
|
||||
}
|
||||
|
||||
function nativeAuthIdentityFiles(kind, env, home, platform = process.platform) {
|
||||
if (kind === 'opencodereview') {
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
const configuredHome = platform === 'win32' ? env?.USERPROFILE : env?.HOME
|
||||
const roots = [...new Set([home, configuredHome].filter(root => typeof root === 'string' && paths.isAbsolute(root)))]
|
||||
return roots.flatMap(root => ['config.json', '.env'].map(name => paths.join(root, '.opencodereview', name)))
|
||||
}
|
||||
if (kind === 'pi' || kind === 'claude' || kind === 'hermes') {
|
||||
return cloudIdentityFiles(kind, env, home, platform)
|
||||
}
|
||||
if (kind === 'qwen') {
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
const configuredHome = platform === 'win32' ? env?.USERPROFILE : env?.HOME
|
||||
const effectiveHome = typeof configuredHome === 'string' && configuredHome.trim() ? configuredHome.trim() : home
|
||||
const settings = env?.QWEN_CODE_SYSTEM_SETTINGS_PATH || (platform === 'darwin'
|
||||
? '/Library/Application Support/QwenCode/settings.json' : platform === 'win32'
|
||||
? 'C:\\ProgramData\\qwen-code\\settings.json' : '/etc/qwen-code/settings.json')
|
||||
const defaults = env?.QWEN_CODE_SYSTEM_DEFAULTS_PATH
|
||||
|| (typeof settings === 'string' && paths.isAbsolute(settings) ? paths.join(paths.dirname(settings), 'system-defaults.json') : '')
|
||||
const userRoot = typeof effectiveHome === 'string' && paths.isAbsolute(effectiveHome) ? paths.join(effectiveHome, '.qwen') : ''
|
||||
return [settings, defaults, ...(userRoot ? ['settings.json', 'oauth_creds.json', '.env'].map(name => paths.join(userRoot, name)) : []),
|
||||
env?.GEMINI_CLI_TRUSTED_FOLDERS_PATH || (userRoot && paths.join(userRoot, 'trustedFolders.json')),
|
||||
...(typeof effectiveHome === 'string' && paths.isAbsolute(effectiveHome) ? [paths.join(effectiveHome, '.env')] : [])]
|
||||
.filter(filename => typeof filename === 'string' && paths.isAbsolute(filename))
|
||||
}
|
||||
if (kind === 'gemini') {
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
const settings = env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH || (platform === 'darwin'
|
||||
? '/Library/Application Support/GeminiCli/settings.json' : platform === 'win32'
|
||||
? 'C:\\ProgramData\\gemini-cli\\settings.json' : '/etc/gemini-cli/settings.json')
|
||||
const defaults = env?.GEMINI_CLI_SYSTEM_DEFAULTS_PATH
|
||||
|| (typeof settings === 'string' && paths.isAbsolute(settings) ? paths.join(paths.dirname(settings), 'system-defaults.json') : '')
|
||||
const cloudRoot = env?.CLOUDSDK_CONFIG || (platform === 'win32'
|
||||
? env?.APPDATA && paths.join(env.APPDATA, 'gcloud')
|
||||
: paths.join(env?.XDG_CONFIG_HOME || paths.join(env?.HOME || home || '', '.config'), 'gcloud'))
|
||||
const credentials = env?.GOOGLE_APPLICATION_CREDENTIALS
|
||||
|| (typeof cloudRoot === 'string' && paths.isAbsolute(cloudRoot)
|
||||
? paths.join(cloudRoot, 'application_default_credentials.json') : '')
|
||||
return [settings, defaults, credentials, env?.GEMINI_CLI_TRUSTED_FOLDERS_PATH,
|
||||
...(typeof home === 'string' && paths.isAbsolute(home) ? [paths.join(home, '.env')] : [])]
|
||||
.filter(filename => typeof filename === 'string' && paths.isAbsolute(filename))
|
||||
}
|
||||
if (kind === 'zcode') {
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
const configured = platform === 'win32' ? env?.USERPROFILE : env?.HOME
|
||||
const root = typeof configured === 'string' && configured.trim() ? configured.trim() : home
|
||||
return [typeof root === 'string' && paths.isAbsolute(root) ? paths.join(root, '.zcode', 'cli', 'config.json') : '',
|
||||
env?.ZCODE_AGENT_CA_CERT].filter(filename => typeof filename === 'string' && paths.isAbsolute(filename))
|
||||
}
|
||||
if (kind === 'mimo') {
|
||||
const explicit = env?.MIMOCODE_CONFIG
|
||||
const userRoot = typeof home === 'string' && path.isAbsolute(home) ? path.join(home, '.mimocode') : ''
|
||||
const configRoot = env?.XDG_CONFIG_HOME || (home && path.join(home, '.config'))
|
||||
const sharedRoot = typeof configRoot === 'string' && path.isAbsolute(configRoot) ? path.join(configRoot, 'mimocode') : ''
|
||||
const stateRoot = env?.XDG_STATE_HOME || (home && path.join(home, '.local', 'state'))
|
||||
return [explicit, ...[userRoot, sharedRoot].filter(Boolean)
|
||||
.flatMap(root => ['mimocode.json', 'mimocode.jsonc'].map(name => path.join(root, name))),
|
||||
typeof stateRoot === 'string' && path.isAbsolute(stateRoot) ? path.join(stateRoot, 'mimocode', 'model.json') : '']
|
||||
.filter(filename => typeof filename === 'string' && path.isAbsolute(filename))
|
||||
}
|
||||
if (kind !== 'opencode') return []
|
||||
const root = env?.XDG_DATA_HOME || (typeof home === 'string' && path.isAbsolute(home) ? path.join(home, '.local', 'share') : '')
|
||||
const configRoot = env?.XDG_CONFIG_HOME || (home && path.join(home, '.config'))
|
||||
const sharedRoot = typeof configRoot === 'string' && path.isAbsolute(configRoot) ? path.join(configRoot, 'opencode') : ''
|
||||
const explicit = typeof env?.OPENCODE_CONFIG === 'string' && typeof home === 'string' && path.isAbsolute(home)
|
||||
? env.OPENCODE_CONFIG.replace(/^~(?=$|[\\/])/, home) : env?.OPENCODE_CONFIG
|
||||
const stateRoot = env?.XDG_STATE_HOME || (home && path.join(home, '.local', 'state'))
|
||||
const cacheRoot = env?.XDG_CACHE_HOME || (home && path.join(home, '.cache'))
|
||||
return [typeof root === 'string' && path.isAbsolute(root) ? path.join(root, 'opencode', 'auth.json') : '', explicit,
|
||||
...(sharedRoot ? ['config.json', 'opencode.json', 'opencode.jsonc'].map(name => path.join(sharedRoot, name)) : []),
|
||||
...(typeof home === 'string' && path.isAbsolute(home)
|
||||
? ['opencode.json', 'opencode.jsonc'].map(name => path.join(home, '.opencode', name)) : []),
|
||||
typeof stateRoot === 'string' && path.isAbsolute(stateRoot) ? path.join(stateRoot, 'opencode', 'model.json') : '',
|
||||
typeof cacheRoot === 'string' && path.isAbsolute(cacheRoot) ? path.join(cacheRoot, 'opencode', 'models.json') : '',
|
||||
...cloudIdentityFiles(kind, env, home, platform)]
|
||||
.filter(filename => typeof filename === 'string' && path.isAbsolute(filename))
|
||||
}
|
||||
|
||||
const NATIVE_CONFIG_DIRECTORIES = Object.freeze({
|
||||
hermes: { key: 'HERMES_HOME', files: ['config.yaml', 'auth.json', '.env'], identityFiles: ['config.json'] },
|
||||
openclaw: { key: 'OPENCLAW_STATE_DIR', files: ['openclaw.json'], directories: ['agents'] },
|
||||
codex: { key: 'CODEX_HOME', files: ['config.toml', 'auth.json'], identityFiles: ['config.json', '.env'] },
|
||||
claude: { key: 'CLAUDE_CONFIG_DIR', files: ['settings.json', '.credentials.json'], identityFiles: ['settings.local.json', '.env'] },
|
||||
pi: { key: 'PI_CODING_AGENT_DIR', files: ['settings.json', 'auth.json', 'models.json'] },
|
||||
opencode: { key: 'OPENCODE_CONFIG_DIR', files: ['opencode.json', 'opencode.jsonc'] },
|
||||
gemini: { key: 'GEMINI_CLI_HOME', files: ['oauth_creds.json', 'gemini-credentials.json', 'google_accounts.json', 'settings.json', '.env'], identityFiles: ['trustedFolders.json'] },
|
||||
kimi: { key: 'KIMI_CODE_HOME', files: ['config.toml'], identityFiles: ['.env'], directories: ['credentials'] },
|
||||
mimo: { key: 'MIMOCODE_CONFIG_DIR', files: ['mimocode.json', 'mimocode.jsonc'] },
|
||||
deepseek: { key: 'DSH_HOME', files: ['cordis.patch.yml', '.credentials.yaml'], identityFiles: ['.env'], directories: ['profiles'] },
|
||||
workbuddy: { key: 'CODEBUDDY_CONFIG_DIR', files: ['models.json', 'settings.json', '.credentials.json'] },
|
||||
zcode: { key: 'HOME', files: ['config.json'] },
|
||||
qwen: { key: 'HOME', files: ['settings.json', 'oauth_creds.json', '.env'], identityFiles: ['trustedFolders.json'] },
|
||||
opencodereview: { key: 'HOME', files: ['config.json'], identityFiles: ['.env'] },
|
||||
})
|
||||
|
||||
function openClawNativeRoot(env = {}, home, platform = process.platform) {
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
if (typeof home !== 'string' || !paths.isAbsolute(home)) return ''
|
||||
const expand = (value, base) => value.replace(/^~(?=$|[\\/])/, () => base)
|
||||
const selectedHome = typeof env.OPENCLAW_HOME === 'string' && env.OPENCLAW_HOME.trim()
|
||||
? expand(env.OPENCLAW_HOME.trim(), home) : home
|
||||
if (!paths.isAbsolute(selectedHome)) return ''
|
||||
const state = typeof env.OPENCLAW_STATE_DIR === 'string' ? env.OPENCLAW_STATE_DIR.trim() : ''
|
||||
if (state) {
|
||||
const root = expand(state, selectedHome)
|
||||
return paths.isAbsolute(root) ? paths.resolve(root) : ''
|
||||
}
|
||||
const current = paths.join(selectedHome, '.openclaw')
|
||||
const legacy = paths.join(selectedHome, '.clawdbot')
|
||||
return !fs.existsSync(current) && fs.existsSync(legacy) ? legacy : current
|
||||
}
|
||||
|
||||
function nativeIdentityDirectory(kind, env, home, platform = process.platform) {
|
||||
if (kind === 'openclaw') return openClawNativeRoot(env, home, platform)
|
||||
if (kind === 'qwen' || kind === 'opencodereview') {
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
const base = (platform === 'win32' ? env?.USERPROFILE : env?.HOME) || home
|
||||
return typeof base === 'string' && paths.isAbsolute(base) ? paths.join(base, kind === 'qwen' ? '.qwen' : '.opencodereview') : ''
|
||||
}
|
||||
if (kind === 'zcode') {
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
const base = (platform === 'win32' ? env?.USERPROFILE : env?.HOME) || home
|
||||
return typeof base === 'string' && paths.isAbsolute(base) ? paths.join(base, '.zcode', 'cli') : ''
|
||||
}
|
||||
if (kind === 'gemini') {
|
||||
const base = env?.GEMINI_CLI_HOME || home
|
||||
return typeof base === 'string' && path.isAbsolute(base) ? path.join(base, '.gemini') : ''
|
||||
}
|
||||
const spec = Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, kind) ? NATIVE_CONFIG_DIRECTORIES[kind] : null
|
||||
if (!spec || !path.isAbsolute(home)) return ''
|
||||
const configured = env?.[spec.key]
|
||||
if (typeof configured === 'string' && configured) {
|
||||
const directory = kind === 'hermes' ? configured.replace(/^~(?=$|[\\/])/, home) : configured
|
||||
return path.isAbsolute(directory) ? directory : ''
|
||||
}
|
||||
const defaults = {
|
||||
hermes: platform === 'win32'
|
||||
? path.join(env?.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), `hermes${env?.HERMES_DATA_DIR_SUFFIX || ''}`)
|
||||
: path.join(home, `.hermes${env?.HERMES_DATA_DIR_SUFFIX || ''}`),
|
||||
codex: path.join(home, '.codex'), claude: path.join(home, '.claude'), openclaw: path.join(home, '.openclaw'),
|
||||
pi: path.join(home, '.pi', 'agent'), kimi: path.join(home, '.kimi-code'),
|
||||
mimo: path.join(env?.XDG_CONFIG_HOME || path.join(home, '.config'), 'mimocode'),
|
||||
opencode: path.join(env?.XDG_CONFIG_HOME || path.join(home, '.config'), 'opencode'),
|
||||
deepseek: path.join(home, '.dsh'), workbuddy: path.join(home, '.workbuddy'),
|
||||
}
|
||||
return defaults[kind] || ''
|
||||
}
|
||||
|
||||
function nativeConfigEnvironment(kind, preferences, env = {}, platform = process.platform) {
|
||||
const spec = Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, kind) ? NATIVE_CONFIG_DIRECTORIES[kind] : null
|
||||
const directory = preferences?.[kind]?.nativeConfigDirectory
|
||||
if (!spec || directory === undefined || directory === '') return env
|
||||
if (typeof directory !== 'string' || directory.length > 4096
|
||||
|| directory.includes('\u0000') || !path.isAbsolute(directory)) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
if (kind === 'gemini') {
|
||||
const name = path.basename(path.resolve(directory))
|
||||
if ((platform === 'win32' ? name.toLowerCase() : name) !== '.gemini') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
}
|
||||
if (kind === 'qwen' || kind === 'opencodereview') {
|
||||
const resolved = path.resolve(directory)
|
||||
const name = path.basename(resolved)
|
||||
if ((platform === 'win32' ? name.toLowerCase() : name) !== (kind === 'qwen' ? '.qwen' : '.opencodereview')) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
const home = path.dirname(resolved)
|
||||
return platform === 'win32' ? { ...env, HOME: home, USERPROFILE: home } : { ...env, HOME: home }
|
||||
}
|
||||
if (kind === 'zcode') {
|
||||
const resolved = path.resolve(directory)
|
||||
const name = path.basename(resolved)
|
||||
const parent = path.basename(path.dirname(resolved))
|
||||
if ((platform === 'win32' ? name.toLowerCase() : name) !== 'cli'
|
||||
|| (platform === 'win32' ? parent.toLowerCase() : parent) !== '.zcode') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
const home = path.dirname(path.dirname(resolved))
|
||||
return platform === 'win32' ? { ...env, HOME: home, USERPROFILE: home } : { ...env, HOME: home }
|
||||
}
|
||||
return { ...env, [spec.key]: kind === 'gemini' ? path.dirname(path.resolve(directory)) : directory }
|
||||
}
|
||||
|
||||
function validateNativeConfigDirectory(kind, directory) {
|
||||
const spec = Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, kind) ? NATIVE_CONFIG_DIRECTORIES[kind] : null
|
||||
if (!spec || typeof directory !== 'string' || directory.length > 4096
|
||||
|| directory.includes('\u0000') || !path.isAbsolute(directory)) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
const caseName = value => process.platform === 'win32' ? value.toLowerCase() : value
|
||||
if (kind === 'gemini' && caseName(path.basename(path.resolve(directory))) !== '.gemini') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
if (kind === 'qwen' && caseName(path.basename(path.resolve(directory))) !== '.qwen') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
if (kind === 'opencodereview' && caseName(path.basename(path.resolve(directory))) !== '.opencodereview') throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
if (kind === 'zcode' && (caseName(path.basename(path.resolve(directory))) !== 'cli'
|
||||
|| caseName(path.basename(path.dirname(path.resolve(directory)))) !== '.zcode')) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
let resolved
|
||||
try {
|
||||
resolved = fs.realpathSync(directory)
|
||||
if (!fs.statSync(resolved).isDirectory()
|
||||
|| !(spec.files.some(name => {
|
||||
try { return fs.statSync(path.join(resolved, name)).isFile() } catch { return false }
|
||||
}) || (spec.directories || []).some(name => {
|
||||
try { return fs.statSync(path.join(resolved, name)).isDirectory() } catch { return false }
|
||||
}))) throw new Error('invalid directory')
|
||||
} catch { throw new Error('LOCAL_NATIVE_CONFIG_INVALID') }
|
||||
return ['gemini', 'zcode', 'qwen', 'opencodereview'].includes(kind) ? path.resolve(directory) : resolved
|
||||
}
|
||||
|
||||
module.exports = { NATIVE_CONFIG_DIRECTORIES, NATIVE_CONFIG_IDENTITY_DIRECTORY, NATIVE_ENVIRONMENT_IDENTITY, NATIVE_AUTH_IDENTITY_FILES, NATIVE_PROJECT_CONFIG_PATHS, openClawNativeRoot,
|
||||
nativeProjectConfigPaths, nativeNetworkIdentityFiles,
|
||||
nativeEnvironmentIdentity, nativeAuthIdentityFiles, nativeConfigEnvironment,
|
||||
nativeIdentityDirectory, validateNativeConfigDirectory }
|
||||
@@ -0,0 +1,38 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { redactSecrets } = require('../security/secret-redaction.cjs')
|
||||
const { qwenConfiguredAuth } = require('./cli/qwen-native-config.cjs')
|
||||
|
||||
function nativeUserConfigSummary(kind, options = {}) {
|
||||
if (!['qwen', 'zcode'].includes(kind)) return null
|
||||
const env = options.env || process.env
|
||||
const platform = options.platform || process.platform
|
||||
const configuredHome = platform === 'win32' ? env.USERPROFILE : env.HOME
|
||||
const home = typeof configuredHome === 'string' && configuredHome.trim()
|
||||
? configuredHome.trim() : (options.home || os.homedir())
|
||||
const field = kind === 'qwen' ? 'authType' : 'provider'
|
||||
const empty = { [field]: '' }
|
||||
if (!path.isAbsolute(home)) return empty
|
||||
try {
|
||||
let value
|
||||
if (kind === 'qwen') value = qwenConfiguredAuth(options)
|
||||
else {
|
||||
const envModel = typeof env.ZCODE_MODEL === 'string' ? env.ZCODE_MODEL.trim() : ''
|
||||
if (envModel) value = envModel.includes('/') ? envModel.split('/')[0] : 'anthropic'
|
||||
else {
|
||||
const file = path.join(home, '.zcode', 'cli', 'config.json')
|
||||
const stat = fs.statSync(file)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return empty
|
||||
const model = JSON.parse(fs.readFileSync(file, 'utf8'))?.model?.main
|
||||
value = typeof model === 'string' ? (model.includes('/') ? model.split('/')[0] : 'anthropic') : model?.provider
|
||||
}
|
||||
}
|
||||
if (typeof value !== 'string') return empty
|
||||
const text = value.trim()
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(text) && redactSecrets(text) === text
|
||||
? { [field]: text } : empty
|
||||
} catch { return empty }
|
||||
}
|
||||
|
||||
module.exports = { nativeUserConfigSummary }
|
||||
@@ -1,7 +1,8 @@
|
||||
const crypto = require('node:crypto')
|
||||
const path = require('node:path')
|
||||
|
||||
const STORE_VERSION = 1
|
||||
const STORE_VERSION = 2
|
||||
const ATTACHMENT_CHUNK_BYTES = 256 * 1024
|
||||
const MAX_ATTACHMENT_BYTES = 128 * 1024 * 1024
|
||||
const MAX_ATTACHMENTS = 4
|
||||
const MAX_METADATA_BYTES = 64 * 1024
|
||||
@@ -213,8 +214,13 @@ function detectAttachmentType(bytes, nameType = null, declaredMime = '') {
|
||||
&& bytes.subarray(8, 12).toString('ascii') === 'WEBP') {
|
||||
return TYPE_BY_MIME.get('image/webp')
|
||||
}
|
||||
if (bytes.length >= 3 && (bytes.subarray(0, 3).toString('ascii') === 'ID3'
|
||||
|| (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0))) {
|
||||
const id3Header = bytes.length >= 10 && bytes.subarray(0, 3).toString('ascii') === 'ID3'
|
||||
&& bytes[3] >= 2 && bytes[3] <= 4 && bytes[4] !== 0xff
|
||||
&& [...bytes.subarray(6, 10)].every(byte => byte < 0x80)
|
||||
const mpegFrame = bytes.length >= 4 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0
|
||||
&& ((bytes[1] >> 3) & 0x03) !== 1 && ((bytes[1] >> 1) & 0x03) !== 0
|
||||
&& ((bytes[2] >> 4) & 0x0f) !== 15 && ((bytes[2] >> 2) & 0x03) !== 3
|
||||
if (id3Header || mpegFrame) {
|
||||
return TYPE_BY_MIME.get('audio/mpeg')
|
||||
}
|
||||
if (bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF'
|
||||
@@ -341,6 +347,18 @@ function validateAttachment(bytes, name, mimeType, requireMimeType) {
|
||||
return actual
|
||||
}
|
||||
|
||||
function chunkHashes(bytes) {
|
||||
return Array.from({ length: Math.ceil(bytes.length / ATTACHMENT_CHUNK_BYTES) }, (_, index) => (
|
||||
crypto.createHash('sha256').update(bytes.subarray(
|
||||
index * ATTACHMENT_CHUNK_BYTES, (index + 1) * ATTACHMENT_CHUNK_BYTES,
|
||||
)).digest('hex')
|
||||
))
|
||||
}
|
||||
|
||||
function chunkChecksum(chunks) {
|
||||
return crypto.createHash('sha256').update(chunks.join('')).digest('hex')
|
||||
}
|
||||
|
||||
function createAttachmentRecord(id, name, bytes, type) {
|
||||
const metadata = {
|
||||
id,
|
||||
@@ -348,12 +366,14 @@ function createAttachmentRecord(id, name, bytes, type) {
|
||||
mimeType: type.mimeType,
|
||||
size: bytes.length,
|
||||
}
|
||||
const chunks = /^(?:image|audio|video)\//.test(type.mimeType) ? chunkHashes(bytes) : null
|
||||
return {
|
||||
metadata,
|
||||
document: {
|
||||
version: STORE_VERSION,
|
||||
version: chunks ? STORE_VERSION : 1,
|
||||
...metadata,
|
||||
checksum: crypto.createHash('sha256').update(bytes).digest('hex'),
|
||||
checksum: chunks ? chunkChecksum(chunks) : crypto.createHash('sha256').update(bytes).digest('hex'),
|
||||
...(chunks ? { chunks } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -361,14 +381,22 @@ function createAttachmentRecord(id, name, bytes, type) {
|
||||
function parseAttachmentRecord(metadataBytes, id) {
|
||||
let document
|
||||
try { document = JSON.parse(metadataBytes.toString('utf8')) } catch { fail('LOCAL_ATTACHMENT_TAMPERED') }
|
||||
if (sortedKeys(document) !== 'checksum,id,mimeType,name,size,version'
|
||||
|| document.version !== STORE_VERSION || document.id !== id
|
||||
const legacy = document?.version === 1
|
||||
if (sortedKeys(document) !== (legacy
|
||||
? 'checksum,id,mimeType,name,size,version' : 'checksum,chunks,id,mimeType,name,size,version')
|
||||
|| (!legacy && document.version !== STORE_VERSION) || document.id !== id
|
||||
|| typeof document.name !== 'string' || typeof document.mimeType !== 'string'
|
||||
|| !Number.isSafeInteger(document.size) || document.size <= 0
|
||||
|| document.size > MAX_ATTACHMENT_BYTES
|
||||
|| typeof document.checksum !== 'string' || !/^[a-f0-9]{64}$/.test(document.checksum)) {
|
||||
fail('LOCAL_ATTACHMENT_TAMPERED')
|
||||
}
|
||||
if (!legacy && (!Array.isArray(document.chunks)
|
||||
|| document.chunks.length !== Math.ceil(document.size / ATTACHMENT_CHUNK_BYTES)
|
||||
|| document.chunks.some(hash => typeof hash !== 'string' || !/^[a-f0-9]{64}$/.test(hash))
|
||||
|| chunkChecksum(document.chunks) !== document.checksum)) {
|
||||
fail('LOCAL_ATTACHMENT_TAMPERED')
|
||||
}
|
||||
const type = TYPE_BY_MIME.get(document.mimeType)
|
||||
if (!type || sanitizeName(document.name, type) !== document.name) {
|
||||
fail('LOCAL_ATTACHMENT_TAMPERED')
|
||||
@@ -390,17 +418,21 @@ function validateStoredAttachment(bytes, document) {
|
||||
|| detectAttachmentType(
|
||||
bytes, declaredNameType(document.name), declaredMimeType(document.mimeType),
|
||||
)?.mimeType !== document.mimeType
|
||||
|| crypto.createHash('sha256').update(bytes).digest('hex') !== document.checksum) {
|
||||
|| (document.version === 1
|
||||
? crypto.createHash('sha256').update(bytes).digest('hex') !== document.checksum
|
||||
: chunkChecksum(chunkHashes(bytes)) !== document.checksum)) {
|
||||
fail('LOCAL_ATTACHMENT_TAMPERED')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ATTACHMENT_FILE_EXTENSIONS,
|
||||
ATTACHMENT_CHUNK_BYTES,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
MAX_METADATA_BYTES,
|
||||
attachmentError,
|
||||
createAttachmentRecord,
|
||||
detectAttachmentType,
|
||||
fail,
|
||||
isAttachmentError,
|
||||
normalizeDiscardReferences,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const fs = require('node:fs')
|
||||
const { Readable } = require('node:stream')
|
||||
|
||||
const {
|
||||
assertImagePixelLimit,
|
||||
@@ -12,6 +13,15 @@ const MAX_ATTACHMENT_DISCARD_REQUEST = 32
|
||||
|
||||
function attachmentIdsFromSnapshot(snapshot, groupId = '') {
|
||||
const ids = []
|
||||
for (const task of snapshot?.deliveryTasks || []) {
|
||||
if (groupId && task.groupId !== groupId) continue
|
||||
for (const version of task.versions) {
|
||||
for (const attachment of version.attachments) ids.push(attachment.id)
|
||||
}
|
||||
for (const attempt of task.attempts) {
|
||||
for (const source of attempt.sources) if (source.attachmentId) ids.push(source.attachmentId)
|
||||
}
|
||||
}
|
||||
for (const message of Array.isArray(snapshot?.messages) ? snapshot.messages : []) {
|
||||
if (groupId && message?.groupId !== groupId) continue
|
||||
for (const attachment of Array.isArray(message?.attachments) ? message.attachments : []) {
|
||||
@@ -109,6 +119,10 @@ function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath
|
||||
|
||||
function preview(id) {
|
||||
const { metadata, bytes } = availableStore().readWithMetadata(id)
|
||||
return renderPreview(metadata, bytes)
|
||||
}
|
||||
|
||||
function renderPreview(metadata, bytes) {
|
||||
const attachment = {
|
||||
id: metadata.id,
|
||||
name: metadata.name,
|
||||
@@ -142,35 +156,45 @@ function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath
|
||||
const id = mediaRequestId(request?.url)
|
||||
if (!id) return new Response(null, { status: 404 })
|
||||
let entry
|
||||
try { entry = availableStore().readWithMetadata(id) } catch { return new Response(null, { status: 404 }) }
|
||||
try {
|
||||
const store = availableStore()
|
||||
entry = typeof store.openMedia === 'function' ? store.openMedia(id) : store.readWithMetadata(id)
|
||||
} catch { return new Response(null, { status: 404 }) }
|
||||
const { metadata, bytes } = entry
|
||||
if (!/^(?:image|audio|video)\//.test(metadata.mimeType) || bytes.length !== metadata.size) {
|
||||
if (!/^(?:image|audio|video)\//.test(metadata.mimeType)
|
||||
|| (bytes && bytes.length !== metadata.size)) {
|
||||
return new Response(null, { status: 415 })
|
||||
}
|
||||
const range = mediaByteRange(request?.headers?.get?.('range'), bytes.length)
|
||||
const range = mediaByteRange(request?.headers?.get?.('range'), metadata.size)
|
||||
if (range === false) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: { 'Content-Range': `bytes */${bytes.length}` },
|
||||
headers: { 'Content-Range': `bytes */${metadata.size}` },
|
||||
})
|
||||
}
|
||||
const body = range ? bytes.subarray(range.start, range.end + 1) : bytes
|
||||
const start = range?.start ?? 0
|
||||
const end = range?.end ?? metadata.size - 1
|
||||
const body = bytes ? bytes.subarray(start, end + 1)
|
||||
: Readable.toWeb(Readable.from(entry.readRange(start, end)))
|
||||
const headers = {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
'Content-Length': String(body.length),
|
||||
'Content-Length': String(end - start + 1),
|
||||
'Content-Type': metadata.mimeType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
}
|
||||
if (range) headers['Content-Range'] = `bytes ${range.start}-${range.end}/${bytes.length}`
|
||||
if (range) headers['Content-Range'] = `bytes ${range.start}-${range.end}/${metadata.size}`
|
||||
return new Response(body, { status: range ? 206 : 200, headers })
|
||||
}
|
||||
|
||||
function importBuffer(input) {
|
||||
const store = availableStore()
|
||||
const metadata = store.importBuffer(input)
|
||||
const imported = typeof store.importBufferWithBytes === 'function'
|
||||
? store.importBufferWithBytes(input)
|
||||
: { metadata: store.importBuffer(input), bytes: null }
|
||||
const { metadata, bytes } = imported
|
||||
try {
|
||||
return preview(metadata.id)
|
||||
return bytes ? renderPreview(metadata, bytes) : preview(metadata.id)
|
||||
} catch (error) {
|
||||
try { store.discard([metadata]) } catch { /* best effort */ }
|
||||
throw error
|
||||
@@ -178,6 +202,7 @@ function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath
|
||||
}
|
||||
|
||||
function importFiles(filenames) {
|
||||
if (!Array.isArray(filenames)) throw new Error('LOCAL_ATTACHMENT_REFERENCE_INVALID')
|
||||
if (filenames.length > MAX_ATTACHMENT_PICK_REQUEST) {
|
||||
throw new Error('LOCAL_ATTACHMENT_COUNT_LIMIT')
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
ATTACHMENT_CHUNK_BYTES,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
MAX_METADATA_BYTES,
|
||||
attachmentError,
|
||||
@@ -77,12 +78,17 @@ class AttachmentStore {
|
||||
}
|
||||
|
||||
importBuffer(input) {
|
||||
return this.importBufferWithBytes(input).metadata
|
||||
}
|
||||
|
||||
importBufferWithBytes(input) {
|
||||
if (sortedKeys(input) !== 'bytes,mimeType,name'
|
||||
|| typeof input.name !== 'string' || input.name.length > 4096
|
||||
|| typeof input.mimeType !== 'string') {
|
||||
fail('LOCAL_ATTACHMENT_INPUT_INVALID')
|
||||
}
|
||||
return this.importBytes(toBuffer(input.bytes), input.name, input.mimeType, true)
|
||||
const bytes = toBuffer(input.bytes)
|
||||
return { metadata: this.importBytes(bytes, input.name, input.mimeType, true), bytes }
|
||||
}
|
||||
|
||||
importFile(filename) {
|
||||
@@ -135,6 +141,67 @@ class AttachmentStore {
|
||||
}
|
||||
}
|
||||
|
||||
openMedia(id) {
|
||||
const entry = this.loadEntryMetadata(normalizeId(id))
|
||||
if (!entry.document.chunks) {
|
||||
const legacy = this.loadEntry(id)
|
||||
return { metadata: legacy.metadata, bytes: legacy.bytes }
|
||||
}
|
||||
try {
|
||||
const stat = fs.lstatSync(entry.path)
|
||||
if (stat.isSymbolicLink() || !stat.isFile() || stat.size !== entry.document.size
|
||||
|| (process.platform !== 'win32' && (stat.mode & 0o777) !== FILE_MODE)
|
||||
|| !isInside(this.rootRealPath, fs.realpathSync(entry.path))) {
|
||||
fail('LOCAL_ATTACHMENT_TAMPERED')
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAttachmentError(error)) throw error
|
||||
throw attachmentError('LOCAL_ATTACHMENT_TAMPERED')
|
||||
}
|
||||
const store = this
|
||||
return {
|
||||
metadata: entry.metadata,
|
||||
async *readRange(start, end) {
|
||||
let descriptor
|
||||
try {
|
||||
store.assertRoot()
|
||||
const stat = fs.lstatSync(entry.path)
|
||||
if (stat.isSymbolicLink() || !stat.isFile() || stat.size !== entry.document.size
|
||||
|| (process.platform !== 'win32' && (stat.mode & 0o777) !== FILE_MODE)
|
||||
|| !isInside(store.rootRealPath, fs.realpathSync(entry.path))) {
|
||||
fail('LOCAL_ATTACHMENT_TAMPERED')
|
||||
}
|
||||
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0)
|
||||
descriptor = fs.openSync(entry.path, fs.constants.O_RDONLY | noFollow)
|
||||
const opened = fs.fstatSync(descriptor)
|
||||
if (!opened.isFile() || opened.size !== stat.size
|
||||
|| opened.dev !== stat.dev || opened.ino !== stat.ino
|
||||
|| (process.platform !== 'win32' && (opened.mode & 0o777) !== FILE_MODE)) {
|
||||
fail('LOCAL_ATTACHMENT_TAMPERED')
|
||||
}
|
||||
const first = Math.floor(start / ATTACHMENT_CHUNK_BYTES)
|
||||
const last = Math.floor(end / ATTACHMENT_CHUNK_BYTES)
|
||||
for (let index = first; index <= last; index += 1) {
|
||||
const chunkStart = index * ATTACHMENT_CHUNK_BYTES
|
||||
const chunkEnd = Math.min(entry.document.size, chunkStart + ATTACHMENT_CHUNK_BYTES) - 1
|
||||
const parts = []
|
||||
for await (const part of fs.createReadStream(entry.path, {
|
||||
fd: descriptor, autoClose: false, start: chunkStart, end: chunkEnd,
|
||||
})) parts.push(part)
|
||||
const chunk = Buffer.concat(parts)
|
||||
if (chunk.length !== chunkEnd - chunkStart + 1
|
||||
|| crypto.createHash('sha256').update(chunk).digest('hex') !== entry.document.chunks[index]) {
|
||||
fail('LOCAL_ATTACHMENT_TAMPERED')
|
||||
}
|
||||
yield chunk.subarray(Math.max(0, start - chunkStart), Math.min(chunk.length, end - chunkStart + 1))
|
||||
}
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
discard(refs) {
|
||||
this.assertRoot()
|
||||
const discardedIds = []
|
||||
@@ -281,7 +348,7 @@ class AttachmentStore {
|
||||
}
|
||||
}
|
||||
|
||||
loadEntry(id) {
|
||||
loadEntryMetadata(id) {
|
||||
this.assertRoot()
|
||||
const directory = path.join(this.rootPath, id)
|
||||
let directoryStat
|
||||
@@ -306,17 +373,22 @@ class AttachmentStore {
|
||||
)
|
||||
const { document, type, metadata } = parseAttachmentRecord(metadataBytes, id)
|
||||
const contentPath = path.join(directory, `${type.storageBase}.${type.extension}`)
|
||||
return { directory, path: contentPath, document, metadata }
|
||||
}
|
||||
|
||||
loadEntry(id) {
|
||||
const entry = this.loadEntryMetadata(id)
|
||||
const bytes = this.readStoredFile(
|
||||
contentPath,
|
||||
entry.path,
|
||||
'LOCAL_ATTACHMENT_FILE_MISSING',
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
)
|
||||
validateStoredAttachment(bytes, document)
|
||||
validateStoredAttachment(bytes, entry.document)
|
||||
return {
|
||||
directory,
|
||||
path: contentPath,
|
||||
directory: entry.directory,
|
||||
path: entry.path,
|
||||
bytes,
|
||||
metadata,
|
||||
metadata: entry.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const { V4_RECEIPT_MARKER_PREFIX } = require('./collaboration-receipt.cjs')
|
||||
|
||||
// Control markers an Agent appends to its answer. They steer the harness and
|
||||
// must never reach the user's screen, even while the answer is still streaming.
|
||||
const CONSENSUS_MARKERS = Object.freeze([
|
||||
'[[MELDWORK_CONSENSUS:agree]]',
|
||||
'[[MELDWORK_CONSENSUS:continue]]',
|
||||
])
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
// Length of the longest buffer suffix that could still grow into a marker.
|
||||
function heldSuffixLength(buffer, markers) {
|
||||
let hold = 0
|
||||
for (const marker of markers) {
|
||||
for (let size = Math.min(marker.length - 1, buffer.length); size > hold; size -= 1) {
|
||||
if (buffer.slice(-size).toLowerCase() === marker.slice(0, size).toLowerCase()) {
|
||||
hold = size
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return hold
|
||||
}
|
||||
|
||||
// Streams answer deltas through a marker filter.
|
||||
// plain nothing to hide: deltas pass through untouched.
|
||||
// auto hide the legacy consensus markers.
|
||||
// receipt hide the consensus markers and everything from the start of the
|
||||
// collaboration receipt on (the receipt is parsed from the final text).
|
||||
// `push` returns the text that is safe to show now; text that might be the
|
||||
// start of a marker is held back until the next delta settles it, and
|
||||
// `flush` releases what is left once the answer has finished.
|
||||
function createAnswerMarkerFilter({ mode = 'plain' } = {}) {
|
||||
if (mode === 'plain') {
|
||||
return { push: delta => String(delta || ''), flush: () => '' }
|
||||
}
|
||||
const hideReceipt = mode === 'receipt'
|
||||
const markers = hideReceipt
|
||||
? [...CONSENSUS_MARKERS, V4_RECEIPT_MARKER_PREFIX]
|
||||
: [...CONSENSUS_MARKERS]
|
||||
const pattern = new RegExp(markers.map(escapeRegExp).join('|'), 'i')
|
||||
let buffer = ''
|
||||
let receiptStarted = false
|
||||
return {
|
||||
push(delta) {
|
||||
if (receiptStarted) return ''
|
||||
buffer += String(delta || '')
|
||||
let visible = ''
|
||||
while (buffer && !receiptStarted) {
|
||||
const match = pattern.exec(buffer)
|
||||
if (match) {
|
||||
visible += buffer.slice(0, match.index)
|
||||
buffer = buffer.slice(match.index + match[0].length)
|
||||
if (hideReceipt
|
||||
&& match[0].toLowerCase() === V4_RECEIPT_MARKER_PREFIX.toLowerCase()) {
|
||||
receiptStarted = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
const hold = heldSuffixLength(buffer, markers)
|
||||
visible += hold ? buffer.slice(0, -hold) : buffer
|
||||
buffer = hold ? buffer.slice(-hold) : ''
|
||||
break
|
||||
}
|
||||
return visible
|
||||
},
|
||||
flush(finalText = '') {
|
||||
const rest = buffer
|
||||
buffer = ''
|
||||
if (!hideReceipt) return rest
|
||||
// A held fragment is real answer text only when the final answer ends
|
||||
// with it; otherwise it was the start of the (hidden) receipt.
|
||||
return !receiptStarted && rest && String(finalText).endsWith(rest) ? rest : ''
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CONSENSUS_MARKERS, createAnswerMarkerFilter }
|
||||
@@ -0,0 +1,295 @@
|
||||
const { canonicalJson } = require('./context-pack-records.cjs')
|
||||
const { parseLenientTaskDecision } = require('./task-decision.cjs')
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/
|
||||
|
||||
const V4_AGENT_PHASES = new Set([
|
||||
'proposal', 'discussion', 'challenge', 'work', 'synthesis', 'verification',
|
||||
])
|
||||
const V4_RECEIPT_MARKER_PREFIX = '[[MELDWORK_COLLABORATION'
|
||||
const V4_RECEIPT_MARKER = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?:([\s\S]*?)\]\]/i
|
||||
const V4_RECEIPT_BLOCK = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?\]\]([\s\S]*?)\[\[\/MELDWORK_COLLABORATION(?:_JSON)?\]\]/i
|
||||
const LEGACY_CONSENSUS_MARKER = /^\s*\[\[MELDWORK_CONSENSUS:(?:agree|continue)\]\]\s*$/gim
|
||||
const V4_RECEIPT_MAX_TEXT = 800
|
||||
const V4_RECEIPT_MAX_ITEMS = 16
|
||||
const V4_RECEIPT_MAX_BYTES = 16 * 1024
|
||||
|
||||
function isV4InternalProtocolCommentary(value) {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
||||
if (!text || text.length > 4000 || /\[\[MELDWORK_COLLABORATION/i.test(text)) return false
|
||||
const mentionsControl = /(?:\breceipt\b|structured marker|MELDWORK framework|ExitPlanMode|\u7ed3\u6784\u5316\s*receipt|receipt\s*\u6807\u8bb0)/i.test(text)
|
||||
const describesInternalProcess = /(?:coordination\/self-introduction|not a code implementation task|no plan file|implementation planning|plan mode|waiting for (?:the )?(?:next|subsequent).*(?:phase|instruction)|\u534f\u4f5c\u534f\u8c03\u7c7b\u6587\u672c\u4ea7\u51fa|\u975e\u4ee3\u7801\u5b9e\u73b0\u4efb\u52a1|\u6267\u884c\u8ba1\u5212\u6d41\u7a0b|\u4e0d\u8c03\u7528\s*ExitPlanMode|\u672c\u8f6e\u4ea4\u4ed8|\u7b49\u5f85\u540e\u7eed.*(?:\u9636\u6bb5|\u6307\u4ee4))/i.test(text)
|
||||
return mentionsControl && describesInternalProcess
|
||||
}
|
||||
|
||||
function stripV4InternalProtocolTail(value) {
|
||||
const paragraphs = String(value || '').trim().split(/\n{2,}/)
|
||||
while (paragraphs.length && isV4InternalProtocolCommentary(paragraphs.at(-1))) {
|
||||
paragraphs.pop()
|
||||
}
|
||||
return paragraphs.join('\n\n').trim()
|
||||
}
|
||||
|
||||
function boundedReceiptText(value, limit = V4_RECEIPT_MAX_TEXT) {
|
||||
if (typeof value !== 'string' || value.trim().length > limit) return ''
|
||||
const text = cleanReceiptText(value, limit)
|
||||
return text && !text.includes('\u0000') ? text : ''
|
||||
}
|
||||
|
||||
function boundedReceiptList(value) {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value) || value.length > V4_RECEIPT_MAX_ITEMS) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
const result = value.map(item => boundedReceiptText(item))
|
||||
if (result.some(item => !item)) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
return result
|
||||
}
|
||||
|
||||
// A decision that is still unreadable after normalisation is treated as no
|
||||
// decision: the orchestrator then asks the owner again instead of failing
|
||||
// the Agent and removing it from the discussion.
|
||||
function leniencyTaskDecision(value, summary) {
|
||||
try {
|
||||
return { taskDecision: parseLenientTaskDecision(value, { fallbackReason: summary }) }
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function parseV4CollaborationReceiptStrict(result, required = false, expectedPhase = '', options = {}) {
|
||||
const rawText = typeof result?.text === 'string' ? result.text : ''
|
||||
if (!required) return { text: rawText, collaboration: null }
|
||||
let text = rawText
|
||||
let receipt = result?.collaboration
|
||||
// Natural discussion turns are free-form answers; a slightly malformed
|
||||
// receipt there must not fail the turn and end the discussion. Strict
|
||||
// phased-V4 receipts keep failing closed.
|
||||
const lenient = expectedPhase === 'discussion'
|
||||
let trailingVisibleText = ''
|
||||
let unreadableReceipt = false
|
||||
const block = rawText.match(V4_RECEIPT_BLOCK) || rawText.match(V4_RECEIPT_MARKER)
|
||||
if (block) {
|
||||
const markerStart = Number(block.index) || 0
|
||||
const suffix = rawText.slice(markerStart + block[0].length).trim()
|
||||
// A closing code fence after the receipt is formatting, not content.
|
||||
if (suffix && !isV4InternalProtocolCommentary(suffix) && !/^[\s`~]*$/.test(suffix)) {
|
||||
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
trailingVisibleText = suffix
|
||||
}
|
||||
const encoded = String(block[1] || '').trim()
|
||||
if (!encoded || Buffer.byteLength(encoded) > V4_RECEIPT_MAX_BYTES) {
|
||||
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
unreadableReceipt = true
|
||||
}
|
||||
if (!unreadableReceipt) {
|
||||
try {
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(encoded)
|
||||
} catch (error) {
|
||||
if (!lenient) throw error
|
||||
// Common hand-written slips: smart quotes and trailing commas.
|
||||
parsed = JSON.parse(encoded
|
||||
.replace(/[\u201c\u201d]/g, '"')
|
||||
.replace(/[\u2018\u2019]/g, "'")
|
||||
.replace(/,\s*([}\]])/g, '$1'))
|
||||
}
|
||||
if (receipt != null && canonicalJson(receipt) !== canonicalJson(parsed)) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_MISMATCH')
|
||||
}
|
||||
receipt = parsed
|
||||
} catch (error) {
|
||||
if (error?.message === 'LOCAL_RUN_COLLABORATION_RECEIPT_MISMATCH') throw error
|
||||
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
unreadableReceipt = true
|
||||
}
|
||||
}
|
||||
text = rawText.slice(0, markerStart) + (trailingVisibleText ? `\n\n${trailingVisibleText}` : '')
|
||||
} else if (/\[\[MELDWORK_COLLABORATION/i.test(rawText)) {
|
||||
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
unreadableReceipt = true
|
||||
text = rawText.slice(0, rawText.search(/\[\[MELDWORK_COLLABORATION/i))
|
||||
}
|
||||
if (unreadableReceipt) receipt = null
|
||||
if (lenient && receipt && typeof receipt === 'object' && !Array.isArray(receipt)) {
|
||||
// Keep only discussion fields and fill a missing summary, so an extra key
|
||||
// or a long summary does not discard the Agent's whole contribution.
|
||||
const decisionReason = typeof receipt.taskDecision?.reason === 'string'
|
||||
? receipt.taskDecision.reason
|
||||
: ''
|
||||
const summary = typeof receipt.summary === 'string' && receipt.summary.trim()
|
||||
? receipt.summary.trim().slice(0, V4_RECEIPT_MAX_TEXT)
|
||||
: (decisionReason.trim().slice(0, V4_RECEIPT_MAX_TEXT) || 'Discussion contribution')
|
||||
receipt = {
|
||||
version: 1,
|
||||
phase: 'discussion',
|
||||
summary,
|
||||
...(receipt.taskDecision != null ? { taskDecision: receipt.taskDecision } : {}),
|
||||
}
|
||||
}
|
||||
text = stripV4InternalProtocolTail(
|
||||
text.replace(LEGACY_CONSENSUS_MARKER, '').replace(/\n{3,}/g, '\n\n'),
|
||||
)
|
||||
if (receipt == null && options.allowMissingV4Receipt === true && text) {
|
||||
return { text, collaboration: null }
|
||||
}
|
||||
if (receipt == null && expectedPhase === 'proposal'
|
||||
&& options.allowMissingProposalReceipt === true && text) {
|
||||
receipt = {
|
||||
version: 1,
|
||||
phase: 'proposal',
|
||||
summary: 'The Agent returned a visible proposal without a structured receipt.',
|
||||
capabilities: ['Delivered a user-facing proposal'],
|
||||
intendedWork: ['Addressed the current user task'],
|
||||
deliverables: ['Visible Agent response'],
|
||||
dependencies: [],
|
||||
}
|
||||
}
|
||||
if (receipt == null) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_REQUIRED')
|
||||
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
try {
|
||||
if (Buffer.byteLength(canonicalJson(receipt)) > V4_RECEIPT_MAX_BYTES) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.message === 'LOCAL_RUN_COLLABORATION_RECEIPT_INVALID') throw error
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
const phase = String(expectedPhase || '')
|
||||
const summary = boundedReceiptText(receipt.summary)
|
||||
const allowed = new Set([
|
||||
'version', 'phase', 'summary',
|
||||
...(phase === 'discussion' ? ['taskDecision'] : []),
|
||||
...(['challenge', 'verification'].includes(phase) ? ['verdict'] : []),
|
||||
...(phase === 'proposal'
|
||||
? ['capabilities', 'intendedWork', 'deliverables', 'dependencies'] : []),
|
||||
...(phase === 'challenge'
|
||||
? [
|
||||
'proposedAssignments', 'finalizerKind', 'verifierKinds',
|
||||
'supportedPlanHash', 'agreeToPlan',
|
||||
] : []),
|
||||
...(phase === 'work' ? ['workItemId', 'deliverables'] : []),
|
||||
...(phase === 'synthesis' ? ['resolvedIssueIds'] : []),
|
||||
])
|
||||
if (!V4_AGENT_PHASES.has(phase) || !summary
|
||||
|| Object.keys(receipt).some(key => !allowed.has(key))
|
||||
|| (receipt.version != null && receipt.version !== 1)
|
||||
|| (receipt.phase != null && receipt.phase !== phase)) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
if (['challenge', 'verification'].includes(phase)
|
||||
&& !['support', 'contradict'].includes(receipt.verdict)) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
const resolvedIssueIds = phase === 'synthesis'
|
||||
? boundedReceiptList(receipt.resolvedIssueIds)
|
||||
: null
|
||||
if (phase === 'synthesis' && !Array.isArray(receipt.resolvedIssueIds)) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
if (phase === 'challenge' && receipt.supportedPlanHash != null
|
||||
&& !SHA256.test(String(receipt.supportedPlanHash))) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
const proposalList = (field, requireItem = false) => {
|
||||
if (!Array.isArray(receipt[field])) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
const list = boundedReceiptList(receipt[field])
|
||||
if (requireItem && list.length === 0) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
return list
|
||||
}
|
||||
const boundedList = value => {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value) || value.length > V4_RECEIPT_MAX_ITEMS) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
const result = value.map(item => boundedReceiptText(item))
|
||||
if (result.some(item => !item)) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
return result
|
||||
}
|
||||
const normalized = {
|
||||
version: 1,
|
||||
phase,
|
||||
summary,
|
||||
...(phase === 'discussion' && receipt.taskDecision != null
|
||||
? leniencyTaskDecision(receipt.taskDecision, summary) : {}),
|
||||
...(['challenge', 'verification'].includes(phase) ? { verdict: receipt.verdict } : {}),
|
||||
...(phase === 'proposal' ? {
|
||||
capabilities: proposalList('capabilities', true),
|
||||
intendedWork: proposalList('intendedWork', true),
|
||||
deliverables: proposalList('deliverables', true),
|
||||
dependencies: proposalList('dependencies'),
|
||||
} : {}),
|
||||
...(phase === 'challenge' ? {
|
||||
...(Array.isArray(receipt.proposedAssignments)
|
||||
? {
|
||||
proposedAssignments: receipt.proposedAssignments.map(assignment => (
|
||||
assignment && typeof assignment === 'object' && !Array.isArray(assignment)
|
||||
&& assignment.role === 'finalizer'
|
||||
? { ...assignment, role: 'integrator' }
|
||||
: assignment
|
||||
)),
|
||||
}
|
||||
: {}),
|
||||
...(receipt.finalizerKind ? { finalizerKind: boundedReceiptText(receipt.finalizerKind, 120) } : {}),
|
||||
...(Array.isArray(receipt.verifierKinds) ? { verifierKinds: boundedList(receipt.verifierKinds) } : {}),
|
||||
...(receipt.supportedPlanHash ? { supportedPlanHash: receipt.supportedPlanHash } : {}),
|
||||
...(typeof receipt.agreeToPlan === 'boolean' ? { agreeToPlan: receipt.agreeToPlan } : {}),
|
||||
} : {}),
|
||||
...(phase === 'work' ? {
|
||||
...(receipt.workItemId ? { workItemId: boundedReceiptText(receipt.workItemId, 120) } : {}),
|
||||
deliverables: boundedList(receipt.deliverables),
|
||||
} : {}),
|
||||
...(phase === 'synthesis' ? { resolvedIssueIds } : {}),
|
||||
}
|
||||
return { text, collaboration: Object.freeze(JSON.parse(canonicalJson(normalized))) }
|
||||
}
|
||||
|
||||
function parseV4CollaborationReceipt(result, required = false, expectedPhase = '', options = {}) {
|
||||
try {
|
||||
return parseV4CollaborationReceiptStrict(result, required, expectedPhase, options)
|
||||
} catch (error) {
|
||||
const code = String(error?.message || '')
|
||||
if (expectedPhase !== 'proposal'
|
||||
|| options.allowMissingProposalReceipt !== true
|
||||
|| !['LOCAL_RUN_COLLABORATION_RECEIPT_INVALID', 'LOCAL_RUN_COLLABORATION_RECEIPT_REQUIRED']
|
||||
.includes(code)) {
|
||||
throw error
|
||||
}
|
||||
const rawText = typeof result?.text === 'string' ? result.text : ''
|
||||
const markerIndex = rawText.search(/\[\[MELDWORK_COLLABORATION/i)
|
||||
const visibleText = (markerIndex >= 0 ? rawText.slice(0, markerIndex) : rawText)
|
||||
.replace(LEGACY_CONSENSUS_MARKER, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
if (!visibleText) throw error
|
||||
return {
|
||||
text: visibleText,
|
||||
collaboration: Object.freeze({
|
||||
version: 1,
|
||||
phase: 'proposal',
|
||||
summary: 'The Agent returned a visible proposal without a valid structured receipt.',
|
||||
capabilities: ['Delivered a user-facing proposal'],
|
||||
intendedWork: ['Addressed the current user task'],
|
||||
deliverables: ['Visible Agent response'],
|
||||
dependencies: [],
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanReceiptText(value, limit) {
|
||||
return String(value || '').trim().slice(0, limit)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
V4_AGENT_PHASES,
|
||||
V4_RECEIPT_MARKER_PREFIX,
|
||||
parseV4CollaborationReceipt,
|
||||
}
|
||||
@@ -533,6 +533,37 @@ function publicCollaborationText(value, limit = MAX_TEXT_CHARS) {
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
const PRIVATE_FILESYSTEM_ROOT = /^\/(?:Users|home|private|var|tmp|etc|opt|Volumes|root|usr|System|Library|Applications|mnt|srv)(?:\/|$)/
|
||||
|
||||
function peerPathText(candidate, workspaceRoot) {
|
||||
const trailing = candidate.match(/[.,;:!?]+$/)?.[0] || ''
|
||||
const clean = trailing ? candidate.slice(0, -trailing.length) : candidate
|
||||
if (workspaceRoot && (clean === workspaceRoot || clean.startsWith(`${workspaceRoot}/`))) {
|
||||
return `${clean === workspaceRoot ? '.' : clean.slice(workspaceRoot.length + 1)}${trailing}`
|
||||
}
|
||||
if (PRIVATE_FILESYSTEM_ROOT.test(clean)) return `[path]${trailing}`
|
||||
// Routes and commands such as /api/users or /login are not local files.
|
||||
return candidate
|
||||
}
|
||||
|
||||
// Peer-visible text for natural discussion transcripts. Unlike
|
||||
// publicCollaborationText, files inside the shared workspace stay readable as
|
||||
// workspace-relative paths and non-filesystem tokens (API routes, slash
|
||||
// commands) are kept; other absolute local paths and secrets stay redacted.
|
||||
function peerCollaborationText(value, options = {}) {
|
||||
const workspaceRoot = typeof options.workspaceRoot === 'string'
|
||||
? options.workspaceRoot.replace(/\/+$/, '')
|
||||
: ''
|
||||
return redactSecrets(String(value || ''))
|
||||
.replace(/\bfile:\/\/(\/[^\s"'`<>]+)/giu, (_match, filePath) => peerPathText(filePath, workspaceRoot))
|
||||
.replace(/(^|[\s("'`])(\/(?!\/)[^\s"'`<>)]*)/gmu, (_match, lead, candidate) => (
|
||||
`${lead}${peerPathText(candidate, workspaceRoot)}`
|
||||
))
|
||||
.replace(/\b[A-Za-z]:\\(?:[^\s"'`<>]+\\)*[^\s"'`<>]*/gu, '[path]')
|
||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function collaborationPackageText(handoff, entries) {
|
||||
const lines = [
|
||||
'MELDWORK_COLLABORATION_PACKAGE_V1',
|
||||
@@ -557,6 +588,7 @@ function collaborationPackageText(handoff, entries) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
peerCollaborationText,
|
||||
BLACKBOARD_ENTRY_VERSION,
|
||||
COLLABORATION_STATE_VERSION,
|
||||
HANDOFF_VERSION,
|
||||
|
||||
@@ -256,10 +256,14 @@ const deliveryContentFields = {
|
||||
wirePayloadRef: blobRefSchema,
|
||||
wirePayloadHash: hashSchema,
|
||||
wirePayloadBytes: z.number().int().nonnegative().max(MAX_CONTENT_BLOB_BYTES),
|
||||
inputTokenCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
|
||||
outputTokenCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
|
||||
serialization: z.enum([
|
||||
'cli-argv-stdin-v1',
|
||||
'acp-session-prompt-v1',
|
||||
'custom-cli-argv-stdin-v1',
|
||||
'http-json-body-v1',
|
||||
'ssh-stdin-v1',
|
||||
]),
|
||||
runtimeAdditions: z.array(runtimeAdditionSchema).max(MAX_RUNTIME_ADDITIONS),
|
||||
sessionProvenance: sessionProvenanceSchema,
|
||||
@@ -269,14 +273,20 @@ function validateDeliveryRelations(delivery, context) {
|
||||
if (delivery.payloadHash !== delivery.payloadRef.hash) {
|
||||
context.addIssue({ code: 'custom', path: ['payloadHash'], message: 'payload hash mismatch' })
|
||||
}
|
||||
if (delivery.wirePayloadHash !== delivery.wirePayloadRef.hash
|
||||
|| delivery.wirePayloadBytes !== delivery.wirePayloadRef.size) {
|
||||
if (delivery.wirePayloadHash !== delivery.wirePayloadRef.hash) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['wirePayloadHash'],
|
||||
message: 'wire payload reference mismatch',
|
||||
})
|
||||
}
|
||||
if (delivery.wirePayloadBytes !== delivery.wirePayloadRef.size) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['wirePayloadBytes'],
|
||||
message: 'wire payload byte count mismatch',
|
||||
})
|
||||
}
|
||||
const additionIds = delivery.runtimeAdditions.map(addition => addition.additionId)
|
||||
if (new Set(additionIds).size !== additionIds.length) {
|
||||
context.addIssue({
|
||||
@@ -297,23 +307,18 @@ const deliveryRecordSchema = z.strictObject({
|
||||
}).superRefine(validateDeliveryRelations)
|
||||
|
||||
function forbiddenField(key) {
|
||||
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
return normalized.includes('credential')
|
||||
|| normalized.includes('password')
|
||||
|| normalized.includes('apikey')
|
||||
|| normalized.includes('accesskey')
|
||||
|| normalized.includes('token')
|
||||
|| normalized.includes('secret')
|
||||
|| normalized.includes('authorization')
|
||||
|| normalized.includes('privatekey')
|
||||
|| normalized.includes('executable')
|
||||
|| normalized.includes('command')
|
||||
|| normalized.includes('reasoning')
|
||||
|| normalized.includes('chainofthought')
|
||||
|| normalized === 'cot'
|
||||
|| normalized.includes('sessionref')
|
||||
|| normalized === 'thought'
|
||||
|| normalized === 'thoughts'
|
||||
const words = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
|
||||
.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
|
||||
const joined = words.join('')
|
||||
if (['inputtokencount', 'outputtokencount', 'commandhistorycount'].includes(joined)) return false
|
||||
return words.some(word => [
|
||||
'credential', 'credentials', 'password', 'passwords', 'apikey', 'accesskey',
|
||||
'token', 'tokens', 'secret', 'secrets', 'authorization', 'privatekey',
|
||||
'executable', 'command', 'commands', 'reasoning', 'chainofthought',
|
||||
'cot', 'sessionref', 'thought', 'thoughts',
|
||||
].includes(word)) || /\b(?:api key|access key|private key|chain of thought|session ref)\b/
|
||||
.test(words.join(' '))
|
||||
}
|
||||
|
||||
function assertNoForbiddenContent(value, prefix, seen = new Set()) {
|
||||
@@ -336,6 +341,7 @@ function assertNoForbiddenContent(value, prefix, seen = new Set()) {
|
||||
|
||||
function parseInput(value, prefix, maxBytes) {
|
||||
if (Buffer.isBuffer(value) || value instanceof Uint8Array || typeof value === 'string') {
|
||||
if (typeof value === 'string' && value.length > maxBytes) fail(`${prefix}_JSON_INVALID`)
|
||||
const bytes = typeof value === 'string' ? Buffer.from(value, 'utf8') : Buffer.from(value)
|
||||
if (!bytes.length || bytes.length > maxBytes) fail(`${prefix}_JSON_INVALID`)
|
||||
let parsed
|
||||
@@ -347,7 +353,14 @@ function parseInput(value, prefix, maxBytes) {
|
||||
|
||||
function validated(schema, value, prefix) {
|
||||
const result = schema.safeParse(value)
|
||||
if (!result.success) fail(`${prefix}_SCHEMA_INVALID`)
|
||||
if (!result.success) {
|
||||
const error = contextRecordError(`${prefix}_SCHEMA_INVALID`)
|
||||
const field = result.error.issues.find(issue => (
|
||||
issue.code === 'custom' && ['wirePayloadHash', 'wirePayloadBytes'].includes(issue.path[0])
|
||||
))?.path[0]
|
||||
if (field) error.field = field
|
||||
throw error
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
@@ -375,14 +388,14 @@ function createContextPackRecord(input) {
|
||||
...body,
|
||||
}
|
||||
if (record.parentPackId === record.contextPackId) fail('CONTEXT_PACK_SCHEMA_INVALID')
|
||||
enforceCanonicalSize(record, 'CONTEXT_PACK', MAX_CONTEXT_PACK_RECORD_BYTES)
|
||||
return JSON.parse(canonicalJson(record))
|
||||
return JSON.parse(enforceCanonicalSize(record, 'CONTEXT_PACK', MAX_CONTEXT_PACK_RECORD_BYTES))
|
||||
}
|
||||
|
||||
function parseContextPackRecord(input) {
|
||||
const { parsed, serialized } = parseInput(
|
||||
input, 'CONTEXT_PACK', MAX_CONTEXT_PACK_RECORD_BYTES,
|
||||
)
|
||||
if (!isPlainObject(parsed)) fail('CONTEXT_PACK_SCHEMA_INVALID')
|
||||
assertNoForbiddenContent(parsed, 'CONTEXT_PACK')
|
||||
const record = validated(contextPackRecordSchema, parsed, 'CONTEXT_PACK')
|
||||
const { contextPackId, ...body } = record
|
||||
@@ -405,14 +418,14 @@ function createDeliveryRecord(input) {
|
||||
deliveryRecordId: deriveId('delivery-record', body),
|
||||
...body,
|
||||
}
|
||||
enforceCanonicalSize(record, 'DELIVERY_RECORD', MAX_DELIVERY_RECORD_BYTES)
|
||||
return JSON.parse(canonicalJson(record))
|
||||
return JSON.parse(enforceCanonicalSize(record, 'DELIVERY_RECORD', MAX_DELIVERY_RECORD_BYTES))
|
||||
}
|
||||
|
||||
function parseDeliveryRecord(input) {
|
||||
const { parsed, serialized } = parseInput(
|
||||
input, 'DELIVERY_RECORD', MAX_DELIVERY_RECORD_BYTES,
|
||||
)
|
||||
if (!isPlainObject(parsed)) fail('DELIVERY_RECORD_SCHEMA_INVALID')
|
||||
assertNoForbiddenContent(parsed, 'DELIVERY_RECORD')
|
||||
const record = validated(deliveryRecordSchema, parsed, 'DELIVERY_RECORD')
|
||||
const { deliveryRecordId, ...body } = record
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const crypto = require('node:crypto')
|
||||
const { parseTaskDecision } = require('./task-decision.cjs')
|
||||
|
||||
const { normalizeContentBlobRef } = require('../attachments/content-blob-store.cjs')
|
||||
const { canonicalJson } = require('./context-pack-records.cjs')
|
||||
@@ -9,7 +10,7 @@ const V4_TEMPLATES = new Set(['concurrent-batch', 'discussion'])
|
||||
const V4_WORKFLOWS = new Set(['manual', 'auto', 'concurrent-batch', 'discussion'])
|
||||
const V4_PHASES = new Set([
|
||||
'prepare', 'dispatch', 'running', 'reconcile',
|
||||
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
'commit', 'committed', 'completed', 'failed', 'stopped', 'human-gate',
|
||||
])
|
||||
const V4_SLOT_STATUSES = new Set([
|
||||
@@ -60,7 +61,7 @@ const MESSAGE_ID = /^message-[a-f0-9]{64}$/u
|
||||
const EVIDENCE_ID = /^evidence-[a-f0-9]{64}$/u
|
||||
|
||||
const ORCHESTRATION_FIELDS = new Set([
|
||||
'version', 'workflow', 'template', 'phase', 'batchId', 'round',
|
||||
'version', 'workflow', 'template', 'phase', 'discussionStyle', 'discussionWriterKind', 'batchId', 'round',
|
||||
'currentKind', 'currentKinds', 'pendingKinds', 'activeKinds',
|
||||
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
|
||||
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'taskGraph',
|
||||
@@ -278,10 +279,24 @@ function normalizeAssignment(input, targetKinds, index) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWorkAcceptance(input, errorCode) {
|
||||
if (!isRecord(input) || !exactFields(input, ['minArtifactRefs', 'minEvidenceRefs', 'requiredEvidenceKinds'])
|
||||
|| !Number.isInteger(input.minArtifactRefs) || input.minArtifactRefs < 1 || input.minArtifactRefs > MAX_V4_REFS
|
||||
|| !Number.isInteger(input.minEvidenceRefs) || input.minEvidenceRefs < 1 || input.minEvidenceRefs > MAX_V4_REFS) {
|
||||
fail(errorCode)
|
||||
}
|
||||
const allowedKinds = ['declaration', 'source-snapshot', 'observation', 'test-result', 'file-hash', 'review', 'other']
|
||||
if (hasOwn(input, 'requiredEvidenceKinds') && (!Array.isArray(input.requiredEvidenceKinds)
|
||||
|| input.requiredEvidenceKinds.some(kind => !allowedKinds.includes(kind))
|
||||
|| new Set(input.requiredEvidenceKinds).size !== input.requiredEvidenceKinds.length)) fail(errorCode)
|
||||
return { minArtifactRefs: input.minArtifactRefs, minEvidenceRefs: input.minEvidenceRefs,
|
||||
...(hasOwn(input, 'requiredEvidenceKinds') ? { requiredEvidenceKinds: [...input.requiredEvidenceKinds].sort() } : {}) }
|
||||
}
|
||||
|
||||
function normalizeCoordinationAssignment(input, targetKinds, index) {
|
||||
if (!isRecord(input) || !exactFields(input, [
|
||||
'taskId', 'ownerKind', 'role', 'objective', 'expectedOutput',
|
||||
'inputRefs', 'artifactIds', 'dependsOn',
|
||||
'inputRefs', 'artifactIds', 'dependsOn', 'acceptance',
|
||||
])) fail('ORCHESTRATION_V4_COORDINATION_PLAN_INVALID')
|
||||
const taskId = cleanId(input.taskId)
|
||||
const ownerKind = cleanId(input.ownerKind)
|
||||
@@ -299,6 +314,7 @@ function normalizeCoordinationAssignment(input, targetKinds, index) {
|
||||
inputRefs: uniqueIds(input.inputRefs || [], MAX_V4_REFS).sort(),
|
||||
artifactIds: uniqueIds(input.artifactIds || [], MAX_V4_REFS).sort(),
|
||||
dependsOn: uniqueIds(input.dependsOn || [], MAX_V4_ASSIGNMENTS).sort(),
|
||||
...(hasOwn(input, 'acceptance') ? { acceptance: normalizeWorkAcceptance(input.acceptance, 'ORCHESTRATION_V4_COORDINATION_PLAN_INVALID') } : {}),
|
||||
index,
|
||||
}
|
||||
}
|
||||
@@ -347,13 +363,13 @@ function createCoordinationPlan(input = {}) {
|
||||
}
|
||||
const finalizerKind = cleanId(input.finalizerKind)
|
||||
const verifierKinds = uniqueIds(
|
||||
input.verifierKinds || [], Math.min(2, MAX_V4_SLOTS), true,
|
||||
input.verifierKinds || [], MAX_V4_SLOTS, true,
|
||||
).sort()
|
||||
const agreedBy = uniqueIds(input.agreedBy || [], MAX_V4_SLOTS, true).sort()
|
||||
const supportReceiptIds = input.supportReceiptIds == null
|
||||
? null
|
||||
: uniqueIds(input.supportReceiptIds, MAX_V4_SLOTS, true).sort()
|
||||
const requiredVerifierCount = Math.min(2, targetKinds.length - 1)
|
||||
const requiredVerifierCount = targetKinds.length - 1
|
||||
if (!finalizerKind || !targetKinds.includes(finalizerKind)
|
||||
|| verifierKinds.length !== requiredVerifierCount
|
||||
|| verifierKinds.some(kind => !targetKinds.includes(kind) || kind === finalizerKind)
|
||||
@@ -516,6 +532,16 @@ function createWorkReceipt(input = {}) {
|
||||
!== canonicalJson(collaborationReceipt.artifactIds)) {
|
||||
fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
|
||||
}
|
||||
let dependencyWorkReceiptIds
|
||||
if (Object.hasOwn(input, 'dependencyWorkReceiptIds')) {
|
||||
if (!Array.isArray(input.dependencyWorkReceiptIds)
|
||||
|| input.dependencyWorkReceiptIds.length > MAX_V4_ASSIGNMENTS
|
||||
|| input.dependencyWorkReceiptIds.some(id => typeof id !== 'string' || !WORK_RECEIPT_ID.test(id))
|
||||
|| new Set(input.dependencyWorkReceiptIds).size !== input.dependencyWorkReceiptIds.length) {
|
||||
fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
|
||||
}
|
||||
dependencyWorkReceiptIds = [...input.dependencyWorkReceiptIds].sort()
|
||||
}
|
||||
const resultHash = hashValue({ collaborationReceipt, artifacts })
|
||||
const body = {
|
||||
version: 1,
|
||||
@@ -530,6 +556,7 @@ function createWorkReceipt(input = {}) {
|
||||
collaborationReceipt,
|
||||
resultHash,
|
||||
artifacts,
|
||||
...(dependencyWorkReceiptIds ? { dependencyWorkReceiptIds } : {}),
|
||||
}
|
||||
return {
|
||||
...body,
|
||||
@@ -541,7 +568,7 @@ function normalizeWorkReceipt(input) {
|
||||
if (!isRecord(input) || !exactFields(input, [
|
||||
'version', 'workReceiptId', 'snapshotHash', 'snapshotBodyHash',
|
||||
'snapshotContentRef', 'planHash', 'taskId', 'ownerKind', 'slotId',
|
||||
'operationId', 'collaborationReceipt', 'resultHash', 'artifacts',
|
||||
'operationId', 'collaborationReceipt', 'resultHash', 'artifacts', 'dependencyWorkReceiptIds',
|
||||
]) || input.version !== 1 || !WORK_RECEIPT_ID.test(String(input.workReceiptId || ''))) {
|
||||
fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
|
||||
}
|
||||
@@ -705,7 +732,7 @@ function normalizeSlot(input, targetKinds, topPhase, index, template) {
|
||||
'slotId', 'agentKind', 'phase', 'status', 'operationId', 'agentRunId', 'queuePosition',
|
||||
'snapshotHash', 'deliveryWatermark', 'receiptId', 'resultHash',
|
||||
'assignedAt', 'startedAt', 'finishedAt', 'commitStatus', 'attempt', 'permission',
|
||||
'resultRefs', 'resultBodyArtifactId', 'commitId', 'messageId', 'blackboardEntryId',
|
||||
'resultRefs', 'resultBodyArtifactId', 'commitId', 'messageId', 'blackboardEntryId', 'workDependencyBinding',
|
||||
])) fail('ORCHESTRATION_V4_SLOT_INVALID')
|
||||
const slotId = cleanId(input.slotId)
|
||||
const agentKind = cleanId(input.agentKind)
|
||||
@@ -742,6 +769,19 @@ function normalizeSlot(input, targetKinds, topPhase, index, template) {
|
||||
const messageId = input.messageId == null ? '' : cleanId(input.messageId)
|
||||
const blackboardEntryId = input.blackboardEntryId == null
|
||||
? '' : String(input.blackboardEntryId)
|
||||
let workDependencyBinding
|
||||
if (hasOwn(input, 'workDependencyBinding')) {
|
||||
const binding = input.workDependencyBinding
|
||||
if (phase !== 'work' || !isRecord(binding)
|
||||
|| !exactFields(binding, ['taskId', 'dependencyWorkReceiptIds'])
|
||||
|| !cleanId(binding.taskId) || !Array.isArray(binding.dependencyWorkReceiptIds)
|
||||
|| binding.dependencyWorkReceiptIds.length > MAX_V4_ASSIGNMENTS
|
||||
|| binding.dependencyWorkReceiptIds.some(id => typeof id !== 'string' || !WORK_RECEIPT_ID.test(id))
|
||||
|| new Set(binding.dependencyWorkReceiptIds).size !== binding.dependencyWorkReceiptIds.length) {
|
||||
fail('ORCHESTRATION_V4_SLOT_INVALID')
|
||||
}
|
||||
workDependencyBinding = { taskId: binding.taskId, dependencyWorkReceiptIds: [...binding.dependencyWorkReceiptIds].sort() }
|
||||
}
|
||||
if ((resultBodyArtifactId && !ARTIFACT_ID.test(resultBodyArtifactId))
|
||||
|| (input.commitId != null && !commitId)
|
||||
|| (input.messageId != null && !messageId)
|
||||
@@ -791,6 +831,7 @@ function normalizeSlot(input, targetKinds, topPhase, index, template) {
|
||||
deliveryWatermark, receiptId, resultHash, assignedAt, startedAt, finishedAt, commitStatus,
|
||||
attempt,
|
||||
...(permission ? { permission } : {}),
|
||||
...(workDependencyBinding ? { workDependencyBinding } : {}),
|
||||
...(resultRefs ? { resultRefs } : {}),
|
||||
...(resultBodyArtifactId ? { resultBodyArtifactId } : {}),
|
||||
...(commitId ? { commitId } : {}),
|
||||
@@ -829,7 +870,7 @@ function normalizeDeliveryState(input, targetKinds, index) {
|
||||
if (!isRecord(input) || !exactFields(input, [
|
||||
'recipientKind', 'sessionRefHash', 'sessionProvenanceHash',
|
||||
'sourceAgentKind', 'sourcePhase', 'watermark', 'snapshotHash', 'operationId',
|
||||
'packageHash', 'deliveryId', 'status', 'updatedAt',
|
||||
'packageHash', 'deliveryId', 'status', 'updatedAt', 'sourceMessages',
|
||||
])) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
|
||||
const recipientKind = cleanId(input.recipientKind)
|
||||
const sourceAgentKind = cleanId(input.sourceAgentKind)
|
||||
@@ -841,6 +882,20 @@ function normalizeDeliveryState(input, targetKinds, index) {
|
||||
const packageHash = String(input.packageHash || '')
|
||||
const deliveryId = cleanId(input.deliveryId)
|
||||
const status = String(input.status || '')
|
||||
let sourceMessages
|
||||
if (hasOwn(input, 'sourceMessages')) {
|
||||
if (!Array.isArray(input.sourceMessages) || !input.sourceMessages.length
|
||||
|| input.sourceMessages.length > 100) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
|
||||
sourceMessages = input.sourceMessages.map(message => {
|
||||
if (!isRecord(message) || !exactFields(message, ['id', 'hash'])
|
||||
|| !cleanId(message.id) || cleanId(message.id) !== message.id
|
||||
|| typeof message.hash !== 'string' || !SHA256.test(message.hash)) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
|
||||
return { id: message.id, hash: message.hash }
|
||||
})
|
||||
if (new Set(sourceMessages.map(message => message.id)).size !== sourceMessages.length) {
|
||||
fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
|
||||
}
|
||||
}
|
||||
if (!recipientKind || !sourceAgentKind || !targetKinds.includes(recipientKind)
|
||||
|| !targetKinds.includes(sourceAgentKind) || !V4_PHASES.has(sourcePhase)
|
||||
|| !Number.isSafeInteger(input.watermark) || input.watermark < 0
|
||||
@@ -861,6 +916,7 @@ function normalizeDeliveryState(input, targetKinds, index) {
|
||||
packageHash,
|
||||
deliveryId,
|
||||
status,
|
||||
...(sourceMessages ? { sourceMessages } : {}),
|
||||
updatedAt: safeTimestamp(input.updatedAt, index),
|
||||
}
|
||||
}
|
||||
@@ -1545,6 +1601,8 @@ function validateV4Relations(orchestration, targetKinds) {
|
||||
&& watermark.watermark >= workReceipt.collaborationReceipt.deliveryWatermark
|
||||
))
|
||||
if (!assignment || assignment.ownerKind !== workReceipt.ownerKind
|
||||
|| (assignment.acceptance && (new Set(workReceipt.collaborationReceipt.artifactIds).size < assignment.acceptance.minArtifactRefs
|
||||
|| new Set(workReceipt.collaborationReceipt.evidenceIds).size < assignment.acceptance.minEvidenceRefs))
|
||||
|| !slot || slot.agentKind !== workReceipt.ownerKind
|
||||
|| !operationBound
|
||||
|| workReceipt.snapshotHash !== orchestration.snapshotHash
|
||||
@@ -1564,6 +1622,32 @@ function validateV4Relations(orchestration, targetKinds) {
|
||||
taskIds.add(workReceipt.taskId)
|
||||
operationIds.add(workReceipt.operationId)
|
||||
}
|
||||
if (orchestration.coordinationPlan.assignments.some(assignment => (
|
||||
taskIds.has(assignment.taskId) && assignment.dependsOn.some(id => !taskIds.has(id))
|
||||
))) fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
|
||||
for (const receipt of orchestration.workReceipts) {
|
||||
if (!Object.hasOwn(receipt, 'dependencyWorkReceiptIds')) continue
|
||||
const assignment = orchestration.coordinationPlan.assignments.find(item => item.taskId === receipt.taskId)
|
||||
const dependencies = assignment.dependsOn.map(id => (
|
||||
orchestration.workReceipts.find(item => item.taskId === id)?.workReceiptId
|
||||
)).sort()
|
||||
if (canonicalJson(receipt.dependencyWorkReceiptIds) !== canonicalJson(dependencies)) {
|
||||
fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const slot of orchestration.slots) {
|
||||
const binding = slot.workDependencyBinding
|
||||
if (!binding) continue
|
||||
const assignment = orchestration.coordinationPlan?.assignments.find(item => item.taskId === binding.taskId)
|
||||
if (!assignment || assignment.ownerKind !== slot.agentKind) fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
|
||||
const dependencies = assignment.dependsOn.map(id => (
|
||||
orchestration.workReceipts?.find(item => item.taskId === id)?.workReceiptId
|
||||
)).sort()
|
||||
if (dependencies.some(id => !id)
|
||||
|| canonicalJson(binding.dependencyWorkReceiptIds) !== canonicalJson(dependencies)) {
|
||||
fail('ORCHESTRATION_V4_WORK_RECEIPT_INVALID')
|
||||
}
|
||||
}
|
||||
const assignmentsBySlot = new Map(orchestration.plan.assignments.map(item => [item.slotId, item]))
|
||||
for (const slot of orchestration.slots) {
|
||||
@@ -1585,11 +1669,12 @@ function validateV4Relations(orchestration, targetKinds) {
|
||||
assignment.readOnly === false
|
||||
))
|
||||
const validWriterBinding = writerKind
|
||||
? writableSlots.length === 1
|
||||
&& writableAssignments.length === 1
|
||||
&& writableSlots[0].agentKind === writerKind
|
||||
&& writableAssignments[0].agentKind === writerKind
|
||||
&& writableAssignments[0].slotId === writableSlots[0].slotId
|
||||
? writableSlots.some(slot => slot.agentKind === writerKind)
|
||||
&& writableSlots.length === writableAssignments.length
|
||||
&& writableSlots.every(slot => writableAssignments.some(assignment => (
|
||||
assignment.slotId === slot.slotId && assignment.agentKind === slot.agentKind
|
||||
&& (writableSlots.length === 1 || assignment.role === 'writer')
|
||||
)))
|
||||
: writableSlots.length === 0 && writableAssignments.length === 0
|
||||
if (!validWriterBinding) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
|
||||
}
|
||||
@@ -1601,10 +1686,21 @@ function validateV4Relations(orchestration, targetKinds) {
|
||||
|| orchestration.synthesisBinding?.writerKind
|
||||
|| orchestration.coordinationPlan?.finalizerKind
|
||||
|| orchestration.commitState.writerKind
|
||||
if (writableSlots.length > 1 || writableSlots.some(slot => (
|
||||
|| orchestration.discussionWriterKind
|
||||
const nativeWrites = Boolean(orchestration.discussionWriterKind
|
||||
&& !orchestration.coordinationPlan && !orchestration.synthesisBinding
|
||||
&& ['proposal', 'discussion'].includes(orchestration.phase))
|
||||
if (!nativeWrites && (writableSlots.length > 1 || writableSlots.some(slot => (
|
||||
slot.agentKind !== effectiveWriterKind
|
||||
|| !['synthesis', 'human-gate'].includes(orchestration.phase)
|
||||
))) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
|
||||
|| !['discussion', 'synthesis', 'human-gate'].includes(orchestration.phase)
|
||||
|| (orchestration.phase === 'discussion' && !orchestration.discussionWriterKind)
|
||||
)))) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
|
||||
for (const assignment of ['proposal', 'discussion'].includes(orchestration.phase) ? orchestration.plan.assignments : []) {
|
||||
const slot = orchestration.slots.find(item => item.slotId === assignment.slotId)
|
||||
if (slot && assignment.readOnly !== (slot.permission !== 'workspace-write')) {
|
||||
fail('ORCHESTRATION_V4_PERMISSION_INVALID')
|
||||
}
|
||||
}
|
||||
}
|
||||
const watermarkKeys = orchestration.deliveryWatermarks.map(item => `${item.agentKind}\u0000${item.phase}`)
|
||||
if (new Set(watermarkKeys).size !== watermarkKeys.length) {
|
||||
@@ -1784,6 +1880,14 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
}
|
||||
const { workflow, template } = normalizeWorkflow(input.workflow, input.template)
|
||||
const phase = phaseForTemplate(template, String(input.phase || ''))
|
||||
const discussionStyle = hasOwn(input, 'discussionStyle')
|
||||
? String(input.discussionStyle || '')
|
||||
: ''
|
||||
if (discussionStyle && (
|
||||
workflow !== 'auto'
|
||||
|| template !== 'discussion'
|
||||
|| !['sequential', 'agent-led'].includes(discussionStyle)
|
||||
)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
|
||||
const batchId = cleanId(input.batchId)
|
||||
const snapshotHash = String(input.snapshotHash || '')
|
||||
if (!batchId || !SHA256.test(snapshotHash)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
|
||||
@@ -1831,6 +1935,15 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
const planKinds = plan.assignments.map(assignment => assignment.agentKind)
|
||||
const effectiveKinds = targetKinds || [...new Set([...inferredKinds, ...planKinds])]
|
||||
if (currentKinds.some(kind => !effectiveKinds.includes(kind))) fail('ORCHESTRATION_V4_REFERENCE_INVALID')
|
||||
const activeKinds = hasOwn(input, 'activeKinds')
|
||||
? uniqueIds(input.activeKinds, MAX_V4_SLOTS)
|
||||
: effectiveKinds
|
||||
if (activeKinds.some(kind => !effectiveKinds.includes(kind))) {
|
||||
fail('ORCHESTRATION_V4_REFERENCE_INVALID')
|
||||
}
|
||||
const participantKinds = workflow === 'auto' && template === 'discussion'
|
||||
? activeKinds
|
||||
: effectiveKinds
|
||||
if (!Array.isArray(input.slots) || input.slots.length > MAX_V4_SLOTS) {
|
||||
fail('ORCHESTRATION_V4_SLOT_INVALID')
|
||||
}
|
||||
@@ -1865,26 +1978,35 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
|
||||
}
|
||||
const challengeBindings = hasOwn(input, 'challengeBindings')
|
||||
? normalizeChallengeBindings(input.challengeBindings, effectiveKinds, input.round)
|
||||
? normalizeChallengeBindings(input.challengeBindings, participantKinds, input.round)
|
||||
: null
|
||||
if (challengeBindings && (workflow !== 'auto' || template !== 'discussion')) {
|
||||
fail('ORCHESTRATION_V4_CHALLENGE_BINDING_INVALID')
|
||||
}
|
||||
const synthesisBinding = hasOwn(input, 'synthesisBinding')
|
||||
? normalizeSynthesisBinding(input.synthesisBinding, effectiveKinds, snapshot?.bodyHash)
|
||||
? normalizeSynthesisBinding(input.synthesisBinding, participantKinds, snapshot?.bodyHash)
|
||||
: null
|
||||
if (synthesisBinding && (workflow !== 'auto' || template !== 'discussion')) {
|
||||
fail('ORCHESTRATION_V4_SYNTHESIS_BINDING_INVALID')
|
||||
}
|
||||
const coordinationPlan = hasOwn(input, 'coordinationPlan')
|
||||
? normalizeCoordinationPlan(input.coordinationPlan, effectiveKinds, snapshotHash)
|
||||
? normalizeCoordinationPlan(input.coordinationPlan, participantKinds, snapshotHash)
|
||||
: null
|
||||
if (coordinationPlan && (workflow !== 'auto' || template !== 'discussion')) {
|
||||
fail('ORCHESTRATION_V4_COORDINATION_PLAN_INVALID')
|
||||
}
|
||||
const discussionWriterKind = hasOwn(input, 'discussionWriterKind') ? cleanId(input.discussionWriterKind) : ''
|
||||
if (hasOwn(input, 'discussionWriterKind') && (!discussionWriterKind
|
||||
|| input.discussionWriterKind !== discussionWriterKind
|
||||
|| !effectiveKinds.includes(discussionWriterKind)
|
||||
|| workflow !== 'auto' || template !== 'discussion'
|
||||
|| !['proposal', 'discussion', 'stopped', 'failed'].includes(phase)
|
||||
|| synthesisBinding || coordinationPlan || commitState.writerKind)) {
|
||||
fail('ORCHESTRATION_V4_PERMISSION_INVALID')
|
||||
}
|
||||
if (coordinationPlan) {
|
||||
validateCoordinationAgreement(
|
||||
coordinationPlan, slots, challengeBindings, effectiveKinds, snapshotHash,
|
||||
coordinationPlan, slots, challengeBindings, participantKinds, snapshotHash,
|
||||
)
|
||||
}
|
||||
const workReceipts = hasOwn(input, 'workReceipts')
|
||||
@@ -1901,7 +2023,7 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
}
|
||||
const synthesisRecovery = hasOwn(input, 'synthesisRecovery')
|
||||
? normalizeSynthesisRecovery(
|
||||
input.synthesisRecovery, effectiveKinds, synthesisBinding, coordinationPlan, input.round,
|
||||
input.synthesisRecovery, participantKinds, synthesisBinding, coordinationPlan, input.round,
|
||||
)
|
||||
: null
|
||||
if (synthesisRecovery && (workflow !== 'auto' || template !== 'discussion')) {
|
||||
@@ -1915,7 +2037,7 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
fail('ORCHESTRATION_V4_CONVERGENCE_INVALID')
|
||||
}
|
||||
const hasCompletedChallengeEvidence = completedChallengeEvidence(
|
||||
slots, effectiveKinds, snapshotHash, challengeBindings,
|
||||
slots, participantKinds, snapshotHash, challengeBindings,
|
||||
)
|
||||
const synthesisObservable = workflow === 'auto' && template === 'discussion'
|
||||
&& (synthesisStateObservable(phase, slots)
|
||||
@@ -1924,10 +2046,12 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
const completedChallengeCheckpoint = phase === 'challenge'
|
||||
&& currentKinds.length === 0
|
||||
&& (!Array.isArray(input.pendingKinds) || input.pendingKinds.length === 0)
|
||||
&& slots.every(slot => slot.status === 'completed')
|
||||
&& participantKinds.every(kind => slots.some(slot => (
|
||||
slot.agentKind === kind && slot.status === 'completed'
|
||||
)))
|
||||
const retainedChallengeBinding = phase === 'challenge'
|
||||
&& completedChallengeEvidence(
|
||||
slots, effectiveKinds, snapshotHash, challengeBindings, deliveryWatermarks, true,
|
||||
slots, participantKinds, snapshotHash, challengeBindings, deliveryWatermarks, true,
|
||||
)
|
||||
&& postChallengeReceiptObservable(slots, deliveryWatermarks, snapshotHash)
|
||||
if (synthesisBinding && (
|
||||
@@ -1995,8 +2119,23 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
}
|
||||
if (hasOwn(input, 'taskGraph')) {
|
||||
try {
|
||||
const { parseTaskGraphCursor } = require('./task-graph-records.cjs')
|
||||
parseTaskGraphCursor(input.taskGraph, effectiveKinds)
|
||||
const { parseTaskGraphCursor, assertTaskGraphDependencies, terminalTaskGraphState } = require('./task-graph-records.cjs')
|
||||
const cursor = assertTaskGraphDependencies(parseTaskGraphCursor(input.taskGraph, effectiveKinds))
|
||||
if (cursor.terminalState !== terminalTaskGraphState(cursor)) throw new Error('TASK_GRAPH_CURSOR_INVALID')
|
||||
const nodes = new Map(cursor.graph.nodes.map(node => [node.nodeId, node]))
|
||||
for (const state of cursor.nodeStates) {
|
||||
const node = nodes.get(state.nodeId)
|
||||
if (node.role === 'human' && ['accepted', 'rejected'].includes(state.status)) {
|
||||
const decision = node.decisionOptions.find(option => option.optionId === state.decisionOptionId)
|
||||
if (!decision || decision.kind !== (state.status === 'accepted' ? 'allow_once' : 'reject_once')
|
||||
|| !state.entryIds.length) throw new Error('TASK_GRAPH_CURSOR_INVALID')
|
||||
} else if (node.role !== 'human' && state.status === 'accepted'
|
||||
&& ((node.acceptance.requireConclusion && !state.conclusionHash)
|
||||
|| state.artifactIds.length < node.acceptance.minArtifactRefs
|
||||
|| state.evidenceIds.length < node.acceptance.minEvidenceRefs)) {
|
||||
throw new Error('TASK_GRAPH_CURSOR_INVALID')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
fail('ORCHESTRATION_V4_TASK_GRAPH_INVALID')
|
||||
}
|
||||
@@ -2006,8 +2145,11 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
workflow,
|
||||
template,
|
||||
phase,
|
||||
...(discussionStyle ? { discussionStyle } : {}),
|
||||
...(discussionWriterKind ? { discussionWriterKind } : {}),
|
||||
batchId,
|
||||
currentKinds,
|
||||
activeKinds,
|
||||
snapshotHash,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
plan,
|
||||
@@ -2024,7 +2166,7 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
...(workReceipts ? { workReceipts } : {}),
|
||||
}
|
||||
for (const key of [
|
||||
'round', 'currentKind', 'pendingKinds', 'activeKinds', 'successfulKinds',
|
||||
'round', 'currentKind', 'pendingKinds', 'successfulKinds',
|
||||
'agreementKinds', 'attachmentRecipients', 'totalSuccesses', 'terminalFailureOccurred',
|
||||
'collaboration', 'taskGraph',
|
||||
]) {
|
||||
@@ -2149,7 +2291,7 @@ function parseCollaborationControlBlock(input, options = {}) {
|
||||
'claims', 'findings', 'refs', 'writer', 'writerKind', 'batchId', 'entryIds',
|
||||
'capabilities', 'intendedWork', 'deliverables', 'dependencies',
|
||||
'proposedAssignments', 'finalizerKind', 'verifierKinds', 'supportedPlanHash',
|
||||
'agreeToPlan', 'workItemId',
|
||||
'agreeToPlan', 'workItemId', 'taskDecision',
|
||||
]) || input.version !== 1) {
|
||||
fail('COLLABORATION_CONTROL_BLOCK_INVALID')
|
||||
}
|
||||
@@ -2159,6 +2301,8 @@ function parseCollaborationControlBlock(input, options = {}) {
|
||||
fail('COLLABORATION_CONTROL_BLOCK_INVALID')
|
||||
}
|
||||
const summary = safeText(input.summary, MAX_V4_SUMMARY_CHARS, true)
|
||||
const taskDecision = input.taskDecision == null ? null : parseTaskDecision(input.taskDecision)
|
||||
if (taskDecision && phase !== 'discussion') fail('COLLABORATION_CONTROL_BLOCK_INVALID')
|
||||
const boundedList = (value, limit = MAX_V4_REFS, textLimit = MAX_V4_TEXT_CHARS) => {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value) || value.length > limit) fail('COLLABORATION_CONTROL_BLOCK_INVALID')
|
||||
@@ -2183,7 +2327,7 @@ function parseCollaborationControlBlock(input, options = {}) {
|
||||
const proposedAssignments = (input.proposedAssignments || []).map(item => {
|
||||
if (!isRecord(item) || !exactFields(item, [
|
||||
'taskId', 'ownerKind', 'role', 'objective', 'expectedOutput',
|
||||
'inputRefs', 'artifactIds', 'dependsOn',
|
||||
'inputRefs', 'artifactIds', 'dependsOn', 'acceptance',
|
||||
])) fail('COLLABORATION_CONTROL_BLOCK_INVALID')
|
||||
const taskId = safeText(item.taskId, 120, true)
|
||||
const ownerKind = safeText(item.ownerKind, 120, true)
|
||||
@@ -2200,10 +2344,11 @@ function parseCollaborationControlBlock(input, options = {}) {
|
||||
inputRefs: boundedList(item.inputRefs || [], MAX_V4_REFS, 120),
|
||||
artifactIds: boundedList(item.artifactIds || [], MAX_V4_REFS, 120),
|
||||
dependsOn: boundedList(item.dependsOn || [], MAX_V4_ASSIGNMENTS, 120),
|
||||
...(hasOwn(item, 'acceptance') ? { acceptance: normalizeWorkAcceptance(item.acceptance, 'COLLABORATION_CONTROL_BLOCK_INVALID') } : {}),
|
||||
}
|
||||
})
|
||||
const finalizerKind = input.finalizerKind == null ? '' : safeText(input.finalizerKind, 120, false)
|
||||
const verifierKinds = boundedList(input.verifierKinds, Math.min(2, MAX_V4_SLOTS), 120)
|
||||
const verifierKinds = boundedList(input.verifierKinds, MAX_V4_SLOTS, 120)
|
||||
const supportedPlanHash = input.supportedPlanHash == null
|
||||
? '' : String(input.supportedPlanHash)
|
||||
const workItemId = input.workItemId == null ? '' : safeText(input.workItemId, 120, false)
|
||||
@@ -2289,6 +2434,7 @@ function parseCollaborationControlBlock(input, options = {}) {
|
||||
status,
|
||||
summary,
|
||||
conclusion,
|
||||
...(taskDecision ? { taskDecision } : {}),
|
||||
artifactIds,
|
||||
evidenceIds,
|
||||
findingIds,
|
||||
@@ -2327,6 +2473,7 @@ function collaborationChars(block) {
|
||||
...(Array.isArray(block.intendedWork) ? block.intendedWork : []),
|
||||
...(Array.isArray(block.deliverables) ? block.deliverables : []),
|
||||
...(Array.isArray(block.dependencies) ? block.dependencies : []),
|
||||
...(block.taskDecision ? [block.taskDecision.reason, ...block.taskDecision.deliverables] : []),
|
||||
...(Array.isArray(block.proposedAssignments)
|
||||
? block.proposedAssignments.flatMap(item => [item.objective, item.expectedOutput])
|
||||
: []),
|
||||
@@ -2535,6 +2682,7 @@ function createCollaborationReceipt(input, options = {}) {
|
||||
...(input.supportedPlanHash ? { supportedPlanHash: input.supportedPlanHash } : {}),
|
||||
...(typeof input.agreeToPlan === 'boolean' ? { agreeToPlan: input.agreeToPlan } : {}),
|
||||
...(input.workItemId ? { workItemId: input.workItemId } : {}),
|
||||
...(input.taskDecision ? { taskDecision: input.taskDecision } : {}),
|
||||
deliveryWatermark: input.deliveryWatermark || 0,
|
||||
snapshotHash: input.snapshotHash,
|
||||
...(typeof input.writer === 'boolean' ? { writer: input.writer } : {}),
|
||||
|
||||
@@ -3,15 +3,17 @@ const crypto = require('node:crypto')
|
||||
const wireBytesByPayload = new WeakMap()
|
||||
|
||||
function canonicalJson(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(canonicalJson).join(',')}]`
|
||||
const normalized = JSON.parse(JSON.stringify(value))
|
||||
function serialize(item) {
|
||||
if (Array.isArray(item)) return `[${item.map(serialize).join(',')}]`
|
||||
if (item && typeof item === 'object') {
|
||||
return `{${Object.keys(item).sort().map(key => (
|
||||
`${JSON.stringify(key)}:${serialize(item[key])}`
|
||||
)).join(',')}}`
|
||||
}
|
||||
return JSON.stringify(item)
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.keys(value).sort().map(key => (
|
||||
`${JSON.stringify(key)}:${canonicalJson(value[key])}`
|
||||
)).join(',')}}`
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
return serialize(normalized)
|
||||
}
|
||||
|
||||
function payloadWithBytes(fields, value) {
|
||||
@@ -68,6 +70,19 @@ function createCustomOutboundPayload({ prompt, command, args, cwd, stdin, prompt
|
||||
}, cliWirePayload(command, args, cwd, stdin))
|
||||
}
|
||||
|
||||
function createHttpOutboundPayload({ prompt, destination, wireBytes, tunneled = false }) {
|
||||
return payloadWithBytes({
|
||||
prompt, destination, transport: 'http',
|
||||
...(tunneled ? { tunneled: true } : {}),
|
||||
serialization: 'http-json-body-v1', promptMode: 'json',
|
||||
}, wireBytes)
|
||||
}
|
||||
|
||||
function createSshOutboundPayload({ prompt }) {
|
||||
return payloadWithBytes({ prompt, transport: 'ssh',
|
||||
serialization: 'ssh-stdin-v1', promptMode: 'stdin' }, Buffer.from(prompt, 'utf8'))
|
||||
}
|
||||
|
||||
function outboundWirePayloadBytes(payload) {
|
||||
const bytes = payload && typeof payload === 'object' ? wireBytesByPayload.get(payload) : null
|
||||
return bytes ? Buffer.from(bytes) : null
|
||||
@@ -76,6 +91,8 @@ function outboundWirePayloadBytes(payload) {
|
||||
module.exports = {
|
||||
createAcpOutboundPayload,
|
||||
createCustomOutboundPayload,
|
||||
createHttpOutboundPayload,
|
||||
createSshOutboundPayload,
|
||||
createLegacyOutboundPayload,
|
||||
outboundWirePayloadBytes,
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
const { redactSecrets } = require('../security/secret-redaction.cjs')
|
||||
|
||||
function parseTaskDecision(input) {
|
||||
const invalid = () => { throw new Error('LOCAL_RUN_TASK_DECISION_INVALID') }
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)
|
||||
|| Object.keys(input).some(key => !['status', 'reason', 'deliverables', 'handoffTo', 'nextKinds', 'reviewedVersion'].includes(key))
|
||||
|| !['completed', 'continue', 'blocked', 'needs-human'].includes(input.status)) invalid()
|
||||
const text = (value, limit) => {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > limit
|
||||
|| /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) invalid()
|
||||
return redactSecrets(value.trim())
|
||||
}
|
||||
const reason = text(input.reason, 1600)
|
||||
if (!Array.isArray(input.deliverables) || input.deliverables.length > 16) invalid()
|
||||
const deliverables = input.deliverables.map(value => text(value, 800))
|
||||
if (input.status === 'completed' && !deliverables.length) invalid()
|
||||
if (input.handoffTo != null && (input.status !== 'continue'
|
||||
|| typeof input.handoffTo !== 'string'
|
||||
|| !/^[a-z0-9][a-z0-9-]{0,79}$/.test(input.handoffTo))) invalid()
|
||||
if (input.nextKinds != null && (!Array.isArray(input.nextKinds)
|
||||
|| input.nextKinds.length > 32
|
||||
|| input.nextKinds.some(kind => typeof kind !== 'string'
|
||||
|| !/^[a-z0-9][a-z0-9-]{0,79}$/i.test(kind))
|
||||
|| (input.nextKinds.length > 0 && input.status !== 'continue'))) invalid()
|
||||
const nextKinds = input.nextKinds?.map(kind => kind.toLowerCase())
|
||||
if (nextKinds && new Set(nextKinds).size !== nextKinds.length) invalid()
|
||||
if (input.reviewedVersion != null && (typeof input.reviewedVersion !== 'string'
|
||||
|| !/^[a-f0-9]{64}$/.test(input.reviewedVersion))) invalid()
|
||||
return {
|
||||
status: input.status, reason, deliverables,
|
||||
...(input.handoffTo != null ? { handoffTo: input.handoffTo } : {}),
|
||||
...(nextKinds ? { nextKinds } : {}),
|
||||
...(input.reviewedVersion != null ? { reviewedVersion: input.reviewedVersion } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_SYNONYMS = new Map([
|
||||
['completed', 'completed'], ['complete', 'completed'], ['done', 'completed'],
|
||||
['finished', 'completed'], ['success', 'completed'], ['succeeded', 'completed'],
|
||||
['continue', 'continue'], ['continuing', 'continue'], ['in-progress', 'continue'],
|
||||
['in_progress', 'continue'], ['ongoing', 'continue'], ['pending', 'continue'],
|
||||
['blocked', 'blocked'], ['blocker', 'blocked'],
|
||||
['needs-human', 'needs-human'], ['needs_human', 'needs-human'],
|
||||
['needs human', 'needs-human'], ['needshuman', 'needs-human'], ['human', 'needs-human'],
|
||||
])
|
||||
|
||||
function agentKindToken(value) {
|
||||
return typeof value === 'string'
|
||||
? value.trim().replace(/^@+/, '').toLowerCase()
|
||||
: ''
|
||||
}
|
||||
|
||||
// Agents write task decisions by hand at the end of free text. Small slips
|
||||
// (capitalised status, "done", an @handle, a single string instead of a list,
|
||||
// a completed answer with a named deliverable) used to fail the whole turn
|
||||
// and silently end the discussion. Normalise them first; the strict parser
|
||||
// still validates the result.
|
||||
function normalizeTaskDecision(input, options = {}) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return input
|
||||
let status = STATUS_SYNONYMS.get(String(input.status || '').trim().toLowerCase())
|
||||
|| String(input.status || '')
|
||||
const textList = value => (Array.isArray(value) ? value : (value == null ? [] : [value]))
|
||||
.filter(item => typeof item === 'string' && item.trim())
|
||||
.map(item => item.trim().slice(0, 800))
|
||||
.slice(0, 16)
|
||||
const hasBlocker = value => Array.isArray(value) ? value.length > 0
|
||||
: typeof value === 'string' ? Boolean(value.trim())
|
||||
: value && typeof value === 'object' ? Object.keys(value).length > 0 : value === true
|
||||
const explicitBlocker = input.blocked === true || hasBlocker(input.blockers) || hasBlocker(input.blocker)
|
||||
if (status === 'completed' && explicitBlocker) status = 'blocked'
|
||||
const blockerReason = Array.isArray(input.blockers) ? input.blockers.find(item => (
|
||||
typeof item === 'string' && item.trim()
|
||||
)) : (input.blockers || input.blocker)
|
||||
const reason = explicitBlocker && typeof blockerReason === 'string' && blockerReason.trim()
|
||||
? blockerReason.trim().slice(0, 1600)
|
||||
: typeof input.reason === 'string' && input.reason.trim()
|
||||
? input.reason.trim().slice(0, 1600)
|
||||
: String(options.fallbackReason || '').trim().slice(0, 1600) || 'No reason given.'
|
||||
const deliverables = textList(input.deliverables)
|
||||
const nextKinds = [...new Set(textList(input.nextKinds).map(agentKindToken).filter(Boolean))]
|
||||
const handoffTo = agentKindToken(input.handoffTo)
|
||||
return {
|
||||
status,
|
||||
reason,
|
||||
deliverables,
|
||||
...(status === 'continue' && handoffTo ? { handoffTo } : {}),
|
||||
// Keep an explicit list (even an empty one) so the stored decision says
|
||||
// what the Agent wrote; peers can only be requested while continuing.
|
||||
...(input.nextKinds != null
|
||||
? { nextKinds: status === 'continue' ? nextKinds.slice(0, 32) : [] }
|
||||
: {}),
|
||||
...(input.reviewedVersion != null ? { reviewedVersion: input.reviewedVersion } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function parseLenientTaskDecision(input, options = {}) {
|
||||
return parseTaskDecision(normalizeTaskDecision(input, options))
|
||||
}
|
||||
|
||||
module.exports = { normalizeTaskDecision, parseLenientTaskDecision, parseTaskDecision }
|
||||
@@ -3,6 +3,7 @@ const { z } = require('zod')
|
||||
|
||||
const { canonicalJson } = require('./context-pack-records.cjs')
|
||||
const { redactSecrets } = require('../security/secret-redaction.cjs')
|
||||
const { normalizeSummary } = require('../gates/human-gate-records.cjs')
|
||||
|
||||
const TASK_GRAPH_VERSION = 1
|
||||
const TASK_GRAPH_CURSOR_VERSION = 1
|
||||
@@ -55,12 +56,16 @@ const nodeContentFields = {
|
||||
dependsOn: uniqueArray(publicIdSchema),
|
||||
inputNodeIds: uniqueArray(publicIdSchema),
|
||||
expectedOutput: boundedTextSchema,
|
||||
claimKey: publicIdSchema.optional(),
|
||||
acceptance: acceptanceSchema,
|
||||
terminal: z.boolean(),
|
||||
parallel: z.boolean(),
|
||||
decisionOptions: uniqueArray(decisionOptionSchema, 16),
|
||||
}
|
||||
const nodeSchema = z.strictObject(nodeContentFields).superRefine((node, context) => {
|
||||
if (node.claimKey !== undefined && (node.role !== 'primary' || redactSecrets(node.claimKey) !== node.claimKey)) {
|
||||
context.addIssue({ code: 'custom', path: ['claimKey'], message: 'invalid claim key' })
|
||||
}
|
||||
if (node.inputNodeIds.some(nodeId => !node.dependsOn.includes(nodeId))) {
|
||||
context.addIssue({ code: 'custom', path: ['inputNodeIds'], message: 'input must be a dependency' })
|
||||
}
|
||||
@@ -119,9 +124,11 @@ function validateGraphRelations(graph, targetKinds = null) {
|
||||
fail('TASK_GRAPH_SCHEMA_INVALID')
|
||||
}
|
||||
const allowedKinds = targetKinds ? new Set(targetKinds) : null
|
||||
const terminalIds = new Set(graph.nodes.filter(node => node.terminal).map(node => node.nodeId))
|
||||
for (const node of graph.nodes) {
|
||||
if (node.dependsOn.some(nodeId => !nodeIds.has(nodeId))
|
||||
|| node.inputNodeIds.some(nodeId => !nodeIds.has(nodeId))
|
||||
|| new Set(node.decisionOptions.map(option => option.optionId)).size !== node.decisionOptions.length
|
||||
|| (allowedKinds && node.agentKind && !allowedKinds.has(node.agentKind))) {
|
||||
fail('TASK_GRAPH_SCHEMA_INVALID')
|
||||
}
|
||||
@@ -138,11 +145,33 @@ function validateGraphRelations(graph, targetKinds = null) {
|
||||
visited.add(nodeId)
|
||||
}
|
||||
for (const node of graph.nodes) visit(node.nodeId)
|
||||
if (graph.nodes.some(node => !node.terminal && node.dependsOn.some(nodeId => terminalIds.has(nodeId)))) {
|
||||
fail('TASK_GRAPH_SCHEMA_INVALID')
|
||||
}
|
||||
}
|
||||
|
||||
function createTaskGraph(input, targetKinds = null) {
|
||||
const parsed = graphInputSchema.safeParse(input)
|
||||
if (!parsed.success) fail('TASK_GRAPH_SCHEMA_INVALID')
|
||||
if (parsed.data.nodes.some(node => node.role !== 'human'
|
||||
&& !node.acceptance.requireConclusion && !node.acceptance.minArtifactRefs
|
||||
&& !node.acceptance.minEvidenceRefs)) {
|
||||
fail('TASK_GRAPH_SCHEMA_INVALID')
|
||||
}
|
||||
if (parsed.data.nodes.some(node => redactSecrets(node.nodeId) !== node.nodeId
|
||||
|| node.decisionOptions.some(option => redactSecrets(option.optionId) !== option.optionId
|
||||
|| redactSecrets(option.name) !== option.name || /[\u0000-\u001f\u007f]/u.test(option.name)))) {
|
||||
fail('TASK_GRAPH_SCHEMA_INVALID')
|
||||
}
|
||||
if (parsed.data.nodes.some(node => node.role === 'human'
|
||||
&& (!node.decisionOptions.some(option => option.kind === 'allow_once')
|
||||
|| !node.decisionOptions.some(option => option.kind === 'reject_once')))) {
|
||||
fail('TASK_GRAPH_SCHEMA_INVALID')
|
||||
}
|
||||
for (const node of parsed.data.nodes) {
|
||||
if (node.role !== 'human') continue
|
||||
try { normalizeSummary(node.expectedOutput) } catch { fail('TASK_GRAPH_SCHEMA_INVALID') }
|
||||
}
|
||||
const body = {
|
||||
version: TASK_GRAPH_VERSION,
|
||||
recordType: 'task-graph',
|
||||
@@ -185,7 +214,7 @@ function createTaskGraphCursor(graphInput, now = Date.now()) {
|
||||
})
|
||||
}
|
||||
|
||||
function terminalStateFor(graph, nodeStates) {
|
||||
function legacyTerminalStateFor(graph, nodeStates) {
|
||||
const states = new Map(nodeStates.map(state => [state.nodeId, state]))
|
||||
const terminal = graph.nodes.filter(node => node.terminal)
|
||||
if (terminal.every(node => states.get(node.nodeId)?.status === 'accepted')) return 'accepted'
|
||||
@@ -194,6 +223,13 @@ function terminalStateFor(graph, nodeStates) {
|
||||
return 'running'
|
||||
}
|
||||
|
||||
function terminalStateFor(graph, nodeStates) {
|
||||
if (nodeStates.some(state => state.status === 'rejected')) return 'rejected'
|
||||
if (nodeStates.some(state => state.status === 'failed')) return 'failed'
|
||||
if (nodeStates.every(state => state.status === 'accepted')) return 'accepted'
|
||||
return 'running'
|
||||
}
|
||||
|
||||
function parseTaskGraphCursor(input, targetKinds = null) {
|
||||
const parsed = cursorSchema.safeParse(input)
|
||||
if (!parsed.success) fail('TASK_GRAPH_CURSOR_INVALID')
|
||||
@@ -211,12 +247,23 @@ function parseTaskGraphCursor(input, targetKinds = null) {
|
||||
? !['running', 'waiting'].includes(state.status)
|
||||
: ['running', 'waiting'].includes(state.status)
|
||||
))) fail('TASK_GRAPH_CURSOR_INVALID')
|
||||
if (cursor.terminalState !== terminalStateFor(graph, cursor.nodeStates)) {
|
||||
if (cursor.terminalState !== terminalStateFor(graph, cursor.nodeStates)
|
||||
&& cursor.terminalState !== legacyTerminalStateFor(graph, cursor.nodeStates)) {
|
||||
fail('TASK_GRAPH_CURSOR_INVALID')
|
||||
}
|
||||
return { ...cursor, graph }
|
||||
}
|
||||
|
||||
function assertTaskGraphDependencies(cursorInput) {
|
||||
const cursor = parseTaskGraphCursor(cursorInput)
|
||||
const states = new Map(cursor.nodeStates.map(state => [state.nodeId, state.status]))
|
||||
if (cursor.graph.nodes.some(node => states.get(node.nodeId) !== 'pending'
|
||||
&& node.dependsOn.some(id => states.get(id) !== 'accepted'))) {
|
||||
fail('TASK_GRAPH_CURSOR_INVALID')
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
function updateTaskGraphCursor(cursorInput, nodeId, updates, now = Date.now()) {
|
||||
const cursor = parseTaskGraphCursor(cursorInput)
|
||||
const index = cursor.nodeStates.findIndex(state => state.nodeId === nodeId)
|
||||
@@ -227,7 +274,7 @@ function updateTaskGraphCursor(cursorInput, nodeId, updates, now = Date.now()) {
|
||||
const currentNodeIds = nodeStates
|
||||
.filter(state => ['running', 'waiting'].includes(state.status))
|
||||
.map(state => state.nodeId)
|
||||
return parseTaskGraphCursor({
|
||||
return assertTaskGraphDependencies({
|
||||
...cursor,
|
||||
nodeStates,
|
||||
currentNodeIds,
|
||||
@@ -262,6 +309,7 @@ module.exports = {
|
||||
TASK_GRAPH_VERSION,
|
||||
createTaskGraph,
|
||||
createTaskGraphCursor,
|
||||
assertTaskGraphDependencies,
|
||||
parseTaskGraph,
|
||||
parseTaskGraphCursor,
|
||||
readyTaskGraphNodes,
|
||||
|
||||
@@ -194,11 +194,14 @@ class HumanGateCoordinator {
|
||||
const option = current.options.find(candidate => candidate.optionId === input.optionId)
|
||||
if (!option) throw gateError('HUMAN_GATE_DECISION_INVALID')
|
||||
const status = statusForHumanGateOption(option)
|
||||
if (status === 'approved' && this.store.publicRecord(current).decisionContextUnavailable) {
|
||||
throw gateError('HUMAN_GATE_CONTEXT_UNAVAILABLE')
|
||||
}
|
||||
if (Object.hasOwn(input, 'status') && input.status !== status) {
|
||||
throw gateError('HUMAN_GATE_DECISION_INVALID')
|
||||
}
|
||||
const response = typeof input.response === 'string' ? input.response.trim() : ''
|
||||
if ((current.type === 'input' && status === 'approved' && !response)
|
||||
const response = typeof input.response === 'string' ? input.response : ''
|
||||
if ((current.type === 'input' && status === 'approved' && !response.trim())
|
||||
|| response.length > 32 * 1024
|
||||
|| (current.type !== 'input' && Object.hasOwn(input, 'response'))
|
||||
|| (status !== 'approved' && Object.hasOwn(input, 'response'))) {
|
||||
@@ -208,9 +211,11 @@ class HumanGateCoordinator {
|
||||
status,
|
||||
optionId: option.optionId,
|
||||
actorId: input.actorId || 'local-user',
|
||||
decidedAt: input.decidedAt || this.now(),
|
||||
decidedAt: input.decidedAt || current.decision?.decidedAt || this.now(),
|
||||
...(response ? { response } : {}),
|
||||
})
|
||||
// Recovery owns recorded decisions; repeated submissions must not dispatch them again.
|
||||
if (current.status !== 'pending') return publicHumanGate(record)
|
||||
this.clearExpiry(record.gateId)
|
||||
try {
|
||||
this.onResumed(record, record.decision)
|
||||
|
||||
@@ -117,6 +117,11 @@ function createHumanGateRecord(input) {
|
||||
...input,
|
||||
summary: normalizeSummary(input?.summary),
|
||||
})
|
||||
if (normalized.options.some(option => redactSecrets(option.optionId) !== option.optionId
|
||||
|| redactSecrets(option.kind) !== option.kind
|
||||
|| redactSecrets(option.name) !== option.name || /[\u0000-\u001f\u007f]/u.test(option.name))) {
|
||||
fail('HUMAN_GATE_SCHEMA_INVALID')
|
||||
}
|
||||
if (normalized.requestHash !== normalized.requestRef.hash) fail('HUMAN_GATE_SCHEMA_INVALID')
|
||||
const body = {
|
||||
version: HUMAN_GATE_VERSION,
|
||||
@@ -196,6 +201,7 @@ module.exports = {
|
||||
SYSTEM_REJECT_OPTION_ID,
|
||||
createHumanGateRecord,
|
||||
decideHumanGateRecord,
|
||||
normalizeSummary,
|
||||
parseHumanGateRecord,
|
||||
publicHumanGate,
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { createHash } = require('node:crypto')
|
||||
|
||||
const { ContentBlobStore } = require('../attachments/content-blob-store.cjs')
|
||||
const {
|
||||
canonicalJson,
|
||||
} = require('../collaboration/outcome-records.cjs')
|
||||
const { publicCollaborationText } = require('../collaboration/collaboration-records.cjs')
|
||||
const {
|
||||
createHumanGateRecord,
|
||||
decideHumanGateRecord,
|
||||
@@ -117,17 +119,22 @@ class HumanGateStore {
|
||||
|
||||
create(input = {}) {
|
||||
const { request, ...recordInput } = input
|
||||
const requestRef = this.contentBlobStore.put(canonicalPayload(request), {
|
||||
const requestBytes = canonicalPayload(request)
|
||||
const requestRef = {
|
||||
algorithm: 'sha256',
|
||||
hash: createHash('sha256').update(requestBytes).digest('hex'),
|
||||
size: requestBytes.length,
|
||||
mediaType: 'application/json',
|
||||
})
|
||||
}
|
||||
const record = createHumanGateRecord({
|
||||
...recordInput,
|
||||
requestRef,
|
||||
requestHash: requestRef.hash,
|
||||
})
|
||||
const existing = this.records.find(candidate => candidate.gateId === record.gateId)
|
||||
if (!existing && this.records.length >= MAX_GATES) throw storeError('HUMAN_GATE_LIMIT')
|
||||
this.contentBlobStore.put(requestBytes, { mediaType: 'application/json' })
|
||||
if (existing) return existing
|
||||
if (this.records.length >= MAX_GATES) throw storeError('HUMAN_GATE_LIMIT')
|
||||
const next = [...this.records, record]
|
||||
this.save(next)
|
||||
this.records = next
|
||||
@@ -184,11 +191,36 @@ class HumanGateStore {
|
||||
return pending
|
||||
}
|
||||
|
||||
publicRecord(record) {
|
||||
const gate = publicHumanGate(record)
|
||||
if (record.type !== 'decision' || record.status !== 'pending' || !record.agentRunId.startsWith('graph-decision-')) return gate
|
||||
const unavailable = () => ({ ...gate, decisionContextUnavailable: true })
|
||||
try {
|
||||
const request = this.validateRequest(record)
|
||||
if (!/^task-graph-[a-f0-9]{64}$/.test(request.graphId || '')
|
||||
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(request.nodeId || '')
|
||||
|| record.agentRunId !== `graph-decision-${createHash('sha256').update(JSON.stringify([request.graphId, request.nodeId])).digest('hex')}`) return unavailable()
|
||||
if (request.decisionContext === undefined) return gate
|
||||
if (!Array.isArray(request.decisionContext) || request.decisionContext.length > 32) return unavailable()
|
||||
const decisionContext = request.decisionContext.map(item => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)
|
||||
|| Object.keys(item).sort().join(',') !== 'agentKind,text,truncated'
|
||||
|| !/^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/.test(item.agentKind || '')
|
||||
|| typeof item.text !== 'string' || !item.text || item.text.length > 2000
|
||||
|| typeof item.truncated !== 'boolean'
|
||||
|| publicCollaborationText(item.text, 2000) !== item.text) return null
|
||||
return { agentKind: item.agentKind, text: item.text, truncated: item.truncated }
|
||||
})
|
||||
if (!decisionContext.every(Boolean)) return unavailable()
|
||||
return decisionContext.length ? { ...gate, decisionContext } : gate
|
||||
} catch { return unavailable() }
|
||||
}
|
||||
|
||||
list({ runId = '', pendingOnly = false } = {}) {
|
||||
return this.records
|
||||
.filter(record => (!runId || record.runId === runId)
|
||||
&& (!pendingOnly || record.status === 'pending'))
|
||||
.map(publicHumanGate)
|
||||
.map(record => this.publicRecord(record))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
} = require('./knowledge-connector-memory.cjs')
|
||||
const { KnowledgeConnectorRegistry } = require('./knowledge-connector-registry.cjs')
|
||||
const { redactSecrets } = require('../security/secret-redaction.cjs')
|
||||
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
|
||||
|
||||
const OBSIDIAN_KIND = 'obsidian'
|
||||
const MEMORY_KIND = 'memory'
|
||||
@@ -239,7 +240,13 @@ class LocalKnowledgeConnectors {
|
||||
if (existing) return existing
|
||||
const authorized = await this.registry.authorize(connectorId, input)
|
||||
this.revokedConnectorIds.delete(connectorId)
|
||||
this.saveRecords()
|
||||
try {
|
||||
this.saveRecords()
|
||||
} catch (error) {
|
||||
this.revokedConnectorIds.add(connectorId)
|
||||
try { await this.registry.revoke(authorized.instanceId) } catch { this.registry = null }
|
||||
throw error
|
||||
}
|
||||
return authorized
|
||||
}
|
||||
|
||||
@@ -529,17 +536,12 @@ class LocalKnowledgeConnectors {
|
||||
}
|
||||
|
||||
saveRecords() {
|
||||
const directory = path.dirname(this.storagePath)
|
||||
const temporaryPath = `${this.storagePath}.tmp`
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify({
|
||||
atomicWritePrivateFile(this.storagePath, `${JSON.stringify({
|
||||
version: 1,
|
||||
revokedConnectorIds: [...this.revokedConnectorIds].sort(),
|
||||
snapshots: [...this.snapshots.values()],
|
||||
citations: [...this.citations.values()].map(result => result.citation),
|
||||
}, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
||||
fs.chmodSync(temporaryPath, 0o600)
|
||||
fs.renameSync(temporaryPath, this.storagePath)
|
||||
}, null, 2)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
const electron = require('electron')
|
||||
const { app, BrowserWindow, dialog, ipcMain, Menu, Notification, protocol, shell } = electron
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { redactSecrets } = require('./security/secret-redaction.cjs')
|
||||
const { ensurePrivateDirectory } = require('./security/private-file.cjs')
|
||||
const { agentVerificationIdentity, projectVerificationIdentity } = require('./workspace/agent-verification-identity.cjs')
|
||||
const { NATIVE_CONFIG_DIRECTORIES, NATIVE_CONFIG_IDENTITY_DIRECTORY, NATIVE_ENVIRONMENT_IDENTITY, NATIVE_AUTH_IDENTITY_FILES, NATIVE_PROJECT_CONFIG_PATHS,
|
||||
nativeConfigEnvironment, nativeIdentityDirectory, nativeEnvironmentIdentity, nativeAuthIdentityFiles, nativeProjectConfigPaths, nativeNetworkIdentityFiles, validateNativeConfigDirectory } = require('./agents/native-config-directory.cjs')
|
||||
const { AttachmentStore } = require('./attachments/attachment-store.cjs')
|
||||
const { ContentBlobStore } = require('./attachments/content-blob-store.cjs')
|
||||
const { ContextPackStore } = require('./collaboration/context-pack-store.cjs')
|
||||
@@ -25,10 +31,16 @@ const {
|
||||
shutdownAcpSessionRuntime,
|
||||
} = require('./agents/cli/cli-adapters.cjs')
|
||||
const { AgentInstaller } = require('./agents/installer/agent-installer.cjs')
|
||||
const { networkEnvironment } = require('./agents/cli/cli-network-environment.cjs')
|
||||
const { ocrDeclaredCredentialKeys } = require('./agents/cli/ocr-provider-environment.cjs')
|
||||
const { mimoDeclaredCredentialKeys } = require('./agents/cli/mimo-native-config.cjs')
|
||||
const { qwenDeclaredCredentialKeys } = require('./agents/cli/qwen-native-config.cjs')
|
||||
const { opencodeDeclaredCredentialKeys } = require('./agents/cli/opencode-native-config.cjs')
|
||||
const { AgentConnectorInstanceStore } = require('./agents/connectors/agent-connector-instance-store.cjs')
|
||||
const { LocalAgentConnectors } = require('./agents/connectors/agent-connector-local.cjs')
|
||||
const { AgentConnectorPackageStore } = require('./agents/connectors/agent-connector-package-store.cjs')
|
||||
const { CloudAgentOperationStore } = require('./agents/cloud/cloud-agent-operation-store.cjs')
|
||||
const { CloudAgentBridgeStore } = require('./agents/cloud/cloud-agent-bridge.cjs')
|
||||
const {
|
||||
CloudAgentRuntime,
|
||||
createCloudAgentStartRetry = ({ runtime }) => Object.freeze({
|
||||
@@ -39,16 +51,24 @@ const {
|
||||
const { ChannelInboxStore } = require('./channels/channel-inbox-store.cjs')
|
||||
const { ChannelIngressRuntime } = require('./channels/channel-ingress-runtime.cjs')
|
||||
const { CustomAgentStore } = require('./agents/custom-agent-store.cjs')
|
||||
const { configDirectoryEnvironment } = require('./agents/cli/cli-config-directories.cjs')
|
||||
const { nativeUserConfigSummary } = require('./agents/native-config-summary.cjs')
|
||||
const { deepseekNativeConfig } = require('./agents/cli/deepseek-native-config.cjs')
|
||||
const { hermesConfiguredProvider } = require('./agents/hermes-config-summary.cjs')
|
||||
const { codexConfiguredProvider } = require('./agents/codex-config-summary.cjs')
|
||||
const {
|
||||
codexDeclaredCredentialKeys,
|
||||
hermesDeclaredCredentialKeys,
|
||||
zcodeDeclaredCredentialKeys,
|
||||
nativeCredentialEnvironment,
|
||||
piDeclaredCredentialKeys,
|
||||
resolveNativeOpenClawRuntime,
|
||||
resolveNativeCredentialState,
|
||||
resolveNativeShellEnvironment,
|
||||
} = require('./agents/local-agent-readiness.cjs')
|
||||
const { LocalWorkspace } = require('./workspace/local-workspace.cjs')
|
||||
const { MediaGenerationRuntime } = require('./media/media-generation-runtime.cjs')
|
||||
const { resolveMediaProvider } = require('./media/media-provider-resolution.cjs')
|
||||
const { registerDesktopIpc } = require('./shell/main-ipc.cjs')
|
||||
const { workBuddyEnvironment, workBuddySettings } = require('./agents/cli/workbuddy-runtime.cjs')
|
||||
const {
|
||||
isAllowedExternalUrl,
|
||||
isAllowedLocalNavigation: isAllowedLocalNavigationForPaths,
|
||||
@@ -69,29 +89,43 @@ const {
|
||||
resolveKnowledgeBaseSources,
|
||||
} = require('./knowledge/local-knowledge-base.cjs')
|
||||
const { LocalKnowledgeConnectors } = require('./knowledge/local-knowledge-connectors.cjs')
|
||||
const { ProviderStore } = require('./providers/provider-store.cjs')
|
||||
const { nativeOpenClawOptions } = require('./agents/cli/openclaw-runtime.cjs')
|
||||
const { migrateLegacyProductData } = require('./product-data-migration.cjs')
|
||||
const {
|
||||
EXTERNAL_PROVIDER_KINDS,
|
||||
providerAgentKind,
|
||||
providerOptionsFor,
|
||||
} = require('./providers/provider-options.cjs')
|
||||
const LOCAL_AGENT_KINDS = new Set([
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'zcode', 'pi', 'kimi', 'mimo', 'deepseek', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'opencodereview',
|
||||
])
|
||||
const QUIT_CLEANUP_TIMEOUT_MS = 2500
|
||||
let mainWindow = null
|
||||
|
||||
// Faults outside a request handler are written to a small private log in
|
||||
// userData (secrets redacted, size-capped) instead of disappearing or
|
||||
// taking the app down, so instability reports have evidence.
|
||||
function recordMainProcessFault(kind, detail) {
|
||||
const text = String(detail?.stack || detail?.message || detail || '').slice(0, 4000)
|
||||
console.error(`[meldwork] ${kind}`, text)
|
||||
try {
|
||||
const file = path.join(app.getPath('userData'), 'meldwork-diagnostics.log')
|
||||
try {
|
||||
if (fs.statSync(file).size > 1024 * 1024) fs.renameSync(file, `${file}.1`)
|
||||
} catch { /* first entry */ }
|
||||
fs.appendFileSync(file, `${new Date().toISOString()} ${kind} ${redactSecrets(text)}\n`, { mode: 0o600 })
|
||||
} catch { /* diagnostics must never throw */ }
|
||||
}
|
||||
process.on('unhandledRejection', reason => recordMainProcessFault('unhandledRejection', reason))
|
||||
process.on('uncaughtException', error => recordMainProcessFault('uncaughtException', error))
|
||||
let workspace = null
|
||||
let workspaceStartupError = ''
|
||||
let workspaceChangedListener = null
|
||||
let workspaceRunFinishedListener = null
|
||||
let workspaceRunEventListener = null
|
||||
let installer = null
|
||||
let nativeSkillDirectories = {}
|
||||
let localAgentRefreshQueue = Promise.resolve()
|
||||
let providerStore = null
|
||||
let customAgentStore = null
|
||||
let agentConnectors = null
|
||||
let cloudAgentRuntime = null
|
||||
let cloudAgentBridges = null
|
||||
let cloudAgentStartRetry = null
|
||||
let channelIngressRuntime = null
|
||||
let knowledgeBaseStore = null
|
||||
@@ -224,62 +258,181 @@ function openExternalUrl(value) {
|
||||
return true
|
||||
}
|
||||
|
||||
function providerOptions(kind, context = {}) {
|
||||
if (!EXTERNAL_PROVIDER_KINDS.has(kind)) return {}
|
||||
const status = providerStore?.status(kind) || {}
|
||||
if (!status.configured && !(kind === 'openclaw' && context.nativeRuntime)) return {}
|
||||
return providerOptionsFor(
|
||||
kind,
|
||||
status.configured ? providerStore.envForAgent(kind) : {},
|
||||
context,
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
async function nativeShellEnvironment() {
|
||||
async function nativeShellEnvironment(options = {}) {
|
||||
const home = app.getPath('home')
|
||||
const resolved = await resolveNativeShellEnvironment({ home })
|
||||
let resolved = await resolveNativeShellEnvironment({ home, refresh: options.refresh === true })
|
||||
const initialEnv = { ...process.env, ...resolved.env }
|
||||
for (const key of resolved.unsetKeys || []) delete initialEnv[key]
|
||||
const codexEnv = nativeConfigEnvironment('codex', workspace?.state.agentPreferences, initialEnv)
|
||||
const codexCredentialKeys = codexDeclaredCredentialKeys({ home, env: codexEnv })
|
||||
const hermesEnv = nativeConfigEnvironment('hermes', workspace?.state.agentPreferences, initialEnv)
|
||||
const hermesCredentialKeys = hermesDeclaredCredentialKeys({ home, env: hermesEnv })
|
||||
const deepseekEnv = nativeConfigEnvironment('deepseek', workspace?.state.agentPreferences, initialEnv)
|
||||
const deepseekCredentialKeys = deepseekNativeConfig({ home, env: deepseekEnv }).credentialKeys
|
||||
const zcodeEnv = nativeConfigEnvironment('zcode', workspace?.state.agentPreferences, initialEnv)
|
||||
const zcodeCredentialKeys = zcodeDeclaredCredentialKeys({ home, workdir: options.workdir, env: zcodeEnv })
|
||||
const qwenEnv = nativeConfigEnvironment('qwen', workspace?.state.agentPreferences, initialEnv)
|
||||
const qwenCredentialKeys = qwenDeclaredCredentialKeys({ home, workdir: options.workdir, env: qwenEnv })
|
||||
const ocrEnv = nativeConfigEnvironment('opencodereview', workspace?.state.agentPreferences, initialEnv)
|
||||
const ocrCredentialKeys = ocrDeclaredCredentialKeys({ home, env: ocrEnv })
|
||||
const piEnv = nativeConfigEnvironment('pi', workspace?.state.agentPreferences, initialEnv)
|
||||
const piCredentialKeys = piDeclaredCredentialKeys({ home, env: piEnv })
|
||||
const opencodeEnv = nativeConfigEnvironment('opencode', workspace?.state.agentPreferences, initialEnv)
|
||||
let opencodeCredentialKeys = opencodeDeclaredCredentialKeys({ home, env: opencodeEnv, workdir: options.workdir })
|
||||
const mimoEnv = nativeConfigEnvironment('mimo', workspace?.state.agentPreferences, initialEnv)
|
||||
let mimoCredentialKeys = mimoDeclaredCredentialKeys({ home, env: mimoEnv, workdir: options.workdir })
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
const declaredKeys = [...new Set([...codexCredentialKeys, ...hermesCredentialKeys, ...zcodeCredentialKeys, ...piCredentialKeys,
|
||||
...opencodeCredentialKeys, ...mimoCredentialKeys, ...deepseekCredentialKeys, ...qwenCredentialKeys, ...ocrCredentialKeys])]
|
||||
if (!declaredKeys.length) break
|
||||
const chunks = []
|
||||
for (let index = 0; index < declaredKeys.length; index += 32) chunks.push(declaredKeys.slice(index, index + 32))
|
||||
const results = await Promise.all(chunks.map(extraEnvKeys => resolveNativeShellEnvironment({
|
||||
home, refresh: options.refresh === true, extraEnvKeys,
|
||||
})))
|
||||
const extra = {}
|
||||
const unset = []
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
for (const key of chunks[index]) {
|
||||
if (Object.hasOwn(results[index].env, key)) extra[key] = results[index].env[key]
|
||||
if (results[index].unsetKeys?.includes(key)) unset.push(key)
|
||||
}
|
||||
}
|
||||
resolved = { ...resolved, env: { ...resolved.env, ...extra },
|
||||
unsetKeys: [...new Set([...(resolved.unsetKeys || []).filter(key => !declaredKeys.includes(key)),
|
||||
...unset])] }
|
||||
if (pass === 0) {
|
||||
const enriched = { ...process.env, ...resolved.env }
|
||||
for (const key of resolved.unsetKeys || []) delete enriched[key]
|
||||
const nextEnv = nativeConfigEnvironment('opencode', workspace?.state.agentPreferences, enriched)
|
||||
const nextKeys = opencodeDeclaredCredentialKeys({ home, env: nextEnv, workdir: options.workdir })
|
||||
const nextMimoEnv = nativeConfigEnvironment('mimo', workspace?.state.agentPreferences, enriched)
|
||||
const nextMimoKeys = mimoDeclaredCredentialKeys({ home, env: nextMimoEnv, workdir: options.workdir })
|
||||
const needsAnotherPass = nextKeys.some(key => !opencodeCredentialKeys.includes(key))
|
||||
|| nextMimoKeys.some(key => !mimoCredentialKeys.includes(key))
|
||||
opencodeCredentialKeys = nextKeys
|
||||
mimoCredentialKeys = nextMimoKeys
|
||||
if (needsAnotherPass) continue
|
||||
}
|
||||
break
|
||||
}
|
||||
const env = { ...process.env, ...resolved.env }
|
||||
for (const key of resolved.unsetKeys || []) delete env[key]
|
||||
opencodeCredentialKeys = opencodeCredentialKeys.filter(key => typeof env[key] === 'string' && env[key].length > 0)
|
||||
for (const [kind, { key }] of Object.entries(NATIVE_CONFIG_DIRECTORIES)) {
|
||||
if (kind === 'zcode' || kind === 'opencodereview') continue
|
||||
if (kind === 'qwen') {
|
||||
const directory = (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || ''
|
||||
if (Object.hasOwn(nativeSkillDirectories, key) && nativeSkillDirectories[key] !== directory) skillCatalog?.invalidate(kind)
|
||||
nativeSkillDirectories[key] = directory
|
||||
continue
|
||||
}
|
||||
const directory = typeof env[key] === 'string' ? env[key] : ''
|
||||
if ((nativeSkillDirectories[key] || '') !== directory) skillCatalog?.invalidate(kind)
|
||||
nativeSkillDirectories[key] = directory
|
||||
}
|
||||
const sharedDirectory = typeof env.DSH_AGENTS_HOME === 'string' ? env.DSH_AGENTS_HOME : ''
|
||||
if ((nativeSkillDirectories.DSH_AGENTS_HOME || '') !== sharedDirectory) skillCatalog?.invalidate('deepseek')
|
||||
nativeSkillDirectories.DSH_AGENTS_HOME = sharedDirectory
|
||||
const xdgConfig = typeof env.XDG_CONFIG_HOME === 'string' && path.isAbsolute(env.XDG_CONFIG_HOME)
|
||||
? env.XDG_CONFIG_HOME : ''
|
||||
if ((nativeSkillDirectories.XDG_CONFIG_HOME || '') !== xdgConfig) skillCatalog?.invalidate('opencode')
|
||||
nativeSkillDirectories.XDG_CONFIG_HOME = xdgConfig
|
||||
return {
|
||||
...resolved,
|
||||
env: { ...process.env, ...resolved.env },
|
||||
env,
|
||||
home,
|
||||
codexCredentialKeys,
|
||||
hermesCredentialKeys,
|
||||
deepseekCredentialKeys,
|
||||
zcodeCredentialKeys,
|
||||
qwenCredentialKeys,
|
||||
ocrCredentialKeys,
|
||||
piCredentialKeys,
|
||||
opencodeCredentialKeys,
|
||||
mimoCredentialKeys,
|
||||
}
|
||||
}
|
||||
|
||||
function nativeDeclaredCredentialKeys(kind, captured) {
|
||||
const properties = {
|
||||
codex: 'codexCredentialKeys', hermes: 'hermesCredentialKeys', zcode: 'zcodeCredentialKeys',
|
||||
pi: 'piCredentialKeys', opencode: 'opencodeCredentialKeys', mimo: 'mimoCredentialKeys',
|
||||
deepseek: 'deepseekCredentialKeys', qwen: 'qwenCredentialKeys', opencodereview: 'ocrCredentialKeys',
|
||||
}
|
||||
const keys = Object.hasOwn(properties, kind) ? captured?.[properties[kind]] || [] : []
|
||||
return kind === 'codex' ? ['CODEX_API_KEY', ...keys] : keys
|
||||
}
|
||||
|
||||
async function detectLocalAgents() {
|
||||
const shellEnvironment = await nativeShellEnvironment()
|
||||
return detectAgents({ env: shellEnvironment.env, home: shellEnvironment.home })
|
||||
const agents = await detectAgents({
|
||||
env: shellEnvironment.env,
|
||||
home: shellEnvironment.home,
|
||||
nativeCredentialKeysForKind: kind => nativeDeclaredCredentialKeys(kind, shellEnvironment),
|
||||
nativeEnvironmentForKind: kind => kind === 'openclaw'
|
||||
? {} : nativeCredentialEnvironment(kind, nativeConfigEnvironment(kind, workspace?.state.agentPreferences, shellEnvironment.env),
|
||||
nativeDeclaredCredentialKeys(kind, shellEnvironment)),
|
||||
})
|
||||
return agents.map(agent => {
|
||||
const env = nativeConfigEnvironment(agent.kind, workspace?.state.agentPreferences, shellEnvironment.env)
|
||||
const directory = nativeIdentityDirectory(agent.kind, env, shellEnvironment.home)
|
||||
const result = { ...agent }
|
||||
if (directory) Object.defineProperty(result, NATIVE_CONFIG_IDENTITY_DIRECTORY, { value: directory })
|
||||
const authFiles = [...new Set([...nativeAuthIdentityFiles(agent.kind, env, shellEnvironment.home),
|
||||
...nativeNetworkIdentityFiles(env)])]
|
||||
if (authFiles.length) Object.defineProperty(result, NATIVE_AUTH_IDENTITY_FILES, { value: authFiles })
|
||||
const projectPaths = nativeProjectConfigPaths(agent.kind, env)
|
||||
if (projectPaths.length) Object.defineProperty(result, NATIVE_PROJECT_CONFIG_PATHS, { value: projectPaths })
|
||||
if (agent.kind !== 'openclaw') {
|
||||
const keys = nativeDeclaredCredentialKeys(agent.kind, shellEnvironment)
|
||||
const fingerprint = nativeEnvironmentIdentity({
|
||||
...(env.PATH ? { PATH: env.PATH } : {}),
|
||||
...networkEnvironment(env), ...configDirectoryEnvironment(env),
|
||||
...nativeCredentialEnvironment(agent.kind, env, keys),
|
||||
})
|
||||
Object.defineProperty(result, NATIVE_ENVIRONMENT_IDENTITY, { value: fingerprint, enumerable: true })
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
function mediaFallbackProviders(env = process.env) {
|
||||
const apiKey = String(
|
||||
env.ZGCI_MEDIA_API_KEY
|
||||
|| env.ZGCI_API_KEY
|
||||
|| env.ZCGI_API_KEY
|
||||
|| env.ZGCI_LLM_API_KEY
|
||||
|| '',
|
||||
).trim()
|
||||
if (!apiKey) return []
|
||||
const baseUrl = String(env.ZGCI_MEDIA_BASE_URL || 'https://hub.zgci.org/v1')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/chat\/completions$/i, '')
|
||||
let parsed
|
||||
try { parsed = new URL(baseUrl) } catch { return [] }
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password
|
||||
|| parsed.search || parsed.hash) {
|
||||
return []
|
||||
async function resolveProjectEnvironmentIdentities(workdir, kinds) {
|
||||
const captured = await nativeShellEnvironment({ workdir, refresh: true })
|
||||
const result = {}
|
||||
for (const kind of kinds) {
|
||||
if (!LOCAL_AGENT_KINDS.has(kind)) continue
|
||||
if (kind === 'openclaw') {
|
||||
const agent = workspace?.detectedAgents?.find(item => item.kind === kind)
|
||||
if (!agent?.executable) continue
|
||||
try {
|
||||
const runtime = await resolveNativeOpenClawRuntime({ executable: agent.executable,
|
||||
env: nativeConfigEnvironment(kind, workspace?.state.agentPreferences, captured.env), home: captured.home })
|
||||
result[kind] = nativeEnvironmentIdentity({ runtime: JSON.stringify(runtime),
|
||||
...networkEnvironment(captured.env) })
|
||||
} catch { /* Fail closed for this Agent without blocking other kinds. */ }
|
||||
continue
|
||||
}
|
||||
const keys = nativeDeclaredCredentialKeys(kind, captured)
|
||||
const env = nativeConfigEnvironment(kind, workspace?.state.agentPreferences, captured.env)
|
||||
const authFiles = [...new Set([...nativeAuthIdentityFiles(kind, env, captured.home),
|
||||
...nativeNetworkIdentityFiles(env)])]
|
||||
const agent = { kind, [NATIVE_AUTH_IDENTITY_FILES]: authFiles }
|
||||
const environmentIdentity = nativeEnvironmentIdentity({
|
||||
...(env.PATH ? { PATH: env.PATH } : {}),
|
||||
...networkEnvironment(env), ...configDirectoryEnvironment(env),
|
||||
...nativeCredentialEnvironment(kind, env, keys),
|
||||
nativeAuthFiles: agentVerificationIdentity(agent),
|
||||
})
|
||||
const projectPaths = nativeProjectConfigPaths(kind, env)
|
||||
if (projectPaths.length) {
|
||||
agent[NATIVE_PROJECT_CONFIG_PATHS] = projectPaths
|
||||
result[kind] = projectVerificationIdentity(kind, workdir, agent, environmentIdentity)
|
||||
} else {
|
||||
result[kind] = environmentIdentity
|
||||
}
|
||||
}
|
||||
return [{
|
||||
kind: 'zgci-media',
|
||||
status: {
|
||||
configured: true,
|
||||
provider: 'ZGCI Media',
|
||||
baseUrl,
|
||||
model: 'glm',
|
||||
},
|
||||
credentials: { OPENAI_API_KEY: apiKey },
|
||||
}]
|
||||
return result
|
||||
}
|
||||
|
||||
function workspaceStoragePath(userData = app.getPath('userData')) {
|
||||
@@ -369,30 +522,65 @@ async function validateKnowledgeBaseSelections(targetKinds, selections) {
|
||||
|
||||
async function runCoreAgent(agent, prompt, workdir, options = {}) {
|
||||
const connectorCredentialIsolation = options.connectorCredentialIsolation === true
|
||||
const connectorInstanceId = options.connectorInstanceId
|
||||
const runOptions = { ...options }
|
||||
delete runOptions.connectorCredentialIsolation
|
||||
const status = providerStore.status(agent.kind)
|
||||
const shellEnvironment = connectorCredentialIsolation ? null : await nativeShellEnvironment()
|
||||
const nativeRuntime = !connectorCredentialIsolation
|
||||
&& agent.kind === 'openclaw' && !status.configured
|
||||
delete runOptions.connectorInstanceId
|
||||
delete runOptions.nativeCredentialKeys
|
||||
let connectorConfigEnv = {}
|
||||
if (connectorCredentialIsolation) {
|
||||
if (agent.kind !== 'codex' || !/^custom-[a-f0-9]{16}$/.test(String(connectorInstanceId || ''))) {
|
||||
throw new Error('AGENT_CONNECTOR_CREDENTIAL_ISOLATION_UNSUPPORTED')
|
||||
}
|
||||
const privateRoot = path.join(app.getPath('userData'), 'meldwork-private')
|
||||
const connectorRoot = path.join(privateRoot, 'connector-native')
|
||||
const instanceRoot = path.join(connectorRoot, connectorInstanceId)
|
||||
for (const directory of [privateRoot, connectorRoot, instanceRoot]) ensurePrivateDirectory(directory)
|
||||
connectorConfigEnv = { CODEX_HOME: instanceRoot }
|
||||
for (const [key, name] of [['XDG_CONFIG_HOME', 'config'], ['XDG_DATA_HOME', 'data'],
|
||||
['XDG_STATE_HOME', 'state'], ['XDG_CACHE_HOME', 'cache'], ['XDG_RUNTIME_DIR', 'runtime']]) {
|
||||
const directory = path.join(instanceRoot, name)
|
||||
ensurePrivateDirectory(directory)
|
||||
connectorConfigEnv[key] = directory
|
||||
}
|
||||
}
|
||||
const shellEnvironment = connectorCredentialIsolation ? null : await nativeShellEnvironment({ workdir })
|
||||
const nativeKeys = connectorCredentialIsolation ? ['CODEX_API_KEY']
|
||||
: nativeDeclaredCredentialKeys(agent.kind, shellEnvironment)
|
||||
if (!connectorCredentialIsolation) {
|
||||
const directory = workspace?.state.agentPreferences?.[agent.kind]?.nativeConfigDirectory
|
||||
if (directory) validateNativeConfigDirectory(agent.kind, directory)
|
||||
}
|
||||
const selectedNativeEnv = shellEnvironment
|
||||
? nativeConfigEnvironment(agent.kind, workspace?.state.agentPreferences, shellEnvironment.env) : null
|
||||
// OpenClaw runs through its isolated native runtime scope; every other Agent
|
||||
// CLI uses its own native credentials.
|
||||
const nativeRuntime = !connectorCredentialIsolation && agent.kind === 'openclaw'
|
||||
? await resolveNativeOpenClawRuntime({
|
||||
executable: agent.executable,
|
||||
env: shellEnvironment.env,
|
||||
env: selectedNativeEnv,
|
||||
home: shellEnvironment.home,
|
||||
})
|
||||
: null
|
||||
const injected = connectorCredentialIsolation
|
||||
const injected = connectorCredentialIsolation || agent.kind !== 'openclaw' || !nativeRuntime
|
||||
? {}
|
||||
: providerOptions(agent.kind, {
|
||||
...runOptions,
|
||||
workdir,
|
||||
: nativeOpenClawOptions({
|
||||
storageRoot: app.getPath('userData'),
|
||||
nativeRuntime,
|
||||
workdir,
|
||||
sessionRef: runOptions.sessionRef,
|
||||
allowWrite: runOptions.sandbox === 'workspace-write',
|
||||
runtime: nativeRuntime,
|
||||
openclawVersion: agent.version,
|
||||
})
|
||||
const nativeEnv = connectorCredentialIsolation || agent.kind === 'openclaw'
|
||||
? {}
|
||||
: {
|
||||
...nativeCredentialEnvironment(agent.kind, shellEnvironment.env),
|
||||
...configDirectoryEnvironment(selectedNativeEnv),
|
||||
...nativeCredentialEnvironment(agent.kind, selectedNativeEnv, nativeKeys),
|
||||
...(agent.kind === 'workbuddy' ? workBuddyEnvironment({
|
||||
env: selectedNativeEnv, home: shellEnvironment.home,
|
||||
modelId: workspace?.state.agentPreferences.workbuddy?.modelId,
|
||||
}) : {}),
|
||||
...(shellEnvironment.env.PATH ? { PATH: shellEnvironment.env.PATH } : {}),
|
||||
}
|
||||
const callerEnv = connectorCredentialIsolation || agent.kind !== 'openclaw'
|
||||
@@ -400,8 +588,16 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
|
||||
: {}
|
||||
return runAgent(agent, prompt, workdir, {
|
||||
...runOptions,
|
||||
...(shellEnvironment?.unsetKeys ? { nativeUnsetKeys: shellEnvironment.unsetKeys } : {}),
|
||||
nativeCredentialKeys: nativeKeys,
|
||||
...injected,
|
||||
env: { ...nativeEnv, ...callerEnv, ...injected.env },
|
||||
env: {
|
||||
...networkEnvironment(shellEnvironment?.env),
|
||||
...nativeEnv, ...callerEnv,
|
||||
...(connectorCredentialIsolation ? {} : nativeConfigEnvironment(agent.kind, workspace?.state.agentPreferences)),
|
||||
...injected.env,
|
||||
...connectorConfigEnv,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -473,6 +669,7 @@ async function requestLocalSkillTrust({ binding, coordinates, manifest }) {
|
||||
function createWorkspace() {
|
||||
cloudAgentStartRetry?.cancel()
|
||||
const privateRoot = path.join(app.getPath('userData'), 'meldwork-private')
|
||||
ensurePrivateDirectory(privateRoot)
|
||||
const contentBlobStore = new ContentBlobStore({
|
||||
rootPath: path.join(privateRoot, 'content-blobs'),
|
||||
})
|
||||
@@ -506,6 +703,7 @@ function createWorkspace() {
|
||||
safeStorage: lazySafeStorage,
|
||||
}),
|
||||
runAgent: runCoreAgent,
|
||||
cloudBridges: cloudAgentBridges,
|
||||
})
|
||||
const outcomeStore = new OutcomeStore({
|
||||
rootPath: path.join(privateRoot, 'outcomes'),
|
||||
@@ -530,17 +728,6 @@ function createWorkspace() {
|
||||
}),
|
||||
connectors: [],
|
||||
})
|
||||
const mediaGeneration = new MediaGenerationRuntime({
|
||||
getProvider: (kind, type, excludedKinds) => resolveMediaProvider({
|
||||
requestedKind: kind,
|
||||
type,
|
||||
kinds: EXTERNAL_PROVIDER_KINDS,
|
||||
excludedKinds,
|
||||
statusFor: candidateKind => providerStore.status(candidateKind),
|
||||
credentialsFor: candidateKind => providerStore.envForAgent(candidateKind),
|
||||
fallbackProviders: mediaFallbackProviders(),
|
||||
}),
|
||||
})
|
||||
const localWorkspace = new LocalWorkspace({
|
||||
storagePath: workspaceStoragePath(),
|
||||
contentBlobStore,
|
||||
@@ -549,8 +736,10 @@ function createWorkspace() {
|
||||
}),
|
||||
outcomeStore,
|
||||
agentFitMatrix: FROZEN_AGENT_FIT_MATRIX,
|
||||
resolveProjectEnvironmentIdentities,
|
||||
runLedger: cloudAgentRuntime.workspaceLedger(),
|
||||
detectAgents: async () => {
|
||||
await cloudAgentBridges?.refresh()
|
||||
const [installedAgents, customAgents] = await Promise.all([
|
||||
installer.detectedAgents(),
|
||||
customAgentStore.detectAgents(),
|
||||
@@ -566,7 +755,10 @@ function createWorkspace() {
|
||||
captureArtifactOutputs: (workdir, options) => captureArtifactOutputState(workdir, options),
|
||||
captureAgentOutcomeDescriptors,
|
||||
importAgentOutputs: input => importAgentOutputs(input, attachments.availableStore()),
|
||||
validateSkillSelections: (kind, selections) => skillSnapshotSelections.prepare(kind, selections),
|
||||
validateSkillSelections: async (kind, selections) => {
|
||||
if (selections?.length) await nativeShellEnvironment()
|
||||
return skillSnapshotSelections.prepare(kind, selections)
|
||||
},
|
||||
validateKnowledgeBaseSelections,
|
||||
imageAttachmentLimit,
|
||||
attachmentSupport: localAttachmentSupport,
|
||||
@@ -574,17 +766,25 @@ function createWorkspace() {
|
||||
if (customAgentStore.has(kind)) return { state: 'ready', source: 'custom-agent' }
|
||||
if (agentConnectors.has(kind)) return { state: 'ready', source: 'agent-connector' }
|
||||
const shellEnvironment = await nativeShellEnvironment()
|
||||
const directory = workspace?.state.agentPreferences?.[kind]?.nativeConfigDirectory
|
||||
if (directory) {
|
||||
try { validateNativeConfigDirectory(kind, directory) } catch {
|
||||
return { state: 'unknown', source: 'native-runtime-unavailable' }
|
||||
}
|
||||
}
|
||||
return resolveNativeCredentialState(kind, {
|
||||
executable: agent?.executable,
|
||||
env: shellEnvironment.env,
|
||||
env: nativeConfigEnvironment(kind, workspace?.state.agentPreferences, shellEnvironment.env),
|
||||
home: shellEnvironment.home,
|
||||
credentialSource: shellEnvironment.source,
|
||||
nativeCredentialKeys: nativeDeclaredCredentialKeys(kind, shellEnvironment),
|
||||
...(kind === 'workbuddy' ? { modelId: workspace?.state.agentPreferences.workbuddy?.modelId } : {}),
|
||||
})
|
||||
},
|
||||
agentLabel: kind => customAgentStore.label(kind) || agentConnectors.label(kind),
|
||||
sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured,
|
||||
isKnownAgent: kind => cloudAgentBridges.catalogEntries().some(agent => agent.kind === kind),
|
||||
connectorRuntime: agentConnectors,
|
||||
generateMedia: input => mediaGeneration.generate(input),
|
||||
naturalAgentResponses: true,
|
||||
runAgent: async (agent, prompt, workdir, options = {}) => {
|
||||
if (customAgentStore.has(agent.kind)) {
|
||||
return customAgentStore.run(agent.kind, prompt, workdir, {
|
||||
@@ -610,6 +810,7 @@ function createWorkspace() {
|
||||
}
|
||||
|
||||
async function localAgentCatalog() {
|
||||
await cloudAgentBridges?.refresh()
|
||||
const [catalog, customAgents, installedAgents] = await Promise.all([
|
||||
installer.catalog(),
|
||||
customAgentStore.catalog(),
|
||||
@@ -617,10 +818,17 @@ async function localAgentCatalog() {
|
||||
])
|
||||
agentConnectors?.refresh(installedAgents)
|
||||
const connectorAgents = agentConnectors?.catalog() || []
|
||||
const mergedAgents = new Map()
|
||||
for (const agent of [...catalog.agents, ...customAgents, ...connectorAgents]) {
|
||||
mergedAgents.set(agent.kind, agent)
|
||||
}
|
||||
for (const agent of cloudAgentBridges?.catalogEntries?.() || []) {
|
||||
mergedAgents.set(agent.kind, { ...(mergedAgents.get(agent.kind) || {}), ...agent })
|
||||
}
|
||||
const states = new Map((workspace?.snapshot().agents || []).map(agent => [agent.kind, agent]))
|
||||
return {
|
||||
...catalog,
|
||||
agents: [...catalog.agents, ...customAgents, ...connectorAgents].map((agent) => {
|
||||
agents: [...mergedAgents.values()].map((agent) => {
|
||||
const state = states.get(agent.kind)
|
||||
const support = localAttachmentSupport(agent.kind)
|
||||
return {
|
||||
@@ -635,9 +843,12 @@ async function localAgentCatalog() {
|
||||
recentlyVerified: Boolean(agent.installed && state?.recentlyVerified),
|
||||
capabilities: state?.capabilities || agent.capabilities || null,
|
||||
available: Boolean(agent.installed && state?.invocable),
|
||||
credentialState: agent.installed ? (state?.credentialState || 'unknown') : 'missing',
|
||||
availabilitySource: agent.installed ? (state?.availabilitySource || 'unverified') : 'none',
|
||||
showInSidebar: Boolean(agent.installed && state?.showInSidebar),
|
||||
credentialState: agent.installed
|
||||
? (state?.credentialState || agent.credentialState || 'unknown')
|
||||
: 'missing',
|
||||
availabilitySource: agent.installed ? (state?.availabilitySource || agent.availabilitySource || 'unverified') : 'none',
|
||||
showInSidebar: Boolean(agent.installed && (state?.showInSidebar
|
||||
?? (agent.cloud ? workspace?.state?.agentPreferences?.[agent.kind]?.showInSidebar ?? true : false))),
|
||||
attachmentTypes: Object.entries(support)
|
||||
.filter(([, limit]) => Number(limit) > 0)
|
||||
.map(([type]) => type),
|
||||
@@ -651,6 +862,7 @@ async function refreshLocalAgentState() {
|
||||
if (!target) return null
|
||||
const task = localAgentRefreshQueue.then(async () => {
|
||||
if (workspace !== target) return workspace?.snapshot() || null
|
||||
await nativeShellEnvironment({ refresh: true })
|
||||
const snapshot = await target.refreshAgents()
|
||||
if (workspace !== target) return workspace?.snapshot() || null
|
||||
return snapshot
|
||||
@@ -672,7 +884,7 @@ function initializeWorkspace() {
|
||||
return next
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
function createWindow(startupError = workspaceStartupError) {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 920,
|
||||
@@ -719,11 +931,22 @@ function createWindow() {
|
||||
showOfflinePage(errorCode)
|
||||
})
|
||||
mainWindow.webContents.on('did-finish-load', flushPendingOpenGroup)
|
||||
// A crashed renderer used to leave a blank window; reload it so the
|
||||
// workspace (whose state lives in the main process) comes back.
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
recordMainProcessFault('render-process-gone', details?.reason || 'unknown')
|
||||
if (details?.reason !== 'clean-exit' && mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.reload()
|
||||
}
|
||||
})
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
loadFrontend()
|
||||
flushPendingOpenGroup()
|
||||
if (startupError) showOfflinePage(startupError)
|
||||
else {
|
||||
loadFrontend()
|
||||
flushPendingOpenGroup()
|
||||
}
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
@@ -755,8 +978,25 @@ function registerIpc() {
|
||||
getAttachmentStore: () => attachmentStore,
|
||||
getAgentConnectors: () => agentConnectors,
|
||||
getCloudAgentRuntime: () => cloudAgentRuntime,
|
||||
getCloudAgentBridges: () => cloudAgentBridges,
|
||||
getMainWindow: () => mainWindow,
|
||||
getWorkspace: () => workspace,
|
||||
getNativeConfigSummary: async (kind, currentWorkspace) => {
|
||||
if (!['hermes', 'codex', 'qwen', 'zcode', 'deepseek'].includes(kind)) return null
|
||||
const shell = await nativeShellEnvironment()
|
||||
const env = nativeConfigEnvironment(kind, currentWorkspace.state.agentPreferences, shell.env)
|
||||
if (kind === 'deepseek') return { provider: deepseekNativeConfig({ home: shell.home, env }).explicitModel ? '' : 'zgci' }
|
||||
if (kind === 'qwen' || kind === 'zcode') return nativeUserConfigSummary(kind, { home: shell.home, env })
|
||||
return { provider: kind === 'hermes'
|
||||
? hermesConfiguredProvider({ home: shell.home, env })
|
||||
: codexConfiguredProvider({ home: shell.home, env }) }
|
||||
},
|
||||
getWorkBuddySettings: async () => {
|
||||
const shell = await nativeShellEnvironment()
|
||||
return workBuddySettings({ ...shell,
|
||||
env: nativeConfigEnvironment('workbuddy', workspace?.state.agentPreferences, shell.env),
|
||||
modelId: workspace?.state.agentPreferences.workbuddy?.modelId })
|
||||
},
|
||||
installer,
|
||||
isShutdownStarted: () => shutdownStarted,
|
||||
knowledgeBaseStore,
|
||||
@@ -767,10 +1007,11 @@ function registerIpc() {
|
||||
openExternalUrl,
|
||||
getOutcomeStore: () => workspace?.outcomeStore,
|
||||
getSkillTrustStore: () => skillTrustStore,
|
||||
providerStore,
|
||||
providerAgentKind,
|
||||
refreshLocalAgentState,
|
||||
registerTrustedHandle,
|
||||
resolveConnectorEnvironment: async names => (await resolveNativeShellEnvironment({
|
||||
home: app.getPath('home'), extraEnvKeys: names, refresh: true,
|
||||
})).env,
|
||||
skillCatalog,
|
||||
})
|
||||
}
|
||||
@@ -783,6 +1024,13 @@ if (!hasSingleInstanceLock) {
|
||||
app.on('second-instance', activateMainWindow)
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
try {
|
||||
ensurePrivateDirectory(app.getPath('userData'))
|
||||
} catch (error) {
|
||||
console.error('MELDWORK_PRIVATE_DIRECTORY_UNAVAILABLE', { code: String(error?.code || '') })
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
try {
|
||||
migrateLegacyProductData(app.getPath('userData'))
|
||||
} catch (error) {
|
||||
@@ -796,12 +1044,18 @@ if (!hasSingleInstanceLock) {
|
||||
const attachmentReferences = persistedAttachmentReferences(workspaceStoragePath())
|
||||
customAgentStore = new CustomAgentStore({
|
||||
storagePath: path.join(app.getPath('userData'), 'meldwork-custom-agents.json'),
|
||||
environment: async () => (await nativeShellEnvironment()).env,
|
||||
})
|
||||
providerStore = new ProviderStore({
|
||||
storagePath: path.join(app.getPath('userData'), 'meldwork-provider.json'),
|
||||
safeStorage: lazySafeStorage,
|
||||
allowedKinds: [...LOCAL_AGENT_KINDS],
|
||||
cloudAgentBridges = new CloudAgentBridgeStore({
|
||||
storagePath: path.join(app.getPath('userData'), 'meldwork-cloud-agent-bridges.json'),
|
||||
fetch: globalThis.fetch,
|
||||
})
|
||||
// The shared Provider store was removed; drop any legacy encrypted
|
||||
// credentials so they do not linger on disk unread.
|
||||
try {
|
||||
const { rmSync } = require('node:fs')
|
||||
rmSync(path.join(app.getPath('userData'), 'meldwork-provider.json'), { force: true })
|
||||
} catch { /* best-effort cleanup */ }
|
||||
knowledgeBaseStore = new KnowledgeBaseStore({
|
||||
storagePath: path.join(app.getPath('userData'), 'meldwork-knowledge-base.json'),
|
||||
})
|
||||
@@ -813,10 +1067,36 @@ if (!hasSingleInstanceLock) {
|
||||
attachmentStore = null
|
||||
}
|
||||
attachments.registerProtocol(protocol)
|
||||
skillCatalog = new LocalSkillCatalog({ home: app.getPath('home') })
|
||||
skillCatalog = new LocalSkillCatalog({
|
||||
home: app.getPath('home'),
|
||||
configDirectoryForKind: kind => {
|
||||
const key = NATIVE_CONFIG_DIRECTORIES[kind]?.key
|
||||
if (!key) return ''
|
||||
let directory = nativeConfigEnvironment(kind, workspace?.state.agentPreferences, nativeSkillDirectories)[key] || ''
|
||||
if (directory === '~' || directory.startsWith('~/') || (process.platform === 'win32' && directory.startsWith('~\\'))) {
|
||||
directory = path.join(app.getPath('home'), directory.slice(2))
|
||||
}
|
||||
if (directory && !path.isAbsolute(directory)) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
return directory
|
||||
},
|
||||
sharedDirectoryForKind: kind => {
|
||||
if (kind === 'opencode') return nativeSkillDirectories.XDG_CONFIG_HOME || ''
|
||||
if (kind !== 'deepseek') return ''
|
||||
let directory = nativeSkillDirectories.DSH_AGENTS_HOME || ''
|
||||
if (!directory.trim()) return ''
|
||||
if (directory === '~' || directory.startsWith('~/') || directory.startsWith('~\\')) {
|
||||
directory = path.join(app.getPath('home'), directory.slice(2))
|
||||
}
|
||||
if (!path.isAbsolute(directory)) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
return directory
|
||||
},
|
||||
})
|
||||
installer = new AgentInstaller({
|
||||
detectAgents: detectLocalAgents,
|
||||
listSkills: kind => skillCatalog.list(kind),
|
||||
listSkills: async kind => {
|
||||
await nativeShellEnvironment()
|
||||
return skillCatalog.list(kind)
|
||||
},
|
||||
})
|
||||
installer.on('changed', state => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()
|
||||
@@ -827,7 +1107,17 @@ if (!hasSingleInstanceLock) {
|
||||
refreshLocalAgentState().catch(() => {})
|
||||
}
|
||||
})
|
||||
initializeWorkspace()
|
||||
try {
|
||||
initializeWorkspace()
|
||||
} catch (error) {
|
||||
if (error?.message !== 'RUN_LEDGER_LOAD_FAILED') throw error
|
||||
workspaceStartupError = 'RUN_LEDGER_LOAD_FAILED'
|
||||
createWindow()
|
||||
app.on('activate', () => {
|
||||
if (!shutdownStarted && BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
return
|
||||
}
|
||||
if (attachmentStore && attachmentReferences) {
|
||||
try {
|
||||
attachmentStore.cleanup(attachmentReferences)
|
||||
@@ -871,6 +1161,7 @@ if (!hasSingleInstanceLock) {
|
||||
if (installRunning) installer.cancel(installerState.taskId)
|
||||
quitCleanup = runQuitCleanup([
|
||||
() => channelIngressRuntime?.shutdown(),
|
||||
() => cloudAgentBridges?.close(),
|
||||
() => cloudAgentRuntime?.shutdown(),
|
||||
async () => {
|
||||
try {
|
||||
|
||||