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 |
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,82 @@
|
||||
# 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
|
||||
@@ -86,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
|
||||
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
# Commercial Use And Licensing
|
||||
# Commercial use / 商业使用
|
||||
|
||||
Meldwork is licensed under the [Apache License 2.0](LICENSE). Commercial use,
|
||||
private use, modification, redistribution, and use inside a paid service are
|
||||
permitted under the license terms.
|
||||
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.
|
||||
|
||||
This page is a practical summary and does not replace the license text. Apache
|
||||
License 2.0 does not grant trademark rights to the Meldwork name, logo,
|
||||
screenshots, or other brand assets. Third-party software, Agent products,
|
||||
models, names, logos, and other materials remain subject to their own licenses
|
||||
and terms.
|
||||
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).
|
||||
|
||||
Before distributing a product that includes third-party materials, review the
|
||||
relevant notices and preserve the required attribution and license information.
|
||||
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.
|
||||
|
||||
Meldwork 原创源代码、文档、README 和官网均采用 Apache-2.0。依照许可证条款,
|
||||
允许商业使用、私人使用、修改、再分发,以及用于收费产品或服务;无需另行取得商业
|
||||
授权或支付许可费。再分发时应保留许可证、适用的版权与归属声明及 NOTICE 信息,并
|
||||
按许可证要求标明修改。Apache-2.0 第 6 条不授予商标权。第三方依赖、字体、Agent
|
||||
名称和标志仍遵循各自条款;官网附带的 Fusion Pixel 字体保留 OFL 许可证与声明。
|
||||
|
||||
This summary does not replace LICENSE. 本说明不替代 LICENSE 正文。
|
||||
@@ -9,7 +9,7 @@ Unless a separate written agreement applies, a contribution accepted into this r
|
||||
- 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 contribute and do not introduce incompatible license obligations.
|
||||
|
||||
|
||||
@@ -10,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,122 +1,129 @@
|
||||
<!-- <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.4/Meldwork-0.1.4-arm64.dmg"><strong>Download Meldwork V1.0.4 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>
|
||||
|
||||
## Who it is for
|
||||
## What Meldwork is for
|
||||
|
||||
Use Meldwork when you:
|
||||
[](https://youtu.be/ttyJxo1s9ns)
|
||||
|
||||
- already have multiple local Agent CLIs and need one task to span them;
|
||||
- need independent review for code, research, or product analysis before choosing a path;
|
||||
- need inspectable traces, evidence, and human review before workspace writes.
|
||||
[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>
|
||||
|
||||
## Workflow
|
||||
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:
|
||||
|
||||
1. **Select** the local Agents and participants.
|
||||
2. **Scope** the goal, working directory, context, and permissions.
|
||||
3. **Run** Direct, Concurrent Responses, or Auto Discussion V4.
|
||||
4. **Review and adopt** the result, evidence, and any Human Gate before changing the workspace.
|
||||
- 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.
|
||||
|
||||
## How a task moves through Meldwork
|
||||
|
||||
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.
|
||||
|
||||
## Collaboration modes
|
||||
|
||||
| Mode | What happens | Best for |
|
||||
| --- | --- | --- |
|
||||
| **Direct** | One selected Agent keeps its conversation and native session when supported. | Focused work with one Agent. |
|
||||
| **Concurrent Responses** | Selected Agents receive the same frozen task snapshot and return independent replies in stable order. | Comparing approaches before choosing one. |
|
||||
| **Auto Discussion V4** | The first round runs concurrent proposals. Later turns follow the discussion: one Agent can route the next turn with `@Agent`, or several Agents can be selected for a concurrent response. Native Agent sessions continue across rounds. | Multi-round work that benefits from discussion, flexible routing, and review. |
|
||||
| **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. |
|
||||
|
||||
Participants are always selected by the user. Automatic selection from a larger roster is outside the current preview.
|
||||
Participants are selected by the user. Automatic selection from a larger roster is outside the current preview.
|
||||
|
||||
## How Meldwork differs
|
||||
## Revisit the process and continue the work
|
||||
|
||||
Meldwork is a local-first AI Agent workspace for multi-Agent review and decision traceability, not a terminal, cloud Agent fleet, communication network, or programmable orchestration framework. It connects the local Agent tools you already use to a decision-ready review workflow and keeps independent findings, evidence, responsibility, and the human adoption decision visible in one local work cell.
|
||||
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.
|
||||
|
||||
| Project | Category | What it focuses on | Meldwork's difference |
|
||||
| --- | --- | --- | --- |
|
||||
| [Buzz](https://github.com/block/buzz) | Agent communication network | Identity, channels, events, and persistent collaboration. | Case-scoped independent judgments with evidence-backed Decision and Disposition. |
|
||||
| [Pragma](https://github.com/pqpo/pragma) | Method and workflow runtime | Reusable Expert, Flow, Memory, Evaluation, and DSL assets. | Keeps disagreement, re-checks, and adoption records visible before a workflow is fixed. |
|
||||
| [Munder Difflin](https://github.com/chaitanyagiri/munder-difflin) | Visual Agent company | Boss -> Manager -> Workers, with task and activity status. | Tracks Finding -> Evidence -> Decision -> Disposition without hiding dissent behind a central manager. |
|
||||
| [Superset](https://github.com/superset-sh/superset) / [Conductor](https://www.conductor.build/) / [Vibe Kanban](https://github.com/BloopAI/vibe-kanban) | Parallel coding workspaces | Parallel coding Agents, isolated workspaces, diffs, and merges. | Local-first review across heterogeneous CLIs, not terminal count, worktree throughput, or cloud sandboxes. |
|
||||
| [cmux](https://github.com/manaflow-ai/cmux) | Agent-native terminal | Low-friction terminals, notifications, and multitasking. | Adds frozen context, independent results, and a human adoption gate. |
|
||||
| [Nimbalyst](https://github.com/nimbalyst/nimbalyst) | Agent and artifact workspace | Parallel Agents with Markdown, mockups, and diagrams. | Places evidence and accountable decisions alongside artifacts. |
|
||||
| [Paperclip](https://github.com/paperclipai/paperclip) | Agent organization management | Companies, roles, budgets, approvals, and org charts. | Grounds responsibility in real findings and outcomes, not a virtual company. |
|
||||
| [MCP Agent Mail](https://github.com/Dicklesworthstone/mcp_agent_mail) | Agent communication infrastructure | Identity, inboxes, threads, and advisory file leases. | Preserves Case and review semantics above the transport layer. |
|
||||
| LangGraph / CrewAI / AutoGen / OpenAI Agents SDK / Google ADK | Programmable multi-Agent frameworks | Graphs, roles, routing, tools, and approvals for developers. | Delivers a desktop review workflow without requiring teams to build an orchestration framework. |
|
||||
| Warp Oz / Devin / Factory / OpenHands Cloud | Cloud Agent control planes | Hosted sandboxes, remote execution, and Agent fleets. | Runs in the user's local Electron work cell with existing CLIs and local data boundaries. |
|
||||
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.
|
||||
|
||||
## See it in action
|
||||
## General Agent catalog
|
||||
|
||||
Meldwork detects and invokes compatible locally installed Agent commands. The catalog currently includes:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Local Agent discovery</th>
|
||||
<th>Multi-agent review</th>
|
||||
<th>Direct multimodal work</th>
|
||||
<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"><a href="assets/meldwork-agent-discovery.png"><img src="assets/meldwork-agent-discovery.png" alt="Meldwork local-first multi-agent workspace detecting Agent CLIs" width="320" height="205"></a></td>
|
||||
<td align="center"><a href="assets/meldwork-multi-agent-review.png"><img src="assets/meldwork-multi-agent-review.png" alt="Meldwork multi-agent collaboration with evidence and human review" width="320" height="205"></a></td>
|
||||
<td align="center"><a href="assets/meldwork-direct-multimodal.png"><img src="assets/meldwork-direct-multimodal.png" alt="Meldwork direct Agent workspace with local files and media" width="320" height="205"></a></td>
|
||||
<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>
|
||||
|
||||
Example: give Codex, Claude Code, and Gemini CLI the same change-review context, compare their independent findings, then adopt only the evidence-backed result you approve.
|
||||
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.
|
||||
|
||||
## Supported local Agent CLIs
|
||||
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.
|
||||
|
||||
Meldwork detects and invokes an installed command when its adapter and the CLI version are compatible:
|
||||
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.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
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.
|
||||
|
||||
Approved Agent Connectors can be added through the [Agent Connector SDK](docs/agent-connector-sdk.md); custom executable Agents use the desktop custom-Agent path. See the [desktop guide](desktop/README.md) for the full adapter and capability matrix.
|
||||
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.
|
||||
|
||||
## Quick start
|
||||
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.
|
||||
|
||||
### Apple silicon macOS preview
|
||||
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.
|
||||
|
||||
Download [`Meldwork-0.1.4-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.4/Meldwork-0.1.4-arm64.dmg) from the [official V1.0.4 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.4), move Meldwork to Applications, and install at least one supported local Agent CLI. The prerelease is ad-hoc signed and not notarized; macOS may require **Open Anyway** in **System Settings -> Privacy & Security** on first launch.
|
||||
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.
|
||||
|
||||
### Run from source
|
||||
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.
|
||||
|
||||
@@ -135,19 +142,13 @@ npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
```
|
||||
|
||||
## Current boundary
|
||||
|
||||
Meldwork is a local Electron app, not a hosted Agent fleet or a general Agent framework. The current preview does not provide remote/cloud/channel Agent execution, automatic participant selection, enterprise SSO/RBAC/governance, or an Outcome Network. Local-first does not mean fully offline: a selected Agent may send prompts, attachments, or Skills to its configured Provider. Workspace writes are opt-in workflow controls, not an operating-system sandbox.
|
||||
|
||||
## Docs
|
||||
## Documentation
|
||||
|
||||
- [Architecture and product boundary](architecture.md)
|
||||
- [Desktop setup and Agent matrix](desktop/README.md)
|
||||
- [Agent Connector SDK](docs/agent-connector-sdk.md)
|
||||
- [AI discoverability index](docs/ai-discoverability.md)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
- [Security policy](SECURITY.md)
|
||||
|
||||
## License
|
||||
|
||||
Meldwork is source-available under the [Meldwork Non-Commercial Source License 1.0](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,122 +1,127 @@
|
||||
<!-- <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 现在可以并发回复,也可以围绕同一目标先独立提案,再根据讨论互相指定下一位参与者,最后协作完成工作并进行复核。
|
||||
你无需安排每一轮发言,但仍负责目标、权限和关键决策。遇到信息缺失、权限请求或需要取舍的问题时,任务可能等待你处理。工作区写入需要明确授权;协作过程、运行状态和产物保存在本地,方便回看和继续工作。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.4/Meldwork-0.1.4-arm64.dmg"><strong>下载 Meldwork V1.0.4 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>
|
||||
|
||||
## 适合谁
|
||||
## Meldwork 适合什么工作
|
||||
|
||||
当你有以下需求时,Meldwork 更适合:
|
||||
[](https://youtu.be/ttyJxo1s9ns)
|
||||
|
||||
- 已经安装多个本地 Agent CLI,希望让同一任务跨 Agent 协作;
|
||||
- 需要在代码评审、研究或产品分析中获得独立判断,再选择方案;
|
||||
- 需要在写入工作区前检查可追溯的运行轨迹、证据并保留人工复核。
|
||||
[在 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>
|
||||
|
||||
## 工作流程
|
||||
当一个任务需要多个 Agent 共同推进,而你不想反复传递上下文、协调下一步时,Meldwork 更适合:
|
||||
|
||||
1. **选择**本地 Agent 和参与者。
|
||||
2. **限定**目标、工作目录、上下文和权限。
|
||||
3. **运行**直接会话、并发回复或 Auto Discussion V4。
|
||||
4. **复核并采用**结果、证据和待处理的人工审批门(Human Gate),再写入工作区。
|
||||
- 研究与信息综合:结合本地 Agent 工具和明确选择的知识源;
|
||||
- 产品、市场、运营和技术分析;
|
||||
- 写作、规划、评审和结构化文档工作;
|
||||
- 处理本地文档、图片、音频、视频、PDF、代码和配置文件的多模态工作;
|
||||
- 在授权工作目录内执行、由其他 Agent 检查成果并继续修改的实施工作。
|
||||
|
||||
## 一个任务如何在 Meldwork 中推进
|
||||
|
||||
1. **选择**一起处理任务的 Agent。
|
||||
2. **限定**目标、工作目录、上下文、附件、Skill、知识源和权限。
|
||||
3. **协作**:启动 Auto Discussion,让 Agent 讨论方案、接续执行并检查成果;也可选择直接会话或并发回复。
|
||||
4. **决策**:在需要补充信息、授权或取舍时介入。
|
||||
5. **查看结果**:检查交付物,必要时提出修改,让工作继续推进。
|
||||
|
||||
## 协作模式
|
||||
|
||||
| 模式 | 运行方式 | 适合场景 |
|
||||
| --- | --- | --- |
|
||||
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 使用单个 Agent 完成聚焦工作。 |
|
||||
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同路径。 |
|
||||
| **Auto Discussion V4** | 首轮并发提案。后续轮次由 Agent 根据讨论结果通过最后一行的 `@Agent` 决定下一位参与者;@ 一个 Agent 时单独运行,@ 多个 Agent 时并发运行。每个 Agent 在同一任务中持续使用原生会话。 | 需要讨论、灵活分工和复核的多轮工作。 |
|
||||
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 聚焦研究、写作、分析或执行。 |
|
||||
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同视角。 |
|
||||
| **Auto Discussion V4** | Agent 独立提出方案后,接续讨论、请求同伴贡献、在授权范围内执行并检查成果。交付负责人判断任务进度,必要时请求用户决策;支持时保持原生会话。 | 希望减少人工协调的多步骤协作任务。 |
|
||||
|
||||
参与者始终由用户选择。从更大候选池自动选人不属于当前预览版。
|
||||
参与者始终由用户选择。从更大候选池自动选人组队不属于当前预览版。
|
||||
|
||||
## Meldwork 与相邻产品的区别
|
||||
## 过程可回看,工作可继续
|
||||
|
||||
Meldwork 是面向多 Agent 评审与决策可追溯性的本地优先 AI Agent 工作空间,不是终端、云端 Agent Fleet、通信网络或可编程编排框架。它把你已经在使用的本地 Agent 工具连接到可形成决定的评审流程,并在一个本地工作单元中保留独立发现、证据、责任和人工采用决定。
|
||||
Meldwork 在本地保留任务上下文、Agent 回复、运行状态和产物,方便了解任务如何推进、哪里遇到阻塞,以及接下来需要处理什么。Run Ledger 保留阶段、参与者、尝试、产物、恢复状态和人工审批门;诊断工具输出与敏感运行信息留在 Electron 主进程边界内。
|
||||
|
||||
| 项目 | 类别 | 主要解决什么 | Meldwork 的区别 |
|
||||
| --- | --- | --- | --- |
|
||||
| [Buzz](https://github.com/block/buzz) | Agent 通信网络 | 身份、频道、事件和持续协作。 | 面向单个 Case 的独立判断,以及有证据支持的 Decision 和 Disposition。 |
|
||||
| [Pragma](https://github.com/pqpo/pragma) | 方法与工作流运行时 | 可复用的 Expert、Flow、Memory、Evaluation 和 DSL 资产。 | 在流程固定前保留分歧、复验和采用记录。 |
|
||||
| [Munder Difflin](https://github.com/chaitanyagiri/munder-difflin) | 可视化 Agent 公司 | Boss -> Manager -> Workers,以及任务和活动状态。 | 追踪 Finding -> Evidence -> Decision -> Disposition,不让中心经理隐藏分歧。 |
|
||||
| [Superset](https://github.com/superset-sh/superset) / [Conductor](https://www.conductor.build/) / [Vibe Kanban](https://github.com/BloopAI/vibe-kanban) | 并行编码工作台 | 并行 Coding Agent、隔离工作区、Diff 和 Merge。 | 面向异构 CLI 的本地优先评审,不以终端数量、Worktree 吞吐量或云 Sandbox 竞争。 |
|
||||
| [cmux](https://github.com/manaflow-ai/cmux) | Agent 原生终端 | 低摩擦终端、通知和多任务处理。 | 增加冻结上下文、独立结果和人工采用门。 |
|
||||
| [Nimbalyst](https://github.com/nimbalyst/nimbalyst) | Agent 与 Artifact 工作空间 | 并行 Agent,以及 Markdown、Mockup 和 Diagram。 | 在 Artifact 旁边保留证据和可追责的决定。 |
|
||||
| [Paperclip](https://github.com/paperclipai/paperclip) | Agent 组织管理 | 公司、岗位、预算、审批和组织图。 | 将责任落在真实 Finding 和结果上,而不是虚拟公司。 |
|
||||
| [MCP Agent Mail](https://github.com/Dicklesworthstone/mcp_agent_mail) | Agent 通信基础设施 | 身份、收件箱、Thread 和 advisory file lease。 | 在传输层之上保留 Case 和评审语义。 |
|
||||
| LangGraph / CrewAI / AutoGen / OpenAI Agents SDK / Google ADK | 可编程多 Agent 框架 | 面向开发者的 Graph、角色、路由、工具和审批。 | 团队无需编写编排框架代码即可获得桌面评审流程。 |
|
||||
| Warp Oz / Devin / Factory / OpenHands Cloud | 云端 Agent 控制面 | 托管 Sandbox、远程执行和 Agent Fleet。 | 在本地 Electron 工作单元中使用现有 CLI,并保持本地数据边界。 |
|
||||
过程记录帮助检查协作情况,不代表每条结论都经过事实核验。Agent 的相互检查也不能保证结果正确,重要交付物仍需按实际用途验收。
|
||||
|
||||
## 运行示例
|
||||
## General Agent 目录
|
||||
|
||||
当适配器和 CLI 版本兼容时,Meldwork 会检测并调用已安装的 Agent 命令。当前目录包括:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>本地 Agent 检测</th>
|
||||
<th>多 Agent 评审</th>
|
||||
<th>直接会话中的多模态工作</th>
|
||||
<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"><a href="assets/meldwork-agent-discovery.png"><img src="assets/meldwork-agent-discovery.png" alt="Meldwork 本地优先多 Agent 工作空间检测 Agent CLI" width="320" height="205"></a></td>
|
||||
<td align="center"><a href="assets/meldwork-multi-agent-review.png"><img src="assets/meldwork-multi-agent-review.png" alt="Meldwork 多 Agent 协作、证据与人工复核" width="320" height="205"></a></td>
|
||||
<td align="center"><a href="assets/meldwork-direct-multimodal.png"><img src="assets/meldwork-direct-multimodal.png" alt="Meldwork 直接 Agent 工作空间处理本地文件和媒体" width="320" height="205"></a></td>
|
||||
<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>
|
||||
|
||||
示例:把同一份变更评审上下文交给 Codex、Claude Code 和 Gemini CLI,比较各自的独立发现,再只采用你批准且有证据支持的结果。
|
||||
已批准的 Agent Connector 和自定义可执行 Agent 使用桌面端的连接器入口。完整的适配器、安装、Provider 和能力矩阵见[桌面端指南](desktop/README.md)。
|
||||
[Agent Connector SDK 指南](desktop/AGENT_CONNECTOR_SDK.md)介绍当前支持的包、审批、升级和一致性检查。
|
||||
|
||||
## 支持的本地 Agent CLI
|
||||
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 会优先使用该配置,并传递其中声明的凭据环境变量。
|
||||
|
||||
当适配器和 CLI 版本兼容时,Meldwork 会检测并调用已安装的命令:
|
||||
WorkBuddy 也可以在「设置 → Agent」中选择已有的原生配置目录;模型列表、执行环境以及本地和插件 Skill 都会使用所选目录。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><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" width="16.66%"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
OpenClaw 也可以在「设置 → Agent」中选择原生状态目录。Meldwork 会从该目录读取所选 Agent 的模型配置,并在受保护的本地运行环境中执行。
|
||||
|
||||
已批准的 Agent Connector 可通过 [Agent Connector SDK](docs/agent-connector-sdk.md) 接入;自定义可执行 Agent 使用桌面端的自定义 Agent 入口。完整适配器和能力矩阵见[桌面端指南](desktop/README.md)。
|
||||
切换 Agent 的原生配置目录时,Meldwork 会按目录分别保存本地会话,切回原目录后恢复对应会话。任务运行中或等待恢复时,暂时不能切换配置。
|
||||
|
||||
## 快速开始
|
||||
Zcode 请选择包含 `config.json` 的 `.zcode/cli` 目录。运行时 Zcode 会使用该目录所属的用户主目录,这也会影响其会话及其他文件中的主目录相对路径;项目配置仍可能覆盖用户配置。
|
||||
|
||||
### Apple 芯片 macOS 预览版
|
||||
Qwen Code 请选择包含设置或认证文件的 `.qwen` 目录。Qwen 会使用其所属的用户主目录保存原生会话、读取 Skill 和其他主目录相对文件;项目和系统设置仍遵循原生优先级。
|
||||
|
||||
从[官方 V1.0.4 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.4)下载 [`Meldwork-0.1.4-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.4/Meldwork-0.1.4-arm64.dmg),将 Meldwork 移入“应用程序”,并至少安装一个受支持的本地 Agent CLI。该预发布版使用 ad-hoc 临时签名且未公证;首次启动时,macOS 可能要求你在“系统设置 -> 隐私与安全性”中选择“仍要打开 / Open Anyway”。
|
||||
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。
|
||||
|
||||
@@ -135,19 +140,13 @@ npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
```
|
||||
|
||||
## 当前边界
|
||||
|
||||
Meldwork 是本地 Electron 应用,不是托管式 Agent Fleet,也不是通用 Agent 框架。当前预览版不提供远程、云端或频道 Agent 执行,不提供自动选人组队、企业 SSO/RBAC/治理或 Outcome Network。Local-first 不等于完全离线:选定的 Agent 仍可能把 Prompt、附件或 Skill(技能)发送到其配置的 Provider(模型服务商)。工作区写入是可选的工作流控制,不是操作系统级沙箱。
|
||||
|
||||
## 文档
|
||||
|
||||
- [架构与产品边界](architecture.md)
|
||||
- [桌面端设置与 Agent 矩阵](desktop/README.md)
|
||||
- [Agent Connector SDK](docs/agent-connector-sdk.md)
|
||||
- [AI 可发现性索引](docs/ai-discoverability.md)
|
||||
- [贡献指南](CONTRIBUTING.md)
|
||||
- [安全策略](SECURITY.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
Meldwork 采用 [Meldwork 非商用源码许可 1.0](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、
|
||||
@@ -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.4",
|
||||
"version": "1.0.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.4",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.4",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"description": "A local-first desktop workspace for multi-Agent collaboration",
|
||||
"author": "Ryder-MHumble",
|
||||
@@ -12,7 +12,7 @@
|
||||
"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",
|
||||
@@ -26,7 +26,10 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -38,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/**/*",
|
||||
@@ -59,6 +66,10 @@
|
||||
{
|
||||
"from": "../COMMERCIAL_USE.md",
|
||||
"to": "COMMERCIAL_USE.md"
|
||||
},
|
||||
{
|
||||
"from": "build/dmg-background-ai-v4.tiff",
|
||||
"to": "dmg-background.tiff"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -70,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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -5,7 +5,7 @@ const TEST_SUITES = Object.freeze({
|
||||
agents: ['agents'],
|
||||
attachments: ['attachments'],
|
||||
collaboration: ['collaboration'],
|
||||
core: ['channels', 'knowledge', 'media', 'providers', 'skills'],
|
||||
core: ['channels', 'knowledge', 'media', 'skills'],
|
||||
runs: ['runs'],
|
||||
security: ['security', 'shell'],
|
||||
workspace: ['workspace', 'gates'],
|
||||
|
||||
@@ -1,13 +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],
|
||||
@@ -19,9 +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'],
|
||||
@@ -56,8 +60,18 @@ 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'], []),
|
||||
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'], [
|
||||
@@ -70,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',
|
||||
@@ -99,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,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
|
||||
@@ -174,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))]
|
||||
@@ -199,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 => (
|
||||
@@ -261,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 }) {
|
||||
@@ -341,35 +349,56 @@ class AgentRouter {
|
||||
&& explicitTargets.every(kind => isCodeReviewAgentKind(kind))
|
||||
const detected = new Map((Array.isArray(agents) ? agents : []).map(agent => [agent.kind, agent]))
|
||||
const permissionTargets = effectiveMode === 'explicit' ? explicitTargets : approvedKinds
|
||||
const targetWriteSupport = permissionTargets.map((kind) => (
|
||||
agentRuntimeCapabilities(kind, {
|
||||
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 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
|
||||
@@ -385,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
|
||||
@@ -405,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,10 @@ 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'],
|
||||
@@ -91,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'],
|
||||
@@ -213,16 +222,32 @@ function normalizeExternalRunRef(value) {
|
||||
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)\b|(?:invalid|expired|missing)\b[\s\S]*(?:token|credential|api[ _-]?key|auth))/i
|
||||
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'
|
||||
@@ -299,9 +324,19 @@ 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,
|
||||
@@ -310,7 +345,10 @@ function requireTerminalAgentResult(result) {
|
||||
}
|
||||
const authenticationDiagnostic = terminalAuthenticationDiagnostic(normalized.text)
|
||||
if (authenticationDiagnostic) {
|
||||
throw agentRuntimeError('LOCAL_AGENT_AUTH_REQUIRED', authenticationDiagnostic)
|
||||
throw agentRuntimeError(
|
||||
authFailureCode(authenticationDiagnostic) || 'LOCAL_AGENT_ACCESS_DENIED',
|
||||
authenticationDiagnostic,
|
||||
)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -338,6 +376,8 @@ module.exports = {
|
||||
agentRuntimeCapabilities,
|
||||
agentRuntimeError,
|
||||
agentRuntimeFailure,
|
||||
authConfigurationFailure,
|
||||
authFailureCode,
|
||||
isCodeReviewAgentKind,
|
||||
isReviewOnlyAgentKind,
|
||||
normalizeAgentResult,
|
||||
|
||||
@@ -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
|
||||
@@ -753,6 +914,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
})).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(() => {
|
||||
@@ -813,22 +994,25 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
try {
|
||||
if (!protocolSessionRef) {
|
||||
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
|
||||
@@ -844,14 +1028,28 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
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
|
||||
}
|
||||
@@ -880,15 +1078,21 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
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)
|
||||
@@ -901,7 +1105,26 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,11 +1188,17 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
try {
|
||||
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()
|
||||
}
|
||||
@@ -1007,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) {
|
||||
|
||||
@@ -30,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,
|
||||
@@ -45,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
|
||||
@@ -123,13 +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,
|
||||
@@ -213,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 })
|
||||
@@ -245,11 +264,13 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
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
|
||||
@@ -282,6 +303,12 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -294,6 +321,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
clearTimeout(forceSettleTimeout)
|
||||
clearTimeout(terminalKillTimer)
|
||||
clearTimeout(terminalForceKillTimer)
|
||||
clearTimeout(brokenInputTimer)
|
||||
options.signal?.removeEventListener('abort', abort)
|
||||
callback()
|
||||
}
|
||||
@@ -313,9 +341,13 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
if (terminalObserved || !event || typeof event !== 'object') return
|
||||
const terminal = (
|
||||
(['workbuddy', 'claude', 'qwen'].includes(agent.kind) && event.type === 'result')
|
||||
|| (agent.kind === 'pi' && ['turn_end', 'agent_end'].includes(event.type))
|
||||
|| (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')
|
||||
|| (['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')
|
||||
)
|
||||
@@ -333,21 +365,17 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
if (settled || stopRequested) return
|
||||
stopRequested = true
|
||||
const terminalError = inputError || new Error('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
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(() => {
|
||||
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(
|
||||
terminalError,
|
||||
inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped'),
|
||||
)
|
||||
}), KILL_SETTLE_MS)
|
||||
rejectTerminal(error, 'failed')
|
||||
}))
|
||||
return
|
||||
}
|
||||
signalProcessTree('SIGTERM')
|
||||
@@ -384,22 +412,40 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
})
|
||||
child.stdin.on('error', (error) => {
|
||||
if (settled || inputError) return
|
||||
inputError = agentExecutionError(
|
||||
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 => 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) => finish(() => {
|
||||
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) {
|
||||
@@ -421,6 +467,12 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
})
|
||||
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'
|
||||
@@ -428,16 +480,18 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
&& ['completed', 'partial'].includes(result.outcome)
|
||||
if (code !== 0 && !usableNonZeroResult) {
|
||||
const detail = redactChildSecrets(
|
||||
[rawStderr, structuredDetail].filter(Boolean).join('\n'),
|
||||
[result?.error, rawStderr, structuredDetail].filter(Boolean).join('\n'),
|
||||
childEnv,
|
||||
)
|
||||
rejectTerminal(failedAgentProcessError(detail, { sessionRef: failureSessionRef }))
|
||||
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
|
||||
}
|
||||
@@ -484,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()
|
||||
})
|
||||
|
||||
@@ -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,9 +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'] },
|
||||
@@ -26,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',
|
||||
@@ -38,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()
|
||||
@@ -99,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'
|
||||
@@ -114,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'),
|
||||
@@ -143,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',
|
||||
)
|
||||
@@ -188,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(
|
||||
@@ -206,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)
|
||||
@@ -243,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 */ }
|
||||
@@ -330,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,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)
|
||||
@@ -396,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)
|
||||
@@ -433,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',
|
||||
@@ -446,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 } : {}),
|
||||
} : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,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)
|
||||
}
|
||||
|
||||
@@ -485,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,
|
||||
@@ -182,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,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) {
|
||||
@@ -450,14 +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,
|
||||
@@ -466,7 +582,7 @@ 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 }),
|
||||
|
||||
@@ -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({
|
||||
@@ -7,12 +8,13 @@ const IMAGE_ATTACHMENT_LIMITS = Object.freeze({
|
||||
opencode: 4,
|
||||
})
|
||||
const DEFAULT_INTERNAL_TURN_LIMITS = Object.freeze({
|
||||
// Meldwork already owns the outer discussion rounds. Keep WorkBuddy's
|
||||
// nested tool loop bounded so one participant cannot dominate a round.
|
||||
// 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]
|
||||
@@ -73,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) {
|
||||
@@ -86,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 {
|
||||
@@ -94,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',
|
||||
@@ -143,20 +161,35 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
'--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',
|
||||
...(maxTurns ? ['--max-turns', String(maxTurns)] : []),
|
||||
...(options.model ? ['--model', options.model] : []),
|
||||
...(sessionRef ? ['--resume', sessionRef] : []),
|
||||
],
|
||||
promptArg: true,
|
||||
eventTransport: 'stream-json',
|
||||
}
|
||||
}
|
||||
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: [
|
||||
'--mode', 'json', '--print',
|
||||
...(options.sandbox === 'workspace-write' ? ['--approve'] : ['--no-approve']),
|
||||
...(options.sandbox === 'workspace-write'
|
||||
? ['--approve']
|
||||
: ['--no-approve', '--tools', 'read,grep,find,ls']),
|
||||
...(sessionRef ? ['--session', sessionRef] : []),
|
||||
],
|
||||
promptArg: true,
|
||||
@@ -164,31 +197,20 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
}
|
||||
}
|
||||
if (kind === 'kimi') {
|
||||
if (options.sandbox !== 'workspace-write') {
|
||||
return {
|
||||
command: executable,
|
||||
args: ['acp'],
|
||||
acpMode: 'plan',
|
||||
eventTransport: 'acp',
|
||||
}
|
||||
}
|
||||
// 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: [
|
||||
'--output-format', 'stream-json',
|
||||
'--auto',
|
||||
...(sessionRef ? ['--session', sessionRef] : []),
|
||||
'--prompt',
|
||||
],
|
||||
promptArg: true,
|
||||
eventTransport: 'stream-json',
|
||||
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',
|
||||
@@ -198,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,
|
||||
@@ -226,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,
|
||||
@@ -249,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 }
|
||||
@@ -172,17 +172,38 @@ 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' && typeof event.result === 'string') {
|
||||
&& event.type === 'result') {
|
||||
if (typeof event.session_id === 'string') state.sessionRef = event.session_id
|
||||
state.failure = null
|
||||
state.diagnostic = ''
|
||||
state.text = ''
|
||||
appendStructuredText(state, event.result, '')
|
||||
state.outcome = event.is_error === true ? 'partial' : 'completed'
|
||||
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)) {
|
||||
@@ -216,13 +237,14 @@ function ingestStructuredEvent(state, event) {
|
||||
}
|
||||
|
||||
if (state.kind === 'pi') {
|
||||
if (typeof event.id === 'string') state.sessionRef = event.id
|
||||
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' && Array.isArray(event.message?.content)) {
|
||||
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, '')
|
||||
@@ -234,7 +256,17 @@ function ingestStructuredEvent(state, event) {
|
||||
if (event.type === 'turn_end') {
|
||||
state.outcome = stepFinishOutcome(event.message?.stopReason || event.stopReason || 'stop')
|
||||
}
|
||||
if (event.type === 'agent_end') state.outcome = 'completed'
|
||||
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
|
||||
}
|
||||
@@ -398,7 +430,8 @@ function finalizeStructuredState(state, context = {}) {
|
||||
}
|
||||
|
||||
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.kind === 'pi'
|
||||
? stripPiPromptEcho(state.text, context.prompt)
|
||||
@@ -709,7 +742,8 @@ function parsePiOutput(stdout) {
|
||||
&& typeof event.assistantMessageEvent.delta === 'string') {
|
||||
text += event.assistantMessageEvent.delta
|
||||
}
|
||||
if (!text && event.type === 'message_end' && Array.isArray(event.message?.content)) {
|
||||
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)
|
||||
@@ -718,7 +752,7 @@ function parsePiOutput(stdout) {
|
||||
}
|
||||
const parsedText = text.trim()
|
||||
const answer = stripPiInternalPromptEcho(parsedText)
|
||||
return { text: parsedText ? answer : String(stdout || '').trim(), sessionRef }
|
||||
return { text: parsedText ? answer : events.length ? '' : String(stdout || '').trim(), sessionRef }
|
||||
}
|
||||
|
||||
function openCodeReviewText(output) {
|
||||
@@ -833,8 +867,13 @@ function classifyCliOutcome(kind, stdout) {
|
||||
}
|
||||
if (kind === 'pi') {
|
||||
const terminal = events.findLast(event => event?.type === 'turn_end' || event?.type === 'agent_end')
|
||||
const reason = terminal?.message?.stopReason || terminal?.stopReason || 'stop'
|
||||
return { outcome: stepFinishOutcome(reason) }
|
||||
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 => (
|
||||
@@ -872,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) {
|
||||
@@ -899,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 {
|
||||
@@ -917,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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -425,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'
|
||||
@@ -447,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
|
||||
@@ -465,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 } : {}),
|
||||
|
||||
@@ -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]')
|
||||
|
||||
@@ -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 }
|
||||
@@ -2,6 +2,7 @@ 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)
|
||||
@@ -33,6 +34,21 @@ const AGENT_DEFINITIONS = Object.freeze([
|
||||
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) {
|
||||
@@ -56,10 +72,20 @@ function commandResult(spawnSyncFn, command, args) {
|
||||
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(home, '.codex', 'skills'), path.join(home, '.agents', 'skills')],
|
||||
hermes: [path.join(home, '.hermes', 'skills'), path.join(home, '.agents', 'skills')],
|
||||
claude: [path.join(home, '.claude', 'skills'), path.join(home, '.agents', 'skills')],
|
||||
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) {
|
||||
@@ -79,25 +105,27 @@ function discoverAgents(options = {}) {
|
||||
const command = (name, args) => commandResult(spawnSyncFn, name, args)
|
||||
const agents = []
|
||||
for (const definition of AGENT_DEFINITIONS) {
|
||||
const versionResult = command(definition.id, ['--version'])
|
||||
const version = versionResult.status === 0 ? versionFrom(versionResult.stdout) : ''
|
||||
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 available = definition.auth(command)
|
||||
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,
|
||||
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,
|
||||
credentialState: available ? 'ready' : 'missing',
|
||||
executionSupported: Boolean(definition.auth),
|
||||
credentialState: auth === true ? 'ready' : auth === false ? 'missing' : 'unknown',
|
||||
skills: listSkillsFn(definition.id),
|
||||
session: {
|
||||
supported: true,
|
||||
resume: true,
|
||||
supported: Boolean(definition.auth),
|
||||
resume: Boolean(definition.auth),
|
||||
cancel: true,
|
||||
checkpoint: false,
|
||||
},
|
||||
@@ -133,23 +161,50 @@ function invocationFor(agentId, prompt, sessionRef = '') {
|
||||
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 text ? { text, sessionRef, outcome: 'completed' } : null
|
||||
return completed && text ? { text, sessionRef, outcome: 'completed' } : null
|
||||
}
|
||||
if (agentId === 'hermes') {
|
||||
const lines = String(stdout || '').split(/\r?\n/)
|
||||
@@ -162,6 +217,7 @@ function parseAgentOutput(agentId, stdout) {
|
||||
}
|
||||
return true
|
||||
}).join('\n').trim()
|
||||
if (hermesFailedTurn(text)) return null
|
||||
return text ? { text, sessionRef, outcome: 'completed' } : null
|
||||
}
|
||||
if (agentId === 'claude') {
|
||||
@@ -173,6 +229,17 @@ function parseAgentOutput(agentId, stdout) {
|
||||
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 = ''
|
||||
@@ -196,8 +263,9 @@ function parentSessionDetached(parentPid = process.ppid, readFileSync = fs.readF
|
||||
}
|
||||
}
|
||||
|
||||
function runAgent(agentId, prompt, sessionRef = '') {
|
||||
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' } })
|
||||
@@ -205,16 +273,85 @@ function runAgent(agentId, prompt, sessionRef = '') {
|
||||
}
|
||||
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 => {
|
||||
stdout += chunk.toString('utf8')
|
||||
if (Buffer.byteLength(stdout) > MAX_RESPONSE_BYTES) child.kill('SIGTERM')
|
||||
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', () => resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_UNAVAILABLE' } }))
|
||||
child.once('close', code => {
|
||||
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' } })
|
||||
})
|
||||
@@ -239,7 +376,13 @@ function createServer() {
|
||||
if (!agent || typeof body.prompt !== 'string' || body.permissionMode !== 'read-only') {
|
||||
return json(res, 400, { error: 'REQUEST_INVALID' })
|
||||
}
|
||||
return json(res, 200, await runAgent(agent.id, body.prompt, body.sessionRef || ''))
|
||||
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' })
|
||||
}
|
||||
@@ -265,6 +408,7 @@ function startServer() {
|
||||
if (require.main === module || !require.main) startServer()
|
||||
|
||||
module.exports = {
|
||||
AGENT_DEFINITIONS,
|
||||
createServer,
|
||||
discoverAgents,
|
||||
invocationFor,
|
||||
|
||||
@@ -2,8 +2,10 @@ 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')
|
||||
|
||||
@@ -22,7 +24,12 @@ 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 SSH_DISCOVERY_COMMAND = 'if command -v codex >/dev/null 2>&1; then codex --version; fi'
|
||||
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')
|
||||
@@ -131,16 +138,18 @@ function normalizeAgent(input) {
|
||||
return {
|
||||
id, sourceKind, label, version,
|
||||
description: cleanText(input.description, 400),
|
||||
available: input.available !== false,
|
||||
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 !== false,
|
||||
resume: input.session?.resume !== false,
|
||||
cancel: input.session?.cancel !== false,
|
||||
supported: input.session?.supported === true,
|
||||
resume: input.session?.supported === true && input.session?.resume === true,
|
||||
cancel: input.session?.cancel === true,
|
||||
checkpoint: false,
|
||||
},
|
||||
}
|
||||
@@ -184,8 +193,10 @@ function manifestFor(record, agent) {
|
||||
invocation: { recipeId: ssh ? SSH_RECIPE_ID : BRIDGE_RECIPE_ID, idempotencyMode: 'durable' },
|
||||
domains: agent.domains,
|
||||
session: agent.session,
|
||||
inputTypes: agent.inputTypes,
|
||||
permissionModes: agent.permissionModes,
|
||||
// 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: {
|
||||
@@ -224,6 +235,7 @@ class CloudAgentBridgeStore {
|
||||
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
|
||||
@@ -273,7 +285,13 @@ class CloudAgentBridgeStore {
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs)
|
||||
? Math.max(1000, Math.min(MAX_REQUEST_TIMEOUT_MS, Math.floor(options.timeoutMs)))
|
||||
: this.timeoutMs
|
||||
const timer = setTimeout(() => controller.abort(), 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 */ }
|
||||
@@ -282,22 +300,34 @@ class CloudAgentBridgeStore {
|
||||
const activityTimer = options.onActivity
|
||||
? setInterval(reportActivity, activityIntervalMs)
|
||||
: null
|
||||
const abortFromCaller = () => controller.abort()
|
||||
const abortFromCaller = () => abort('caller')
|
||||
options.signal?.addEventListener?.('abort', abortFromCaller, { once: true })
|
||||
if (options.signal?.aborted) abortFromCaller()
|
||||
try {
|
||||
const response = await this.fetch(`${options.address}${pathname}`, {
|
||||
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: JSON.stringify(options.body) } : {}),
|
||||
...(options.body ? { body: options.bodyText ?? JSON.stringify(options.body) } : {}),
|
||||
})
|
||||
if (!response?.ok) throw bridgeError(`CLOUD_AGENT_BRIDGE_HTTP_${response?.status || 'FAILED'}`)
|
||||
const text = await response.text()
|
||||
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) fail('CLOUD_AGENT_BRIDGE_RESPONSE_TOO_LARGE')
|
||||
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('CLOUD_AGENT_BRIDGE_TIMEOUT')
|
||||
if (error?.name === 'AbortError') {
|
||||
fail(abortCause === 'caller' ? 'LOCAL_AGENT_EXECUTION_STOPPED' : 'CLOUD_AGENT_BRIDGE_TIMEOUT')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
@@ -436,17 +466,31 @@ class CloudAgentBridgeStore {
|
||||
}
|
||||
|
||||
async probeSsh(address) {
|
||||
const result = await this.sshCommand(address, SSH_DISCOVERY_COMMAND)
|
||||
const version = String(result.stdout || '').match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/)?.[1]
|
||||
if (!version) fail('CLOUD_AGENT_SSH_NO_AGENT')
|
||||
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: [{
|
||||
id: 'codex', label: 'Codex CLI', version, description: '', domains: ['general'],
|
||||
inputTypes: ['text'], permissionModes: ['read-only'],
|
||||
session: { supported: false, resume: false, cancel: true, checkpoint: false },
|
||||
}],
|
||||
agents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,6 +652,12 @@ class CloudAgentBridgeStore {
|
||||
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 }
|
||||
}
|
||||
@@ -632,14 +682,13 @@ class CloudAgentBridgeStore {
|
||||
installed: true,
|
||||
installSupported: false,
|
||||
installErrorCode: '',
|
||||
providerCompatible: false,
|
||||
providerMode: 'connector',
|
||||
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,
|
||||
})))
|
||||
}
|
||||
@@ -661,28 +710,85 @@ class CloudAgentBridgeStore {
|
||||
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')
|
||||
if (record.transport === 'ssh') return this.runSsh(record, agent, input)
|
||||
return this.request(`/v1/agents/${encodeURIComponent(agentId)}/runs`, {
|
||||
address: record.transport === 'ssh-tunnel' ? record.endpoint : record.address,
|
||||
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: input.signal,
|
||||
signal,
|
||||
timeoutMs: this.runTimeoutMs,
|
||||
onActivity: input.onActivity,
|
||||
body: {
|
||||
prompt: input.prompt,
|
||||
sessionRef: input.sessionRef || null,
|
||||
permissionMode: input.permissionMode,
|
||||
operationId: input.operationId,
|
||||
resume: input.resume || null,
|
||||
},
|
||||
})
|
||||
body,
|
||||
bodyText: serializedBody,
|
||||
}))
|
||||
}
|
||||
|
||||
async runSsh(record, agent, input) {
|
||||
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')
|
||||
}
|
||||
const result = await this.sshCommand(record.address, SSH_CODEX_COMMAND, input.prompt, input.signal)
|
||||
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')) {
|
||||
|
||||
@@ -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')
|
||||
@@ -21,6 +25,9 @@ const { BRIDGE_RECIPE_ID, SSH_RECIPE_ID } = require('../cloud/cloud-agent-bridge
|
||||
|
||||
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',
|
||||
@@ -138,7 +145,7 @@ function fail(code) {
|
||||
function exactOptions(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
&& Object.keys(value).every(key => [
|
||||
'cloudBridges', 'fetch', 'instanceStore', 'manifestDirectory', 'packageStore', 'runAgent', 'seedSample',
|
||||
'cloudBridges', 'fetch', 'httpTimeoutMs', 'instanceStore', 'manifestDirectory', 'packageStore', 'runAgent', 'seedSample',
|
||||
].includes(key))
|
||||
}
|
||||
|
||||
@@ -185,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'] }
|
||||
: {}
|
||||
}
|
||||
|
||||
@@ -223,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)
|
||||
@@ -235,6 +245,8 @@ 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)
|
||||
@@ -260,6 +272,8 @@ 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),
|
||||
@@ -336,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 {
|
||||
@@ -475,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,
|
||||
@@ -487,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
|
||||
}
|
||||
@@ -534,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 => (
|
||||
@@ -544,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
|
||||
@@ -555,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
|
||||
}
|
||||
|
||||
@@ -603,8 +675,6 @@ class LocalAgentConnectors {
|
||||
installed: true,
|
||||
installSupported: false,
|
||||
installErrorCode: '',
|
||||
providerCompatible: false,
|
||||
providerMode: 'connector',
|
||||
imageAttachmentLimit: manifest.inputTypes.includes('image') ? 32 : 0,
|
||||
custom: !cloud,
|
||||
connector: true,
|
||||
@@ -635,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,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') {
|
||||
@@ -739,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) {
|
||||
@@ -747,59 +820,128 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,9 +954,6 @@ class LocalAgentConnectors {
|
||||
sequence: 1,
|
||||
}
|
||||
try {
|
||||
if (!['ssh', 'ssh-tunnel'].includes(entry.record.transport)) {
|
||||
input.onOutboundPayload({ destination: entry.record.address, transport: 'http' })
|
||||
}
|
||||
const result = await this.cloudBridges.run({
|
||||
bridgeId: entry.record.bridgeId,
|
||||
agentId: entry.agent.id,
|
||||
@@ -824,9 +963,11 @@ class LocalAgentConnectors {
|
||||
permissionMode: 'read-only',
|
||||
operationId: input.operationId,
|
||||
resume: input.resume,
|
||||
signal: input.signal,
|
||||
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
|
||||
@@ -851,9 +992,10 @@ class LocalAgentConnectors {
|
||||
outcome: result.outcome || 'completed',
|
||||
}
|
||||
} catch (error) {
|
||||
if (input.signal?.aborted || error?.name === 'AbortError') {
|
||||
input.emit({ ...terminal, type: 'Cancelled', reason: 'user' })
|
||||
return { outcome: 'cancelled', sessionRef: input.sessionRef || '' }
|
||||
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 })
|
||||
|
||||
@@ -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
|
||||
@@ -90,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',
|
||||
@@ -99,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: 'codex', label: 'Codex', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'responses-required',
|
||||
},
|
||||
{
|
||||
kind: 'claude', label: 'Claude Code', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'anthropic-required',
|
||||
},
|
||||
{
|
||||
kind: 'hermes', label: 'Hermes', recommended: true, providerCompatible: true,
|
||||
providerSupport: 'supported',
|
||||
},
|
||||
{
|
||||
kind: 'workbuddy', label: 'WorkBuddy', recommended: true, providerCompatible: true,
|
||||
providerSupport: 'experimental',
|
||||
},
|
||||
{
|
||||
kind: 'openclaw', label: 'OpenClaw', recommended: true, providerCompatible: true,
|
||||
providerSupport: 'supported',
|
||||
},
|
||||
{
|
||||
kind: 'pi', label: 'Pi Agent', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'native-config',
|
||||
},
|
||||
{
|
||||
kind: 'opencode', label: 'OpenCode', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'native-config',
|
||||
},
|
||||
{
|
||||
kind: 'opencodereview', label: 'OpenCodeReview', recommended: false, providerCompatible: true,
|
||||
providerSupport: 'supported',
|
||||
},
|
||||
{
|
||||
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: 'qwen', label: 'Qwen Code', 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) {
|
||||
@@ -372,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 || ''
|
||||
|
||||
@@ -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')
|
||||
@@ -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', 'discussionStyle', '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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -1899,6 +1995,15 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
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, participantKinds, snapshotHash,
|
||||
@@ -2014,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')
|
||||
}
|
||||
@@ -2026,6 +2146,7 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
template,
|
||||
phase,
|
||||
...(discussionStyle ? { discussionStyle } : {}),
|
||||
...(discussionWriterKind ? { discussionWriterKind } : {}),
|
||||
batchId,
|
||||
currentKinds,
|
||||
activeKinds,
|
||||
@@ -2170,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')
|
||||
}
|
||||
@@ -2180,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')
|
||||
@@ -2204,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)
|
||||
@@ -2221,6 +2344,7 @@ 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)
|
||||
@@ -2310,6 +2434,7 @@ function parseCollaborationControlBlock(input, options = {}) {
|
||||
status,
|
||||
summary,
|
||||
conclusion,
|
||||
...(taskDecision ? { taskDecision } : {}),
|
||||
artifactIds,
|
||||
evidenceIds,
|
||||
findingIds,
|
||||
@@ -2348,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])
|
||||
: []),
|
||||
@@ -2556,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,6 +31,11 @@ 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')
|
||||
@@ -40,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,
|
||||
@@ -70,26 +89,39 @@ 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', 'pi', '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
|
||||
@@ -226,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')) {
|
||||
@@ -371,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'
|
||||
@@ -402,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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -475,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'),
|
||||
})
|
||||
@@ -533,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,
|
||||
@@ -552,6 +736,7 @@ function createWorkspace() {
|
||||
}),
|
||||
outcomeStore,
|
||||
agentFitMatrix: FROZEN_AGENT_FIT_MATRIX,
|
||||
resolveProjectEnvironmentIdentities,
|
||||
runLedger: cloudAgentRuntime.workspaceLedger(),
|
||||
detectAgents: async () => {
|
||||
await cloudAgentBridges?.refresh()
|
||||
@@ -570,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,
|
||||
@@ -578,18 +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,
|
||||
naturalAgentResponses: true,
|
||||
generateMedia: input => mediaGeneration.generate(input),
|
||||
runAgent: async (agent, prompt, workdir, options = {}) => {
|
||||
if (customAgentStore.has(agent.kind)) {
|
||||
return customAgentStore.run(agent.kind, prompt, workdir, {
|
||||
@@ -651,8 +846,9 @@ async function localAgentCatalog() {
|
||||
credentialState: agent.installed
|
||||
? (state?.credentialState || agent.credentialState || 'unknown')
|
||||
: 'missing',
|
||||
availabilitySource: agent.installed ? (state?.availabilitySource || 'unverified') : 'none',
|
||||
showInSidebar: Boolean(agent.installed && state?.showInSidebar),
|
||||
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),
|
||||
@@ -666,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
|
||||
@@ -687,7 +884,7 @@ function initializeWorkspace() {
|
||||
return next
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
function createWindow(startupError = workspaceStartupError) {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 920,
|
||||
@@ -734,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
|
||||
}
|
||||
|
||||
@@ -773,6 +981,22 @@ function registerIpc() {
|
||||
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,
|
||||
@@ -783,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,
|
||||
})
|
||||
}
|
||||
@@ -799,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) {
|
||||
@@ -812,16 +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,
|
||||
})
|
||||
cloudAgentBridges = new CloudAgentBridgeStore({
|
||||
storagePath: path.join(app.getPath('userData'), 'meldwork-cloud-agent-bridges.json'),
|
||||
fetch: globalThis.fetch,
|
||||
})
|
||||
providerStore = new ProviderStore({
|
||||
storagePath: path.join(app.getPath('userData'), 'meldwork-provider.json'),
|
||||
safeStorage: lazySafeStorage,
|
||||
allowedKinds: [...LOCAL_AGENT_KINDS],
|
||||
})
|
||||
// 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'),
|
||||
})
|
||||
@@ -833,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()
|
||||
@@ -847,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)
|
||||
|
||||
@@ -89,13 +89,18 @@
|
||||
},
|
||||
}
|
||||
const locale = navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
||||
const copy = messages[locale]
|
||||
const errorCode = new URLSearchParams(location.search).get('code') || 'UNKNOWN'
|
||||
const ledgerMessages = {
|
||||
zh: { status: '本地运行记录无法读取', title: '无法打开 Meldwork', message: '为保护已有运行记录,应用已停止初始化。请备份应用数据并从有效备份恢复记录,之后重新启动。', code: '错误代码' },
|
||||
en: { status: 'Local run history could not be read', title: 'Meldwork could not open', message: 'Initialization stopped to protect your existing run history. Back up the application data, restore the records from a valid backup, and restart.', code: 'Error code' },
|
||||
}
|
||||
const copy = errorCode === 'RUN_LEDGER_LOAD_FAILED' ? ledgerMessages[locale] : messages[locale]
|
||||
document.documentElement.lang = locale === 'zh' ? 'zh-CN' : 'en'
|
||||
document.title = copy.title
|
||||
document.querySelector('#status').textContent = copy.status
|
||||
document.querySelector('#title').textContent = copy.title
|
||||
document.querySelector('#message').textContent = copy.message
|
||||
document.querySelector('#error-code').textContent = `${copy.code}: ${new URLSearchParams(location.search).get('code') || 'UNKNOWN'}`
|
||||
document.querySelector('#error-code').textContent = `${copy.code}: ${errorCode}`
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -126,6 +126,21 @@ if (isLocalDocument) Object.assign(desktopApi, {
|
||||
localOutcome: Object.freeze({
|
||||
recordAdoption: input => ipcRenderer.invoke('local-outcome:record-adoption', input),
|
||||
}),
|
||||
localDelivery: Object.freeze({
|
||||
save: input => ipcRenderer.invoke('local-delivery:save', input),
|
||||
review: input => ipcRenderer.invoke('local-delivery:review', input),
|
||||
revise: input => ipcRenderer.invoke('local-delivery:revise', input),
|
||||
reuse: input => ipcRenderer.invoke('local-delivery:reuse', input),
|
||||
export: input => ipcRenderer.invoke('local-delivery:export', input),
|
||||
}),
|
||||
workBuddy: Object.freeze({
|
||||
settings: () => ipcRenderer.invoke('local-workbuddy:settings'),
|
||||
setModel: modelId => ipcRenderer.invoke('local-workbuddy:set-model', modelId),
|
||||
}),
|
||||
workPreferences: Object.freeze({
|
||||
get: () => ipcRenderer.invoke('local-work-preferences:get'),
|
||||
save: input => ipcRenderer.invoke('local-work-preferences:save', input),
|
||||
}),
|
||||
agentInstaller: Object.freeze({
|
||||
catalog: () => ipcRenderer.invoke('local-agent-installer:catalog'),
|
||||
skills: kind => ipcRenderer.invoke('local-agent-installer:skills', kind),
|
||||
@@ -141,6 +156,11 @@ if (isLocalDocument) Object.assign(desktopApi, {
|
||||
return () => ipcRenderer.removeListener('local-agent-installer:changed', listener)
|
||||
},
|
||||
}),
|
||||
localAgentNativeConfig: Object.freeze({
|
||||
get: kind => ipcRenderer.invoke('local-agent-native-config:get', kind),
|
||||
choose: kind => ipcRenderer.invoke('local-agent-native-config:choose', kind),
|
||||
clear: kind => ipcRenderer.invoke('local-agent-native-config:clear', kind),
|
||||
}),
|
||||
localSkillTrust: Object.freeze({
|
||||
list: () => ipcRenderer.invoke('local-skill-trust:list'),
|
||||
revoke: bindingId => ipcRenderer.invoke('local-skill-trust:revoke', bindingId),
|
||||
@@ -158,6 +178,7 @@ if (isLocalDocument) Object.assign(desktopApi, {
|
||||
approve: packageId => ipcRenderer.invoke('local-agent-connector:approve', packageId),
|
||||
install: packageId => ipcRenderer.invoke('local-agent-connector:install', packageId),
|
||||
configure: input => ipcRenderer.invoke('local-agent-connector:configure', input),
|
||||
configureFromEnvironment: input => ipcRenderer.invoke('local-agent-connector:configure-from-environment', input),
|
||||
test: instanceId => ipcRenderer.invoke('local-agent-connector:test', instanceId),
|
||||
disable: packageId => ipcRenderer.invoke('local-agent-connector:disable', packageId),
|
||||
revoke: packageId => ipcRenderer.invoke('local-agent-connector:revoke', packageId),
|
||||
@@ -176,13 +197,6 @@ if (isLocalDocument) Object.assign(desktopApi, {
|
||||
open: id => ipcRenderer.invoke('local-attachments:open', id),
|
||||
discard: ids => ipcRenderer.invoke('local-attachments:discard', ids),
|
||||
}),
|
||||
localAgentProvider: Object.freeze({
|
||||
status: kind => ipcRenderer.invoke('local-agent-provider:status', kind),
|
||||
probe: kind => ipcRenderer.invoke('local-agent-provider:probe', kind),
|
||||
save: (kind, input) => ipcRenderer.invoke('local-agent-provider:save', kind, input),
|
||||
activate: (kind, preset) => ipcRenderer.invoke('local-agent-provider:activate', kind, preset),
|
||||
delete: (kind, preset) => ipcRenderer.invoke('local-agent-provider:delete', kind, preset),
|
||||
}),
|
||||
localKnowledgeBase: Object.freeze({
|
||||
status: kind => (kind ? ipcRenderer.invoke('local-knowledge-base:status', kind) : ipcRenderer.invoke('local-knowledge-base:status')),
|
||||
openGuide: (kind, action) => ipcRenderer.invoke('local-knowledge-base:open-guide', kind, action),
|
||||
|
||||
@@ -7,7 +7,6 @@ const MIGRATIONS = Object.freeze([
|
||||
['workspace.json', 'workspace.json'],
|
||||
['run-ledger.json', 'run-ledger.json'],
|
||||
['custom-agents.json', 'custom-agents.json'],
|
||||
['provider.json', 'provider.json'],
|
||||
['knowledge-base.json', 'knowledge-base.json'],
|
||||
['private', 'private'],
|
||||
])
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
const { managedOpenClawOptions, nativeOpenClawOptions } = require('../agents/cli/openclaw-runtime.cjs')
|
||||
|
||||
const EXTERNAL_PROVIDER_KINDS = new Set([
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'opencodereview',
|
||||
])
|
||||
|
||||
const OFFICIAL_PROVIDER_BASE_URLS = Object.freeze({
|
||||
codex: 'https://api.openai.com/v1',
|
||||
hermes: 'https://api.openai.com/v1',
|
||||
openclaw: 'https://api.openai.com/v1',
|
||||
workbuddy: '',
|
||||
pi: '',
|
||||
kimi: 'https://api.moonshot.cn/v1',
|
||||
mimo: '',
|
||||
claude: 'https://api.anthropic.com',
|
||||
gemini: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
opencode: '',
|
||||
qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
opencodereview: 'https://api.openai.com/v1',
|
||||
})
|
||||
|
||||
function chatCompletionsUrl(baseUrl) {
|
||||
const normalized = String(baseUrl || '').trim().replace(/\/+$/, '')
|
||||
if (!normalized) return ''
|
||||
return /\/chat\/completions$/i.test(normalized)
|
||||
? normalized
|
||||
: `${normalized}/chat/completions`
|
||||
}
|
||||
|
||||
function providerPresetFromStatus(kind, status = {}) {
|
||||
if (['official', 'openrouter', 'custom'].includes(status.activePreset)) {
|
||||
return status.activePreset
|
||||
}
|
||||
const provider = String(status.provider || '').trim().toLowerCase()
|
||||
const baseUrl = String(status.baseUrl || '').trim().replace(/\/+$/, '')
|
||||
if (/openrouter/i.test(provider) || /openrouter\.ai$/i.test(new URL(baseUrl || 'https://example.com').hostname)) {
|
||||
return 'openrouter'
|
||||
}
|
||||
if (OFFICIAL_PROVIDER_BASE_URLS[kind]
|
||||
&& baseUrl
|
||||
&& baseUrl === OFFICIAL_PROVIDER_BASE_URLS[kind]) {
|
||||
return 'official'
|
||||
}
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
function providerOptionsFor(kind, generic, context = {}, status = {}) {
|
||||
const preset = providerPresetFromStatus(kind, status)
|
||||
if (kind === 'codex') return { env: generic }
|
||||
if (kind === 'hermes') {
|
||||
return {
|
||||
provider: { id: 'openai-api', model: generic.OPENAI_MODEL },
|
||||
env: {
|
||||
...generic,
|
||||
HERMES_INFERENCE_PROVIDER: 'openai-api',
|
||||
HERMES_INFERENCE_MODEL: generic.OPENAI_MODEL,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (kind === 'workbuddy') {
|
||||
return {
|
||||
env: {
|
||||
...generic,
|
||||
CODEBUDDY_MODEL: generic.OPENAI_MODEL,
|
||||
CODEBUDDY_API_KEY: generic.OPENAI_API_KEY,
|
||||
CODEBUDDY_BASE_URL: generic.OPENAI_BASE_URL,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (kind === 'pi') {
|
||||
return {
|
||||
env: {
|
||||
...generic,
|
||||
PI_MODEL: generic.OPENAI_MODEL,
|
||||
PI_API_KEY: generic.OPENAI_API_KEY,
|
||||
PI_BASE_URL: generic.OPENAI_BASE_URL,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (kind === 'kimi') {
|
||||
return {
|
||||
env: {
|
||||
...generic,
|
||||
MOONSHOT_API_KEY: generic.OPENAI_API_KEY,
|
||||
KIMI_API_KEY: generic.OPENAI_API_KEY,
|
||||
KIMI_MODEL_API_KEY: generic.OPENAI_API_KEY,
|
||||
KIMI_MODEL_BASE_URL: generic.OPENAI_BASE_URL,
|
||||
KIMI_MODEL_NAME: generic.OPENAI_MODEL,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (kind === 'mimo') {
|
||||
return {
|
||||
env: {
|
||||
...generic,
|
||||
MIMO_API_KEY: generic.OPENAI_API_KEY,
|
||||
MIMO_BASE_URL: generic.OPENAI_BASE_URL,
|
||||
MIMO_MODEL: generic.OPENAI_MODEL,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (kind === 'claude') {
|
||||
return {
|
||||
env: {
|
||||
...generic,
|
||||
ANTHROPIC_API_KEY: generic.OPENAI_API_KEY,
|
||||
ANTHROPIC_BASE_URL: generic.OPENAI_BASE_URL,
|
||||
ANTHROPIC_MODEL: generic.OPENAI_MODEL,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (kind === 'gemini') {
|
||||
return {
|
||||
env: {
|
||||
...generic,
|
||||
GEMINI_API_KEY: generic.OPENAI_API_KEY,
|
||||
GOOGLE_API_KEY: generic.OPENAI_API_KEY,
|
||||
GEMINI_MODEL: generic.OPENAI_MODEL,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (kind === 'qwen') {
|
||||
return {
|
||||
provider: { id: 'openai', model: generic.OPENAI_MODEL },
|
||||
env: {
|
||||
...generic,
|
||||
DASHSCOPE_API_KEY: generic.OPENAI_API_KEY,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (kind === 'opencode') {
|
||||
return {
|
||||
env: {
|
||||
...generic,
|
||||
...(preset === 'openrouter' ? { OPENROUTER_API_KEY: generic.OPENAI_API_KEY } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (kind === 'opencodereview') {
|
||||
return {
|
||||
env: {
|
||||
...generic,
|
||||
OCR_LLM_URL: chatCompletionsUrl(generic.OPENAI_BASE_URL),
|
||||
OCR_LLM_TOKEN: generic.OPENAI_API_KEY,
|
||||
OCR_LLM_MODEL: generic.OPENAI_MODEL,
|
||||
OCR_USE_ANTHROPIC: 'false',
|
||||
},
|
||||
}
|
||||
}
|
||||
if (kind === 'openclaw' && context.storageRoot && context.workdir) {
|
||||
if (!generic?.OPENAI_API_KEY) {
|
||||
return nativeOpenClawOptions({
|
||||
storageRoot: context.storageRoot,
|
||||
workdir: context.workdir,
|
||||
sessionRef: context.sessionRef,
|
||||
allowWrite: context.sandbox === 'workspace-write',
|
||||
runtime: context.nativeRuntime,
|
||||
})
|
||||
}
|
||||
return managedOpenClawOptions({
|
||||
storageRoot: context.storageRoot,
|
||||
workdir: context.workdir,
|
||||
sessionRef: context.sessionRef,
|
||||
allowWrite: context.sandbox === 'workspace-write',
|
||||
provider: generic,
|
||||
})
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function providerAgentKind(value) {
|
||||
const kind = String(value || '').trim()
|
||||
if (!EXTERNAL_PROVIDER_KINDS.has(kind)) throw new Error('PROVIDER_AGENT_UNSUPPORTED')
|
||||
return kind
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EXTERNAL_PROVIDER_KINDS,
|
||||
providerAgentKind,
|
||||
providerOptionsFor,
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
const fs = require('node:fs')
|
||||
|
||||
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
|
||||
|
||||
const STORE_VERSION = 3
|
||||
const SINGLE_PROFILE_STORE_VERSION = 2
|
||||
const LEGACY_STORE_VERSION = 1
|
||||
const PROVIDER_KIND = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||
const PROVIDER_PRESETS = new Set(['official', 'openrouter', 'custom'])
|
||||
const EMPTY_METADATA = Object.freeze({ provider: '', baseUrl: '', model: '' })
|
||||
const OPENROUTER_PROVIDER = Object.freeze({
|
||||
provider: 'OpenRouter',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
})
|
||||
const OFFICIAL_PROVIDERS = Object.freeze({
|
||||
codex: { provider: 'OpenAI', baseUrl: 'https://api.openai.com/v1' },
|
||||
hermes: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1' },
|
||||
openclaw: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1' },
|
||||
workbuddy: { provider: 'WorkBuddy Official', baseUrl: '' },
|
||||
pi: { provider: 'Pi Native', baseUrl: '' },
|
||||
kimi: { provider: 'Moonshot AI', baseUrl: 'https://api.moonshot.cn/v1' },
|
||||
mimo: { provider: 'MiMo Native', baseUrl: '' },
|
||||
claude: { provider: 'Anthropic', baseUrl: 'https://api.anthropic.com' },
|
||||
gemini: { provider: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta' },
|
||||
opencode: { provider: 'OpenCode Provider', baseUrl: '' },
|
||||
qwen: { provider: 'DashScope', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
|
||||
opencodereview: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1' },
|
||||
})
|
||||
function normalizeMetadata(input) {
|
||||
const provider = String(input?.provider || '').trim()
|
||||
let baseUrl = String(input?.baseUrl || '').trim().replace(/\/+$/, '')
|
||||
baseUrl = baseUrl.replace(/\/chat\/completions$/i, '')
|
||||
const model = String(input?.model || '').trim()
|
||||
if (!provider || provider.length > 80 || !baseUrl || baseUrl.length > 300
|
||||
|| !model || model.length > 160) {
|
||||
throw new Error('PROVIDER_INVALID_METADATA')
|
||||
}
|
||||
let parsed
|
||||
try { parsed = new URL(baseUrl) } catch { throw new Error('PROVIDER_INVALID_METADATA') }
|
||||
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
|
||||
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
|
||||
throw new Error('PROVIDER_INSECURE_BASE_URL')
|
||||
}
|
||||
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
||||
throw new Error('PROVIDER_INVALID_METADATA')
|
||||
}
|
||||
return { provider, baseUrl, model }
|
||||
}
|
||||
|
||||
function sortedKeys(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? Reflect.ownKeys(value).sort().join(',')
|
||||
: ''
|
||||
}
|
||||
|
||||
function inferStoredPreset(entry) {
|
||||
const provider = String(entry?.provider || '').toLowerCase()
|
||||
const baseUrl = String(entry?.baseUrl || '')
|
||||
return /openrouter/i.test(provider) || /(^|\.)openrouter\.ai$/i.test(hostname(baseUrl))
|
||||
? 'openrouter'
|
||||
: 'custom'
|
||||
}
|
||||
|
||||
function hostname(value) {
|
||||
try { return new URL(value).hostname } catch { return '' }
|
||||
}
|
||||
|
||||
function validatePresetMetadata(kind, preset, metadata) {
|
||||
if (preset === 'custom') return metadata
|
||||
const expected = preset === 'openrouter' ? OPENROUTER_PROVIDER : OFFICIAL_PROVIDERS[kind]
|
||||
if (!expected?.baseUrl
|
||||
|| metadata.provider !== expected.provider
|
||||
|| metadata.baseUrl !== expected.baseUrl) {
|
||||
throw new Error('PROVIDER_INVALID_METADATA')
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
class ProviderStore {
|
||||
constructor({ storagePath, safeStorage, allowedKinds = [] }) {
|
||||
if (typeof storagePath !== 'string' || !storagePath) {
|
||||
throw new Error('PROVIDER_STORAGE_PATH_REQUIRED')
|
||||
}
|
||||
const normalizedKinds = [...new Set(allowedKinds.map(kind => String(kind || '').trim()))]
|
||||
if (!normalizedKinds.length || normalizedKinds.some(kind => !PROVIDER_KIND.test(kind))) {
|
||||
throw new Error('PROVIDER_AGENT_KINDS_REQUIRED')
|
||||
}
|
||||
this.storagePath = storagePath
|
||||
this.safeStorage = safeStorage
|
||||
this.allowedKinds = new Set(normalizedKinds)
|
||||
}
|
||||
|
||||
encryptionAvailable() {
|
||||
try {
|
||||
return Boolean(this.safeStorage?.isEncryptionAvailable?.())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
status(kind, { probeEncryption = false } = {}) {
|
||||
const targetKind = this.normalizeKind(kind)
|
||||
const credentialStored = fs.existsSync(this.storagePath)
|
||||
if (!credentialStored && !probeEncryption) {
|
||||
return {
|
||||
provider: '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
activePreset: 'official',
|
||||
profiles: {},
|
||||
encryptionAvailable: null,
|
||||
configured: false,
|
||||
}
|
||||
}
|
||||
|
||||
const encryptionAvailable = this.encryptionAvailable()
|
||||
let metadata = EMPTY_METADATA
|
||||
let configured = false
|
||||
let activePreset = 'official'
|
||||
const profiles = {}
|
||||
if (encryptionAvailable && credentialStored) {
|
||||
try {
|
||||
const agent = this.readDocument().agents[targetKind]
|
||||
if (agent) {
|
||||
activePreset = agent.activePreset
|
||||
for (const [preset, entry] of Object.entries(agent.profiles)) {
|
||||
try {
|
||||
this.decryptEntry(entry)
|
||||
profiles[preset] = {
|
||||
provider: entry.provider,
|
||||
baseUrl: entry.baseUrl,
|
||||
model: entry.model,
|
||||
configured: true,
|
||||
}
|
||||
} catch { /* keep an unreadable profile unavailable */ }
|
||||
}
|
||||
const activeEntry = profiles[activePreset]
|
||||
if (activeEntry) {
|
||||
metadata = activeEntry
|
||||
configured = true
|
||||
}
|
||||
}
|
||||
} catch { /* unreadable credentials stay unavailable */ }
|
||||
}
|
||||
return {
|
||||
provider: metadata.provider,
|
||||
baseUrl: metadata.baseUrl,
|
||||
model: metadata.model,
|
||||
activePreset,
|
||||
profiles,
|
||||
encryptionAvailable,
|
||||
configured,
|
||||
}
|
||||
}
|
||||
|
||||
save(kind, input) {
|
||||
const targetKind = this.normalizeKind(kind)
|
||||
if (sortedKeys(input) !== 'apiKey,baseUrl,model,preset,provider'
|
||||
|| typeof input.apiKey !== 'string' || !input.apiKey.trim()
|
||||
|| input.apiKey.trim().length > 8192) {
|
||||
throw new Error('PROVIDER_INVALID_CREDENTIAL')
|
||||
}
|
||||
this.requireEncryption()
|
||||
|
||||
const apiKey = input.apiKey.trim()
|
||||
const preset = this.normalizePreset(input.preset)
|
||||
const metadata = validatePresetMetadata(targetKind, preset, normalizeMetadata(input))
|
||||
let encrypted
|
||||
try {
|
||||
encrypted = this.safeStorage.encryptString(apiKey)
|
||||
if (!Buffer.isBuffer(encrypted) || encrypted.length === 0
|
||||
|| this.safeStorage.decryptString(encrypted) !== apiKey) {
|
||||
throw new Error('invalid encryption result')
|
||||
}
|
||||
} catch {
|
||||
throw new Error('PROVIDER_ENCRYPTION_FAILED')
|
||||
}
|
||||
|
||||
const document = fs.existsSync(this.storagePath)
|
||||
? this.readDocument()
|
||||
: { version: STORE_VERSION, agents: {} }
|
||||
const agent = document.agents[targetKind] || { activePreset: 'official', profiles: {} }
|
||||
agent.profiles[preset] = {
|
||||
...metadata,
|
||||
encrypted: encrypted.toString('base64'),
|
||||
}
|
||||
agent.activePreset = preset
|
||||
document.agents[targetKind] = agent
|
||||
atomicWritePrivateFile(this.storagePath, JSON.stringify(document))
|
||||
return this.status(targetKind)
|
||||
}
|
||||
|
||||
activate(kind, preset) {
|
||||
const targetKind = this.normalizeKind(kind)
|
||||
const targetPreset = this.normalizePreset(preset)
|
||||
if (!fs.existsSync(this.storagePath)) {
|
||||
if (targetPreset !== 'official') throw new Error('PROVIDER_PROFILE_UNAVAILABLE')
|
||||
return this.status(targetKind, { probeEncryption: true })
|
||||
}
|
||||
this.requireEncryption()
|
||||
const document = this.readDocument()
|
||||
const agent = document.agents[targetKind] || { activePreset: 'official', profiles: {} }
|
||||
if (targetPreset !== 'official' && !agent.profiles[targetPreset]) {
|
||||
throw new Error('PROVIDER_PROFILE_UNAVAILABLE')
|
||||
}
|
||||
if (agent.profiles[targetPreset]) this.decryptEntry(agent.profiles[targetPreset])
|
||||
agent.activePreset = targetPreset
|
||||
document.agents[targetKind] = agent
|
||||
atomicWritePrivateFile(this.storagePath, JSON.stringify(document))
|
||||
return this.status(targetKind)
|
||||
}
|
||||
|
||||
delete(kind, preset = '') {
|
||||
const targetKind = this.normalizeKind(kind)
|
||||
if (fs.existsSync(this.storagePath)) {
|
||||
const document = this.readDocument()
|
||||
if (preset) {
|
||||
const targetPreset = this.normalizePreset(preset)
|
||||
const agent = document.agents[targetKind]
|
||||
if (agent) {
|
||||
delete agent.profiles[targetPreset]
|
||||
if (agent.activePreset === targetPreset) agent.activePreset = 'official'
|
||||
if (Object.keys(agent.profiles).length) document.agents[targetKind] = agent
|
||||
else delete document.agents[targetKind]
|
||||
}
|
||||
} else {
|
||||
delete document.agents[targetKind]
|
||||
}
|
||||
if (Object.keys(document.agents).length) {
|
||||
atomicWritePrivateFile(this.storagePath, JSON.stringify(document))
|
||||
} else {
|
||||
fs.unlinkSync(this.storagePath)
|
||||
}
|
||||
}
|
||||
return this.status(targetKind, { probeEncryption: true })
|
||||
}
|
||||
|
||||
envForAgent(kind) {
|
||||
const targetKind = this.normalizeKind(kind)
|
||||
this.requireEncryption()
|
||||
const agent = this.readDocument().agents[targetKind]
|
||||
const entry = agent?.profiles?.[agent.activePreset]
|
||||
if (!entry) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
return {
|
||||
OPENAI_API_KEY: this.decryptEntry(entry),
|
||||
OPENAI_BASE_URL: entry.baseUrl,
|
||||
OPENAI_MODEL: entry.model,
|
||||
}
|
||||
}
|
||||
|
||||
requireEncryption() {
|
||||
if (!this.encryptionAvailable()) throw new Error('PROVIDER_ENCRYPTION_UNAVAILABLE')
|
||||
}
|
||||
|
||||
normalizeKind(kind) {
|
||||
const normalized = String(kind || '').trim()
|
||||
if (!PROVIDER_KIND.test(normalized) || !this.allowedKinds.has(normalized)) {
|
||||
throw new Error('PROVIDER_AGENT_UNSUPPORTED')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
normalizePreset(preset) {
|
||||
const normalized = String(preset || '').trim().toLowerCase()
|
||||
if (!PROVIDER_PRESETS.has(normalized)) throw new Error('PROVIDER_PRESET_UNSUPPORTED')
|
||||
return normalized
|
||||
}
|
||||
|
||||
decryptEntry(entry) {
|
||||
this.requireEncryption()
|
||||
try {
|
||||
const encrypted = Buffer.from(entry.encrypted, 'base64')
|
||||
if (!encrypted.length || encrypted.toString('base64') !== entry.encrypted) {
|
||||
throw new Error('invalid ciphertext')
|
||||
}
|
||||
const apiKey = this.safeStorage.decryptString(encrypted)
|
||||
if (typeof apiKey !== 'string' || !apiKey.trim()) throw new Error('invalid credential')
|
||||
return apiKey
|
||||
} catch {
|
||||
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
}
|
||||
}
|
||||
|
||||
readDocument() {
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(fs.readFileSync(this.storagePath, 'utf8'))
|
||||
} catch {
|
||||
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
}
|
||||
if (payload?.version === LEGACY_STORE_VERSION) {
|
||||
if (sortedKeys(payload) !== 'baseUrl,encrypted,model,provider,version'
|
||||
|| typeof payload.encrypted !== 'string') {
|
||||
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
}
|
||||
const rawEntry = {
|
||||
provider: payload.provider,
|
||||
baseUrl: payload.baseUrl,
|
||||
model: payload.model,
|
||||
encrypted: payload.encrypted,
|
||||
}
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
agents: Object.fromEntries([...this.allowedKinds].map((kind) => {
|
||||
const entry = this.normalizeEntry(rawEntry)
|
||||
const preset = inferStoredPreset(entry)
|
||||
return [kind, {
|
||||
activePreset: preset,
|
||||
profiles: { [preset]: { ...entry } },
|
||||
}]
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (payload?.version === SINGLE_PROFILE_STORE_VERSION) {
|
||||
if (sortedKeys(payload) !== 'agents,version'
|
||||
|| !payload.agents || typeof payload.agents !== 'object'
|
||||
|| Array.isArray(payload.agents)) {
|
||||
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
}
|
||||
const agents = {}
|
||||
for (const [kind, rawEntry] of Object.entries(payload.agents)) {
|
||||
if (!this.allowedKinds.has(kind)) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
const entry = this.normalizeEntry(rawEntry)
|
||||
const preset = inferStoredPreset(entry)
|
||||
agents[kind] = { activePreset: preset, profiles: { [preset]: entry } }
|
||||
}
|
||||
return { version: STORE_VERSION, agents }
|
||||
}
|
||||
if (sortedKeys(payload) !== 'agents,version'
|
||||
|| payload.version !== STORE_VERSION
|
||||
|| !payload.agents || typeof payload.agents !== 'object'
|
||||
|| Array.isArray(payload.agents)) {
|
||||
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
}
|
||||
const agents = {}
|
||||
for (const [kind, entry] of Object.entries(payload.agents)) {
|
||||
if (!this.allowedKinds.has(kind)) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
agents[kind] = this.normalizeAgentEntry(entry)
|
||||
}
|
||||
return { version: STORE_VERSION, agents }
|
||||
}
|
||||
|
||||
normalizeAgentEntry(entry) {
|
||||
try {
|
||||
if (sortedKeys(entry) !== 'activePreset,profiles'
|
||||
|| !entry.profiles || typeof entry.profiles !== 'object'
|
||||
|| Array.isArray(entry.profiles)) {
|
||||
throw new Error('invalid agent entry')
|
||||
}
|
||||
const activePreset = this.normalizePreset(entry.activePreset)
|
||||
const profiles = {}
|
||||
for (const [preset, profile] of Object.entries(entry.profiles)) {
|
||||
const normalizedPreset = this.normalizePreset(preset)
|
||||
profiles[normalizedPreset] = this.normalizeEntry(profile)
|
||||
}
|
||||
if (activePreset !== 'official' && !profiles[activePreset]) {
|
||||
throw new Error('missing active profile')
|
||||
}
|
||||
return { activePreset, profiles }
|
||||
} catch {
|
||||
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
}
|
||||
}
|
||||
|
||||
normalizeEntry(entry) {
|
||||
if (sortedKeys(entry) !== 'baseUrl,encrypted,model,provider'
|
||||
|| typeof entry.encrypted !== 'string') {
|
||||
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
}
|
||||
const encrypted = Buffer.from(entry.encrypted, 'base64')
|
||||
if (!encrypted.length || encrypted.toString('base64') !== entry.encrypted) {
|
||||
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
|
||||
}
|
||||
return { ...normalizeMetadata(entry), encrypted: entry.encrypted }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ProviderStore }
|
||||