mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
Compare commits
581
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 |
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,6 +11,7 @@
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+13
-1
@@ -1,5 +1,17 @@
|
||||
# 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
|
||||
@@ -151,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
|
||||
|
||||
|
||||
+20
-11
@@ -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 正文。
|
||||
@@ -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.
|
||||
@@ -2,41 +2,59 @@
|
||||
<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 — A local-first workspace for General 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.**
|
||||
|
||||
**Turn several General Agents into a reviewable working group. Meldwork gives every task a shared context, lets selected Agents investigate independently or discuss across rounds, and keeps the evidence and human decision visible before anything is adopted.**
|
||||
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.
|
||||
|
||||
Meldwork is a local-first Electron workspace for using the Agent tools you already have on your Mac. It supports focused conversations, independent concurrent responses, and Auto Discussion V4 for proposal, challenge, negotiated work, synthesis, and verification. Agents can work with text, files, images, media, Skills, and selected knowledge sources, so the same workspace can support research, analysis, writing, planning, review, and implementation.
|
||||
|
||||
The product is organized around a durable review record: **Case → Finding → Evidence → Decision → Disposition**. Meldwork keeps the task snapshot, Agent contributions, run state, artifacts, Human Gates, and adoption decision together in one local work cell. Workspace writes are opt-in and remain under the user's control.
|
||||
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.5/Meldwork-0.1.5-arm64.dmg"><strong>Download Meldwork V1.0.5 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>
|
||||
|
||||
## What Meldwork is for
|
||||
|
||||
Meldwork is useful when a decision benefits from more than one perspective and a clear record of why a result was adopted. Typical work includes:
|
||||
[](https://youtu.be/ttyJxo1s9ns)
|
||||
|
||||
[Watch the product video on YouTube](https://youtu.be/ttyJxo1s9ns)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" align="center" valign="top">
|
||||
<a href="assets/meldwork-agent-discovery.png"><img src="assets/readme/agent-discovery.svg" alt="Agent discovery" width="240" height="150"></a>
|
||||
<p><sub>Agent discovery</sub></p>
|
||||
</td>
|
||||
<td width="33%" align="center" valign="top">
|
||||
<a href="assets/meldwork-auto-discussion.png"><img src="assets/readme/auto-discussion.svg" alt="Auto discussion & run details" width="240" height="150"></a>
|
||||
<p><sub>Auto discussion & run details</sub></p>
|
||||
</td>
|
||||
<td width="33%" align="center" valign="top">
|
||||
<a href="assets/meldwork-direct-multimodal.png"><img src="assets/readme/direct-multimodal.svg" alt="Direct multimodal conversation" width="240" height="150"></a>
|
||||
<p><sub>Direct multimodal conversation</sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Meldwork is 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:
|
||||
|
||||
- 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 and review tasks where findings, evidence, permissions, and the final adoption decision should remain inspectable.
|
||||
- implementation in an authorized workspace, with other Agents checking the deliverables and requesting revisions.
|
||||
|
||||
## How a task moves through Meldwork
|
||||
|
||||
1. **Select** the Agents and participants for this Case.
|
||||
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. **Run** a direct conversation, Concurrent Responses, or Auto Discussion V4.
|
||||
4. **Review** the findings, evidence, artifacts, trace, and any Human Gate.
|
||||
5. **Adopt** the approved result or leave the Case unresolved for further work.
|
||||
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
|
||||
|
||||
@@ -44,21 +62,15 @@ Meldwork is useful when a decision benefits from more than one perspective and a
|
||||
| --- | --- | --- |
|
||||
| **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, challenge, negotiate responsibilities, execute agreed work, synthesize a candidate result, and verify it across bounded rounds. Native Agent sessions continue when supported. | Multi-step work that needs discussion, division of responsibility, and review. |
|
||||
| **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 selected by the user. Automatic selection from a larger roster is outside the current preview.
|
||||
|
||||
## The review record
|
||||
## Revisit the process and continue the work
|
||||
|
||||
Meldwork treats collaboration as a decision process rather than a stream of chat messages:
|
||||
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.
|
||||
|
||||
- **Case** defines the question, scope, context, and permissions.
|
||||
- **Finding** records an Agent's claim, observation, or proposed action.
|
||||
- **Evidence** links the finding to a response, artifact, file, knowledge result, or other bounded reference.
|
||||
- **Decision** records what the evidence supports, what remains uncertain, and which path is selected.
|
||||
- **Disposition** records whether the result was accepted, revised, rejected, superseded, or left unresolved.
|
||||
|
||||
The Run Ledger preserves phase, participant, attempt, receipt, artifact, recovery, and Human Gate state. Diagnostic tool output and sensitive runtime details stay behind the Electron main-process boundary.
|
||||
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.
|
||||
|
||||
## General Agent catalog
|
||||
|
||||
@@ -66,24 +78,44 @@ Meldwork detects and invokes compatible locally installed Agent commands. The ca
|
||||
|
||||
<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>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/zcode.png" alt="Zcode logo" width="32" height="32"><br><strong>Zcode</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" 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>
|
||||
<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>
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
For Zcode, choose an existing `.zcode/cli` directory containing `config.json`. Zcode runs with that directory's parent user home, which also affects its home-relative session and other file paths; project configuration may still override user configuration.
|
||||
|
||||
For Qwen Code, choose an existing `.qwen` directory containing settings or credentials. Qwen uses its parent user home for native sessions, Skills and other home-relative files; project and system settings retain their native precedence.
|
||||
|
||||
For OpenCodeReview, choose an existing `.opencodereview` directory containing `config.json`. OCR runs with that directory's parent user home, including its native session and other home-relative paths.
|
||||
|
||||
Hermes uses its native `config.yaml` and Provider settings. When its model, fallback model, or provider entries declare credential variables with `key_env` or `api_key_env`, Meldwork reads those variables from the launch environment or login shell for Hermes; unrelated variables and unsafe process controls are excluded. Hermes may prepare its own dependencies when started with a new native data directory.
|
||||
|
||||
## Context and local boundaries
|
||||
|
||||
@@ -91,12 +123,6 @@ Meldwork runs as a local Electron application. Conversations, group configuratio
|
||||
|
||||
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.
|
||||
|
||||
## Download V1.0.5
|
||||
|
||||
For Apple silicon macOS, download the [Meldwork V1.0.5 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.5) and choose [`Meldwork-0.1.5-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.5/Meldwork-0.1.5-arm64.dmg). The release also includes a ZIP archive and SHA-256 manifest. Install at least one supported local Agent CLI before opening the app.
|
||||
|
||||
The preview artifacts are ad-hoc signed and not notarized with an Apple Developer ID. macOS may require **Open Anyway** under **System Settings → Privacy & Security** on first launch.
|
||||
|
||||
## Run from source
|
||||
|
||||
Prerequisites: Node.js `22.12+` and npm.
|
||||
@@ -116,12 +142,6 @@ npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
```
|
||||
|
||||
## V1.0.5
|
||||
|
||||
V1.0.5 improves local Agent readiness and recovery, preserves healthy participants when another Agent fails, keeps completed and stopped work visible after restart, and tightens group execution and output handling. It also updates the workspace preference flow, sidebar and titlebar details, and the Pi capability probe contract.
|
||||
|
||||
Verification for this prerelease includes 343/343 frontend tests, 1,562/1,562 desktop tests, six deterministic evaluation cases with 18 results, web and desktop builds, packaging, ZIP integrity, deep code-signature verification, and packaged macOS acceptance for group execution, cancellation, and restart recovery. The artifacts remain ad-hoc signed and unnotarized; live behavior still depends on the installed Agent CLI version, authentication, Provider, and capabilities.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture and product boundary](architecture.md)
|
||||
@@ -131,4 +151,4 @@ Verification for this prerelease includes 343/343 frontend tests, 1,562/1,562 de
|
||||
|
||||
## 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).
|
||||
+70
-52
@@ -2,41 +2,59 @@
|
||||
<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:本地优先的 General Agent 工作空间
|
||||
**提出目标,让 Agent 们协作完成。Meldwork 把你已有的 Agent 组织成一个工作小组,共享上下文、讨论方案、协作执行并相互检查,减少你在它们之间转述、催办和整合结果的工作。**
|
||||
|
||||
**把多个 General Agent 组织成一个可复核的工作小组。Meldwork 为每个任务建立共享上下文,让选定的 Agent 独立调查或分轮讨论,并在采用结果前保留完整的证据和人工决策。**
|
||||
Meldwork 是一个本地优先的多 Agent 协作工作空间,用来连接你已经在电脑上使用的 Agent 工具。你选定参与者、目标、上下文和权限后,自动讨论中的 Agent 可以接续彼此的工作,请求同伴补充或检查,并在授权范围内推进任务。它也支持单个 Agent 的直接会话和多个 Agent 的独立并发回复,覆盖研究、分析、写作、规划、评审和实施。
|
||||
|
||||
Meldwork 是一个本地优先的 Electron 工作空间,用来连接你已经在电脑上使用的 Agent 工具。它支持直接会话、独立并发回复,以及包含提案、质询、职责协商、分工执行、整合和复核的 Auto Discussion V4。Agent 可以处理文字、文件、图片、音视频、Skill 和选定的知识源,因此同一个工作空间可以覆盖研究、分析、写作、规划、评审和实施。
|
||||
|
||||
产品围绕一条可持续复核的记录组织工作:**Case → Finding → Evidence → Decision → Disposition**。Meldwork 将任务快照、Agent 贡献、运行状态、产物、人工审批门和采用决定保存在同一个本地工作单元中。工作区写入默认受控,并由用户明确授权。
|
||||
你无需安排每一轮发言,但仍负责目标、权限和关键决策。遇到信息缺失、权限请求或需要取舍的问题时,任务可能等待你处理。工作区写入需要明确授权;协作过程、运行状态和产物保存在本地,方便回看和继续工作。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.5/Meldwork-0.1.5-arm64.dmg"><strong>下载 Meldwork V1.0.5 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)
|
||||
|
||||
[在 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 更适合:
|
||||
|
||||
- 研究与信息综合:结合本地 Agent 工具和明确选择的知识源;
|
||||
- 产品、市场、运营和技术分析;
|
||||
- 写作、规划、评审和结构化文档工作;
|
||||
- 处理本地文档、图片、音频、视频、PDF、代码和配置文件的多模态工作;
|
||||
- 需要保留发现、证据、权限、运行轨迹和最终采用决定的实施工作。
|
||||
- 在授权工作目录内执行、由其他 Agent 检查成果并继续修改的实施工作。
|
||||
|
||||
## 一个任务如何在 Meldwork 中推进
|
||||
|
||||
1. **选择**本 Case 的 Agent 和参与者。
|
||||
1. **选择**一起处理任务的 Agent。
|
||||
2. **限定**目标、工作目录、上下文、附件、Skill、知识源和权限。
|
||||
3. **运行**直接会话、并发回复或 Auto Discussion V4。
|
||||
4. **复核**发现、证据、产物、运行轨迹和人工审批门。
|
||||
5. **采用**批准的结果,或保留为待解决状态继续处理。
|
||||
3. **协作**:启动 Auto Discussion,让 Agent 讨论方案、接续执行并检查成果;也可选择直接会话或并发回复。
|
||||
4. **决策**:在需要补充信息、授权或取舍时介入。
|
||||
5. **查看结果**:检查交付物,必要时提出修改,让工作继续推进。
|
||||
|
||||
## 协作模式
|
||||
|
||||
@@ -44,21 +62,15 @@ Meldwork 是一个本地优先的 Electron 工作空间,用来连接你已经
|
||||
| --- | --- | --- |
|
||||
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 聚焦研究、写作、分析或执行。 |
|
||||
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同视角。 |
|
||||
| **Auto Discussion V4** | Agent 先提出方案,再进行质询、职责协商、分工执行、候选结果整合和独立复核;支持时会在多轮中保持原生会话。 | 需要讨论、分工和复核的多步骤工作。 |
|
||||
| **Auto Discussion V4** | Agent 独立提出方案后,接续讨论、请求同伴贡献、在授权范围内执行并检查成果。交付负责人判断任务进度,必要时请求用户决策;支持时保持原生会话。 | 希望减少人工协调的多步骤协作任务。 |
|
||||
|
||||
参与者始终由用户选择。从更大候选池自动选人组队不属于当前预览版。
|
||||
|
||||
## 复核记录
|
||||
## 过程可回看,工作可继续
|
||||
|
||||
Meldwork 将协作组织成一套决策过程,而不是一串聊天消息:
|
||||
Meldwork 在本地保留任务上下文、Agent 回复、运行状态和产物,方便了解任务如何推进、哪里遇到阻塞,以及接下来需要处理什么。Run Ledger 保留阶段、参与者、尝试、产物、恢复状态和人工审批门;诊断工具输出与敏感运行信息留在 Electron 主进程边界内。
|
||||
|
||||
- **Case**:定义问题、范围、上下文和权限;
|
||||
- **Finding**:记录 Agent 的判断、观察或行动建议;
|
||||
- **Evidence**:将 Finding 连接到回复、产物、文件、知识结果或其他受约束的依据;
|
||||
- **Decision**:记录证据支持的结论、未决问题和选定路径;
|
||||
- **Disposition**:记录结果已接受、修订、拒绝、被替代或仍未解决。
|
||||
|
||||
Run Ledger 会保留阶段、参与者、尝试、回执、产物、恢复状态和人工审批门。诊断工具输出与敏感运行信息留在 Electron 主进程边界内。
|
||||
过程记录帮助检查协作情况,不代表每条结论都经过事实核验。Agent 的相互检查也不能保证结果正确,重要交付物仍需按实际用途验收。
|
||||
|
||||
## General Agent 目录
|
||||
|
||||
@@ -66,24 +78,42 @@ Run Ledger 会保留阶段、参与者、尝试、回执、产物、恢复状态
|
||||
|
||||
<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>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/zcode.png" alt="Zcode logo" width="32" height="32"><br><strong>Zcode</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
|
||||
<td align="center" valign="top"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" 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>
|
||||
<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>
|
||||
|
||||
已批准的 Agent Connector 和自定义可执行 Agent 使用桌面端的连接器入口。完整的适配器、安装、Provider 和能力矩阵见[桌面端指南](desktop/README.md)。
|
||||
[Agent Connector SDK 指南](desktop/AGENT_CONNECTOR_SDK.md)介绍当前支持的包、审批、升级和一致性检查。
|
||||
|
||||
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 会优先使用该配置,并传递其中声明的凭据环境变量。
|
||||
|
||||
WorkBuddy 也可以在「设置 → Agent」中选择已有的原生配置目录;模型列表、执行环境以及本地和插件 Skill 都会使用所选目录。
|
||||
|
||||
OpenClaw 也可以在「设置 → Agent」中选择原生状态目录。Meldwork 会从该目录读取所选 Agent 的模型配置,并在受保护的本地运行环境中执行。
|
||||
|
||||
切换 Agent 的原生配置目录时,Meldwork 会按目录分别保存本地会话,切回原目录后恢复对应会话。任务运行中或等待恢复时,暂时不能切换配置。
|
||||
|
||||
Zcode 请选择包含 `config.json` 的 `.zcode/cli` 目录。运行时 Zcode 会使用该目录所属的用户主目录,这也会影响其会话及其他文件中的主目录相对路径;项目配置仍可能覆盖用户配置。
|
||||
|
||||
Qwen Code 请选择包含设置或认证文件的 `.qwen` 目录。Qwen 会使用其所属的用户主目录保存原生会话、读取 Skill 和其他主目录相对文件;项目和系统设置仍遵循原生优先级。
|
||||
|
||||
Hermes 使用自身的 `config.yaml` 和 Provider 设置。如果模型、备用模型或 Provider 条目通过 `key_env` 或 `api_key_env` 声明凭据变量,Meldwork 会从启动环境或登录 shell 中读取这些变量并交给 Hermes;无关变量和不安全的进程控制变量会被排除。首次使用新的原生数据目录启动时,Hermes 可能会自行准备依赖。
|
||||
|
||||
## 上下文与本地边界
|
||||
|
||||
@@ -91,12 +121,6 @@ Meldwork 作为本地 Electron 应用运行。对话、群组配置、运行记
|
||||
|
||||
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送到其自身 CLI 凭据对应的模型服务商。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表 Agent 使用的模型服务商一定离线。
|
||||
|
||||
## 下载 V1.0.5
|
||||
|
||||
Apple 芯片 macOS 用户请前往 [Meldwork V1.0.5 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.5),下载 [`Meldwork-0.1.5-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.5/Meldwork-0.1.5-arm64.dmg)。发布页同时提供 ZIP 压缩包和 SHA-256 校验文件。启动应用前,请至少安装一个受支持的本地 Agent CLI。
|
||||
|
||||
该预览版安装包使用 ad-hoc 临时签名,未使用 Apple Developer ID 签名,也未进行公证。macOS 首次启动时可能需要在“系统设置 → 隐私与安全性”中选择“仍要打开 / Open Anyway”。
|
||||
|
||||
## 从源码运行
|
||||
|
||||
前置条件:Node.js `22.12+` 和 npm。
|
||||
@@ -116,12 +140,6 @@ npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
```
|
||||
|
||||
## V1.0.5
|
||||
|
||||
V1.0.5 改进了本地 Agent 的就绪检测与恢复,在单个 Agent 失败时保留健康参与者,重启后继续显示已完成和已停止的工作,并加强了群组执行与结果导入。同时更新了工作区偏好、侧栏和标题栏细节,以及 Pi 能力探测契约。
|
||||
|
||||
本预览版已完成 343/343 前端测试、1,562/1,562 桌面端测试、6 个确定性评估场景共 18 条结果验证,并通过 Web 与桌面构建、打包、ZIP 完整性、深度代码签名校验,以及 Apple 芯片 macOS 上的群组执行、取消和重启恢复验收。安装包仍为 ad-hoc 临时签名且未公证;实际运行结果仍取决于本机 Agent CLI 版本、鉴权、Provider 和能力。
|
||||
|
||||
## 文档
|
||||
|
||||
- [架构与产品边界](architecture.md)
|
||||
@@ -131,4 +149,4 @@ V1.0.5 改进了本地 Agent 的就绪检测与恢复,在单个 Agent 失败
|
||||
|
||||
## 许可证
|
||||
|
||||
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)。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
Binary file not shown.
|
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 的行为,不代表第三方服务已经通过独立认证。
|
||||
+43
-18
@@ -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 与
|
||||
|
||||
Generated
+14
-8
@@ -1,16 +1,19 @@
|
||||
{
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.6",
|
||||
"version": "1.0.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.6",
|
||||
"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.6",
|
||||
"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": {
|
||||
@@ -39,6 +42,9 @@
|
||||
"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/**/*",
|
||||
|
||||
@@ -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,10 +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'], [
|
||||
'--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'], [
|
||||
@@ -72,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',
|
||||
@@ -101,6 +116,16 @@ const AGENT_COMPATIBILITY = Object.freeze({
|
||||
]),
|
||||
})
|
||||
|
||||
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,
|
||||
@@ -165,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
|
||||
@@ -178,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))]
|
||||
@@ -203,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 => (
|
||||
|
||||
@@ -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 }
|
||||
@@ -8,6 +8,12 @@ function sha256(value) {
|
||||
// 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`
|
||||
@@ -19,7 +25,8 @@ const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
|
||||
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 = options.find(candidate => candidate?.kind === 'reject_once')
|
||||
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
|
||||
}
|
||||
@@ -69,6 +76,7 @@ function assertResumedPermissionBinding(resumed, currentBinding, sessionRef) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
terminalPlanRejection,
|
||||
READ_ONLY_SAFE_TOOL_KINDS,
|
||||
assertResumedPermissionBinding,
|
||||
automaticPermissionResponse,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -80,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'],
|
||||
@@ -92,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'],
|
||||
@@ -237,8 +245,9 @@ function authFailureCode(detail) {
|
||||
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'
|
||||
|
||||
@@ -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,8 +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,
|
||||
@@ -20,15 +23,21 @@ 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
|
||||
// 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.
|
||||
@@ -355,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 || ''}`)
|
||||
))
|
||||
@@ -469,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
|
||||
@@ -484,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 {
|
||||
@@ -528,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?.()
|
||||
})
|
||||
@@ -546,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')
|
||||
@@ -621,12 +626,19 @@ function runtimeSignature(agent, workdir, spec, profile, childEnv) {
|
||||
workdir,
|
||||
command: spec.command,
|
||||
args: spec.args,
|
||||
// The session mode (read-only plan versus write) is applied on every turn
|
||||
// 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')
|
||||
}
|
||||
|
||||
@@ -746,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(
|
||||
@@ -759,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)
|
||||
}
|
||||
},
|
||||
@@ -863,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 = {
|
||||
@@ -877,6 +930,9 @@ 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 = []
|
||||
@@ -903,8 +959,10 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
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) {
|
||||
@@ -914,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(() => {
|
||||
@@ -927,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
|
||||
@@ -968,7 +1038,18 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
prompt: [{ type: 'text', text: prompt }, ...imageBlocks],
|
||||
}))
|
||||
promptRequest.catch(() => {})
|
||||
promptResult = await operation(promptRequest)
|
||||
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
|
||||
}
|
||||
@@ -997,7 +1078,7 @@ 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'
|
||||
@@ -1037,7 +1118,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
ACP_CANCEL_SETTLE_MS,
|
||||
)
|
||||
}
|
||||
if (caughtError && promptSettled && !runtime.closed && !runtime.failureError) {
|
||||
if (caughtError && caughtError.code !== 'LOCAL_AGENT_REPEATED_OUTPUT'
|
||||
&& promptSettled && !runtime.closed && !runtime.failureError) {
|
||||
Object.defineProperty(caughtError, 'acpRuntimeReusable', {
|
||||
value: true, enumerable: false, configurable: true,
|
||||
})
|
||||
@@ -1113,6 +1195,10 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
throw error
|
||||
} finally {
|
||||
runtime.active = false
|
||||
if (runtime.contributionNeedsClose) {
|
||||
acpSessionRuntimes.delete(key)
|
||||
await closeAcpRuntime(runtime)
|
||||
}
|
||||
runtime.lastUsedAt = Date.now()
|
||||
await evictIdleAcpRuntimes()
|
||||
}
|
||||
@@ -1150,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,8 +46,10 @@ 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
|
||||
@@ -135,12 +138,15 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
: agent.acpAvailable
|
||||
const spec = invocation(agent.kind, agent.executable, workdir, sessionRef, {
|
||||
platform,
|
||||
...(agent.kind === 'deepseek' ? { env: options.env } : {}),
|
||||
sandbox: options.sandbox,
|
||||
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,
|
||||
@@ -224,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 })
|
||||
@@ -262,6 +270,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
let forceSettleTimeout
|
||||
let terminalKillTimer
|
||||
let terminalForceKillTimer
|
||||
let windowsTermination = null
|
||||
const progress = []
|
||||
const progressIndexes = new Map()
|
||||
let anonymousProgressId = 0
|
||||
@@ -294,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)
|
||||
}
|
||||
})
|
||||
@@ -326,7 +341,10 @@ 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' && event.type === 'agent_end')
|
||||
|| (agent.kind === 'hermes' && spec.eventTransport === 'stream-json' && event.type === 'result')
|
||||
|| (agent.kind === 'pi' && (event.type === 'agent_settled'
|
||||
|| (event.type === 'agent_end'
|
||||
&& !Object.hasOwn(event, 'willRetry') && !Object.hasOwn(event, 'willContinue'))))
|
||||
|| (agent.kind === 'codex' && event.type === 'turn.completed')
|
||||
|| (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish'
|
||||
&& !stepFinishContinues(event))
|
||||
@@ -347,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')
|
||||
@@ -417,16 +431,21 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
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) {
|
||||
@@ -448,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'
|
||||
@@ -455,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
|
||||
}
|
||||
@@ -511,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,12 +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')
|
||||
|
||||
@@ -16,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'] },
|
||||
@@ -29,6 +39,8 @@ 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',
|
||||
@@ -41,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',
|
||||
'CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'PI_CODING_AGENT_DIR',
|
||||
])
|
||||
|
||||
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 = 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()
|
||||
@@ -102,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'
|
||||
@@ -117,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'),
|
||||
@@ -146,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',
|
||||
)
|
||||
@@ -191,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(
|
||||
@@ -229,7 +276,7 @@ function launchEnvironment(kind, executable, options = {}) {
|
||||
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)
|
||||
@@ -263,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 */ }
|
||||
@@ -353,6 +409,8 @@ async function probeAgentCapabilities(kind, executable, 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),
|
||||
@@ -360,71 +418,140 @@ async function probeAgentCapabilities(kind, executable, options = {}) {
|
||||
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]
|
||||
}
|
||||
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,
|
||||
stat.size, stat.mtimeMs, stat.ctimeMs, shimTargetIdentity, environmentFingerprint, nativeConfiguration,
|
||||
])
|
||||
} catch { /* caching requires a verifiable executable identity */ }
|
||||
const cacheKey = `${kind}:${executable}`
|
||||
const prior = capabilityCache.get(cacheKey)
|
||||
const recentCompatible = identity && prior?.identity === identity
|
||||
&& Date.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 result = await execFileFn(command.command, command.args, {
|
||||
timeout,
|
||||
windowsHide: true,
|
||||
env: childEnv,
|
||||
})
|
||||
return { probe, output: `${result.stdout || ''}\n${result.stderr || ''}` }
|
||||
} catch (error) {
|
||||
const timedOut = error?.killed === true || error?.code === 'ETIMEDOUT'
|
||||
if (timedOut && timeout === 8000) continue
|
||||
return { probe, error: true, timedOut }
|
||||
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: '' }
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (group.latest === record && identity && !results.some(result => result.timedOut)) {
|
||||
capabilityCache.set(cacheKey, { identity, checkedAt: Date.now() })
|
||||
}
|
||||
}
|
||||
}))
|
||||
const failed = results.find(result => !result.timedOut && (result.error
|
||||
|| result.probe.requiredText.some(value => !result.output.includes(value))
|
||||
|| (result.probe.validateOutput && !result.probe.validateOutput(result.output))))
|
||||
if (failed) capabilityCache.delete(cacheKey)
|
||||
for (const result of results) {
|
||||
const { probe } = result
|
||||
if (result.timedOut && !failed) {
|
||||
if (recentCompatible) continue
|
||||
return {
|
||||
compatibilityState: 'unknown',
|
||||
incompatibilityReason: 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT',
|
||||
incompatibilityProbe: probe.id,
|
||||
compatibilityState: 'compatible',
|
||||
incompatibilityReason: '',
|
||||
incompatibilityProbe: '',
|
||||
...(kind === 'hermes' && results.some(result => result.output?.includes('--format')
|
||||
&& result.output.includes('stream-json')) ? { streamJsonAvailable: true } : {}),
|
||||
}
|
||||
}
|
||||
if (result.timedOut) 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))) {
|
||||
return {
|
||||
compatibilityState: 'incompatible',
|
||||
incompatibilityReason: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
|
||||
incompatibilityProbe: probe.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (identity && !results.some(result => result.timedOut)) {
|
||||
capabilityCache.set(cacheKey, { identity, checkedAt: Date.now() })
|
||||
}
|
||||
return {
|
||||
compatibilityState: 'compatible',
|
||||
incompatibilityReason: '',
|
||||
incompatibilityProbe: '',
|
||||
})()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,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)
|
||||
@@ -445,17 +578,17 @@ 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 { /* capability probes can still establish whether this installation works */ }
|
||||
/* Capability probes can still establish whether this installation works. */
|
||||
}
|
||||
return { succeeded: false, version: '', versionIdentified: false }
|
||||
})()
|
||||
|
||||
const { version } = await versionTask
|
||||
const { version, versionIdentified } = await versionTask
|
||||
const versionCompatibility = assessAgentVersion(kind, version)
|
||||
const capabilityTask = Promise.resolve().then(async () => {
|
||||
const result = await (options.probeAgentCapabilitiesFn || probeAgentCapabilities)(
|
||||
@@ -483,6 +616,8 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
|
||||
const [capabilityCompatibility, acpAvailable] = await Promise.all([capabilityTask, acpTask])
|
||||
const compatibility = capabilityCompatibility.compatibilityState !== 'compatible'
|
||||
? { ...versionCompatibility, ...capabilityCompatibility }
|
||||
: kind === 'pi' && !version
|
||||
? { ...versionCompatibility, compatibilityState: 'unknown', incompatibilityProbe: '' }
|
||||
: {
|
||||
...versionCompatibility,
|
||||
compatibilityState: 'compatible',
|
||||
@@ -494,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 } : {}),
|
||||
} : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,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)
|
||||
}
|
||||
|
||||
@@ -534,6 +685,7 @@ module.exports = {
|
||||
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,6 +75,20 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
}
|
||||
}
|
||||
if (kind === 'hermes') {
|
||||
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
|
||||
@@ -150,12 +166,22 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
// 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,
|
||||
@@ -171,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 (!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',
|
||||
@@ -205,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,
|
||||
@@ -254,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',
|
||||
|
||||
@@ -4,6 +4,7 @@ const path = require('node:path')
|
||||
|
||||
const {
|
||||
KILL_SETTLE_MS,
|
||||
terminateWindowsProcessTree,
|
||||
TERMINATE_GRACE_MS,
|
||||
childEnvironment,
|
||||
} = require('./cli-process-support.cjs')
|
||||
@@ -63,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
|
||||
@@ -78,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 {
|
||||
@@ -163,6 +163,8 @@ async function verifyGatewayHealth({
|
||||
gatewayPort,
|
||||
signal,
|
||||
workdir,
|
||||
spawnHealth = spawn,
|
||||
terminateHealth = terminateGateway,
|
||||
}) {
|
||||
if (!gatewayChildAlive(gatewayChild)) throw gatewayStartError()
|
||||
const args = [
|
||||
@@ -173,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,
|
||||
@@ -206,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
|
||||
@@ -314,4 +316,4 @@ async function withOpenClawGateway(options, callback) {
|
||||
throw gatewayStartError()
|
||||
}
|
||||
|
||||
module.exports = { withOpenClawGateway }
|
||||
module.exports = { withOpenClawGateway, verifyGatewayHealth }
|
||||
@@ -257,14 +257,16 @@ function ingestStructuredEvent(state, event) {
|
||||
state.outcome = stepFinishOutcome(event.message?.stopReason || event.stopReason || 'stop')
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
state.piContinuing = event.willRetry === true || event.willContinue === true
|
||||
const lastAssistant = event.messages?.findLast(message => message?.role === 'assistant')
|
||||
if (lastAssistant?.stopReason) state.outcome = stepFinishOutcome(lastAssistant.stopReason)
|
||||
else if (!state.outcome) state.outcome = 'completed'
|
||||
if (state.outcome === 'completed' && event.willRetry !== true) {
|
||||
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
|
||||
}
|
||||
@@ -428,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)
|
||||
@@ -864,6 +867,8 @@ function classifyCliOutcome(kind, stdout) {
|
||||
}
|
||||
if (kind === 'pi') {
|
||||
const terminal = events.findLast(event => event?.type === 'turn_end' || event?.type === 'agent_end')
|
||||
if ((terminal?.willRetry === true || terminal?.willContinue === true)
|
||||
&& events.at(-1)?.type !== 'agent_settled') return { outcome: 'partial' }
|
||||
const lastAssistant = terminal?.messages?.findLast(message => message?.role === 'assistant')
|
||||
const previousTurn = events.findLast(event => event?.type === 'turn_end')
|
||||
const reason = terminal?.message?.stopReason || terminal?.stopReason || lastAssistant?.stopReason
|
||||
@@ -934,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 {
|
||||
@@ -952,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,6 +1,16 @@
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const JSON5 = require('json5')
|
||||
const { launchEnvironment, searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
|
||||
const { NETWORK_ENV_KEYS, networkEnvironment } = require('./cli-network-environment.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,
|
||||
@@ -26,13 +36,64 @@ const OPENCLAW_RUNTIME_PATH_KEYS = Object.freeze([
|
||||
'OPENCLAW_CONFIG_PATH',
|
||||
'OPENCLAW_WORKSPACE_DIR',
|
||||
])
|
||||
const CONTROLLED_RUNTIME_ENV = /^(?:AWS_|AZURE_|GOOGLE_|GEMINI_|ANTHROPIC_|OPENAI_|OPENROUTER_|CODEBUDDY_|HERMES_|PI_|KIMI_|MIMO_|DASHSCOPE_|CLAUDE_|OCR_|CLOUDSDK_|VERTEX_|CLOUD_ML_|MOONSHOT_|CONNECTOR_TEST_|MELDWORK_TEST_)/
|
||||
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 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 || authFailureCertainty(text)
|
||||
@@ -40,6 +101,8 @@ function invalidSessionFailure(detail) {
|
||||
// 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.
|
||||
@@ -52,6 +115,15 @@ function invalidSessionFailure(detail) {
|
||||
|
||||
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'
|
||||
@@ -60,6 +132,12 @@ function failedAgentProcessError(detail, options = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -70,7 +148,7 @@ function openClawChildEnvironment(workdir, options, platform) {
|
||||
}
|
||||
|
||||
const env = {
|
||||
...systemChildEnvironment(process.env, platform),
|
||||
...inheritedEnvironment(options, platform),
|
||||
...networkEnvironment(source, platform),
|
||||
}
|
||||
for (const key of [
|
||||
@@ -110,24 +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 }
|
||||
const controlled = Object.fromEntries(Object.entries(options.env || {}).filter(([key, value]) => (
|
||||
typeof value === 'string'
|
||||
&& (CONTROLLED_RUNTIME_ENV.test(key) || NETWORK_ENV_KEYS.includes(key))
|
||||
&& !BLOCKED_RUNTIME_ENV.test(key)
|
||||
)))
|
||||
return {
|
||||
...systemChildEnvironment(process.env, platform),
|
||||
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 = {
|
||||
@@ -137,4 +287,5 @@ module.exports = {
|
||||
childEnvironment,
|
||||
failedAgentProcessError,
|
||||
invalidSessionFailure,
|
||||
terminateWindowsProcessTree,
|
||||
}
|
||||
@@ -425,13 +425,16 @@ function acpPlanSummary(update) {
|
||||
}
|
||||
|
||||
function createAcpRuntimeState() {
|
||||
return { tools: new Map(), reasoning: 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'
|
||||
@@ -468,6 +471,8 @@ function acpRuntimeEvents(update, state) {
|
||||
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
|
||||
@@ -477,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',
|
||||
@@ -35,11 +70,17 @@ function redactChildSecrets(value, env) {
|
||||
}
|
||||
|
||||
function childSecrets(env) {
|
||||
const secrets = new Set()
|
||||
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 (/(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
|
||||
&& utf8ByteLength(value) >= 8) secrets.add(value)
|
||||
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 {
|
||||
@@ -99,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))
|
||||
}
|
||||
@@ -107,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) {
|
||||
@@ -123,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}$/),
|
||||
@@ -206,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 }
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -638,6 +688,7 @@ class CloudAgentBridgeStore {
|
||||
cloud: true,
|
||||
available: record.available && agent.available !== false,
|
||||
credentialState: agent.credentialState,
|
||||
availabilitySource: agent.executionSupported === false ? 'cloud-bridge-unsupported' : undefined,
|
||||
serverLabel: record.label,
|
||||
})))
|
||||
}
|
||||
@@ -659,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
|
||||
}
|
||||
|
||||
@@ -633,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,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') {
|
||||
@@ -737,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) {
|
||||
@@ -745,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,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,
|
||||
@@ -822,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
|
||||
@@ -849,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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -101,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',
|
||||
@@ -120,6 +125,7 @@ const AGENT_CATALOG = Object.freeze([
|
||||
{ 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 },
|
||||
])
|
||||
|
||||
|
||||
@@ -33,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) {
|
||||
@@ -62,8 +67,8 @@ function publicCompatibility(agent) {
|
||||
: Boolean(resolvedVersion)
|
||||
const compatible = compatibilityState === 'compatible'
|
||||
const incompatibilityReason = compatibilityState === 'unknown'
|
||||
&& agent.incompatibilityReason === 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT'
|
||||
? 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT'
|
||||
&& INCONCLUSIVE_REASONS.has(agent.incompatibilityReason)
|
||||
? agent.incompatibilityReason
|
||||
: compatibilityState === 'incompatible'
|
||||
? (COMPATIBILITY_REASONS.has(agent.incompatibilityReason)
|
||||
? agent.incompatibilityReason
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -279,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)
|
||||
@@ -300,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,
|
||||
}
|
||||
}
|
||||
@@ -517,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,
|
||||
@@ -531,6 +556,7 @@ function createWorkReceipt(input = {}) {
|
||||
collaborationReceipt,
|
||||
resultHash,
|
||||
artifacts,
|
||||
...(dependencyWorkReceiptIds ? { dependencyWorkReceiptIds } : {}),
|
||||
}
|
||||
return {
|
||||
...body,
|
||||
@@ -542,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')
|
||||
}
|
||||
@@ -706,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)
|
||||
@@ -743,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)
|
||||
@@ -792,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 } : {}),
|
||||
@@ -1561,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
|
||||
@@ -1580,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) {
|
||||
@@ -1601,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')
|
||||
}
|
||||
@@ -1618,12 +1687,15 @@ function validateV4Relations(orchestration, targetKinds) {
|
||||
|| orchestration.coordinationPlan?.finalizerKind
|
||||
|| orchestration.commitState.writerKind
|
||||
|| orchestration.discussionWriterKind
|
||||
if (writableSlots.length > 1 || writableSlots.some(slot => (
|
||||
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
|
||||
|| !['discussion', 'synthesis', 'human-gate'].includes(orchestration.phase)
|
||||
|| (orchestration.phase === 'discussion' && !orchestration.discussionWriterKind)
|
||||
))) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
|
||||
for (const assignment of orchestration.phase === 'discussion' ? orchestration.plan.assignments : []) {
|
||||
)))) 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')
|
||||
@@ -2047,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')
|
||||
}
|
||||
@@ -2240,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)
|
||||
@@ -2257,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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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'].includes(key))
|
||||
|| 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
|
||||
@@ -24,10 +24,13 @@ function parseTaskDecision(input) {
|
||||
|| (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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,23 +52,31 @@ function agentKindToken(value) {
|
||||
|
||||
// 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 without a separate file) used to fail the whole turn
|
||||
// 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
|
||||
const status = STATUS_SYNONYMS.get(String(input.status || '').trim().toLowerCase())
|
||||
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 reason = typeof input.reason === 'string' && input.reason.trim()
|
||||
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.'
|
||||
let deliverables = textList(input.deliverables)
|
||||
// A completed answer is itself the deliverable when nothing else was named.
|
||||
if (status === 'completed' && !deliverables.length) deliverables = ['The answer in this message']
|
||||
const deliverables = textList(input.deliverables)
|
||||
const nextKinds = [...new Set(textList(input.nextKinds).map(agentKindToken).filter(Boolean))]
|
||||
const handoffTo = agentKindToken(input.handoffTo)
|
||||
return {
|
||||
@@ -78,6 +89,7 @@ function normalizeTaskDecision(input, options = {}) {
|
||||
...(input.nextKinds != null
|
||||
? { nextKinds: status === 'continue' ? nextKinds.slice(0, 32) : [] }
|
||||
: {}),
|
||||
...(input.reviewedVersion != null ? { reviewedVersion: input.reviewedVersion } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))) {
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+319
-18
@@ -3,6 +3,10 @@ const { app, BrowserWindow, dialog, ipcMain, Menu, Notification, protocol, shell
|
||||
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')
|
||||
@@ -28,6 +32,10 @@ const {
|
||||
} = 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')
|
||||
@@ -43,14 +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 { registerDesktopIpc } = require('./shell/main-ipc.cjs')
|
||||
const { workBuddyEnvironment, workBuddySettings } = require('./agents/cli/workbuddy-runtime.cjs')
|
||||
const {
|
||||
isAllowedExternalUrl,
|
||||
isAllowedLocalNavigation: isAllowedLocalNavigationForPaths,
|
||||
@@ -74,7 +92,7 @@ const { LocalKnowledgeConnectors } = require('./knowledge/local-knowledge-connec
|
||||
const { nativeOpenClawOptions } = require('./agents/cli/openclaw-runtime.cjs')
|
||||
const { migrateLegacyProductData } = require('./product-data-migration.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
|
||||
@@ -97,10 +115,12 @@ function recordMainProcessFault(kind, detail) {
|
||||
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 customAgentStore = null
|
||||
let agentConnectors = null
|
||||
@@ -240,17 +260,179 @@ function openExternalUrl(value) {
|
||||
|
||||
async function nativeShellEnvironment(options = {}) {
|
||||
const home = app.getPath('home')
|
||||
const resolved = await resolveNativeShellEnvironment({ home, refresh: options.refresh === true })
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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 result
|
||||
}
|
||||
|
||||
function workspaceStoragePath(userData = app.getPath('userData')) {
|
||||
@@ -340,15 +522,43 @@ 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 shellEnvironment = connectorCredentialIsolation ? null : await nativeShellEnvironment()
|
||||
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
|
||||
@@ -365,7 +575,12 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
|
||||
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'
|
||||
@@ -373,10 +588,15 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
|
||||
: {}
|
||||
return runAgent(agent, prompt, workdir, {
|
||||
...runOptions,
|
||||
...(shellEnvironment?.unsetKeys ? { nativeUnsetKeys: shellEnvironment.unsetKeys } : {}),
|
||||
nativeCredentialKeys: nativeKeys,
|
||||
...injected,
|
||||
env: {
|
||||
...networkEnvironment(shellEnvironment?.env),
|
||||
...nativeEnv, ...callerEnv, ...injected.env,
|
||||
...nativeEnv, ...callerEnv,
|
||||
...(connectorCredentialIsolation ? {} : nativeConfigEnvironment(agent.kind, workspace?.state.agentPreferences)),
|
||||
...injected.env,
|
||||
...connectorConfigEnv,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -449,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'),
|
||||
})
|
||||
@@ -515,6 +736,7 @@ function createWorkspace() {
|
||||
}),
|
||||
outcomeStore,
|
||||
agentFitMatrix: FROZEN_AGENT_FIT_MATRIX,
|
||||
resolveProjectEnvironmentIdentities,
|
||||
runLedger: cloudAgentRuntime.workspaceLedger(),
|
||||
detectAgents: async () => {
|
||||
await cloudAgentBridges?.refresh()
|
||||
@@ -533,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,
|
||||
@@ -541,14 +766,23 @@ 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),
|
||||
isKnownAgent: kind => cloudAgentBridges.catalogEntries().some(agent => agent.kind === kind),
|
||||
connectorRuntime: agentConnectors,
|
||||
naturalAgentResponses: true,
|
||||
runAgent: async (agent, prompt, workdir, options = {}) => {
|
||||
@@ -612,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),
|
||||
@@ -649,7 +884,7 @@ function initializeWorkspace() {
|
||||
return next
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
function createWindow(startupError = workspaceStartupError) {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 920,
|
||||
@@ -707,8 +942,11 @@ function createWindow() {
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
loadFrontend()
|
||||
flushPendingOpenGroup()
|
||||
if (startupError) showOfflinePage(startupError)
|
||||
else {
|
||||
loadFrontend()
|
||||
flushPendingOpenGroup()
|
||||
}
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
@@ -743,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,
|
||||
@@ -755,6 +1009,9 @@ function registerIpc() {
|
||||
getSkillTrustStore: () => skillTrustStore,
|
||||
refreshLocalAgentState,
|
||||
registerTrustedHandle,
|
||||
resolveConnectorEnvironment: async names => (await resolveNativeShellEnvironment({
|
||||
home: app.getPath('home'), extraEnvKeys: names, refresh: true,
|
||||
})).env,
|
||||
skillCatalog,
|
||||
})
|
||||
}
|
||||
@@ -767,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) {
|
||||
@@ -780,6 +1044,7 @@ 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'),
|
||||
@@ -802,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()
|
||||
@@ -816,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),
|
||||
|
||||
@@ -186,6 +186,8 @@ function classifiedCategory(error, code, status) {
|
||||
}
|
||||
|
||||
function retryability(category, error) {
|
||||
if (error?.[Symbol.for('meldwork.connector-nonretryable')] === true) return 'never'
|
||||
if (failureCode(error) === 'LOCAL_AGENT_PROVIDER_QUOTA_EXHAUSTED') return 'never'
|
||||
if (category === 'session') return 'refresh_session'
|
||||
if (category === 'authentication') return 'never'
|
||||
if (category === 'compatibility') return 'never'
|
||||
|
||||
@@ -35,6 +35,7 @@ function createInvocationWatchdog({
|
||||
let paused = false
|
||||
let established = false
|
||||
let toolActive = false
|
||||
const activeToolIds = new Set()
|
||||
let closed = false
|
||||
let timedOut = false
|
||||
let timeoutError = null
|
||||
@@ -111,12 +112,15 @@ function createInvocationWatchdog({
|
||||
established = true
|
||||
rearm()
|
||||
},
|
||||
toolStarted() {
|
||||
toolStarted(toolCallId = '') {
|
||||
if (toolCallId) activeToolIds.add(String(toolCallId))
|
||||
toolActive = true
|
||||
progress()
|
||||
},
|
||||
toolFinished() {
|
||||
toolActive = false
|
||||
toolFinished(toolCallId = '') {
|
||||
if (toolCallId) activeToolIds.delete(String(toolCallId))
|
||||
else activeToolIds.clear()
|
||||
toolActive = activeToolIds.size > 0
|
||||
progress()
|
||||
},
|
||||
// Waiting for a person (Human Gate) is not the Agent being quiet.
|
||||
@@ -127,6 +131,7 @@ function createInvocationWatchdog({
|
||||
resume() {
|
||||
paused = false
|
||||
lastProgressAt = clock.now()
|
||||
lastSubstantiveAt = lastProgressAt
|
||||
arm()
|
||||
},
|
||||
close() {
|
||||
|
||||
@@ -4,6 +4,7 @@ const path = require('node:path')
|
||||
|
||||
const { hasValidStoredRecordShape } = require('./run-ledger-records.cjs')
|
||||
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
|
||||
const { isLegacyMultiWriter, recoverLegacyRecord } = require('./run-ledger-legacy.cjs')
|
||||
|
||||
const JOURNAL_VERSION = 1
|
||||
const DEFAULT_COMPACTION_BYTES = 4 * 1024 * 1024
|
||||
@@ -99,6 +100,9 @@ class RunJournal {
|
||||
this.hasBaseline = false
|
||||
this.runs = new Map()
|
||||
this.pending = new Map()
|
||||
this.legacyRunIds = new Set()
|
||||
this.uncertainCompaction = null
|
||||
this.uncertainAppend = null
|
||||
this.load()
|
||||
}
|
||||
|
||||
@@ -117,6 +121,14 @@ class RunJournal {
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('RUN_JOURNAL_STORE_INVALID')
|
||||
const buffer = this.repairTruncatedTail(fs.readFileSync(this.storagePath))
|
||||
const lines = buffer.toString('utf8').split('\n').filter(Boolean)
|
||||
// Detect legacy run IDs across the complete journal before replaying
|
||||
// partial patches, so none can restore a previously unsafe policy.
|
||||
for (const line of lines) {
|
||||
const entry = JSON.parse(line)
|
||||
for (const run of entry?.change?.upserts || []) {
|
||||
if (isLegacyMultiWriter(run)) this.legacyRunIds.add(run.runId)
|
||||
}
|
||||
}
|
||||
const committed = new Set()
|
||||
for (const line of lines) {
|
||||
const entry = JSON.parse(line)
|
||||
@@ -130,6 +142,11 @@ class RunJournal {
|
||||
}
|
||||
this.sequence = entry.sequence
|
||||
if (entry.phase === 'prepare') {
|
||||
if (entry.change && Array.isArray(entry.change.upserts)) {
|
||||
entry.change.upserts = entry.change.upserts.map(run => (
|
||||
this.legacyRunIds.has(run?.runId) ? recoverLegacyRecord(run) : run
|
||||
))
|
||||
}
|
||||
if (!validChange(entry.change) || this.pending.has(entry.transactionId)
|
||||
|| committed.has(entry.transactionId)) {
|
||||
throw new Error('RUN_JOURNAL_STORE_INVALID')
|
||||
@@ -154,6 +171,41 @@ class RunJournal {
|
||||
}
|
||||
|
||||
assertLoaded() {
|
||||
if (this.loadError && this.uncertainAppend) {
|
||||
const { originalSize, dev, ino, appended } = this.uncertainAppend
|
||||
let fd
|
||||
try {
|
||||
fd = fs.openSync(this.storagePath, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW || 0))
|
||||
const stat = fs.fstatSync(fd)
|
||||
if (!stat.isFile() || stat.dev !== dev || stat.ino !== ino
|
||||
|| stat.size < originalSize || stat.size > originalSize + appended.length) {
|
||||
throw new Error('RUN_JOURNAL_STORE_INVALID')
|
||||
}
|
||||
const tail = Buffer.alloc(stat.size - originalSize)
|
||||
if (tail.length && (fs.readSync(fd, tail, 0, tail.length, originalSize) !== tail.length
|
||||
|| !tail.equals(appended.subarray(0, tail.length)))) {
|
||||
throw new Error('RUN_JOURNAL_STORE_INVALID')
|
||||
}
|
||||
fs.ftruncateSync(fd, originalSize)
|
||||
fs.fsyncSync(fd)
|
||||
this.uncertainAppend = null
|
||||
this.loadError = null
|
||||
} catch (error) { this.loadError = error }
|
||||
finally { if (fd !== undefined) fs.closeSync(fd) }
|
||||
}
|
||||
if (this.loadError && this.uncertainCompaction) {
|
||||
const { serialized, change } = this.uncertainCompaction
|
||||
try {
|
||||
atomicWritePrivateFile(this.storagePath, serialized)
|
||||
this.sequence = 2
|
||||
this.runs.clear()
|
||||
this.pending.clear()
|
||||
this.hasBaseline = false
|
||||
this.apply(change)
|
||||
this.uncertainCompaction = null
|
||||
this.loadError = null
|
||||
} catch (error) { this.loadError = error }
|
||||
}
|
||||
if (this.loadError) throw new Error('RUN_JOURNAL_LOAD_FAILED')
|
||||
}
|
||||
|
||||
@@ -173,20 +225,28 @@ class RunJournal {
|
||||
const directory = path.dirname(this.storagePath)
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
||||
let originalSize = 0
|
||||
if (fs.existsSync(this.storagePath)) {
|
||||
let originalIdentity = null
|
||||
const existed = fs.existsSync(this.storagePath)
|
||||
if (existed) {
|
||||
const stat = fs.lstatSync(this.storagePath)
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('RUN_JOURNAL_WRITE_FAILED')
|
||||
originalSize = stat.size
|
||||
originalIdentity = { dev: stat.dev, ino: stat.ino }
|
||||
}
|
||||
const next = {
|
||||
version: JOURNAL_VERSION,
|
||||
sequence: this.sequence + 1,
|
||||
...entry,
|
||||
}
|
||||
const appended = Buffer.from(`${JSON.stringify(next)}\n`, 'utf8')
|
||||
let descriptor
|
||||
try {
|
||||
descriptor = fs.openSync(this.storagePath, 'a', 0o600)
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(next)}\n`, 'utf8')
|
||||
if (!originalIdentity) {
|
||||
const stat = fs.fstatSync(descriptor)
|
||||
originalIdentity = { dev: stat.dev, ino: stat.ino }
|
||||
}
|
||||
fs.writeFileSync(descriptor, appended)
|
||||
fs.fsyncSync(descriptor)
|
||||
fs.closeSync(descriptor)
|
||||
descriptor = undefined
|
||||
@@ -197,7 +257,16 @@ class RunJournal {
|
||||
if (descriptor !== undefined) {
|
||||
try { fs.closeSync(descriptor) } catch { /* already closed */ }
|
||||
}
|
||||
try { fs.truncateSync(this.storagePath, originalSize) } catch { /* append did not create a file */ }
|
||||
try {
|
||||
fs.truncateSync(this.storagePath, originalSize)
|
||||
const rollback = fs.openSync(this.storagePath, 'r+')
|
||||
try { fs.fsyncSync(rollback) } finally { fs.closeSync(rollback) }
|
||||
} catch (rollbackError) {
|
||||
if (existed || rollbackError.code !== 'ENOENT') {
|
||||
this.loadError = rollbackError
|
||||
if (originalIdentity) this.uncertainAppend = { originalSize, ...originalIdentity, appended }
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -262,10 +331,19 @@ class RunJournal {
|
||||
phase: 'commit',
|
||||
},
|
||||
]
|
||||
atomicWritePrivateFile(
|
||||
this.storagePath,
|
||||
`${entries.map(entry => JSON.stringify(entry)).join('\n')}\n`,
|
||||
)
|
||||
const serialized = `${entries.map(entry => JSON.stringify(entry)).join('\n')}\n`
|
||||
try {
|
||||
atomicWritePrivateFile(
|
||||
this.storagePath,
|
||||
serialized,
|
||||
)
|
||||
} catch (error) {
|
||||
// The rename may already have succeeded even when the parent sync failed.
|
||||
// Do not append using the old sequence until this baseline is durable again.
|
||||
this.loadError = error
|
||||
this.uncertainCompaction = { serialized, change }
|
||||
throw error
|
||||
}
|
||||
this.sequence = 2
|
||||
this.runs.clear()
|
||||
this.pending.clear()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const { parseOrchestrationV4 } = require('../collaboration/orchestration-v4-records.cjs')
|
||||
const { parseTaskDecision } = require('../collaboration/task-decision.cjs')
|
||||
|
||||
// Older discussion runs admitted multiple writers. Never resume that policy.
|
||||
function isLegacyMultiWriter(record) {
|
||||
const value = record?.orchestration
|
||||
if (value?.version !== 4 || value.workflow !== 'auto' || value.template !== 'discussion'
|
||||
|| !Array.isArray(value.slots)
|
||||
|| value.slots.filter(slot => slot?.permission === 'workspace-write').length < 2) return false
|
||||
try { parseOrchestrationV4(value); return false } catch (error) {
|
||||
return error?.message === 'ORCHESTRATION_V4_PERMISSION_INVALID'
|
||||
}
|
||||
}
|
||||
|
||||
function recoverLegacyRecord(record) {
|
||||
const result = structuredClone(record)
|
||||
result.orchestration = null
|
||||
result.continuation = null
|
||||
if (['preparing', 'queued', 'running', 'waiting', 'reconciling'].includes(result.status)) {
|
||||
result.status = 'interrupted'
|
||||
result.reason = 'Legacy discussion requires a new run.'
|
||||
}
|
||||
for (const agent of result.agentRuns || []) {
|
||||
const decision = agent?.context?.taskDecision
|
||||
if (!decision) continue
|
||||
try { parseTaskDecision(decision) } catch {
|
||||
if (Object.keys(decision).sort().join(',') !== 'deliverables,reason,reviewedVersion,status'
|
||||
|| typeof decision.status !== 'string' || typeof decision.reason !== 'string'
|
||||
|| typeof decision.reviewedVersion !== 'string' || !Array.isArray(decision.deliverables)) throw new Error('RUN_LEDGER_STORE_INVALID')
|
||||
delete agent.context.taskDecision
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = { isLegacyMultiWriter, recoverLegacyRecord }
|
||||
@@ -1,7 +1,10 @@
|
||||
const fs = require('node:fs')
|
||||
const { randomBytes } = require('node:crypto')
|
||||
|
||||
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
|
||||
const { RunJournal } = require('./run-journal.cjs')
|
||||
const { RunTerminalOutbox } = require('./run-terminal-outbox.cjs')
|
||||
const { isLegacyMultiWriter, recoverLegacyRecord } = require('./run-ledger-legacy.cjs')
|
||||
const {
|
||||
DEFAULT_MAX_DURABLE_AGENT_RUNS,
|
||||
RUN_STATUSES,
|
||||
@@ -170,6 +173,69 @@ class RunLedger {
|
||||
compactionBytes: options.journalCompactionBytes,
|
||||
})
|
||||
this.runs = this.load()
|
||||
this.terminalOutbox = new RunTerminalOutbox(this.storagePath)
|
||||
if (!this.loadError) {
|
||||
try { this.recoverTerminalOutbox() } catch (error) { this.loadError = error }
|
||||
}
|
||||
if (!this.loadError && this.journal.legacyRunIds.size) this.backupLegacyFiles()
|
||||
}
|
||||
|
||||
recordTerminalIntent(record) {
|
||||
this.assertLoaded()
|
||||
const existing = this.runs.find(item => item.runId === record?.runId)
|
||||
if (!existing || !TERMINAL_STATUSES.has(record.status)
|
||||
|| record.groupId !== existing.groupId || record.taskId !== existing.taskId
|
||||
|| record.contextPackId !== existing.contextPackId) {
|
||||
throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
|
||||
}
|
||||
return this.terminalOutbox.prepare(record)
|
||||
}
|
||||
|
||||
clearTerminalIntent(runId) {
|
||||
this.terminalOutbox.remove(runId)
|
||||
}
|
||||
|
||||
recoverTerminalOutbox() {
|
||||
for (const record of this.terminalOutbox.list()) {
|
||||
const existing = this.runs.find(item => item.runId === record.runId)
|
||||
// An already committed terminal run can later be removed by history
|
||||
// pruning or group deletion while an outbox cleanup is still pending.
|
||||
if (!existing) {
|
||||
this.terminalOutbox.remove(record.runId)
|
||||
continue
|
||||
}
|
||||
if (existing.groupId !== record.groupId || existing.taskId !== record.taskId
|
||||
|| existing.contextPackId !== record.contextPackId) {
|
||||
throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
|
||||
}
|
||||
if (TERMINAL_STATUSES.has(existing.status)) {
|
||||
if (existing.status !== record.status) throw new Error('RUN_TERMINAL_OUTBOX_CONFLICT')
|
||||
} else {
|
||||
this.checkpoint(record)
|
||||
this.finish(record.runId, record.status, record.reason || '')
|
||||
}
|
||||
this.terminalOutbox.remove(record.runId)
|
||||
}
|
||||
}
|
||||
|
||||
backupLegacyFiles() {
|
||||
try {
|
||||
const suffix = `.legacy-backup-${Date.now()}-${randomBytes(6).toString('hex')}`
|
||||
for (const filename of [this.storagePath, this.journalPath]) {
|
||||
if (!fs.existsSync(filename)) continue
|
||||
const target = `${filename}${suffix}`
|
||||
const descriptor = fs.openSync(target, 'wx', 0o600)
|
||||
try {
|
||||
fs.writeFileSync(descriptor, fs.readFileSync(filename))
|
||||
fs.fsyncSync(descriptor)
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.loadError = error
|
||||
this.runs = []
|
||||
}
|
||||
}
|
||||
|
||||
timestamp() {
|
||||
@@ -183,6 +249,11 @@ class RunLedger {
|
||||
if (!isRecord(parsed) || parsed.version !== STORE_VERSION || !Array.isArray(parsed.runs)) {
|
||||
throw new Error('RUN_LEDGER_STORE_INVALID')
|
||||
}
|
||||
for (const record of parsed.runs) {
|
||||
if (isLegacyMultiWriter(record)) this.journal.legacyRunIds.add(record.runId)
|
||||
}
|
||||
parsed.runs = parsed.runs.map(record => this.journal.legacyRunIds.has(record.runId)
|
||||
? recoverLegacyRecord(record) : record)
|
||||
const runIds = new Set()
|
||||
const agentRunIds = new Set()
|
||||
for (const rawRecord of parsed.runs) {
|
||||
|
||||
@@ -231,10 +231,18 @@ class RunScheduler {
|
||||
}
|
||||
},
|
||||
})
|
||||
let releaseDeferred = false
|
||||
try {
|
||||
return await operation(lease)
|
||||
} catch (error) {
|
||||
const pendingExit = error?.[Symbol.for('meldwork.pending-process-exit')]
|
||||
if (error?.code === 'LOCAL_AGENT_TERMINATION_FAILED' && pendingExit instanceof Promise) {
|
||||
releaseDeferred = true
|
||||
pendingExit.then(() => lease.release(), () => {})
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
lease.release()
|
||||
if (!releaseDeferred) lease.release()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { atomicWritePrivateFile, ensurePrivateDirectory } = require('../security/private-file.cjs')
|
||||
const { TERMINAL_STATUSES, cleanId, isRecord } = require('./run-ledger-records.cjs')
|
||||
|
||||
function syncDirectory(directory) {
|
||||
let fd
|
||||
try {
|
||||
fd = fs.openSync(directory, 'r')
|
||||
fs.fsyncSync(fd)
|
||||
} catch (error) {
|
||||
const unsupported = ['EINVAL', 'ENOTSUP', 'EOPNOTSUPP'].includes(error.code)
|
||||
|| (process.platform === 'win32' && ['EPERM', 'EISDIR'].includes(error.code))
|
||||
if (!unsupported) throw error
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
class RunTerminalOutbox {
|
||||
constructor(storagePath) {
|
||||
this.root = `${storagePath}.terminal-outbox`
|
||||
}
|
||||
|
||||
filename(runId) {
|
||||
if (!cleanId(runId) || cleanId(runId) !== runId) throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
|
||||
return path.join(this.root, `${crypto.createHash('sha256').update(runId).digest('hex')}.json`)
|
||||
}
|
||||
|
||||
read(filename) {
|
||||
const stat = fs.lstatSync(filename)
|
||||
if (stat.isSymbolicLink() || !stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024 * 1024
|
||||
|| (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600)) {
|
||||
throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
|
||||
}
|
||||
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0)
|
||||
const fd = fs.openSync(filename, fs.constants.O_RDONLY | noFollow)
|
||||
try {
|
||||
const opened = fs.fstatSync(fd)
|
||||
if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size
|
||||
|| (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o600)) {
|
||||
throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
|
||||
}
|
||||
const data = JSON.parse(fs.readFileSync(fd, 'utf8'))
|
||||
if (!isRecord(data) || Object.keys(data).sort().join(',') !== 'record,version'
|
||||
|| data.version !== 1 || !isRecord(data.record)
|
||||
|| !TERMINAL_STATUSES.has(data.record.status)
|
||||
|| this.filename(data.record.runId) !== filename) throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
|
||||
return data.record
|
||||
} finally { fs.closeSync(fd) }
|
||||
}
|
||||
|
||||
prepare(record) {
|
||||
if (!isRecord(record) || !TERMINAL_STATUSES.has(record.status)) throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
|
||||
ensurePrivateDirectory(this.root)
|
||||
syncDirectory(path.dirname(this.root))
|
||||
const filename = this.filename(record.runId)
|
||||
if (fs.existsSync(filename)) {
|
||||
const previous = this.read(filename)
|
||||
if (previous.status !== record.status || previous.groupId !== record.groupId
|
||||
|| previous.taskId !== record.taskId || previous.contextPackId !== record.contextPackId) {
|
||||
throw new Error('RUN_TERMINAL_OUTBOX_CONFLICT')
|
||||
}
|
||||
return previous
|
||||
}
|
||||
const serialized = `${JSON.stringify({ version: 1, record })}\n`
|
||||
if (Buffer.byteLength(serialized) > 64 * 1024 * 1024) throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
|
||||
atomicWritePrivateFile(filename, serialized)
|
||||
return record
|
||||
}
|
||||
|
||||
list() {
|
||||
try { fs.lstatSync(this.root) } catch (error) {
|
||||
if (error.code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
ensurePrivateDirectory(this.root)
|
||||
const names = fs.readdirSync(this.root).filter(name => name.endsWith('.json'))
|
||||
if (names.some(name => !/^[a-f0-9]{64}\.json$/.test(name))) {
|
||||
throw new Error('RUN_TERMINAL_OUTBOX_INVALID')
|
||||
}
|
||||
return names.sort().map(name => this.read(path.join(this.root, name)))
|
||||
}
|
||||
|
||||
remove(runId) {
|
||||
try { fs.lstatSync(this.root) } catch (error) {
|
||||
if (error.code === 'ENOENT') return
|
||||
throw error
|
||||
}
|
||||
ensurePrivateDirectory(this.root)
|
||||
try { fs.unlinkSync(this.filename(runId)) } catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
syncDirectory(this.root)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { RunTerminalOutbox }
|
||||
@@ -0,0 +1,9 @@
|
||||
const SESSION_REF = /^[A-Za-z0-9][A-Za-z0-9._:+-]{0,255}$/
|
||||
const SECRET_LIKE_SESSION_REF = /^(?:sk|rk|pk|ghp|github_pat|xox[baprs]?)[_-][A-Za-z0-9_-]{12,}$/i
|
||||
|
||||
function normalizeSessionRef(value) {
|
||||
const sessionRef = String(value || '')
|
||||
return SESSION_REF.test(sessionRef) && !SECRET_LIKE_SESSION_REF.test(sessionRef) ? sessionRef : ''
|
||||
}
|
||||
|
||||
module.exports = { normalizeSessionRef }
|
||||
@@ -2,6 +2,23 @@ const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
function ensurePrivateDirectory(directory) {
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
||||
const stat = fs.lstatSync(directory)
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
throw new Error('PRIVATE_DIRECTORY_INVALID')
|
||||
}
|
||||
if (process.platform === 'win32') return
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY || 0) | (fs.constants.O_NOFOLLOW || 0)
|
||||
const descriptor = fs.openSync(directory, flags)
|
||||
try {
|
||||
if (!fs.fstatSync(descriptor).isDirectory()) throw new Error('PRIVATE_DIRECTORY_INVALID')
|
||||
fs.fchmodSync(descriptor, 0o700)
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
function atomicWritePrivateFile(filename, contents) {
|
||||
const directory = path.dirname(filename)
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
||||
@@ -18,6 +35,17 @@ function atomicWritePrivateFile(filename, contents) {
|
||||
descriptor = undefined
|
||||
fs.chmodSync(temporaryPath, 0o600)
|
||||
fs.renameSync(temporaryPath, filename)
|
||||
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 (descriptor !== undefined) {
|
||||
try { fs.closeSync(descriptor) } catch { /* already closed */ }
|
||||
@@ -27,4 +55,4 @@ function atomicWritePrivateFile(filename, contents) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { atomicWritePrivateFile }
|
||||
module.exports = { atomicWritePrivateFile, ensurePrivateDirectory }
|
||||
@@ -0,0 +1,86 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { z } = require('zod')
|
||||
|
||||
const id = z.string().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/)
|
||||
function request(schema, input) {
|
||||
const parsed = schema.safeParse(input)
|
||||
if (!parsed.success) throw new Error('LOCAL_DELIVERY_INVALID')
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
function registerDeliveryIpc({ registerTrustedHandle, getWorkspace, getAttachmentStore, dialog, getMainWindow }) {
|
||||
const workspace = () => {
|
||||
const value = getWorkspace()
|
||||
if (!value) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
|
||||
return value
|
||||
}
|
||||
for (const method of ['save', 'review', 'revise']) {
|
||||
registerTrustedHandle(`local-delivery:${method}`, input => {
|
||||
const current = workspace()
|
||||
if (method === 'review' && input?.accept === true) {
|
||||
const task = current.delivery.task(input.taskId)
|
||||
const version = current.delivery.version(task, input.versionId)
|
||||
for (const attachment of version.attachments) {
|
||||
const store = getAttachmentStore()
|
||||
if (!store) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
|
||||
store.read(attachment.id)
|
||||
}
|
||||
}
|
||||
const result = current.delivery[method](input)
|
||||
current.emitChanged()
|
||||
return { taskId: method === 'save' ? result : input.taskId }
|
||||
})
|
||||
}
|
||||
registerTrustedHandle('local-delivery:reuse', input => {
|
||||
const { taskId } = request(z.strictObject({ taskId: id }), input)
|
||||
const current = workspace()
|
||||
const task = current.delivery.task(taskId)
|
||||
const original = current.getGroup(task.groupId)
|
||||
const root = path.join(path.dirname(current.storagePath), 'delivery-workspaces')
|
||||
fs.mkdirSync(root, { recursive: true, mode: 0o700 })
|
||||
const workdir = fs.mkdtempSync(path.join(root, 'task-'))
|
||||
let group
|
||||
try {
|
||||
group = current.createGroup({
|
||||
name: task.brief.title, agentKinds: original.agentKinds,
|
||||
conversationType: original.conversationType,
|
||||
directAgentKind: original.directAgentKind,
|
||||
workdir, allowWrite: false,
|
||||
})
|
||||
const newTaskId = current.delivery.save({ groupId: group.id, brief: task.brief })
|
||||
current.emitChanged()
|
||||
return { taskId: newTaskId, groupId: group.id }
|
||||
} catch (error) {
|
||||
if (group) current.deleteGroup(group.id)
|
||||
fs.rmdirSync(workdir)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
registerTrustedHandle('local-delivery:export', async input => {
|
||||
const { taskId, versionId, attachmentId } = request(z.strictObject({
|
||||
taskId: id, versionId: id, attachmentId: id.optional(),
|
||||
}), input)
|
||||
const current = workspace()
|
||||
const task = current.delivery.task(taskId)
|
||||
const version = current.delivery.version(task, versionId)
|
||||
const attachment = attachmentId ? version.attachments.find(item => item.id === attachmentId) : null
|
||||
if (attachmentId && !attachment) throw new Error('LOCAL_DELIVERY_NOT_FOUND')
|
||||
let bytes
|
||||
if (attachment) {
|
||||
const store = getAttachmentStore()
|
||||
if (!store) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
|
||||
bytes = store.read(attachment.id)
|
||||
} else bytes = Buffer.from(current.delivery.markdown(taskId, versionId), 'utf8')
|
||||
const filename = (attachment?.name || `${task.brief.title}-V${task.versions.indexOf(version) + 1}.md`)
|
||||
.replace(/[\\/\x00-\x1f:*?"<>|]/g, '_')
|
||||
const result = await dialog.showSaveDialog(getMainWindow(), { defaultPath: filename })
|
||||
if (result.canceled || !result.filePath) return { saved: false }
|
||||
try {
|
||||
fs.writeFileSync(result.filePath, bytes, { mode: 0o600, flag: fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW })
|
||||
} catch { throw new Error('LOCAL_DELIVERY_EXPORT_FAILED') }
|
||||
return { saved: true }
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerDeliveryIpc }
|
||||
@@ -1,6 +1,14 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { ADOPTION_ACTION_STATUSES } = require('../collaboration/outcome-records.cjs')
|
||||
const { statusForHumanGateOption } = require('../gates/human-gate-coordinator.cjs')
|
||||
const { ATTACHMENT_FILE_EXTENSIONS } = require('../attachments/attachment-records.cjs')
|
||||
const { registerDeliveryIpc } = require('./delivery-ipc.cjs')
|
||||
const { loadedWorkPreferences, parseWorkPreferences } = require('../workspace/work-preferences.cjs')
|
||||
const { NATIVE_CONFIG_DIRECTORIES, validateNativeConfigDirectory } = require('../agents/native-config-directory.cjs')
|
||||
const { nativeSecretEnvKeys } = require('../agents/cli/native-secret-environment.cjs')
|
||||
const { switchNativeSessionProfile } = require('../workspace/native-session-profiles.cjs')
|
||||
|
||||
const LOCAL_GROUP_IDENTIFIER = /^[^\u0000-\u001f\u007f]{1,100}$/u
|
||||
const LOCAL_RUN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
|
||||
@@ -163,6 +171,7 @@ function sanitizeCloudAgentSnapshot(snapshot) {
|
||||
}
|
||||
|
||||
function registerDesktopIpc(options) {
|
||||
registerDeliveryIpc(options)
|
||||
const {
|
||||
app,
|
||||
attachmentIdsFromSnapshot,
|
||||
@@ -175,9 +184,11 @@ function registerDesktopIpc(options) {
|
||||
getCloudAgentBridges,
|
||||
getKnowledgeConnectors,
|
||||
getMainWindow,
|
||||
getNativeConfigSummary,
|
||||
getOutcomeStore,
|
||||
getSkillTrustStore,
|
||||
getWorkspace,
|
||||
getWorkBuddySettings,
|
||||
installer,
|
||||
isShutdownStarted,
|
||||
knowledgeBaseStore,
|
||||
@@ -187,6 +198,7 @@ function registerDesktopIpc(options) {
|
||||
openExternalUrl,
|
||||
refreshLocalAgentState,
|
||||
registerTrustedHandle,
|
||||
resolveConnectorEnvironment,
|
||||
skillCatalog,
|
||||
} = options
|
||||
|
||||
@@ -294,7 +306,7 @@ function registerDesktopIpc(options) {
|
||||
const optionId = typeof decision?.optionId === 'string' ? decision.optionId : ''
|
||||
const hasResponse = Object.hasOwn(decision || {}, 'response')
|
||||
const response = hasResponse && typeof decision.response === 'string'
|
||||
? decision.response.trim()
|
||||
? decision.response
|
||||
: ''
|
||||
const hasStatus = Object.hasOwn(decision || {}, 'status')
|
||||
const requestedStatus = hasStatus ? decision.status : ''
|
||||
@@ -302,7 +314,7 @@ function registerDesktopIpc(options) {
|
||||
|| !HUMAN_GATE_OPTION_IDENTIFIER.test(optionId)
|
||||
|| !isPlainObject(decision)
|
||||
|| (hasStatus && !['approved', 'rejected'].includes(requestedStatus))
|
||||
|| (hasResponse && (!response || response.length > 32 * 1024))
|
||||
|| (hasResponse && (!response.trim() || response.length > 32 * 1024))
|
||||
|| Reflect.ownKeys(decision).some(key => !['status', 'optionId', 'response'].includes(key))) {
|
||||
throw new Error('HUMAN_GATE_DECISION_INVALID')
|
||||
}
|
||||
@@ -417,6 +429,138 @@ function registerDesktopIpc(options) {
|
||||
})
|
||||
registerTrustedHandle('local-workspace:default-directory', () => app.getPath('documents'))
|
||||
registerTrustedHandle('local-agent-installer:catalog', async () => localAgentCatalog())
|
||||
registerTrustedHandle('local-agent-native-config:get', async (...args) => {
|
||||
if (args.length !== 1 || typeof args[0] !== 'string' || !Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, args[0])) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
const workspace = getWorkspace()
|
||||
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
|
||||
const directory = workspace.state.agentPreferences[args[0]]?.nativeConfigDirectory || ''
|
||||
const summary = typeof getNativeConfigSummary === 'function' ? await getNativeConfigSummary(args[0], workspace) : null
|
||||
if (getWorkspace() !== workspace || (workspace.state.agentPreferences[args[0]]?.nativeConfigDirectory || '') !== directory) {
|
||||
throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
}
|
||||
let directoryMissing = false
|
||||
let configurationFiles = null
|
||||
if (directory) {
|
||||
try {
|
||||
validateNativeConfigDirectory(args[0], directory)
|
||||
const spec = NATIVE_CONFIG_DIRECTORIES[args[0]]
|
||||
const entries = [...spec.files.map(name => [name, false]), ...(spec.directories || []).map(name => [name, true])]
|
||||
const present = []
|
||||
const missing = []
|
||||
for (const [name, isDirectory] of entries) {
|
||||
let exists = false
|
||||
try {
|
||||
const stat = fs.statSync(path.join(directory, name))
|
||||
exists = isDirectory ? stat.isDirectory() : stat.isFile()
|
||||
} catch { /* The entry is absent or unreadable. */ }
|
||||
if (exists) present.push(isDirectory ? `${name}/` : name)
|
||||
else missing.push(isDirectory ? `${name}/` : name)
|
||||
}
|
||||
configurationFiles = { present, missing }
|
||||
if (!present.length) directoryMissing = true
|
||||
} catch { directoryMissing = true }
|
||||
}
|
||||
return { directory, ...(directoryMissing ? { directoryMissing: true } : {}),
|
||||
...(configurationFiles ? { configurationFiles } : {}),
|
||||
...(summary && Object.hasOwn(summary, 'provider') ? { provider: summary.provider || '' } : {}),
|
||||
...(summary && Object.hasOwn(summary, 'authType') ? { authType: summary.authType || '' } : {}) }
|
||||
})
|
||||
for (const action of ['choose', 'clear']) {
|
||||
registerTrustedHandle(`local-agent-native-config:${action}`, async (...args) => {
|
||||
if (args.length !== 1 || typeof args[0] !== 'string' || !Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, args[0])) throw new Error('LOCAL_NATIVE_CONFIG_INVALID')
|
||||
const kind = args[0]
|
||||
const workspace = getWorkspace()
|
||||
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
|
||||
if (workspace.activeRuns.size || workspace.preparingRuns?.size) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
|
||||
const originalDirectory = workspace.state.agentPreferences[kind]?.nativeConfigDirectory || ''
|
||||
let directory = ''
|
||||
if (action === 'choose') {
|
||||
const selected = await dialog.showOpenDialog(getMainWindow(), { properties: ['openDirectory'] })
|
||||
if (selected.canceled) return { canceled: true }
|
||||
directory = validateNativeConfigDirectory(kind, selected.filePaths[0])
|
||||
}
|
||||
if (getWorkspace() !== workspace || workspace.activeRuns.size || workspace.preparingRuns?.size) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
|
||||
if ((workspace.state.agentPreferences[kind]?.nativeConfigDirectory || '') !== originalDirectory) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
|
||||
if (directory !== originalDirectory) {
|
||||
try {
|
||||
workspace.runLedger?.assertLoaded?.()
|
||||
const records = workspace.runLedger?.list?.() || []
|
||||
if (workspace.pendingV4AutoRecoveries?.size || records.some(record =>
|
||||
['preparing', 'queued', 'running', 'waiting', 'reconciling'].includes(record.status)
|
||||
|| ['pending', 'ready', 'resuming'].includes(record.continuation?.state))) {
|
||||
throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
|
||||
}
|
||||
} catch { throw new Error('LOCAL_WORK_PREFERENCES_BUSY') }
|
||||
}
|
||||
const previous = workspace.state.agentPreferences[kind]
|
||||
const previousRuntime = workspace.state.agentRuntime[kind]
|
||||
const previousSessions = workspace.state.sessions
|
||||
const previousSessionMeta = workspace.state.sessionMeta
|
||||
const previousProfiles = workspace.state.nativeSessionProfiles
|
||||
switchNativeSessionProfile(workspace.state, kind, originalDirectory, directory)
|
||||
workspace.state.agentPreferences[kind] = { ...previous, nativeConfigDirectory: directory }
|
||||
delete workspace.state.agentRuntime[kind]
|
||||
try { workspace.save() } catch (error) {
|
||||
if (previous) workspace.state.agentPreferences[kind] = previous
|
||||
else delete workspace.state.agentPreferences[kind]
|
||||
if (previousRuntime) workspace.state.agentRuntime[kind] = previousRuntime
|
||||
workspace.state.sessions = previousSessions
|
||||
workspace.state.sessionMeta = previousSessionMeta
|
||||
if (previousProfiles === undefined) delete workspace.state.nativeSessionProfiles
|
||||
else workspace.state.nativeSessionProfiles = previousProfiles
|
||||
throw error
|
||||
}
|
||||
installer.invalidateDetectionCache()
|
||||
skillCatalog?.invalidate(kind)
|
||||
await refreshLocalAgentState()
|
||||
return { canceled: false, directory }
|
||||
})
|
||||
}
|
||||
registerTrustedHandle('local-workbuddy:settings', () => getWorkBuddySettings())
|
||||
registerTrustedHandle('local-work-preferences:get', () => loadedWorkPreferences(getWorkspace()?.state.workPreferences))
|
||||
registerTrustedHandle('local-work-preferences:save', (...args) => {
|
||||
if (args.length !== 1) throw new Error('LOCAL_WORK_PREFERENCES_INVALID')
|
||||
const value = parseWorkPreferences(args[0])
|
||||
const workspace = getWorkspace()
|
||||
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
|
||||
if (workspace.activeRuns.size || workspace.preparingRuns?.size) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
|
||||
const previous = workspace.state.workPreferences
|
||||
workspace.state.workPreferences = value
|
||||
try { workspace.save() } catch (error) {
|
||||
if (previous) workspace.state.workPreferences = previous
|
||||
else delete workspace.state.workPreferences
|
||||
throw error
|
||||
}
|
||||
workspace.emitChanged()
|
||||
return value
|
||||
})
|
||||
registerTrustedHandle('local-workbuddy:set-model', async (...args) => {
|
||||
if (args.length !== 1 || typeof args[0] !== 'string' || args[0].length > 120) {
|
||||
throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
|
||||
}
|
||||
const workspace = getWorkspace()
|
||||
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
|
||||
const modelId = args[0]
|
||||
const settings = await getWorkBuddySettings()
|
||||
if (modelId && !settings.models.some(model => model.id === modelId)) {
|
||||
throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
|
||||
}
|
||||
if (workspace.activeRuns.size || workspace.preparingRuns?.size) {
|
||||
throw new Error('LOCAL_WORKBUDDY_BUSY')
|
||||
}
|
||||
const before = workspace.state.agentPreferences.workbuddy
|
||||
const runtime = workspace.state.agentRuntime.workbuddy
|
||||
workspace.state.agentPreferences.workbuddy = { ...before, modelId }
|
||||
delete workspace.state.agentRuntime.workbuddy
|
||||
try { workspace.save() } catch (error) {
|
||||
if (before) workspace.state.agentPreferences.workbuddy = before
|
||||
else delete workspace.state.agentPreferences.workbuddy
|
||||
if (runtime) workspace.state.agentRuntime.workbuddy = runtime
|
||||
throw error
|
||||
}
|
||||
await refreshLocalAgentState()
|
||||
return { ...settings, modelId }
|
||||
})
|
||||
registerTrustedHandle('local-agent-installer:skills', async (kind) => {
|
||||
const selectedKind = String(kind || '')
|
||||
if (customAgentStore.has(selectedKind)) {
|
||||
@@ -528,6 +672,7 @@ function registerDesktopIpc(options) {
|
||||
const connectors = getAgentConnectors?.()
|
||||
if (!connectors) throw new Error('AGENT_CONNECTOR_RUNTIME_UNAVAILABLE')
|
||||
const inspected = connectors.inspectPackage(args[0])
|
||||
const auditEventId = connectors.auditPackage(args[0]).at(-1)?.eventId
|
||||
const manifest = inspected.manifest
|
||||
const locale = String(app.getLocale?.() || '').toLowerCase()
|
||||
const slots = manifest.credentials.slots.map(slot => (
|
||||
@@ -573,6 +718,10 @@ function registerDesktopIpc(options) {
|
||||
noLink: true,
|
||||
})
|
||||
if (decision.response !== 0) return { canceled: true }
|
||||
if (!auditEventId || getAgentConnectors?.() !== connectors
|
||||
|| connectors.auditPackage(args[0]).at(-1)?.eventId !== auditEventId) {
|
||||
throw new Error('AGENT_CONNECTOR_PACKAGE_CHANGED')
|
||||
}
|
||||
return { canceled: false, package: connectors.approvePackage(args[0]) }
|
||||
})
|
||||
for (const [channel, method] of [
|
||||
@@ -596,7 +745,8 @@ function registerDesktopIpc(options) {
|
||||
registerTrustedHandle('local-agent-connector:configure', async (...args) => {
|
||||
if (args.length !== 1 || !args[0] || typeof args[0] !== 'object'
|
||||
|| Array.isArray(args[0])
|
||||
|| Reflect.ownKeys(args[0]).sort().join(',') !== 'credentials,label,manifestId') {
|
||||
|| Reflect.ownKeys(args[0]).sort().join(',') !== 'credentials,label,manifestId'
|
||||
|| args[0].credentials !== null) {
|
||||
throw new Error('AGENT_CONNECTOR_REQUEST_INVALID')
|
||||
}
|
||||
const connectors = getAgentConnectors?.()
|
||||
@@ -606,11 +756,65 @@ function registerDesktopIpc(options) {
|
||||
const result = connectors.configure({
|
||||
manifestId: input.manifestId,
|
||||
label: input.label,
|
||||
credentials: input.credentials === null ? null : input.credentials,
|
||||
credentials: null,
|
||||
})
|
||||
await refreshLocalAgentState()
|
||||
return result
|
||||
})
|
||||
registerTrustedHandle('local-agent-connector:configure-from-environment', async (...args) => {
|
||||
if (args.length !== 1 || !isPlainObject(args[0])
|
||||
|| Reflect.ownKeys(args[0]).sort().join(',') !== 'environmentKeys,label,packageId'
|
||||
|| typeof args[0].packageId !== 'string' || typeof args[0].label !== 'string'
|
||||
|| !isPlainObject(args[0].environmentKeys)) throw new Error('AGENT_CONNECTOR_REQUEST_INVALID')
|
||||
const connectors = getAgentConnectors?.()
|
||||
if (!connectors || typeof resolveConnectorEnvironment !== 'function') throw new Error('AGENT_CONNECTOR_RUNTIME_UNAVAILABLE')
|
||||
const inspected = connectors.inspectPackage(args[0].packageId)
|
||||
if (inspected.state !== 'installed') throw new Error('AGENT_CONNECTOR_PACKAGE_NOT_INSTALLED')
|
||||
const auditEventId = connectors.auditPackage(args[0].packageId).at(-1)?.eventId
|
||||
const assertPackageUnchanged = () => {
|
||||
try {
|
||||
if (auditEventId && getAgentConnectors?.() === connectors
|
||||
&& connectors.auditPackage(args[0].packageId).at(-1)?.eventId === auditEventId
|
||||
&& connectors.inspectPackage(args[0].packageId).state === 'installed') return
|
||||
} catch { /* A removed or unreadable package is no longer authorized. */ }
|
||||
throw new Error('AGENT_CONNECTOR_PACKAGE_CHANGED')
|
||||
}
|
||||
const slots = inspected.manifest.credentials.slots
|
||||
const entries = Object.entries(args[0].environmentKeys)
|
||||
if (!slots.length || entries.length > slots.length
|
||||
|| entries.some(([slot, name]) => !slots.some(item => item.slotId === slot)
|
||||
|| typeof name !== 'string' || nativeSecretEnvKeys([name])[0] !== name)
|
||||
|| slots.some(slot => slot.required && !Object.hasOwn(args[0].environmentKeys, slot.slotId))) {
|
||||
throw new Error('AGENT_CONNECTOR_CREDENTIAL_INVALID')
|
||||
}
|
||||
const locale = String(app.getLocale?.() || 'en').toLowerCase()
|
||||
const names = [...new Set(entries.map(([, name]) => name))]
|
||||
const decision = await dialog.showMessageBox(getMainWindow(), {
|
||||
type: 'warning',
|
||||
title: locale.startsWith('zh') ? '配置 Connector 凭据' : 'Configure Connector credentials',
|
||||
message: locale.startsWith('zh') ? '允许此 Connector 使用以下环境变量?' : 'Allow this Connector to use these environment variables?',
|
||||
detail: `${inspected.manifest.label}\n${entries.map(([slot, name]) => `${slot}: ${name}`).join('\n')}\n${inspected.manifest.outboundDestinations.join(', ')}`,
|
||||
buttons: locale.startsWith('zh') ? ['允许', '取消'] : ['Allow', 'Cancel'],
|
||||
defaultId: 1, cancelId: 1, noLink: true,
|
||||
})
|
||||
if (decision.response !== 0) return { canceled: true }
|
||||
if (isShutdownStarted()) throw new Error('DESKTOP_CLIENT_SHUTTING_DOWN')
|
||||
assertPackageUnchanged()
|
||||
const env = await resolveConnectorEnvironment(names)
|
||||
if (isShutdownStarted()) throw new Error('DESKTOP_CLIENT_SHUTTING_DOWN')
|
||||
assertPackageUnchanged()
|
||||
const credentials = Object.fromEntries(entries.map(([slot, name]) => [slot, env?.[name]]))
|
||||
if (Object.values(credentials).some(value => typeof value !== 'string' || !value.trim())) {
|
||||
throw new Error('AGENT_CONNECTOR_CREDENTIAL_ENV_MISSING')
|
||||
}
|
||||
const detectedAgents = await installer.detectedAgents()
|
||||
if (isShutdownStarted()) throw new Error('DESKTOP_CLIENT_SHUTTING_DOWN')
|
||||
assertPackageUnchanged()
|
||||
connectors.refresh(detectedAgents)
|
||||
const instance = connectors.configure({ manifestId: inspected.manifest.manifestId, label: args[0].label, credentials })
|
||||
await refreshLocalAgentState()
|
||||
return { canceled: false, instance }
|
||||
})
|
||||
registerTrustedHandle('local-agent-connector:delete', async (...args) => {
|
||||
if (args.length !== 1 || typeof args[0] !== 'string') {
|
||||
throw new Error('AGENT_CONNECTOR_REQUEST_INVALID')
|
||||
@@ -634,7 +838,12 @@ function registerDesktopIpc(options) {
|
||||
const connectors = getAgentConnectors?.()
|
||||
if (!connectors) throw new Error('AGENT_CONNECTOR_RUNTIME_UNAVAILABLE')
|
||||
connectors.refresh(await installer.detectedAgents())
|
||||
return connectors.test(args[0], app.getPath('userData'))
|
||||
const workdir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'meldwork-connector-test-'))
|
||||
try {
|
||||
return await connectors.test(args[0], workdir)
|
||||
} finally {
|
||||
await fs.promises.rm(workdir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
registerTrustedHandle('local-attachments:pick', async (remainingCapacity) => {
|
||||
attachments.availableStore()
|
||||
|
||||
@@ -19,6 +19,7 @@ const AGENT_SKILL_ROOTS = Object.freeze({
|
||||
['.agents', 'skills'],
|
||||
],
|
||||
hermes: [['.hermes', 'skills']],
|
||||
pi: [['.pi', 'agent', 'skills']],
|
||||
openclaw: [
|
||||
['.openclaw', 'workspace', 'skills'],
|
||||
['.openclaw', 'skills'],
|
||||
@@ -28,6 +29,10 @@ const AGENT_SKILL_ROOTS = Object.freeze({
|
||||
['.kimi-code', 'skills'],
|
||||
['.kimi', 'skills'],
|
||||
],
|
||||
deepseek: [
|
||||
['.dsh', 'skills'],
|
||||
['.agents', 'skills'],
|
||||
],
|
||||
claude: [
|
||||
['.claude', 'skills'],
|
||||
['.agents', 'skills'],
|
||||
@@ -159,12 +164,32 @@ function directoryEntryKind(entry, filename) {
|
||||
return ''
|
||||
}
|
||||
|
||||
function globalRoots(kind, home) {
|
||||
return (AGENT_SKILL_ROOTS[kind] || []).map(parts => ({
|
||||
directory: path.join(home, ...parts),
|
||||
boundary: path.join(home, ...parts),
|
||||
namespace: 'global',
|
||||
}))
|
||||
function globalRoots(kind, home, configDirectory = '', sharedDirectory = '') {
|
||||
const roots = (AGENT_SKILL_ROOTS[kind] || []).map(parts => {
|
||||
if (kind === 'deepseek' && parts[0] === '.agents' && sharedDirectory) {
|
||||
const directory = path.join(sharedDirectory, ...parts.slice(1))
|
||||
return { directory, boundary: directory, namespace: 'global' }
|
||||
}
|
||||
const nativeParts = kind === 'pi' && parts[0] === '.pi' && parts[1] === 'agent'
|
||||
? parts.slice(2)
|
||||
: (['codex', 'hermes', 'claude', 'workbuddy'].includes(kind) && parts[0] === `.${kind}`)
|
||||
|| (kind === 'deepseek' && parts[0] === '.dsh')
|
||||
|| (kind === 'kimi' && parts[0] === '.kimi-code') ? parts.slice(1) : null
|
||||
const directory = sharedDirectory && kind === 'opencode'
|
||||
? path.join(sharedDirectory, ...parts.slice(1))
|
||||
: configDirectory && ['gemini', 'qwen'].includes(kind)
|
||||
? path.join(configDirectory, ...parts)
|
||||
: configDirectory && nativeParts
|
||||
? path.join(configDirectory, ...nativeParts) : path.join(home, ...parts)
|
||||
return { directory, boundary: directory, namespace: 'global' }
|
||||
})
|
||||
if (kind === 'opencode' && configDirectory) {
|
||||
for (const name of ['skills', 'skill']) {
|
||||
const directory = path.join(configDirectory, name)
|
||||
roots.push({ directory, boundary: directory, namespace: 'global' })
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
function safePluginRoot(installLocation, ...parts) {
|
||||
@@ -176,8 +201,8 @@ function safePluginRoot(installLocation, ...parts) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
function workBuddyPluginRoots(home) {
|
||||
const workBuddy = path.join(home, '.workbuddy')
|
||||
function workBuddyPluginRoots(home, configDirectory = '') {
|
||||
const workBuddy = configDirectory || path.join(home, '.workbuddy')
|
||||
const settings = readJson(path.join(workBuddy, 'settings.json'))
|
||||
const marketplaces = readJson(path.join(workBuddy, 'plugins', 'known_marketplaces.json'))
|
||||
const enabled = settings?.enabledPlugins
|
||||
@@ -207,8 +232,8 @@ function workBuddyPluginRoots(home) {
|
||||
return roots
|
||||
}
|
||||
|
||||
function codexPluginRoots(home) {
|
||||
const cacheRoot = path.join(home, '.codex', 'plugins', 'cache')
|
||||
function codexPluginRoots(home, configDirectory = '') {
|
||||
const cacheRoot = path.join(configDirectory || path.join(home, '.codex'), 'plugins', 'cache')
|
||||
const roots = []
|
||||
const visited = new Set()
|
||||
let directoryCount = 0
|
||||
@@ -256,10 +281,10 @@ function codexPluginRoots(home) {
|
||||
))
|
||||
}
|
||||
|
||||
function skillRoots(kind, home) {
|
||||
const roots = globalRoots(kind, home)
|
||||
if (kind === 'codex') roots.push(...codexPluginRoots(home))
|
||||
if (kind === 'workbuddy') roots.push(...workBuddyPluginRoots(home))
|
||||
function skillRoots(kind, home, configDirectory = '', sharedDirectory = '') {
|
||||
const roots = globalRoots(kind, home, configDirectory, sharedDirectory)
|
||||
if (kind === 'codex') roots.push(...codexPluginRoots(home, configDirectory))
|
||||
if (kind === 'workbuddy') roots.push(...workBuddyPluginRoots(home, configDirectory))
|
||||
return roots
|
||||
}
|
||||
|
||||
@@ -348,10 +373,12 @@ function copyResult(result) {
|
||||
}
|
||||
|
||||
class LocalSkillCatalog {
|
||||
constructor({ home = os.homedir(), now = Date.now, cacheTtlMs = DEFAULT_CACHE_TTL_MS } = {}) {
|
||||
constructor({ home = os.homedir(), now = Date.now, cacheTtlMs = DEFAULT_CACHE_TTL_MS, configDirectoryForKind = () => '', sharedDirectoryForKind = () => '' } = {}) {
|
||||
this.home = path.resolve(String(home || os.homedir()))
|
||||
this.now = now
|
||||
this.cacheTtlMs = cacheTtlMs
|
||||
this.configDirectoryForKind = configDirectoryForKind
|
||||
this.sharedDirectoryForKind = sharedDirectoryForKind
|
||||
this.cache = new Map()
|
||||
}
|
||||
|
||||
@@ -364,7 +391,9 @@ class LocalSkillCatalog {
|
||||
const cached = this.cache.get(targetKind)
|
||||
if (cached && now < cached.expiresAt) return cached.result
|
||||
|
||||
const skills = collectSkills(targetKind, skillRoots(targetKind, this.home)).sort(compareSkills)
|
||||
const configDirectory = this.configDirectoryForKind(targetKind)
|
||||
const skills = collectSkills(targetKind, skillRoots(targetKind, this.home, configDirectory,
|
||||
this.sharedDirectoryForKind(targetKind))).sort(compareSkills)
|
||||
const result = {
|
||||
supported: true,
|
||||
total: skills.length,
|
||||
|
||||
@@ -273,8 +273,9 @@ class LocalSkillTrustStore {
|
||||
body.previousEventId,
|
||||
)
|
||||
try { writeLine(this.storagePath, `${canonicalJson(event)}\n`) } catch (error) {
|
||||
if (error?.code?.startsWith('LOCAL_SKILL_')) throw error
|
||||
fail('LOCAL_SKILL_TRUST_STORE_UNAVAILABLE')
|
||||
this.unavailableCode = error?.code?.startsWith('LOCAL_SKILL_')
|
||||
? error.code : 'LOCAL_SKILL_TRUST_STORE_UNAVAILABLE'
|
||||
fail(this.unavailableCode)
|
||||
}
|
||||
this.events.push(event)
|
||||
this.states.set(binding.bindingId, {
|
||||
@@ -286,6 +287,7 @@ class LocalSkillTrustStore {
|
||||
}
|
||||
|
||||
decision(input) {
|
||||
this.requireAvailable()
|
||||
const binding = asBinding(input)
|
||||
const state = this.states.get(binding.bindingId)
|
||||
if (state?.state !== 'approved') return null
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
const { createHash, randomBytes } = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { npmShimTarget } = require('../agents/cli/cli-discovery.cjs')
|
||||
const { credentialLiteral } = require('../agents/cli/native-secret-environment.cjs')
|
||||
const { NATIVE_CONFIG_DIRECTORIES, NATIVE_CONFIG_IDENTITY_DIRECTORY, NATIVE_ENVIRONMENT_IDENTITY, NATIVE_AUTH_IDENTITY_FILES, NATIVE_PROJECT_CONFIG_PATHS } = require('../agents/native-config-directory.cjs')
|
||||
|
||||
function fileIdentity(filename) {
|
||||
try {
|
||||
const stat = fs.statSync(filename)
|
||||
return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs]
|
||||
} catch (error) {
|
||||
return error?.code === 'ENOENT' ? null : ['unreadable', randomBytes(32).toString('hex')]
|
||||
}
|
||||
}
|
||||
|
||||
function selectedConfigurationIdentity(agent, preferences) {
|
||||
const directory = preferences?.[agent.kind]?.nativeConfigDirectory || agent[NATIVE_CONFIG_IDENTITY_DIRECTORY]
|
||||
const spec = Object.hasOwn(NATIVE_CONFIG_DIRECTORIES, agent.kind) ? NATIVE_CONFIG_DIRECTORIES[agent.kind] : null
|
||||
if (typeof directory !== 'string' || !path.isAbsolute(directory) || !spec) return null
|
||||
const entries = [['directory', directory], ...[...spec.files, ...(spec.identityFiles || [])]
|
||||
.map(name => [name, fileIdentity(path.join(directory, name))])]
|
||||
for (const name of spec.directories || []) {
|
||||
const root = path.join(directory, name)
|
||||
let children = []
|
||||
try {
|
||||
children = fs.readdirSync(root).sort()
|
||||
if (children.length > 256) return ['unbounded-directory', randomBytes(32).toString('hex')]
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') return ['unreadable-directory', randomBytes(32).toString('hex')]
|
||||
}
|
||||
entries.push([name, fileIdentity(root), children.map(child => {
|
||||
const childPath = path.join(root, child)
|
||||
const nested = agent.kind === 'deepseek' && name === 'profiles'
|
||||
? ['cordis.yml', 'cordis.patch.yml', '.credentials.yaml']
|
||||
.map(file => [file, fileIdentity(path.join(childPath, file))]) : null
|
||||
return [child, fileIdentity(childPath), nested]
|
||||
})])
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function geminiAccessTokenExpired(agent, preferences, now) {
|
||||
const directory = preferences?.gemini?.nativeConfigDirectory || agent[NATIVE_CONFIG_IDENTITY_DIRECTORY]
|
||||
if (typeof directory !== 'string' || !path.isAbsolute(directory)) return false
|
||||
try {
|
||||
const filename = path.join(directory, 'oauth_creds.json')
|
||||
const stat = fs.statSync(filename)
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return false
|
||||
const value = JSON.parse(fs.readFileSync(filename, 'utf8'))
|
||||
if (credentialLiteral(value?.refresh_token)) return false
|
||||
return credentialLiteral(value?.access_token)
|
||||
&& typeof value.expiry_date === 'number' && Number.isFinite(value.expiry_date)
|
||||
&& value.expiry_date > 0 && value.expiry_date <= now + 5 * 60 * 1000
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
function agentVerificationIdentity(agent, preferences = {}, options = {}) {
|
||||
if (!agent?.kind) return ''
|
||||
let executableStat = null
|
||||
if (agent.executable) {
|
||||
try {
|
||||
const stat = fs.statSync(agent.executable)
|
||||
executableStat = [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs]
|
||||
} catch { /* unavailable paths are still bound to their detected identity */ }
|
||||
}
|
||||
const identity = [
|
||||
agent.kind, agent.executable || '', agent.version || '', executableStat,
|
||||
agent.connectorId || '', agent.connectorVersion || '', agent.upstreamVersion || '',
|
||||
preferences?.[agent.kind]?.nativeConfigDirectory || '',
|
||||
]
|
||||
if ((options.platform || process.platform) === 'win32' && /\.(?:cmd|bat)$/i.test(agent.executable || '')) {
|
||||
try {
|
||||
const target = npmShimTarget(agent.executable, options)
|
||||
if (!target) throw new Error('invalid shim')
|
||||
const stat = (options.statFn || fs.statSync)(target)
|
||||
identity.push(['shim-target', target, stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs])
|
||||
} catch {
|
||||
identity.push(['unavailable-shim-target', randomBytes(32).toString('hex')])
|
||||
}
|
||||
}
|
||||
const configuration = selectedConfigurationIdentity(agent, preferences)
|
||||
if (configuration !== null) identity.push(configuration)
|
||||
if (agent.kind === 'gemini' && geminiAccessTokenExpired(agent, preferences, options.now ?? Date.now())) {
|
||||
identity.push(['oauth-access-token-expired'])
|
||||
}
|
||||
if (Array.isArray(agent[NATIVE_AUTH_IDENTITY_FILES])) {
|
||||
identity.push(agent[NATIVE_AUTH_IDENTITY_FILES].filter(filename => typeof filename === 'string' && path.isAbsolute(filename))
|
||||
.map(filename => [filename, fileIdentity(filename)]))
|
||||
}
|
||||
if (agent[NATIVE_ENVIRONMENT_IDENTITY]) identity.push(agent[NATIVE_ENVIRONMENT_IDENTITY])
|
||||
return createHash('sha256').update(JSON.stringify(identity)).digest('hex')
|
||||
}
|
||||
|
||||
function projectVerificationIdentity(kind, workdir, agent = null, environmentIdentity = '') {
|
||||
const files = {
|
||||
codex: ['.codex/config.toml'],
|
||||
claude: ['.claude/settings.json', '.claude/settings.local.json'],
|
||||
zcode: ['zcode.json', '.zcode/config.json'],
|
||||
opencode: ['opencode.json', 'opencode.jsonc', '.opencode/opencode.json', '.opencode/opencode.jsonc'],
|
||||
mimo: ['mimocode.json', 'mimocode.jsonc', '.mimocode/mimocode.json', '.mimocode/mimocode.jsonc'],
|
||||
gemini: ['.gemini/settings.json', '.gemini/.env', '.env'],
|
||||
qwen: ['.qwen/settings.json', '.qwen/.env', '.env'],
|
||||
deepseek: ['.env'],
|
||||
pi: [],
|
||||
}[kind]
|
||||
if (!files && !environmentIdentity && !agent?.[NATIVE_PROJECT_CONFIG_PATHS]?.length) return ''
|
||||
if (kind === 'pi' && !environmentIdentity && !agent?.[NATIVE_PROJECT_CONFIG_PATHS]?.length) return ''
|
||||
if (typeof workdir !== 'string' || !path.isAbsolute(workdir)) return null
|
||||
const root = path.resolve(workdir)
|
||||
if (root.length > 32767) return null
|
||||
try { if (!fs.statSync(root).isDirectory()) return null } catch { return null }
|
||||
const entries = [kind, root]
|
||||
if (environmentIdentity) entries.push(['native-environment', environmentIdentity])
|
||||
for (const item of agent?.[NATIVE_PROJECT_CONFIG_PATHS] || []) {
|
||||
if (!item || typeof item.value !== 'string' || path.isAbsolute(item.value)) continue
|
||||
const target = path.resolve(root, item.value)
|
||||
const names = kind === 'opencode' ? ['opencode.json', 'opencode.jsonc'] : ['mimocode.json', 'mimocode.jsonc']
|
||||
entries.push([target, item.directory ? names.map(name => [name, fileIdentity(path.join(target, name))]) : fileIdentity(target)])
|
||||
}
|
||||
let current = root
|
||||
for (;;) {
|
||||
entries.push([current, ...(files || []).map(file => [file, fileIdentity(path.join(current, file))])])
|
||||
if (kind === 'deepseek') break
|
||||
if (kind !== 'qwen' && kind !== 'gemini' && fs.existsSync(path.join(current, '.git'))) break
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return createHash('sha256').update(JSON.stringify(entries)).digest('hex')
|
||||
}
|
||||
|
||||
module.exports = { agentVerificationIdentity, projectVerificationIdentity }
|
||||
@@ -0,0 +1,257 @@
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { z } = require('zod')
|
||||
const { normalizeAttachmentMetadata } = require('./local-workspace-message-records.cjs')
|
||||
|
||||
const id = z.string().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/)
|
||||
const text = (max) => z.string().trim().min(1).max(max)
|
||||
const briefSchema = z.strictObject({
|
||||
title: text(120), goal: text(4000), output: text(2000),
|
||||
outputKind: z.enum(['text', 'files']),
|
||||
criteria: z.array(text(300)).min(1).max(20),
|
||||
language: z.enum(['zh', 'en']),
|
||||
})
|
||||
const attachmentSchema = z.strictObject({
|
||||
id, name: text(160), mimeType: text(160), size: z.number().int().positive(),
|
||||
})
|
||||
const sourceSchema = z.strictObject({ name: text(200), kind: text(40), attachmentId: id.optional() })
|
||||
const revisionSchema = z.strictObject({ baseVersionId: id, note: text(4000) })
|
||||
const attemptSchema = z.strictObject({
|
||||
runId: id, messageId: id, status: text(40), createdAt: text(80),
|
||||
input: z.string().max(200000), sources: z.array(sourceSchema).max(41),
|
||||
revision: revisionSchema.nullable(),
|
||||
})
|
||||
const versionSchema = z.strictObject({
|
||||
id, runId: id, createdAt: text(80),
|
||||
responses: z.array(z.strictObject({ messageId: id, agentKind: text(40), content: z.string().max(1000000) })).max(32),
|
||||
attachments: z.array(attachmentSchema).max(128),
|
||||
checks: z.array(z.enum(['pending', 'met', 'unmet', 'unknown'])).max(20),
|
||||
notes: z.string().max(4000), acceptedAt: z.string().max(80),
|
||||
})
|
||||
const taskSchema = z.strictObject({
|
||||
id, groupId: text(100), brief: briefSchema, createdAt: text(80),
|
||||
attempts: z.array(attemptSchema).max(200), versions: z.array(versionSchema).max(200),
|
||||
revision: revisionSchema.nullable(),
|
||||
})
|
||||
|
||||
function parse(schema, input) {
|
||||
const result = schema.safeParse(input)
|
||||
if (!result.success) throw new Error('LOCAL_DELIVERY_INVALID')
|
||||
return result.data
|
||||
}
|
||||
|
||||
function parseDeliveryTasks(value = []) {
|
||||
return parse(z.array(taskSchema).max(1000), value)
|
||||
}
|
||||
|
||||
class DeliveryTasks {
|
||||
constructor(workspace) {
|
||||
this.workspace = workspace
|
||||
this.pending = new Map()
|
||||
}
|
||||
|
||||
get tasks() { return this.workspace.state.deliveryTasks || [] }
|
||||
|
||||
task(taskId) {
|
||||
parse(id, taskId)
|
||||
const task = this.tasks.find(item => item.id === taskId)
|
||||
if (!task || !this.workspace.state.groups.some(group => group.id === task.groupId)) {
|
||||
throw new Error('LOCAL_DELIVERY_NOT_FOUND')
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
version(task, versionId) {
|
||||
parse(id, versionId)
|
||||
const version = task.versions.find(item => item.id === versionId)
|
||||
if (!version) throw new Error('LOCAL_DELIVERY_NOT_FOUND')
|
||||
return version
|
||||
}
|
||||
|
||||
commit(change) {
|
||||
const previous = structuredClone(this.tasks)
|
||||
try {
|
||||
this.workspace.state.deliveryTasks ||= []
|
||||
const result = change()
|
||||
parseDeliveryTasks(this.tasks)
|
||||
this.workspace.save()
|
||||
return result
|
||||
} catch (error) {
|
||||
this.workspace.state.deliveryTasks = previous
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
idle(task) {
|
||||
if (this.workspace.isGroupBusy(task.groupId) || this.pending.has(task.groupId)) {
|
||||
throw new Error('LOCAL_GROUP_RUNNING')
|
||||
}
|
||||
}
|
||||
|
||||
save(input) {
|
||||
const request = parse(z.strictObject({ groupId: text(100), taskId: id.optional(), brief: briefSchema }), input)
|
||||
this.workspace.getGroup(request.groupId)
|
||||
if (this.workspace.isGroupBusy(request.groupId)) throw new Error('LOCAL_GROUP_RUNNING')
|
||||
const existing = request.taskId ? this.task(request.taskId) : null
|
||||
if (existing && (existing.groupId !== request.groupId || existing.attempts.length)) {
|
||||
throw new Error('LOCAL_DELIVERY_LOCKED')
|
||||
}
|
||||
return this.commit(() => {
|
||||
if (existing) { existing.brief = request.brief; return existing.id }
|
||||
const task = {
|
||||
id: `delivery-${randomUUID()}`, groupId: request.groupId, brief: request.brief,
|
||||
createdAt: this.workspace.now(), attempts: [], versions: [], revision: null,
|
||||
}
|
||||
this.tasks.push(task)
|
||||
return task.id
|
||||
})
|
||||
}
|
||||
|
||||
prepare(input) {
|
||||
const task = this.task(input.deliveryTaskId)
|
||||
if (task.groupId !== input.groupId || input.regenerateMessageId || input.threadRootId) {
|
||||
throw new Error('LOCAL_DELIVERY_INVALID')
|
||||
}
|
||||
this.idle(task)
|
||||
if (task.versions.length && !task.revision) throw new Error('LOCAL_DELIVERY_REVISION_REQUIRED')
|
||||
const brief = task.brief
|
||||
const base = task.revision ? this.version(task, task.revision.baseVersionId) : null
|
||||
if (task.attempts.length >= 200) throw new Error('LOCAL_DELIVERY_INPUT_LIMIT')
|
||||
if (input.attachments != null && !Array.isArray(input.attachments)) throw new Error('LOCAL_DELIVERY_INVALID')
|
||||
const attachments = [...new Map([...(base?.attachments || []), ...(input.attachments || [])]
|
||||
.map(item => [item.id, item])).values()]
|
||||
if (attachments.length > 32) throw new Error('LOCAL_ATTACHMENT_COUNT_LIMIT')
|
||||
const prompt = [
|
||||
brief.language === 'zh' ? '工作交付要求(请用中文回答)' : 'Work delivery requirements (respond in English)',
|
||||
JSON.stringify(brief),
|
||||
'Produce the requested deliverable. Identify source materials for key claims, conflicting or missing information, and unresolved issues. Never invent missing data or claim that a file exists without creating it. Human acceptance is separate from your self-review.',
|
||||
...(base ? ['Previous version:', ...base.responses.map(item => item.content), `Revision request: ${task.revision.note}`] : []),
|
||||
`Additional instructions: ${String(input.text || '')}`,
|
||||
].join('\n\n')
|
||||
if (prompt.length > 200000) throw new Error('LOCAL_DELIVERY_INPUT_LIMIT')
|
||||
this.pending.set(task.groupId, { taskId: task.id, input: String(input.text || '') })
|
||||
const { deliveryTaskId: _id, ...rest } = input
|
||||
return { ...rest, text: prompt, attachments }
|
||||
}
|
||||
|
||||
bind(groupId, controller, messageId) {
|
||||
const pending = this.pending.get(groupId)
|
||||
if (!pending) return
|
||||
const task = this.task(pending.taskId)
|
||||
const message = this.workspace.state.messages.find(item => item.id === messageId)
|
||||
if (!message || message.groupId !== groupId) throw new Error('LOCAL_DELIVERY_INVALID')
|
||||
this.commit(() => task.attempts.push({
|
||||
runId: controller.runId, messageId, status: 'running', createdAt: this.workspace.now(),
|
||||
input: pending.input, revision: task.revision ? { ...task.revision } : null,
|
||||
sources: [
|
||||
{ name: this.workspace.getGroup(groupId).name || 'Conversation', kind: 'conversation' },
|
||||
...(message.attachments || []).map(item => ({ name: item.name, kind: 'attachment', attachmentId: item.id })),
|
||||
...(message.knowledgeBaseHints || []).map(item => ({ name: item.name, kind: item.kind })),
|
||||
...(message.skillHints || []).map(item => ({ name: item.name, kind: 'skill' })),
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
complete(runId, status, committedMessageIds = []) {
|
||||
const task = this.tasks.find(item => item.attempts.some(attempt => attempt.runId === runId))
|
||||
if (!task) return
|
||||
const attempt = task.attempts.find(item => item.runId === runId)
|
||||
if (!['running', 'waiting', 'preparing'].includes(attempt.status)) return
|
||||
const messages = this.workspace.state.messages
|
||||
const rootIndex = messages.findIndex(item => item.id === attempt.messageId)
|
||||
const nextUserIndex = messages.findIndex((item, index) => index > rootIndex && item.groupId === task.groupId && item.role === 'user')
|
||||
const related = messages.filter((message, index) => {
|
||||
if (message.role !== 'agent' || message.groupId !== task.groupId) return false
|
||||
if (committedMessageIds.includes(message.id)) return true
|
||||
if (message.trace?.runId && message.trace.runId !== runId) return false
|
||||
return message.trace?.runId === runId
|
||||
|| message.threadRootId === attempt.messageId
|
||||
|| (!message.threadRootId && rootIndex >= 0 && index > rootIndex && (nextUserIndex < 0 || index < nextUserIndex))
|
||||
})
|
||||
const outputs = related.filter(message => {
|
||||
const phase = message.trace?.phase || message.trace?.context?.phase
|
||||
return committedMessageIds.includes(message.id)
|
||||
|| !['proposal', 'discussion', 'challenge', 'coordination', 'work', 'verification'].includes(phase)
|
||||
}).filter(message => message.content?.trim())
|
||||
const attachments = [...new Map(related.flatMap(item => item.attachments || [])
|
||||
.map(normalizeAttachmentMetadata).filter(Boolean).map(item => [item.id, item])).values()]
|
||||
this.commit(() => {
|
||||
attempt.status = status
|
||||
if (!outputs.length && !attachments.length) return
|
||||
task.versions.push({
|
||||
id: `version-${randomUUID()}`, runId, createdAt: this.workspace.now(),
|
||||
responses: outputs.map(item => ({ messageId: item.id, agentKind: item.agentKind, content: item.content || '' })),
|
||||
attachments, checks: task.brief.criteria.map(() => 'pending'), notes: '', acceptedAt: '',
|
||||
})
|
||||
task.revision = null
|
||||
})
|
||||
}
|
||||
|
||||
recover() {
|
||||
for (const task of this.tasks) {
|
||||
for (const attempt of task.attempts) {
|
||||
if (!['running', 'waiting', 'preparing'].includes(attempt.status)) continue
|
||||
const record = this.workspace.runLedger?.list?.().find(item => item.runId === attempt.runId)
|
||||
if (this.workspace.activeRuns.has(task.groupId)
|
||||
|| (record?.status === 'waiting' && this.workspace.canResumeHumanGateRecord(record))) continue
|
||||
const status = record && !['running', 'waiting', 'preparing', 'queued'].includes(record.status)
|
||||
? record.status : 'interrupted'
|
||||
this.complete(attempt.runId, status, [
|
||||
...(record?.orchestration?.commitState?.messageIds || []),
|
||||
record?.orchestration?.candidateCommit?.messageId,
|
||||
].filter(Boolean))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
review(input) {
|
||||
const request = parse(z.strictObject({
|
||||
taskId: id, versionId: id, checks: z.array(z.enum(['pending', 'met', 'unmet', 'unknown'])).max(20),
|
||||
notes: z.string().max(4000), accept: z.boolean(),
|
||||
}), input)
|
||||
const task = this.task(request.taskId)
|
||||
this.idle(task)
|
||||
const version = this.version(task, request.versionId)
|
||||
if (version.acceptedAt || request.checks.length !== task.brief.criteria.length) throw new Error('LOCAL_DELIVERY_LOCKED')
|
||||
if (request.accept && (request.checks.some(check => check !== 'met')
|
||||
|| (task.brief.outputKind === 'files' && !version.attachments.length))) {
|
||||
throw new Error('LOCAL_DELIVERY_NOT_READY')
|
||||
}
|
||||
this.commit(() => {
|
||||
version.checks = request.checks
|
||||
version.notes = request.notes.trim()
|
||||
if (request.accept) version.acceptedAt = this.workspace.now()
|
||||
})
|
||||
}
|
||||
|
||||
revise(input) {
|
||||
const request = parse(z.strictObject({ taskId: id, baseVersionId: id, note: text(4000) }), input)
|
||||
const task = this.task(request.taskId)
|
||||
this.idle(task)
|
||||
this.version(task, request.baseVersionId)
|
||||
this.commit(() => { task.revision = { baseVersionId: request.baseVersionId, note: request.note } })
|
||||
}
|
||||
|
||||
markdown(taskId, versionId) {
|
||||
const task = this.task(taskId)
|
||||
const version = this.version(task, versionId)
|
||||
const attempt = task.attempts.find(item => item.runId === version.runId)
|
||||
const zh = task.brief.language === 'zh'
|
||||
const checks = zh
|
||||
? { pending: '待检查', met: '符合', unmet: '不符合', unknown: '无法判断' }
|
||||
: { pending: 'Not checked', met: 'Met', unmet: 'Not met', unknown: 'Unknown' }
|
||||
return [
|
||||
`# ${task.brief.title}`, `V${task.versions.indexOf(version) + 1} · ${version.createdAt}`,
|
||||
`${zh ? '采用时间' : 'Accepted at'}: ${version.acceptedAt || (zh ? '未采用' : 'Not accepted')}`,
|
||||
...version.responses.map(item => `## ${item.agentKind}\n\n${item.content}`),
|
||||
`## ${zh ? '输入材料(不代表逐条引用已核实)' : 'Input materials (not verified claim-level citations)'}`,
|
||||
...attempt.sources.map(item => `- ${item.name} (${item.kind})`),
|
||||
`## ${zh ? '人工验收' : 'Human review'}`,
|
||||
...task.brief.criteria.map((criterion, index) => `- [${checks[version.checks[index]]}] ${criterion}`),
|
||||
`## ${zh ? '备注与未决事项' : 'Notes and unresolved issues'}`, version.notes || '—',
|
||||
`## ${zh ? '附件(需单独导出)' : 'Attachments (export separately)'}`,
|
||||
...version.attachments.map(item => `- ${item.name}`),
|
||||
].join('\n\n') + '\n'
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DeliveryTasks, parseDeliveryTasks }
|
||||
@@ -47,7 +47,10 @@ function resolveInvocationStop({
|
||||
const agentCancelled = agentController.signal.aborted && !parentStopped && !watchdog.timedOut
|
||||
let error = caughtError
|
||||
let status = 'failed'
|
||||
if (parentTimedOut) {
|
||||
if (caughtError?.code === 'LOCAL_AGENT_TERMINATION_FAILED'
|
||||
|| caughtError?.message === 'LOCAL_AGENT_TERMINATION_FAILED') {
|
||||
// A cancellation request is not proof that the process exited.
|
||||
} else if (parentTimedOut) {
|
||||
error = new Error('LOCAL_AGENT_TIMEOUT')
|
||||
status = 'timeout'
|
||||
} else if (parentStopped) {
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
const { agentVerificationIdentity, projectVerificationIdentity } = require('./agent-verification-identity.cjs')
|
||||
const { NATIVE_CONFIG_IDENTITY_DIRECTORY, NATIVE_AUTH_IDENTITY_FILES, NATIVE_PROJECT_CONFIG_PATHS } = require('../agents/native-config-directory.cjs')
|
||||
const {
|
||||
agentRuntimeCapabilities,
|
||||
} = require('../agents/agent-runtime-contract.cjs')
|
||||
|
||||
const RECENT_VERIFICATION_MS = 24 * 60 * 60 * 1000
|
||||
const AUTH_FAILURE_RETRY_MS = 30 * 1000
|
||||
const MAX_PROJECT_AUTH_RECORDS = 64
|
||||
const PROJECT_NATIVE_ENVIRONMENT_KINDS = new Set([
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'zcode', 'pi', 'kimi', 'mimo', 'deepseek',
|
||||
'claude', 'gemini', 'opencode', 'qwen', 'opencodereview',
|
||||
])
|
||||
// A readiness probe that times out or crashes says nothing about whether the
|
||||
// Agent works: it is not a "not signed in" answer. An Agent that was usable
|
||||
// stays usable through such results for this long; a definitive answer
|
||||
// (signed out, incompatible, a recorded auth failure) applies immediately.
|
||||
const INDETERMINATE_PROBE_GRACE_MS = 10 * 60 * 1000
|
||||
const INDETERMINATE_PROBE_SOURCES = new Set(['native-runtime-unavailable', 'native-probe-failed'])
|
||||
const INDETERMINATE_PROBE_SOURCES = new Set(['native-probe-failed'])
|
||||
|
||||
function expiredAuthFailure(runtime, now) {
|
||||
if (runtime?.credentialState !== 'missing') return false
|
||||
const checkedAt = Date.parse(String(runtime.checkedAt || ''))
|
||||
if (runtime?.credentialState !== 'missing' && !runtime?.projectFailureIdentity) return false
|
||||
const checkedAt = Date.parse(String(runtime.projectFailureCheckedAt || runtime.checkedAt || ''))
|
||||
const current = Date.parse(String(now || ''))
|
||||
return !Number.isFinite(checkedAt) || (Number.isFinite(current)
|
||||
&& (current < checkedAt || current - checkedAt >= AUTH_FAILURE_RETRY_MS))
|
||||
}
|
||||
|
||||
function clearProjectFailure(runtime) {
|
||||
if ((runtime?.verificationSource !== 'successful-run' || runtime.credentialState !== 'ready')
|
||||
&& !runtime?.projectVerifications?.length && !runtime?.projectFailures?.length) return undefined
|
||||
const { projectFailureIdentity, projectFailureCheckedAt, failureIdentity, ...retained } = runtime
|
||||
return retained
|
||||
}
|
||||
|
||||
function agentVersionIdentified(agent) {
|
||||
if (agent?.custom === true) return true
|
||||
if (typeof agent?.versionIdentified === 'boolean') return agent.versionIdentified
|
||||
@@ -34,6 +48,17 @@ function agentCompatible(agent) {
|
||||
|| typeof agent?.compatibilityState === 'undefined'
|
||||
}
|
||||
|
||||
function sidebarVisible(agent, preferred) {
|
||||
if (typeof preferred === 'boolean') return preferred
|
||||
return !(agent.custom === true && agent.task && agent.task !== 'general')
|
||||
}
|
||||
|
||||
function agentInvocable({ installed, versionIdentified, compatible, configured, authenticated, canAttempt, runtimePrerequisitesReady, unavailable }) {
|
||||
return installed !== false && versionIdentified && compatible
|
||||
&& ((configured && authenticated) || canAttempt === true)
|
||||
&& runtimePrerequisitesReady !== false && !unavailable
|
||||
}
|
||||
|
||||
function recentlyVerified(runtime, now) {
|
||||
if (runtime?.credentialState !== 'ready') return false
|
||||
const checkedAt = Date.parse(String(runtime.checkedAt || ''))
|
||||
@@ -42,13 +67,22 @@ function recentlyVerified(runtime, now) {
|
||||
&& current >= checkedAt && current - checkedAt <= RECENT_VERIFICATION_MS
|
||||
}
|
||||
|
||||
function matchesRecentVerification(runtime, agent, now, preferences) {
|
||||
return runtime?.verificationSource === 'successful-run' && recentlyVerified(runtime, now)
|
||||
&& Boolean(runtime.verificationIdentity) && runtime.verificationIdentity === agentVerificationIdentity(agent, preferences)
|
||||
}
|
||||
|
||||
class LocalWorkspaceAgentCatalog {
|
||||
constructor(options) {
|
||||
this.state = options.state
|
||||
this.detectedAgents = options.detectedAgents
|
||||
this.setDetectedAgents = options.setDetectedAgents
|
||||
this.detectAgents = options.detectAgents
|
||||
this.isKnownAgent = options.isKnownAgent || (() => false)
|
||||
this.credentialState = options.credentialState
|
||||
this.resolveProjectEnvironmentIdentities = options.resolveProjectEnvironmentIdentities || null
|
||||
this.projectEnvironmentIdentities = new Map()
|
||||
this.projectEnvironmentGeneration = 0
|
||||
this.attachmentSupport = options.attachmentSupport || (() => ({}))
|
||||
this.save = options.save
|
||||
this.emitChanged = options.emitChanged
|
||||
@@ -57,6 +91,67 @@ class LocalWorkspaceAgentCatalog {
|
||||
this.refreshTask = null
|
||||
this.refreshAgain = false
|
||||
this.indeterminateSince = new Map()
|
||||
this.detectedIdentities = new Map(this.detectedAgents().map(agent => [agent.kind, agentVerificationIdentity(agent, this.state().agentPreferences)]))
|
||||
}
|
||||
|
||||
async refreshProjectEnvironment(workdir, kinds) {
|
||||
if (!this.resolveProjectEnvironmentIdentities || typeof workdir !== 'string') return
|
||||
const selected = [...new Set((kinds || []).filter(kind => PROJECT_NATIVE_ENVIRONMENT_KINDS.has(kind)))]
|
||||
if (!selected.length) return
|
||||
const generation = ++this.projectEnvironmentGeneration
|
||||
const keys = selected.map(kind => JSON.stringify([kind, workdir]))
|
||||
for (const key of keys) this.projectEnvironmentIdentities.set(key, { generation, value: null })
|
||||
let identities = null
|
||||
try { identities = await this.resolveProjectEnvironmentIdentities(workdir, selected) } catch { /* Fail closed for routing. */ }
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const current = this.projectEnvironmentIdentities.get(keys[index])
|
||||
if (current?.generation !== generation) continue
|
||||
const value = identities?.[selected[index]]
|
||||
current.value = typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) ? value : null
|
||||
}
|
||||
while (this.projectEnvironmentIdentities.size > 256) {
|
||||
this.projectEnvironmentIdentities.delete(this.projectEnvironmentIdentities.keys().next().value)
|
||||
}
|
||||
}
|
||||
|
||||
projectIdentity(kind, workdir, agent) {
|
||||
let environmentIdentity = ''
|
||||
if (this.resolveProjectEnvironmentIdentities && PROJECT_NATIVE_ENVIRONMENT_KINDS.has(kind)) {
|
||||
environmentIdentity = this.projectEnvironmentIdentities.get(JSON.stringify([kind, workdir]))?.value
|
||||
if (!environmentIdentity) return null
|
||||
}
|
||||
return projectVerificationIdentity(kind, workdir, agent, environmentIdentity)
|
||||
}
|
||||
|
||||
isRecentlyVerified(kind, workdir) {
|
||||
const runtime = this.state().agentRuntime[kind]
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind)
|
||||
if (workdir === undefined) return matchesRecentVerification(runtime, agent, this.now(), this.state().agentPreferences)
|
||||
if (this.isProjectAuthBlocked(kind, workdir)) return false
|
||||
const projectIdentity = this.projectIdentity(kind, workdir, agent)
|
||||
if (projectIdentity === '') return matchesRecentVerification(runtime, agent, this.now(), this.state().agentPreferences)
|
||||
if (!projectIdentity || runtime?.credentialState === 'missing') return false
|
||||
const identity = agentVerificationIdentity(agent, this.state().agentPreferences)
|
||||
const record = Array.isArray(runtime?.projectVerifications)
|
||||
? runtime.projectVerifications.find(item => item?.projectIdentity === projectIdentity && item.verificationIdentity === identity)
|
||||
: null
|
||||
if (record) return recentlyVerified({ credentialState: 'ready', checkedAt: record.checkedAt }, this.now())
|
||||
return matchesRecentVerification(runtime, agent, this.now(), this.state().agentPreferences)
|
||||
&& runtime.projectVerificationIdentity === projectIdentity
|
||||
}
|
||||
|
||||
isProjectAuthBlocked(kind, workdir) {
|
||||
const runtime = this.state().agentRuntime[kind]
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind)
|
||||
const identity = this.projectIdentity(kind, workdir, agent)
|
||||
const agentIdentity = agentVerificationIdentity(agent, this.state().agentPreferences)
|
||||
const failure = Array.isArray(runtime?.projectFailures)
|
||||
? runtime.projectFailures.find(item => item?.projectIdentity === identity && item.verificationIdentity === agentIdentity)
|
||||
: null
|
||||
if (failure) return !expiredAuthFailure({ projectFailureIdentity: identity, projectFailureCheckedAt: failure.checkedAt }, this.now())
|
||||
return Boolean(identity && runtime?.projectFailureIdentity === identity
|
||||
&& runtime.failureIdentity === agentIdentity
|
||||
&& !expiredAuthFailure(runtime, this.now()))
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
@@ -96,20 +191,32 @@ class LocalWorkspaceAgentCatalog {
|
||||
const native = nativeStates[index]
|
||||
let runtime = state.agentRuntime[agent.kind]
|
||||
const nativeState = ['ready', 'missing'].includes(native?.state) ? native.state : 'unknown'
|
||||
const workBuddyUnavailable = ['workbuddy-desktop-locked', 'workbuddy-model-unavailable'].includes(native?.source)
|
||||
const authoritativeNativeState = native?.source === 'native-auth-status'
|
||||
// Give corrected native credentials a retry after the cooldown, never erase a newer failure.
|
||||
if (expiredAuthFailure(runtime, this.now())
|
||||
&& !(authoritativeNativeState && nativeState === 'ready')
|
||||
if ((runtime?.credentialState === 'missing' || runtime?.projectFailureIdentity) && runtime.failureIdentity
|
||||
&& runtime.failureIdentity !== agentVerificationIdentity(agent, state.agentPreferences)
|
||||
&& runtime === runtimeAtRefreshStart.get(agent.kind)) {
|
||||
delete state.agentRuntime[agent.kind]
|
||||
runtime = undefined
|
||||
recoveredRuntimeCredential = true
|
||||
}
|
||||
// Give corrected native credentials a retry after the cooldown, never erase a newer failure.
|
||||
if (expiredAuthFailure(runtime, this.now())
|
||||
&& (runtime?.projectFailureIdentity || !(authoritativeNativeState && nativeState === 'ready'))
|
||||
&& runtime === runtimeAtRefreshStart.get(agent.kind)) {
|
||||
const retained = clearProjectFailure(runtime)
|
||||
if (retained) state.agentRuntime[agent.kind] = retained
|
||||
else delete state.agentRuntime[agent.kind]
|
||||
runtime = retained
|
||||
recoveredRuntimeCredential = true
|
||||
}
|
||||
// A probe that started before a newer runtime failure cannot clear that failure.
|
||||
if (authoritativeNativeState && nativeState === 'ready'
|
||||
&& ['missing', 'unknown'].includes(runtime?.credentialState)
|
||||
&& !runtime?.projectFailureIdentity
|
||||
&& runtime === runtimeAtRefreshStart.get(agent.kind)) {
|
||||
runtime = {
|
||||
...runtime,
|
||||
credentialState: 'ready',
|
||||
checkedAt: this.now(),
|
||||
}
|
||||
@@ -117,10 +224,7 @@ class LocalWorkspaceAgentCatalog {
|
||||
recoveredRuntimeCredential = true
|
||||
}
|
||||
const runtimeMissing = runtime?.credentialState === 'missing'
|
||||
const verifiedReady = runtime?.credentialState === 'ready'
|
||||
|| state.messages.some(message => (
|
||||
message.role === 'agent' && message.agentKind === agent.kind
|
||||
))
|
||||
const verifiedReady = matchesRecentVerification(runtime, agent, this.now(), state.agentPreferences)
|
||||
const nativeReadySource = nativeState === 'ready'
|
||||
? (native.source || 'native-credential')
|
||||
: ''
|
||||
@@ -138,26 +242,29 @@ class LocalWorkspaceAgentCatalog {
|
||||
const versionIdentified = agentVersionIdentified(agent)
|
||||
const compatible = agentCompatible(agent)
|
||||
const nativeConfigurationUnknown = !runtimeMissing
|
||||
&& !workBuddyUnavailable
|
||||
&& nativeState === 'unknown'
|
||||
&& native?.source !== 'native-runtime-unavailable'
|
||||
&& native?.source !== 'native-secret-unresolved'
|
||||
&& native?.source !== 'native-probe-failed'
|
||||
const configured = retained !== null
|
||||
const configured = retained?.configured === true
|
||||
|| nativeState === 'ready'
|
||||
|| native?.source === 'native-auth-status'
|
||||
|| native?.source === 'native-runtime-unavailable'
|
||||
|| verifiedReady
|
||||
|| runtimeMissing
|
||||
|| nativeConfigurationUnknown
|
||||
const authenticated = retained !== null || (!runtimeMissing && nativeState !== 'missing' && (
|
||||
nativeState === 'ready' || verifiedReady || nativeConfigurationUnknown
|
||||
const authenticated = retained?.authenticated === true || (!runtimeMissing && nativeState !== 'missing' && (
|
||||
verifiedReady || (nativeState === 'ready' && native?.source === 'native-auth-status')
|
||||
))
|
||||
const runtimePrerequisitesReady = retained !== null
|
||||
|| (native?.source !== 'native-runtime-unavailable'
|
||||
&& (native?.source !== 'native-probe-failed' || recentlyVerified(runtime, this.now())))
|
||||
const invocable = installed && versionIdentified && compatible && configured
|
||||
&& authenticated && runtimePrerequisitesReady
|
||||
const verifiedRecently = (authoritativeNativeState && nativeState === 'ready')
|
||||
|| recentlyVerified(runtime, this.now())
|
||||
&& native?.source !== 'native-secret-unresolved'
|
||||
&& (native?.source !== 'native-probe-failed' || verifiedReady))
|
||||
const invocable = agentInvocable({ installed, versionIdentified, compatible, configured,
|
||||
authenticated, canAttempt: retained !== null || nativeConfigurationUnknown
|
||||
|| (!runtimeMissing && nativeState === 'ready'),
|
||||
runtimePrerequisitesReady, unavailable: workBuddyUnavailable })
|
||||
const verifiedRecently = matchesRecentVerification(runtime, agent, this.now(), state.agentPreferences)
|
||||
const available = invocable
|
||||
const preferred = state.agentPreferences[agent.kind]?.showInSidebar
|
||||
const capabilities = agentRuntimeCapabilities(agent.kind, {
|
||||
@@ -169,17 +276,23 @@ class LocalWorkspaceAgentCatalog {
|
||||
else if (runtimeMissing) availabilitySource = 'runtime-auth-failure'
|
||||
else if (nativeState === 'missing') availabilitySource = native.source || 'none'
|
||||
else if (native?.source === 'native-probe-failed') availabilitySource = 'native-probe-failed'
|
||||
else if (native?.source === 'native-runtime-unavailable') availabilitySource = 'native-runtime-unavailable'
|
||||
else if (native?.source === 'native-secret-unresolved') availabilitySource = 'native-secret-unresolved'
|
||||
else if (nativeReadySource) availabilitySource = nativeReadySource
|
||||
else if (verifiedReady) availabilitySource = 'verified-run'
|
||||
else if (nativeConfigurationUnknown) availabilitySource = 'local-cli'
|
||||
if (retained) availabilitySource = retained.availabilitySource
|
||||
return {
|
||||
if (workBuddyUnavailable) availabilitySource = native.source
|
||||
const result = {
|
||||
...agent,
|
||||
installed,
|
||||
versionIdentified,
|
||||
compatible,
|
||||
configured,
|
||||
authenticated,
|
||||
runtimePrerequisitesReady,
|
||||
runtimePrerequisitesSource: runtimePrerequisitesReady ? '' : native?.source || '',
|
||||
runtimeBlockSource: workBuddyUnavailable ? native.source : '',
|
||||
invocable,
|
||||
recentlyVerified: verifiedRecently,
|
||||
credentialState,
|
||||
@@ -188,10 +301,21 @@ class LocalWorkspaceAgentCatalog {
|
||||
task: capabilities.task,
|
||||
resumable: capabilities.resumable,
|
||||
capabilities,
|
||||
showInSidebar: typeof preferred === 'boolean' ? preferred : true,
|
||||
showInSidebar: sidebarVisible({ ...agent, task: capabilities.task }, preferred),
|
||||
}
|
||||
if (agent[NATIVE_CONFIG_IDENTITY_DIRECTORY]) {
|
||||
Object.defineProperty(result, NATIVE_CONFIG_IDENTITY_DIRECTORY, { value: agent[NATIVE_CONFIG_IDENTITY_DIRECTORY] })
|
||||
}
|
||||
if (agent[NATIVE_AUTH_IDENTITY_FILES]) {
|
||||
Object.defineProperty(result, NATIVE_AUTH_IDENTITY_FILES, { value: agent[NATIVE_AUTH_IDENTITY_FILES] })
|
||||
}
|
||||
if (agent[NATIVE_PROJECT_CONFIG_PATHS]) {
|
||||
Object.defineProperty(result, NATIVE_PROJECT_CONFIG_PATHS, { value: agent[NATIVE_PROJECT_CONFIG_PATHS] })
|
||||
}
|
||||
return result
|
||||
})
|
||||
this.setDetectedAgents(agents)
|
||||
this.detectedIdentities = new Map(agents.map(agent => [agent.kind, agentVerificationIdentity(agent, state.agentPreferences)]))
|
||||
if (recoveredRuntimeCredential) this.save()
|
||||
this.emitChanged()
|
||||
return this.snapshot()
|
||||
@@ -210,52 +334,103 @@ class LocalWorkspaceAgentCatalog {
|
||||
}
|
||||
const since = this.indeterminateSince.get(agent.kind)
|
||||
if (previous?.available !== true || runtimeMissing || !Number.isFinite(nowMs)
|
||||
|| !Number.isFinite(since) || nowMs - since >= INDETERMINATE_PROBE_GRACE_MS
|
||||
|| !Number.isFinite(since) || nowMs < since || nowMs - since >= INDETERMINATE_PROBE_GRACE_MS
|
||||
|| this.detectedIdentities.get(agent.kind) !== agentVerificationIdentity(agent, this.state().agentPreferences)
|
||||
|| !agentVersionIdentified(agent) || !agentCompatible(agent)) return null
|
||||
return {
|
||||
credentialState: previous.credentialState,
|
||||
availabilitySource: previous.availabilitySource || 'native-probe-failed',
|
||||
configured: previous.configured === true,
|
||||
authenticated: previous.authenticated === true,
|
||||
}
|
||||
}
|
||||
|
||||
setSidebarVisibility(kind, visible) {
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind)
|
||||
if (!agent) throw new Error('LOCAL_AGENT_NOT_INSTALLED')
|
||||
if (!agent && !this.isKnownAgent(kind)) throw new Error('LOCAL_AGENT_NOT_INSTALLED')
|
||||
const state = this.state()
|
||||
state.agentPreferences[kind] = { showInSidebar: Boolean(visible) }
|
||||
agent.showInSidebar = Boolean(visible)
|
||||
state.agentPreferences[kind] = { ...state.agentPreferences[kind], showInSidebar: Boolean(visible) }
|
||||
if (agent) agent.showInSidebar = Boolean(visible)
|
||||
this.save()
|
||||
this.emitChanged()
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
markRuntimeCredential(kind, credentialState) {
|
||||
markRuntimeCredential(kind, credentialState, { verifiedRun = true, verificationIdentity = '', workdir, projectIdentity } = {}) {
|
||||
const state = this.state()
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind)
|
||||
if (projectIdentity === null) return
|
||||
if (verificationIdentity && (!agent || verificationIdentity !== agentVerificationIdentity(agent, state.agentPreferences))) return
|
||||
if (projectIdentity !== undefined && projectIdentity !== this.projectIdentity(kind, workdir, agent)) return
|
||||
const identity = verificationIdentity || agentVerificationIdentity(agent, state.agentPreferences)
|
||||
const projectFailure = credentialState === 'missing' && typeof projectIdentity === 'string' && Boolean(projectIdentity)
|
||||
const previous = state.agentRuntime[kind]
|
||||
const preserveSuccess = projectFailure && previous?.credentialState === 'ready'
|
||||
&& previous.verificationSource === 'successful-run' && previous.verificationIdentity === identity
|
||||
&& previous.projectVerificationIdentity !== projectIdentity
|
||||
const preserveFailure = credentialState === 'ready' && previous?.projectFailureIdentity
|
||||
&& previous.projectFailureIdentity !== projectIdentity && previous.failureIdentity === identity
|
||||
&& !expiredAuthFailure(previous, this.now())
|
||||
const effectiveState = projectFailure ? (preserveSuccess ? 'ready' : 'unknown') : credentialState
|
||||
state.agentRuntime[kind] = {
|
||||
credentialState,
|
||||
checkedAt: this.now(),
|
||||
...(preserveSuccess ? previous : {}),
|
||||
credentialState: effectiveState,
|
||||
checkedAt: preserveSuccess ? previous.checkedAt : this.now(),
|
||||
...(projectFailure ? { projectFailureIdentity: projectIdentity, projectFailureCheckedAt: this.now() } : {}),
|
||||
...(preserveFailure ? { projectFailureIdentity: previous.projectFailureIdentity,
|
||||
projectFailureCheckedAt: previous.projectFailureCheckedAt || previous.checkedAt,
|
||||
failureIdentity: previous.failureIdentity } : {}),
|
||||
...(credentialState === 'missing' && verificationIdentity ? { failureIdentity: verificationIdentity } : {}),
|
||||
...(credentialState === 'ready' && verifiedRun && identity
|
||||
? { verificationSource: 'successful-run', verificationIdentity: identity,
|
||||
...(projectIdentity ? { projectVerificationIdentity: projectIdentity } : {}) } : {}),
|
||||
}
|
||||
if (projectIdentity && (credentialState === 'missing' || (credentialState === 'ready' && verifiedRun))) {
|
||||
const keep = records => (Array.isArray(records) ? records : [])
|
||||
.filter(item => item?.verificationIdentity === identity && item.projectIdentity !== projectIdentity)
|
||||
.slice(-(MAX_PROJECT_AUTH_RECORDS - 1))
|
||||
const projectVerifications = keep(previous?.projectVerifications)
|
||||
const projectFailures = keep(previous?.projectFailures)
|
||||
if (previous?.verificationSource === 'successful-run' && previous.verificationIdentity === identity
|
||||
&& previous.projectVerificationIdentity && previous.projectVerificationIdentity !== projectIdentity
|
||||
&& !projectVerifications.some(item => item.projectIdentity === previous.projectVerificationIdentity)) {
|
||||
projectVerifications.push({ projectIdentity: previous.projectVerificationIdentity,
|
||||
verificationIdentity: identity, checkedAt: previous.checkedAt })
|
||||
}
|
||||
if (previous?.failureIdentity === identity && previous.projectFailureIdentity
|
||||
&& previous.projectFailureIdentity !== projectIdentity
|
||||
&& !projectFailures.some(item => item.projectIdentity === previous.projectFailureIdentity)) {
|
||||
projectFailures.push({ projectIdentity: previous.projectFailureIdentity,
|
||||
verificationIdentity: identity, checkedAt: previous.projectFailureCheckedAt || previous.checkedAt })
|
||||
}
|
||||
const record = { projectIdentity, verificationIdentity: identity, checkedAt: this.now() }
|
||||
if (projectFailure) projectFailures.push(record)
|
||||
else projectVerifications.push(record)
|
||||
state.agentRuntime[kind].projectVerifications = projectVerifications.slice(-MAX_PROJECT_AUTH_RECORDS)
|
||||
state.agentRuntime[kind].projectFailures = projectFailures.slice(-MAX_PROJECT_AUTH_RECORDS)
|
||||
}
|
||||
if (credentialState === 'ready') this.indeterminateSince.delete(kind)
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind)
|
||||
if (agent && !(credentialState === 'unknown' && agent.authenticated === true)) {
|
||||
agent.credentialState = credentialState
|
||||
if (agent && !(effectiveState === 'unknown' && agent.authenticated === true)) {
|
||||
agent.credentialState = effectiveState
|
||||
agent.versionIdentified = agentVersionIdentified(agent)
|
||||
agent.compatible = agentCompatible(agent)
|
||||
agent.configured = credentialState !== 'unknown'
|
||||
agent.authenticated = credentialState === 'ready'
|
||||
agent.invocable = agent.versionIdentified && agent.compatible
|
||||
&& agent.configured && agent.authenticated
|
||||
agent.recentlyVerified = credentialState === 'ready'
|
||||
agent.configured = effectiveState !== 'unknown'
|
||||
agent.authenticated = effectiveState === 'ready'
|
||||
agent.invocable = agentInvocable({ ...agent, canAttempt: effectiveState === 'unknown' && agent.available === true,
|
||||
unavailable: Boolean(agent.runtimeBlockSource) })
|
||||
agent.recentlyVerified = this.isRecentlyVerified(kind)
|
||||
agent.available = agent.invocable
|
||||
agent.availabilitySource = !agent.compatible
|
||||
? 'incompatible'
|
||||
: credentialState === 'ready'
|
||||
? 'verified-run'
|
||||
: credentialState === 'missing'
|
||||
? 'runtime-auth-failure'
|
||||
: 'unverified'
|
||||
: effectiveState === 'missing'
|
||||
? 'runtime-auth-failure'
|
||||
: agent.runtimeBlockSource
|
||||
? agent.runtimeBlockSource
|
||||
: agent.runtimePrerequisitesReady === false
|
||||
? agent.runtimePrerequisitesSource || 'native-runtime-unavailable'
|
||||
: effectiveState === 'ready' ? (agent.recentlyVerified ? 'verified-run' : 'native-credential') : 'unverified'
|
||||
const preferred = state.agentPreferences[kind]?.showInSidebar
|
||||
agent.showInSidebar = typeof preferred === 'boolean' ? preferred : true
|
||||
agent.showInSidebar = sidebarVisible(agent, preferred)
|
||||
}
|
||||
this.save()
|
||||
this.emitChanged()
|
||||
@@ -265,8 +440,13 @@ class LocalWorkspaceAgentCatalog {
|
||||
const state = this.state()
|
||||
let changed = false
|
||||
for (const [kind, runtime] of Object.entries(state.agentRuntime)) {
|
||||
if (runtime?.credentialState !== 'missing') continue
|
||||
delete state.agentRuntime[kind]
|
||||
if (runtime?.credentialState !== 'missing' && !runtime?.projectFailureIdentity && !runtime?.projectFailures?.length) continue
|
||||
const retained = clearProjectFailure(runtime)
|
||||
if (retained) {
|
||||
delete retained.projectFailures
|
||||
state.agentRuntime[kind] = retained
|
||||
}
|
||||
else delete state.agentRuntime[kind]
|
||||
changed = true
|
||||
}
|
||||
if (changed) this.save()
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const { createHash, randomUUID } = require('node:crypto')
|
||||
const { taskPermissionGroup } = require('./run-permission-cap.cjs')
|
||||
const { agentVerificationIdentity, projectVerificationIdentity } = require('./agent-verification-identity.cjs')
|
||||
const { NATIVE_PROJECT_CONFIG_PATHS } = require('../agents/native-config-directory.cjs')
|
||||
const { workPreferencesPrompt } = require('./work-preferences.cjs')
|
||||
const {
|
||||
assertResumedPermissionBinding,
|
||||
automaticPermissionResponse,
|
||||
@@ -157,9 +161,28 @@ function assertRequiredContextFits(packedContext) {
|
||||
throw error
|
||||
}
|
||||
|
||||
async function awaitProjectEnvironment(refresh, workdir, kind, signals) {
|
||||
const activeSignals = [...new Set(signals.filter(Boolean))]
|
||||
if (activeSignals.some(signal => signal.aborted)) throw agentStoppedError()
|
||||
const pending = Promise.resolve().then(() => {
|
||||
if (activeSignals.some(signal => signal.aborted)) throw agentStoppedError()
|
||||
return refresh(workdir, [kind])
|
||||
})
|
||||
if (!activeSignals.length) return pending
|
||||
let abort
|
||||
const stopped = new Promise((_, reject) => { abort = () => reject(agentStoppedError()) })
|
||||
for (const signal of activeSignals) {
|
||||
if (signal.aborted) abort()
|
||||
else signal.addEventListener('abort', abort, { once: true })
|
||||
}
|
||||
try { return await Promise.race([pending, stopped]) }
|
||||
finally { for (const signal of activeSignals) signal.removeEventListener('abort', abort) }
|
||||
}
|
||||
|
||||
class LocalWorkspaceAgentInvocation {
|
||||
constructor(options) {
|
||||
this.state = options.state
|
||||
this.getContextPack = options.getContextPack
|
||||
this.detectedAgents = options.detectedAgents
|
||||
this.activeRuns = options.activeRuns
|
||||
this.runAgentTimeoutMs = options.runAgentTimeoutMs
|
||||
@@ -198,6 +221,9 @@ class LocalWorkspaceAgentInvocation {
|
||||
this.createAttemptContextPack = options.createAttemptContextPack
|
||||
this.recordContextDelivery = options.recordContextDelivery
|
||||
this.markRuntimeCredential = options.markRuntimeCredential
|
||||
this.isProjectAuthBlocked = options.isProjectAuthBlocked || (() => false)
|
||||
this.refreshProjectEnvironment = options.refreshProjectEnvironment || (async () => {})
|
||||
this.projectIdentity = options.projectIdentity || projectVerificationIdentity
|
||||
this.addMessage = options.addMessage
|
||||
this.runScheduler = options.runScheduler || processRunScheduler
|
||||
this.registerAgentController = options.registerAgentController
|
||||
@@ -206,6 +232,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
this.completeHumanGateContinuation = options.completeHumanGateContinuation
|
||||
this.connectorRuntime = options.connectorRuntime || null
|
||||
this.attachmentSupport = options.attachmentSupport || (() => ({}))
|
||||
this.validateSkillSelections = options.validateSkillSelections || (async (_kind, selections) => selections)
|
||||
this.activeAgentSnapshots = new Map()
|
||||
}
|
||||
|
||||
@@ -239,13 +266,19 @@ class LocalWorkspaceAgentInvocation {
|
||||
const activeRun = this.activeRuns.get(group.id)
|
||||
const taskId = cleanText(activeRun?.taskId || context.taskId || threadRootId, 120)
|
||||
if (!taskId) throw new Error('LOCAL_RUN_TASK_INVALID')
|
||||
group = taskPermissionGroup(group, this.state(), taskId,
|
||||
activeRun?.contextPackId || context.contextPackId, this.getContextPack)
|
||||
// Every conversation, group or direct, is its own execution surface. The
|
||||
// writer lease is scoped to the conversation, so a writable chat never
|
||||
// makes an Agent in another conversation wait, including when both use the
|
||||
// same folder (most chats keep the default one). Writers inside one
|
||||
// conversation still take turns.
|
||||
// Independent opening drafts retain parallel execution when the user
|
||||
// enabled native write tools. Later shared deliverable turns serialize.
|
||||
const proposalScope = context.independentProposal && context.phase === 'proposal'
|
||||
? `\u0000proposal\u0000${kind}` : ''
|
||||
const workspaceKey = createHash('sha256')
|
||||
.update(`conversation\u0000${group.id}`)
|
||||
.update(`conversation\u0000${group.id}${proposalScope}`)
|
||||
.digest('hex')
|
||||
const writerKind = cleanText(context.singleWriterKind || context.writerKind, 80)
|
||||
const permissionMode = group.allowWrite === true
|
||||
@@ -298,25 +331,57 @@ class LocalWorkspaceAgentInvocation {
|
||||
async invokeLeased(
|
||||
group, kind, mode, signal, threadRootId = '', context = {}, invocation = {},
|
||||
) {
|
||||
await awaitProjectEnvironment(this.refreshProjectEnvironment, group.workdir, kind,
|
||||
[signal, invocation.agentController?.signal])
|
||||
const detected = this.detectedAgents()
|
||||
const activeRun = this.activeRuns.get(group.id)
|
||||
const candidate = detected.find(item => item.kind === kind)
|
||||
const snapshotKey = `${group.id}:${activeRun?.runId || 'active'}:${kind}`
|
||||
if (candidate) this.activeAgentSnapshots.set(snapshotKey, { ...candidate })
|
||||
const runtime = this.state()?.agentRuntime?.[kind]
|
||||
const projectIdentity = this.projectIdentity(kind, group.workdir, candidate || this.activeAgentSnapshots.get(snapshotKey))
|
||||
const runtimeBlocked = runtime?.credentialState === 'missing'
|
||||
|| this.isProjectAuthBlocked(kind, group.workdir)
|
||||
const usable = item => item.installed !== false && item.invocable !== false
|
||||
&& item.versionIdentified !== false && item.compatible !== false
|
||||
&& item.runtimePrerequisitesReady !== false && !item.runtimeBlockSource
|
||||
&& item.compatibilityState !== 'incompatible' && item.credentialState !== 'missing'
|
||||
&& !['runtime-auth-failure', 'none'].includes(item.availabilitySource)
|
||||
if (!runtimeBlocked && candidate && usable(candidate)) {
|
||||
const snapshot = { ...candidate }
|
||||
if (candidate[NATIVE_PROJECT_CONFIG_PATHS]) {
|
||||
Object.defineProperty(snapshot, NATIVE_PROJECT_CONFIG_PATHS, { value: candidate[NATIVE_PROJECT_CONFIG_PATHS] })
|
||||
}
|
||||
this.activeAgentSnapshots.set(snapshotKey, snapshot)
|
||||
}
|
||||
else if (candidate || runtimeBlocked) this.activeAgentSnapshots.delete(snapshotKey)
|
||||
const snapshot = this.activeAgentSnapshots.get(snapshotKey)
|
||||
const agent = detected.find(item => item.kind === kind && item.available)
|
||||
const agent = !runtimeBlocked && (detected.find(item => item.kind === kind && item.available && usable(item))
|
||||
|| (activeRun?.targetKinds?.includes(kind)
|
||||
&& candidate
|
||||
&& candidate.installed !== false
|
||||
&& candidate.compatibilityState !== 'incompatible'
|
||||
&& candidate.credentialState !== 'missing'
|
||||
&& candidate.availabilitySource !== 'runtime-auth-failure'
|
||||
&& candidate.availabilitySource !== 'none'
|
||||
&& usable(candidate)
|
||||
? candidate
|
||||
: activeRun?.targetKinds?.includes(kind) && !candidate && snapshot
|
||||
: activeRun?.targetKinds?.includes(kind) && !candidate && snapshot && usable(snapshot)
|
||||
? snapshot
|
||||
: null)
|
||||
: null))
|
||||
if (!agent) throw new Error('LOCAL_AGENT_UNAVAILABLE')
|
||||
const verificationIdentity = agentVerificationIdentity(agent, this.state()?.agentPreferences)
|
||||
const revalidateApprovedSkills = async () => {
|
||||
if (!context.skillHints?.some(skill => skill?.approvedSkillManifest)) return
|
||||
const previous = context.skillHints
|
||||
const validated = await this.validateSkillSelections(kind, previous)
|
||||
if (!Array.isArray(validated) || validated.length !== previous.length
|
||||
|| validated.some((skill, index) => !skill?.approvedSkillManifest
|
||||
|| ['targetKind', 'namespace', 'slug', 'snapshotId', 'manifestHash']
|
||||
.some(field => skill[field] !== previous[index]?.[field])
|
||||
|| skill.entryPath !== previous[index]?.entryPath)) {
|
||||
throw new Error('LOCAL_SKILL_SELECTION_INVALID')
|
||||
}
|
||||
if (signal?.aborted || invocation.agentController?.signal.aborted) {
|
||||
throw new Error('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
}
|
||||
context = { ...context, skillHints: validated }
|
||||
}
|
||||
await revalidateApprovedSkills()
|
||||
const reviewOnly = isCodeReviewAgentKind(kind)
|
||||
const invocationContext = isolatedInvocationContext(context)
|
||||
const isolated = invocationContext?.policy === 'isolated'
|
||||
@@ -625,8 +690,9 @@ class LocalWorkspaceAgentInvocation {
|
||||
const answerFilter = createAnswerMarkerFilter({
|
||||
mode: context.v4 === true ? 'receipt' : (mode === 'auto' ? 'auto' : 'plain'),
|
||||
})
|
||||
let flushingTerminalAnswer = false
|
||||
const emitHarnessEvent = (rawEvent) => {
|
||||
if (agentCallbacksClosed || agentController.signal.aborted
|
||||
if (agentCallbacksClosed || (agentController.signal.aborted && !flushingTerminalAnswer)
|
||||
|| this.activeRuns.get(group.id) !== activeRun
|
||||
|| !harness || !harnessRun || !rawEvent) return
|
||||
const event = harness.ingest(kind, round, rawEvent, harnessRun.agentRunId)
|
||||
@@ -638,11 +704,11 @@ class LocalWorkspaceAgentInvocation {
|
||||
const eventStatus = String(event.status || '').toLowerCase()
|
||||
if (['tool_start', 'tool_update'].includes(eventType)
|
||||
&& ['queued', 'running', 'waiting', 'in_progress', 'streaming'].includes(eventStatus)) {
|
||||
watchdog.toolStarted()
|
||||
watchdog.toolStarted(event.id)
|
||||
} else if (eventType === 'tool_result_summary'
|
||||
|| (eventType === 'status' && ['completed', 'failed', 'stopped', 'timeout', 'interrupted']
|
||||
.includes(eventStatus))) {
|
||||
watchdog.toolFinished()
|
||||
watchdog.toolFinished(event.id)
|
||||
}
|
||||
this.emitRunEvent(event)
|
||||
this.armAgentSilence(activeRun, kind, round, harnessRun.agentRunId)
|
||||
@@ -701,12 +767,15 @@ class LocalWorkspaceAgentInvocation {
|
||||
: {
|
||||
status: resumedConnectorGate.status,
|
||||
optionId: resumedConnectorGate.optionId,
|
||||
permission: request.permission,
|
||||
}),
|
||||
}
|
||||
}
|
||||
const finishHarness = (status, finalText = '', runtimeContext = {}) => {
|
||||
if (!harness || !harnessRun || harnessFinished) return null
|
||||
flushingTerminalAnswer = true
|
||||
flushRuntimeEvent(finalText)
|
||||
flushingTerminalAnswer = false
|
||||
closeAgentCallbacks()
|
||||
this.clearAgentSilence(activeRun, kind, round, harnessRun.agentRunId)
|
||||
const finished = harness.finishAgent(kind, round, status, finalText, {
|
||||
@@ -757,7 +826,8 @@ class LocalWorkspaceAgentInvocation {
|
||||
stagedInputs = isolated
|
||||
? null
|
||||
: stageAgentInputs(group.workdir, context.attachmentSnapshots || [], nativeImageLimit)
|
||||
const buildPrompt = (afterKind, contextPackage) => assembleInvocationPrompt({
|
||||
const preferencesText = isolated ? '' : workPreferencesPrompt(this.state().workPreferences)
|
||||
const buildPrompt = (afterKind, contextPackage) => [assembleInvocationPrompt({
|
||||
group,
|
||||
kind,
|
||||
mode,
|
||||
@@ -774,7 +844,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
stagedInputs,
|
||||
promptFor: (...args) => this.promptFor(...args),
|
||||
agentLabel: label => this.agentLabel(label),
|
||||
})
|
||||
}), preferencesText].filter(Boolean).join('\n\n')
|
||||
let prompt = buildPrompt(transcriptAfterKind, packedContext)
|
||||
const recordOutboundPayload = (outbound = {}) => {
|
||||
watchdog.progress()
|
||||
@@ -882,6 +952,8 @@ class LocalWorkspaceAgentInvocation {
|
||||
return prompt
|
||||
}
|
||||
const runOptions = {
|
||||
turnCompletion: context.v4 === true
|
||||
&& (context.allowMissingProposalReceipt === true || context.allowMissingV4Receipt === true),
|
||||
sessionRef: session.ref,
|
||||
...(Number.isInteger(context.executionSequence)
|
||||
? { executionSequence: context.executionSequence }
|
||||
@@ -910,6 +982,10 @@ class LocalWorkspaceAgentInvocation {
|
||||
watchdog.liveness()
|
||||
}
|
||||
},
|
||||
onSubstantiveActivity: () => {
|
||||
watchdog.establish()
|
||||
watchdog.progress()
|
||||
},
|
||||
onProgress,
|
||||
onEvent: emitRuntimeEvent,
|
||||
onOutboundPayload: recordOutboundPayload,
|
||||
@@ -969,8 +1045,8 @@ class LocalWorkspaceAgentInvocation {
|
||||
return pending
|
||||
},
|
||||
sessionTransport: session.transport,
|
||||
...(kind === 'workbuddy' && Number.isInteger(context.maxInternalTurns)
|
||||
? { maxTurns: context.maxInternalTurns }
|
||||
...(kind === 'workbuddy' && (mode === 'auto' || Number.isInteger(context.maxInternalTurns))
|
||||
? { maxTurns: mode === 'auto' ? 0 : context.maxInternalTurns }
|
||||
: {}),
|
||||
attachments: isolated ? [] : (stagedInputs?.nativeImagePaths || []),
|
||||
// ACP runtimes are shared by the stable group/task session key. OpenClaw
|
||||
@@ -981,7 +1057,10 @@ class LocalWorkspaceAgentInvocation {
|
||||
? { hermesAcpAvailable: HERMES_WORKSPACE_ACP_ENABLED && agent.acpAvailable !== false }
|
||||
: {}),
|
||||
}
|
||||
const runCurrentSession = () => {
|
||||
const runCurrentSession = async () => {
|
||||
await revalidateApprovedSkills()
|
||||
operationStarted = true
|
||||
if (allowWrite) sideEffectsStarted = true
|
||||
const currentRunOptions = {
|
||||
...runOptions, sessionRef: session.ref, sessionTransport: session.transport,
|
||||
}
|
||||
@@ -1061,8 +1140,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
const executeCurrentSession = async () => {
|
||||
const reusedSessionRef = session.ref
|
||||
try {
|
||||
operationStarted = true
|
||||
if (allowWrite) sideEffectsStarted = true
|
||||
return await runCurrentSession()
|
||||
} catch (error) {
|
||||
if (agentController.signal.aborted || !reusedSessionRef
|
||||
@@ -1073,8 +1150,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
resumedPermission = null
|
||||
}
|
||||
rebuildFreshSession()
|
||||
operationStarted = true
|
||||
if (allowWrite) sideEffectsStarted = true
|
||||
return await runCurrentSession()
|
||||
}
|
||||
}
|
||||
@@ -1162,7 +1237,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
...binding,
|
||||
...(gateType === 'input'
|
||||
? { response: decision.response }
|
||||
: { status: decision.status, optionId: decision.optionId }),
|
||||
: { status: decision.status, optionId: decision.optionId, permission: request.permission }),
|
||||
}
|
||||
result = await raceCurrentSession()
|
||||
}
|
||||
@@ -1170,15 +1245,22 @@ class LocalWorkspaceAgentInvocation {
|
||||
await Promise.allSettled([...pendingPermissionCallbacks])
|
||||
throw new Error('LOCAL_AGENT_PERMISSION_TERMINAL_RACE')
|
||||
}
|
||||
terminalFailureKnown = ['failed', 'cancelled']
|
||||
.includes(String(result?.outcome || ''))
|
||||
terminalFailureKnown = result?.outcome === 'cancelled'
|
||||
|| (result?.outcome === 'failed'
|
||||
&& !['unknown', 'network', 'timeout'].includes(result?.failure?.category))
|
||||
if (agentController.signal.aborted) throw agentStoppedError()
|
||||
if (resumedPermissionError) throw resumedPermissionError
|
||||
if (resumedPermission && !resumedPermissionUsed) {
|
||||
throw new Error('LOCAL_RUN_PERMISSION_REQUEST_NOT_REISSUED')
|
||||
}
|
||||
result = requireTerminalAgentResult(result)
|
||||
this.markRuntimeCredential(kind, 'ready')
|
||||
try {
|
||||
result = requireTerminalAgentResult(result)
|
||||
} catch (error) {
|
||||
if (agent.connectorInstanceId && result?.outcome === 'failed' && result.failure?.retryable === false) {
|
||||
Object.defineProperty(error, Symbol.for('meldwork.connector-nonretryable'), { value: true })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const collaborationReply = parseV4CollaborationReceipt(
|
||||
result,
|
||||
@@ -1242,6 +1324,10 @@ class LocalWorkspaceAgentInvocation {
|
||||
capturedOutcomeRefs,
|
||||
requestedOutcomeRefs,
|
||||
])
|
||||
// The Agent has returned; shell revalidation is not Agent inactivity.
|
||||
watchdog.pause()
|
||||
await awaitProjectEnvironment(this.refreshProjectEnvironment, group.workdir, kind, [signal, agentController.signal])
|
||||
if (signal?.aborted || agentController.signal.aborted) throw agentStoppedError()
|
||||
const finalStatus = result.outcome
|
||||
const trace = finishHarness(finalStatus, reply.text, {
|
||||
promptChars: prompt.length,
|
||||
@@ -1291,6 +1377,8 @@ class LocalWorkspaceAgentInvocation {
|
||||
lastDeliveredMessageId: packedContext.latestMessageId || session.meta.lastDeliveredMessageId,
|
||||
})
|
||||
}
|
||||
this.markRuntimeCredential(kind, 'ready', { verifiedRun: result.outcome === 'completed', verificationIdentity,
|
||||
workdir: group.workdir, projectIdentity })
|
||||
return {
|
||||
message,
|
||||
pendingMessage,
|
||||
@@ -1305,26 +1393,67 @@ class LocalWorkspaceAgentInvocation {
|
||||
} : {}),
|
||||
}
|
||||
} catch (caughtError) {
|
||||
const stop = resolveInvocationStop({
|
||||
let stop = resolveInvocationStop({
|
||||
caughtError, signal, parentAbortObserved, activeRun, agentController, watchdog,
|
||||
})
|
||||
if (stop.settleOutstanding) {
|
||||
const cleanupPromises = [runPromise, ...outputs.pending()].filter(Boolean)
|
||||
let runSettled = !runPromise
|
||||
let terminationFailure = null
|
||||
const observedRun = runPromise?.then(() => { runSettled = true }, error => {
|
||||
runSettled = true
|
||||
if (error?.code === 'LOCAL_AGENT_TERMINATION_FAILED'
|
||||
|| error?.message === 'LOCAL_AGENT_TERMINATION_FAILED') terminationFailure = error
|
||||
})
|
||||
const cleanupPromises = [observedRun, ...outputs.pending()].filter(Boolean)
|
||||
if (cleanupPromises.length) {
|
||||
await settleWithin(Promise.allSettled(cleanupPromises), this.runAbortGraceMs)
|
||||
const graceMs = process.platform === 'win32' ? Math.max(this.runAbortGraceMs, 2500) : this.runAbortGraceMs
|
||||
await settleWithin(Promise.allSettled(cleanupPromises), graceMs)
|
||||
}
|
||||
if (terminationFailure || (process.platform === 'win32' && !runSettled)) {
|
||||
let failure = terminationFailure
|
||||
if (!failure) {
|
||||
failure = Object.assign(new Error('LOCAL_AGENT_TERMINATION_FAILED'), {
|
||||
code: 'LOCAL_AGENT_TERMINATION_FAILED',
|
||||
})
|
||||
const pendingExit = runPromise.then(() => undefined, lateError => {
|
||||
if (lateError?.code !== 'LOCAL_AGENT_TERMINATION_FAILED'
|
||||
&& lateError?.message !== 'LOCAL_AGENT_TERMINATION_FAILED') return undefined
|
||||
return lateError[Symbol.for('meldwork.pending-process-exit')] || new Promise(() => {})
|
||||
})
|
||||
Object.defineProperty(failure, Symbol.for('meldwork.pending-process-exit'), { value: pendingExit })
|
||||
}
|
||||
stop = resolveInvocationStop({
|
||||
caughtError: failure,
|
||||
signal, parentAbortObserved, activeRun, agentController, watchdog,
|
||||
})
|
||||
}
|
||||
}
|
||||
const { error } = stop
|
||||
let { error } = stop
|
||||
if (activeRun && error?.message === 'LOCAL_AGENT_TERMINATION_FAILED') {
|
||||
activeRun.stopReason = 'termination_failed'
|
||||
}
|
||||
// Only an explicit sign-in failure changes readiness. Network, quota and
|
||||
// provider refusals stay run-scoped so a healthy Agent remains usable.
|
||||
if (error?.code === 'LOCAL_AGENT_AUTH_REQUIRED'
|
||||
&& context.deferCredentialFailure !== true) {
|
||||
this.markRuntimeCredential(kind, 'missing')
|
||||
if (error?.code === 'LOCAL_AGENT_AUTH_REQUIRED') {
|
||||
watchdog.pause()
|
||||
try {
|
||||
await awaitProjectEnvironment(this.refreshProjectEnvironment, group.workdir, kind, [signal, agentController.signal])
|
||||
if (context.deferCredentialFailure !== true) {
|
||||
this.markRuntimeCredential(kind, 'missing', { verificationIdentity, workdir: group.workdir, projectIdentity })
|
||||
}
|
||||
} catch (refreshError) {
|
||||
stop = resolveInvocationStop({
|
||||
caughtError: refreshError, signal, parentAbortObserved, activeRun, agentController, watchdog,
|
||||
})
|
||||
error = stop.error
|
||||
}
|
||||
}
|
||||
// Failure and stop notices take the turn's slot in the timeline.
|
||||
attachErrorMetadata(error, {
|
||||
runTrace: finishHarness(stop.status),
|
||||
turnSeq: context.turnSeq,
|
||||
verificationIdentity,
|
||||
projectIdentity,
|
||||
})
|
||||
throw attachInvocationFailure(error, {
|
||||
outcomeCertainty: terminalFailureKnown
|
||||
|
||||
@@ -103,7 +103,10 @@ const collaborationMethods = {
|
||||
? new Set(options.selectedEntryIds)
|
||||
: null
|
||||
const selectedEntries = selectedEntryIds
|
||||
? collaboration.entries.filter(entry => selectedEntryIds.has(entry.entryId))
|
||||
? collaboration.entries.filter(entry => selectedEntryIds.has(entry.entryId)
|
||||
&& entry.lifecycle.state === 'active'
|
||||
&& (entry.audience.roles.includes(destination.role)
|
||||
|| entry.audience.agentKinds.includes(destination.agentKind)))
|
||||
: visibleBlackboardEntries(collaboration, destination)
|
||||
const objective = this.collaborationObjective(group, controller, threadRootId)
|
||||
const expectedOutput = options.expectedOutput
|
||||
@@ -181,6 +184,7 @@ const collaborationMethods = {
|
||||
const entryType = options.entryType || 'claim'
|
||||
const subject = options.subject || `task:${controller.taskId}:conclusion`
|
||||
const entryRefs = Array.isArray(options.refs) ? options.refs : []
|
||||
const audience = options.audience || this.collaborationAudience(role)
|
||||
const duplicateConclusion = conclusion && collaboration.entries.some(entry => (
|
||||
entry.entryType === entryType
|
||||
&& entry.lifecycle.state === 'active'
|
||||
@@ -188,6 +192,8 @@ const collaborationMethods = {
|
||||
&& entry.value === conclusionValue
|
||||
&& entry.owner.type === 'agent'
|
||||
&& entry.owner.agentKind === kind
|
||||
&& (!options.deduplicateByAgentRun || (entry.provenance.runId === provenance.runId
|
||||
&& entry.provenance.agentRunId === provenance.agentRunId))
|
||||
))
|
||||
if (conclusion && !duplicateConclusion) {
|
||||
collaboration = appendBlackboardEntry(collaboration, {
|
||||
@@ -196,7 +202,7 @@ const collaborationMethods = {
|
||||
statement: conclusion,
|
||||
value: entryType === 'claim' ? conclusionValue : '',
|
||||
owner: { type: 'agent', agentKind: kind, role },
|
||||
audience: this.collaborationAudience(role),
|
||||
audience,
|
||||
lifecycle: {
|
||||
state: 'active', sequence, recordedAt, supersedesEntryId: null,
|
||||
},
|
||||
@@ -205,14 +211,14 @@ const collaborationMethods = {
|
||||
})
|
||||
sequence = collaboration.entries.length + 1
|
||||
}
|
||||
for (const artifactId of duplicateConclusion ? [] : provenance.artifactIds.slice(0, 1)) {
|
||||
for (const artifactId of duplicateConclusion ? [] : options.allOutcomeRefs ? provenance.artifactIds : provenance.artifactIds.slice(0, 1)) {
|
||||
collaboration = appendBlackboardEntry(collaboration, {
|
||||
entryType: 'artifact-ref',
|
||||
subject: `task:${controller.taskId}:artifact`,
|
||||
statement: 'Durable Artifact produced by the Agent.',
|
||||
value: '',
|
||||
owner: { type: 'agent', agentKind: kind, role },
|
||||
audience: this.collaborationAudience(role),
|
||||
audience,
|
||||
lifecycle: {
|
||||
state: 'active', sequence, recordedAt, supersedesEntryId: null,
|
||||
},
|
||||
@@ -221,14 +227,14 @@ const collaborationMethods = {
|
||||
})
|
||||
sequence = collaboration.entries.length + 1
|
||||
}
|
||||
for (const evidenceId of duplicateConclusion ? [] : provenance.evidenceIds.slice(0, 1)) {
|
||||
for (const evidenceId of duplicateConclusion ? [] : options.allOutcomeRefs ? provenance.evidenceIds : provenance.evidenceIds.slice(0, 1)) {
|
||||
collaboration = appendBlackboardEntry(collaboration, {
|
||||
entryType: 'evidence-ref',
|
||||
subject: `task:${controller.taskId}:evidence`,
|
||||
statement: 'Durable Evidence recorded for the Agent conclusion.',
|
||||
value: '',
|
||||
owner: { type: 'agent', agentKind: kind, role },
|
||||
audience: this.collaborationAudience(role),
|
||||
audience,
|
||||
lifecycle: {
|
||||
state: 'active', sequence, recordedAt, supersedesEntryId: null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
const { hashValue } = require('../collaboration/orchestration-v4-records.cjs')
|
||||
const { createHash } = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { explicitTaskFailure, explicitReviewRejection } = require('./local-workspace-auto-runner-natural-decisions.cjs')
|
||||
const { parseLenientTaskDecision } = require('../collaboration/task-decision.cjs')
|
||||
|
||||
// Rebuild consensus from committed, operation-bound messages. No in-memory vote
|
||||
// can survive a crash without its corresponding durable Agent response.
|
||||
const naturalConsensusMethods = {
|
||||
v4NaturalConsensus(group, controller, threadRootId) {
|
||||
const messages = this.state().messages.filter(message => (
|
||||
message.groupId === group.id && message.threadRootId === threadRootId
|
||||
&& message.role === 'agent'
|
||||
&& this.v4NaturalMessageMatchesBinding(message, controller)
|
||||
)).sort((a, b) => (a.turnSeq || 0) - (b.turnSeq || 0))
|
||||
let candidate = null
|
||||
const files = new Map()
|
||||
for (const message of messages) {
|
||||
const artifactIds = message.trace?.context?.outcomeRefs?.artifactIds || []
|
||||
for (const artifactId of artifactIds) {
|
||||
let identity
|
||||
try { identity = this.v4ArtifactIdentity(artifactId) } catch { return null }
|
||||
const { artifact, contentHash } = identity
|
||||
if (artifact.producedBy?.runId !== message.trace?.runId
|
||||
|| artifact.producedBy?.agentRunId !== message.trace?.agentRunId
|
||||
|| artifact.producedBy?.agentKind !== message.agentKind
|
||||
|| !message.trace?.runId || !message.trace?.agentRunId) return null
|
||||
if (artifact.locationRef?.kind === 'workspace-relative') {
|
||||
if (candidate && !candidate.files.some(file => file.path === artifact.locationRef.path
|
||||
&& file.contentHash === contentHash)) candidate = null
|
||||
files.set(artifact.locationRef.path, { path: artifact.locationRef.path,
|
||||
artifactId, contentHash, size: artifact.contentRef.size })
|
||||
}
|
||||
}
|
||||
if (message.trace?.phase !== 'discussion') continue
|
||||
const receipt = message.trace?.context?.taskDecision
|
||||
let decision
|
||||
if (receipt) {
|
||||
try { decision = parseLenientTaskDecision(receipt) } catch { candidate = null; continue }
|
||||
}
|
||||
const contribution = this.v4NaturalMessageContent(message)
|
||||
if (contribution.partial || !contribution.text.trim() || explicitTaskFailure(contribution.text)
|
||||
|| explicitTaskFailure(decision?.reason)) {
|
||||
candidate = null
|
||||
continue
|
||||
}
|
||||
// A later unclassified contribution may withdraw or revise an earlier
|
||||
// approval. It cannot safely inherit the previous candidate's votes.
|
||||
if (!decision) {
|
||||
candidate = null
|
||||
continue
|
||||
}
|
||||
if (decision.status !== 'completed') {
|
||||
candidate = null
|
||||
continue
|
||||
}
|
||||
if (decision.reviewedVersion) {
|
||||
if (explicitReviewRejection(contribution.text) || explicitReviewRejection(decision.reason)) {
|
||||
candidate = null
|
||||
continue
|
||||
}
|
||||
if (candidate && decision.reviewedVersion === candidate.version) {
|
||||
candidate.approvedKinds.add(message.agentKind)
|
||||
} else if (candidate?.approvedKinds.has(message.agentKind)) {
|
||||
if (candidate.message.agentKind === message.agentKind) candidate = null
|
||||
else candidate.approvedKinds.delete(message.agentKind)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// An unbound confirmation cannot replace the version being reviewed.
|
||||
// Revisions must first withdraw it with a non-completed decision.
|
||||
if (candidate) {
|
||||
if (candidate.approvedKinds.has(message.agentKind)) {
|
||||
if (candidate.message.agentKind === message.agentKind) candidate = null
|
||||
else candidate.approvedKinds.delete(message.agentKind)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const candidateFiles = [...files.values()]
|
||||
candidate = {
|
||||
version: hashValue({ messageId: message.id, text: contribution.text, artifactIds, files: candidateFiles }),
|
||||
message,
|
||||
text: contribution.text,
|
||||
artifactIds,
|
||||
files: candidateFiles,
|
||||
approvedKinds: new Set([message.agentKind]),
|
||||
}
|
||||
}
|
||||
// Votes refer to captured bytes, not just a filename. Workspace edits made
|
||||
// outside the discussion must not inherit approval of the previous bytes.
|
||||
if (candidate?.files.length) {
|
||||
try {
|
||||
const root = fs.realpathSync(group.workdir)
|
||||
for (const file of candidate.files) {
|
||||
const filename = fs.realpathSync(path.resolve(root, file.path))
|
||||
const relative = path.relative(root, filename)
|
||||
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`)
|
||||
|| path.isAbsolute(relative) || fs.statSync(filename).size !== file.size
|
||||
|| createHash('sha256').update(fs.readFileSync(filename)).digest('hex') !== file.contentHash) return null
|
||||
}
|
||||
} catch { return null }
|
||||
}
|
||||
return candidate
|
||||
},
|
||||
|
||||
v4NaturalConsensusPrompt(group, controller, threadRootId) {
|
||||
const candidate = this.v4NaturalConsensus(group, controller, threadRootId)
|
||||
if (!candidate) return ''
|
||||
return [
|
||||
`Candidate version: ${candidate.version}`,
|
||||
`Proposed by @${candidate.message.agentKind}. Review this exact complete candidate, including its deliverable files, before accepting.`,
|
||||
`Candidate deliverables: ${JSON.stringify(candidate.message.trace.context.taskDecision.deliverables)}`,
|
||||
`Immutable artifact references: ${JSON.stringify(candidate.artifactIds)}`,
|
||||
`Deliverable file versions (read each file and verify its contents): ${JSON.stringify(candidate.files)}`,
|
||||
'Complete candidate answer (not a transcript excerpt):',
|
||||
candidate.text,
|
||||
`To accept this version, use status completed and reviewedVersion "${candidate.version}" in your receipt. Do not rewrite or replace the candidate while accepting it.`,
|
||||
'If anything needs changing, use status continue, explain the defect and request the peer who should fix it. This withdraws the candidate and all its approvals; the revised result needs fresh approval from everyone.',
|
||||
'Do not modify this candidate while accepting it. Missing, stale or implicit approval never counts as acceptance.',
|
||||
].join('\n\n')
|
||||
},
|
||||
|
||||
async v4NaturalAgentLedOutcome(group, controller, threadRootId, round, activeKinds) {
|
||||
const messages = this.v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round)
|
||||
const candidate = this.v4NaturalConsensus(group, controller, threadRootId)
|
||||
if (candidate) {
|
||||
const missing = controller.targetKinds.filter(kind => !candidate.approvedKinds.has(kind))
|
||||
if (!missing.length) return { status: 'completed' }
|
||||
const available = missing.filter(kind => activeKinds.includes(kind))
|
||||
if (available.length) return { nextKinds: available }
|
||||
return { status: 'partial' }
|
||||
}
|
||||
const decisionMessage = messages.at(-1)
|
||||
const decision = decisionMessage?.trace?.context?.taskDecision
|
||||
const route = this.v4NaturalRouteDecision(decision, activeKinds)
|
||||
if (route.dropped.length) {
|
||||
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoRouteIgnored',
|
||||
'Requests for unavailable participants were skipped.',
|
||||
{ agents: route.dropped.map(kind => this.agentLabel(kind)).join(', ') })
|
||||
}
|
||||
if (decision?.status === 'needs-human' && this.requestTaskDecision) {
|
||||
const response = await this.requestTaskDecision(group, controller, decisionMessage)
|
||||
if (response?.status === 'approved') return { nextKinds: [decisionMessage.agentKind] }
|
||||
if (response?.status === 'rejected') {
|
||||
controller.stopReason = 'human_gate_rejected'
|
||||
return { status: 'stopped' }
|
||||
}
|
||||
}
|
||||
if (['blocked', 'needs-human'].includes(decision?.status)) {
|
||||
this.addMessage(group.id, 'system', decision.reason, '', threadRootId, {
|
||||
key: decision.status === 'needs-human' ? 'system.autoTaskNeedsHuman' : 'system.autoTaskBlocked',
|
||||
params: { reason: decision.reason },
|
||||
})
|
||||
return { status: 'partial' }
|
||||
}
|
||||
const nextKinds = this.v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds)
|
||||
if (nextKinds.length) return { nextKinds }
|
||||
// A missing receipt is not approval. Ask the current speaker to finish
|
||||
// their handoff or propose a concrete candidate instead of inventing votes.
|
||||
return { nextKinds: [], fallbackKind: activeKinds.includes(decisionMessage?.agentKind)
|
||||
? decisionMessage.agentKind : activeKinds[0] }
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = { naturalConsensusMethods }
|
||||
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user