mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
Compare commits
565
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 |
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
<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
|
||||
|
||||
**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.**
|
||||
@@ -15,13 +11,35 @@ Meldwork is a local-first multi-Agent collaboration workspace for the Agent tool
|
||||
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-Sun/Meldwork/releases/download/Meldwork-V1.0.7/Meldwork-1.0.7-arm64.dmg"><strong>Download Meldwork V1.0.7 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
|
||||
|
||||
[](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;
|
||||
@@ -75,10 +93,29 @@ Meldwork detects and invokes compatible locally installed Agent commands. The ca
|
||||
<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
|
||||
|
||||
@@ -86,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.7
|
||||
|
||||
For Apple silicon macOS, download the [Meldwork V1.0.7 prerelease](https://github.com/Ryder-Sun/Meldwork/releases/tag/Meldwork-V1.0.7) and choose [`Meldwork-1.0.7-arm64.dmg`](https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.7/Meldwork-1.0.7-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.
|
||||
@@ -111,12 +142,6 @@ npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
```
|
||||
|
||||
## V1.0.7
|
||||
|
||||
V1.0.7 preserves streamed replies when Auto Discussion is interrupted, handles Hermes native streaming and ACP session boundaries, and prevents MiMo headless runs from waiting on interactive plan prompts. Zcode joins the catalog before Pi Agent, with its official logo and native default configuration. WorkBuddy can reuse a configured desktop model selected in Meldwork.
|
||||
|
||||
Electron acceptance covered interrupted six-Agent unlimited discussions with simulated adapters, persistence after reload, and a real WorkBuddy reply. CLI and UI regression tests cover output parsing, cancellation, authentication errors, and redaction. Native Agent behavior still depends on the CLI and its provider: Zcode's default provider needs a valid API key, and an OpenCode route returning an invalid `tool_calls` finish reason remains an upstream issue. Unlimited discussion does not bypass stalled-process detection.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture and product boundary](architecture.md)
|
||||
|
||||
+42
-19
@@ -2,10 +2,6 @@
|
||||
<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 自主协作推进任务
|
||||
|
||||
**提出目标,让 Agent 们协作完成。Meldwork 把你已有的 Agent 组织成一个工作小组,共享上下文、讨论方案、协作执行并相互检查,减少你在它们之间转述、催办和整合结果的工作。**
|
||||
@@ -15,13 +11,35 @@ Meldwork 是一个本地优先的多 Agent 协作工作空间,用来连接你
|
||||
你无需安排每一轮发言,但仍负责目标、权限和关键决策。遇到信息缺失、权限请求或需要取舍的问题时,任务可能等待你处理。工作区写入需要明确授权;协作过程、运行状态和产物保存在本地,方便回看和继续工作。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.7/Meldwork-1.0.7-arm64.dmg"><strong>下载 Meldwork V1.0.7 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 适合什么工作
|
||||
|
||||
[](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 工具和明确选择的知识源;
|
||||
@@ -75,10 +93,27 @@ Meldwork 在本地保留任务上下文、Agent 回复、运行状态和产物
|
||||
<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 可能会自行准备依赖。
|
||||
|
||||
## 上下文与本地边界
|
||||
|
||||
@@ -86,12 +121,6 @@ Meldwork 作为本地 Electron 应用运行。对话、群组配置、运行记
|
||||
|
||||
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送到其自身 CLI 凭据对应的模型服务商。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表 Agent 使用的模型服务商一定离线。
|
||||
|
||||
## 下载 V1.0.7
|
||||
|
||||
Apple 芯片 macOS 用户请前往 [Meldwork V1.0.7 预发布页](https://github.com/Ryder-Sun/Meldwork/releases/tag/Meldwork-V1.0.7),下载 [`Meldwork-1.0.7-arm64.dmg`](https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.7/Meldwork-1.0.7-arm64.dmg)。发布页同时提供 ZIP 压缩包和 SHA-256 校验文件。启动应用前,请至少安装一个受支持的本地 Agent CLI。
|
||||
|
||||
该预览版安装包使用 ad-hoc 临时签名,未使用 Apple Developer ID 签名,也未进行公证。macOS 首次启动时可能需要在“系统设置 → 隐私与安全性”中选择“仍要打开 / Open Anyway”。
|
||||
|
||||
## 从源码运行
|
||||
|
||||
前置条件:Node.js `22.12+` 和 npm。
|
||||
@@ -111,12 +140,6 @@ npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
```
|
||||
|
||||
## V1.0.7
|
||||
|
||||
V1.0.7 修复自动讨论中断后流式回复丢失,适配 Hermes 原生流式输出与 ACP 会话边界,并避免 MiMo 无界面运行卡在交互式计划提示。新增 Zcode,使用官方标志、排列在 Pi Agent 前并沿用原生默认配置;WorkBuddy 可以复用在 Meldwork 中选定的桌面已配置模型。
|
||||
|
||||
Electron 验收覆盖模拟适配器下六 Agent 无限轮讨论、中断和重载后内容保留,以及真实 WorkBuddy 回复。CLI 与界面回归测试覆盖输出解析、取消、认证报错和脱敏。真实执行仍取决于 CLI 与服务商:Zcode 默认服务商需要有效 API Key,OpenCode 路由返回错误 `tool_calls` 结束原因的问题仍需上游修复。无限轮讨论不会关闭进程停滞检测。
|
||||
|
||||
## 文档
|
||||
|
||||
- [架构与产品边界](architecture.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 的行为,不代表第三方服务已经通过独立认证。
|
||||
@@ -17,12 +17,15 @@ API Key 或任意 Shell 执行能力。
|
||||
- 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 托管工具策略执行;无法
|
||||
满足当前权限契约的 Agent 会安全失败。群聊级工作目录写入默认关闭,只有用户明确授权后才会
|
||||
@@ -75,6 +78,25 @@ CLI 解锁;可在 Meldwork 中选择桌面配置里已有且已配置鉴权的
|
||||
Meldwork 仅保存模型标识,运行时由主进程读取原生配置,不向渲染层返回密钥。
|
||||
Zcode 沿用默认配置;若其默认 Provider 缺少 API Key,需要先修复本机原生配置。
|
||||
|
||||
Hermes 的原生配置目录设置会显示 `config.yaml` 中显式指定的 Provider(包括嵌套模型及旧版根级配置),或配置中没有指定时的
|
||||
`HERMES_INFERENCE_PROVIDER`。显示 `auto` 表示交由 Hermes 自动选择,并不代表已经确认最终路由;
|
||||
无法读取或没有明确配置时显示未指定。此摘要不返回凭据。
|
||||
Codex 设置显示用户配置(含所选 profile)中的 `model_provider`;项目配置或运行参数仍可覆盖它,
|
||||
因此该值不等同于每次调用的最终 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 |
|
||||
@@ -87,6 +109,7 @@ Zcode 沿用默认配置;若其默认 Provider 缺少 API Key,需要先修
|
||||
| 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 本机配置,按授权使用 `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 本机配置 |
|
||||
@@ -128,6 +151,10 @@ npm run dev
|
||||
|
||||
## 测试与打包
|
||||
|
||||
任务图的 `primary` 节点可以设置可选的 `claimKey`:相同键的结论会进行冲突检查,不同键适合独立交付物。未设置时沿用共享结论的检查方式;该字段是工作流输入的一部分,不会自动推断任务含义。
|
||||
|
||||
当前默认的 V4 群聊尚不支持任务图。旧版 V3 任务图的审查节点可能把指出问题的回复判为已通过;在此问题修复前,请人工检查审查内容,不要仅依据节点完成状态判断结果。
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run pack
|
||||
|
||||
Generated
+14
-8
@@ -1,16 +1,19 @@
|
||||
{
|
||||
"name": "meldwork-desktop",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meldwork-desktop",
|
||||
"version": "1.0.7",
|
||||
"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": "1.0.7",
|
||||
"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,14 +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],
|
||||
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],
|
||||
@@ -24,6 +25,7 @@ const AGENT_EXECUTABLE_NAMES = Object.freeze({
|
||||
pi: ['pi', 'pi-agent', 'piagent'],
|
||||
kimi: ['kimi'],
|
||||
mimo: ['mimo'],
|
||||
deepseek: ['dsh'],
|
||||
claude: ['claude'],
|
||||
gemini: ['gemini'],
|
||||
opencode: ['opencode'],
|
||||
@@ -65,6 +67,11 @@ const AGENT_COMPATIBILITY = Object.freeze({
|
||||
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'], [
|
||||
@@ -77,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',
|
||||
@@ -106,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,
|
||||
@@ -170,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
|
||||
@@ -183,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))]
|
||||
@@ -208,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)
|
||||
|
||||
@@ -96,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'],
|
||||
@@ -243,6 +247,7 @@ function failureCategory(code) {
|
||||
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
|
||||
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(
|
||||
@@ -762,9 +784,37 @@ async function createAcpRuntime(input) {
|
||||
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)
|
||||
}
|
||||
},
|
||||
@@ -864,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 = {
|
||||
@@ -878,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 = []
|
||||
@@ -904,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) {
|
||||
@@ -915,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(() => {
|
||||
@@ -928,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
|
||||
@@ -969,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
|
||||
}
|
||||
@@ -998,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'
|
||||
@@ -1038,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,
|
||||
})
|
||||
@@ -1114,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()
|
||||
}
|
||||
@@ -1151,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,6 +138,7 @@ 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,
|
||||
@@ -226,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 })
|
||||
@@ -264,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
|
||||
@@ -296,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)
|
||||
}
|
||||
})
|
||||
@@ -329,7 +342,9 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
const terminal = (
|
||||
(['workbuddy', 'claude', 'qwen'].includes(agent.kind) && event.type === 'result')
|
||||
|| (agent.kind === 'hermes' && spec.eventTransport === 'stream-json' && event.type === 'result')
|
||||
|| (agent.kind === 'pi' && event.type === 'agent_end')
|
||||
|| (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))
|
||||
@@ -350,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')
|
||||
@@ -420,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) {
|
||||
@@ -451,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'
|
||||
@@ -458,7 +480,7 @@ 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, {
|
||||
@@ -516,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')
|
||||
|
||||
@@ -20,6 +28,7 @@ const AGENT_PROFILES = {
|
||||
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'] },
|
||||
@@ -30,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',
|
||||
@@ -42,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()
|
||||
@@ -103,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'
|
||||
@@ -118,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'),
|
||||
@@ -147,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',
|
||||
)
|
||||
@@ -192,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(
|
||||
@@ -230,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)
|
||||
@@ -264,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 */ }
|
||||
@@ -354,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),
|
||||
@@ -361,73 +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: '',
|
||||
...(kind === 'hermes' && results.some(result => result.output?.includes('--format')
|
||||
&& result.output.includes('stream-json')) ? { streamJsonAvailable: true } : {}),
|
||||
})()
|
||||
resolveCompletion(outcome)
|
||||
return outcome
|
||||
} catch (error) {
|
||||
resolveCompletion({ compatibilityState: 'unknown', incompatibilityReason: '', incompatibilityProbe: '' })
|
||||
throw error
|
||||
} finally {
|
||||
group.active -= 1
|
||||
if (!group.active && activeCapabilityProbes.get(cacheKey) === group) activeCapabilityProbes.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,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)
|
||||
@@ -448,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)(
|
||||
@@ -486,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',
|
||||
@@ -497,6 +629,7 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
|
||||
name: `${AGENT_PROFILES[kind].label} CLI`,
|
||||
executable,
|
||||
version,
|
||||
...(!versionIdentified ? { versionIdentified: false } : {}),
|
||||
...compatibility,
|
||||
...(kind === 'hermes' ? {
|
||||
acpAvailable,
|
||||
@@ -511,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)
|
||||
}
|
||||
|
||||
@@ -540,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) {
|
||||
@@ -546,6 +573,7 @@ const CONNECTOR_EVENT_PROFILES = Object.freeze({
|
||||
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,
|
||||
|
||||
@@ -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]
|
||||
@@ -74,6 +76,8 @@ 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,
|
||||
@@ -193,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',
|
||||
@@ -227,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,
|
||||
@@ -276,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,9 @@ 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.')
|
||||
}
|
||||
@@ -66,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
|
||||
@@ -76,7 +148,7 @@ function openClawChildEnvironment(workdir, options, platform) {
|
||||
}
|
||||
|
||||
const env = {
|
||||
...systemChildEnvironment(process.env, platform),
|
||||
...inheritedEnvironment(options, platform),
|
||||
...networkEnvironment(source, platform),
|
||||
}
|
||||
for (const key of [
|
||||
@@ -132,19 +204,80 @@ function childEnvironment(agent, workdir, options, platform) {
|
||||
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 = {
|
||||
@@ -154,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 {
|
||||
|
||||
@@ -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 }
|
||||
@@ -1,23 +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
|
||||
const root = env.XDG_CONFIG_HOME || path.join(options.home || os.homedir(), '.config')
|
||||
try {
|
||||
const file = path.join(root, 'mimocode', 'mimocode.json')
|
||||
if (fs.statSync(file).size > 2 * 1024 * 1024) return false
|
||||
const config = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
if (typeof config.model !== 'string' || !config.model.includes('/')) return false
|
||||
const provider = config.provider?.[config.model.split('/')[0]]
|
||||
let key = provider?.options?.apiKey
|
||||
if (typeof key !== 'string' || !key.trim()) return false
|
||||
const reference = key.match(/^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/)
|
||||
if (reference) key = env[reference[1]]
|
||||
else if (/^\{.*\}$/.test(key)) return false
|
||||
return typeof key === 'string' && Boolean(key.trim())
|
||||
} catch { return false }
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { mimoConfiguredProvider }
|
||||
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 }
|
||||
@@ -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,
|
||||
@@ -118,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,
|
||||
@@ -151,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
|
||||
@@ -187,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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+311
-20
@@ -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,8 +51,17 @@ 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,
|
||||
@@ -75,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', 'zcode', '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
|
||||
@@ -98,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
|
||||
@@ -241,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')) {
|
||||
@@ -341,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
|
||||
@@ -366,9 +575,10 @@ 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: shellEnvironment.env, home: shellEnvironment.home,
|
||||
env: selectedNativeEnv, home: shellEnvironment.home,
|
||||
modelId: workspace?.state.agentPreferences.workbuddy?.modelId,
|
||||
}) : {}),
|
||||
...(shellEnvironment.env.PATH ? { PATH: shellEnvironment.env.PATH } : {}),
|
||||
@@ -378,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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -454,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'),
|
||||
})
|
||||
@@ -520,6 +736,7 @@ function createWorkspace() {
|
||||
}),
|
||||
outcomeStore,
|
||||
agentFitMatrix: FROZEN_AGENT_FIT_MATRIX,
|
||||
resolveProjectEnvironmentIdentities,
|
||||
runLedger: cloudAgentRuntime.workspaceLedger(),
|
||||
detectAgents: async () => {
|
||||
await cloudAgentBridges?.refresh()
|
||||
@@ -538,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,
|
||||
@@ -546,15 +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 = {}) => {
|
||||
@@ -618,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),
|
||||
@@ -655,7 +884,7 @@ function initializeWorkspace() {
|
||||
return next
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
function createWindow(startupError = workspaceStartupError) {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 920,
|
||||
@@ -713,8 +942,11 @@ function createWindow() {
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
loadFrontend()
|
||||
flushPendingOpenGroup()
|
||||
if (startupError) showOfflinePage(startupError)
|
||||
else {
|
||||
loadFrontend()
|
||||
flushPendingOpenGroup()
|
||||
}
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
@@ -749,9 +981,21 @@ 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, modelId: workspace?.state.agentPreferences.workbuddy?.modelId })
|
||||
return workBuddySettings({ ...shell,
|
||||
env: nativeConfigEnvironment('workbuddy', workspace?.state.agentPreferences, shell.env),
|
||||
modelId: workspace?.state.agentPreferences.workbuddy?.modelId })
|
||||
},
|
||||
installer,
|
||||
isShutdownStarted: () => shutdownStarted,
|
||||
@@ -765,6 +1009,9 @@ function registerIpc() {
|
||||
getSkillTrustStore: () => skillTrustStore,
|
||||
refreshLocalAgentState,
|
||||
registerTrustedHandle,
|
||||
resolveConnectorEnvironment: async names => (await resolveNativeShellEnvironment({
|
||||
home: app.getPath('home'), extraEnvKeys: names, refresh: true,
|
||||
})).env,
|
||||
skillCatalog,
|
||||
})
|
||||
}
|
||||
@@ -777,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) {
|
||||
@@ -790,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'),
|
||||
@@ -812,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()
|
||||
@@ -826,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>
|
||||
@@ -156,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),
|
||||
@@ -173,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 }
|
||||
@@ -1,8 +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}$/
|
||||
@@ -178,6 +184,7 @@ function registerDesktopIpc(options) {
|
||||
getCloudAgentBridges,
|
||||
getKnowledgeConnectors,
|
||||
getMainWindow,
|
||||
getNativeConfigSummary,
|
||||
getOutcomeStore,
|
||||
getSkillTrustStore,
|
||||
getWorkspace,
|
||||
@@ -191,6 +198,7 @@ function registerDesktopIpc(options) {
|
||||
openExternalUrl,
|
||||
refreshLocalAgentState,
|
||||
registerTrustedHandle,
|
||||
resolveConnectorEnvironment,
|
||||
skillCatalog,
|
||||
} = options
|
||||
|
||||
@@ -298,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 : ''
|
||||
@@ -306,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')
|
||||
}
|
||||
@@ -421,6 +429,93 @@ 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) => {
|
||||
@@ -577,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 => (
|
||||
@@ -622,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 [
|
||||
@@ -645,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?.()
|
||||
@@ -655,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')
|
||||
@@ -683,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 }
|
||||
@@ -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() {
|
||||
@@ -98,19 +193,30 @@ class LocalWorkspaceAgentCatalog {
|
||||
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(),
|
||||
}
|
||||
@@ -118,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')
|
||||
: ''
|
||||
@@ -142,24 +245,26 @@ class LocalWorkspaceAgentCatalog {
|
||||
&& !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 && !workBuddyUnavailable
|
||||
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, {
|
||||
@@ -171,18 +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
|
||||
if (workBuddyUnavailable) availabilitySource = native.source
|
||||
return {
|
||||
const result = {
|
||||
...agent,
|
||||
installed,
|
||||
versionIdentified,
|
||||
compatible,
|
||||
configured,
|
||||
authenticated,
|
||||
runtimePrerequisitesReady,
|
||||
runtimePrerequisitesSource: runtimePrerequisitesReady ? '' : native?.source || '',
|
||||
runtimeBlockSource: workBuddyUnavailable ? native.source : '',
|
||||
invocable,
|
||||
recentlyVerified: verifiedRecently,
|
||||
credentialState,
|
||||
@@ -191,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()
|
||||
@@ -213,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] = { ...state.agentPreferences[kind], showInSidebar: Boolean(visible) }
|
||||
agent.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()
|
||||
@@ -268,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,7 @@
|
||||
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,
|
||||
@@ -158,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
|
||||
@@ -199,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
|
||||
@@ -207,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()
|
||||
}
|
||||
|
||||
@@ -240,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
|
||||
@@ -299,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'
|
||||
@@ -640,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)
|
||||
@@ -703,6 +767,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
: {
|
||||
status: resumedConnectorGate.status,
|
||||
optionId: resumedConnectorGate.optionId,
|
||||
permission: request.permission,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -887,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 }
|
||||
@@ -915,6 +982,10 @@ class LocalWorkspaceAgentInvocation {
|
||||
watchdog.liveness()
|
||||
}
|
||||
},
|
||||
onSubstantiveActivity: () => {
|
||||
watchdog.establish()
|
||||
watchdog.progress()
|
||||
},
|
||||
onProgress,
|
||||
onEvent: emitRuntimeEvent,
|
||||
onOutboundPayload: recordOutboundPayload,
|
||||
@@ -974,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
|
||||
@@ -986,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,
|
||||
}
|
||||
@@ -1066,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
|
||||
@@ -1078,8 +1150,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
resumedPermission = null
|
||||
}
|
||||
rebuildFreshSession()
|
||||
operationStarted = true
|
||||
if (allowWrite) sideEffectsStarted = true
|
||||
return await runCurrentSession()
|
||||
}
|
||||
}
|
||||
@@ -1167,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()
|
||||
}
|
||||
@@ -1175,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,
|
||||
@@ -1247,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,
|
||||
@@ -1296,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,
|
||||
@@ -1310,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 }
|
||||
@@ -1,5 +1,35 @@
|
||||
const { parseLenientTaskDecision } = require('../collaboration/task-decision.cjs')
|
||||
|
||||
function explicitReviewRejection(text) {
|
||||
let inCodeBlock = false
|
||||
return String(text || '').split(/\r?\n/).some(rawLine => {
|
||||
const line = rawLine.trim()
|
||||
if (/^(?:```|~~~)/.test(line)) { inCodeBlock = !inCodeBlock; return false }
|
||||
if (inCodeBlock || line.startsWith('>')) return false
|
||||
return /(?:^|[.!?;;。!?])\s*(?:(?:i|we)\s+)?(?:do not|don't|cannot|can't)\s+approve\b/i.test(line)
|
||||
|| /(?:^|[.!?;;。!?])\s*(?:我|我们)?(?:不|不要|不能|不应|不予|无法)批准/u.test(line)
|
||||
})
|
||||
}
|
||||
|
||||
function explicitTaskFailure(text) {
|
||||
let inCodeBlock = false
|
||||
return String(text || '').split(/\r?\n/).some(rawLine => {
|
||||
const line = rawLine.trim()
|
||||
if (/^(?:```|~~~)/.test(line)) { inCodeBlock = !inCodeBlock; return false }
|
||||
if (inCodeBlock || line.startsWith('>')) return false
|
||||
const statement = line.replace(/^(?:[-*+]\s+|\d+[.)]\s+)/, '')
|
||||
.replace(/^(?:sorry|unfortunately)[,:,]\s*/i, '')
|
||||
.replace(/^(?:抱歉|很抱歉|对不起)[,,::]\s*/u, '')
|
||||
.replace(/^(?:由于|因为)(?:权限不足|访问被拒绝)[,,::]\s*/u, '')
|
||||
.replace(/^(?:error|fatal):\s*/i, '')
|
||||
return /^(?:permission denied|access denied)[.!。!\s]*$/i.test(statement)
|
||||
|| /^(?:permission denied|access denied)\s*[::]\s*\S/i.test(statement)
|
||||
|| /^(?:权限不足|访问被拒绝)[.!。!\s]*$/u.test(statement)
|
||||
|| /^(?:(?:permission denied|access denied)[.:;,,\s]+)?(?:i|we)\s+(?:cannot|can't|could not|couldn't|am unable to|are unable to|was unable to|were unable to|failed to)\s+(?:access|open|read|execute|perform|complete|finish)\b/i.test(statement)
|
||||
|| /^(?:(?:权限不足|访问被拒绝)[,,::;;\s]+)?(?:我|我们)?(?:无法|不能|没法|未能)(?:访问|打开|读取|执行|完成)/u.test(statement)
|
||||
})
|
||||
}
|
||||
|
||||
const v4NaturalDecisionMethods = {
|
||||
v4NaturalRouteDecision(decision, activeKinds) {
|
||||
const participants = Array.isArray(activeKinds) ? activeKinds : []
|
||||
@@ -11,7 +41,7 @@ const v4NaturalDecisionMethods = {
|
||||
// A request for an absent or paused participant is dropped on its own;
|
||||
// it used to stop the whole discussion with a misleading message.
|
||||
const dropped = requested.filter(kind => !participants.includes(kind))
|
||||
const kinds = participants.filter(kind => requested.includes(kind))
|
||||
const kinds = requested.filter(kind => participants.includes(kind))
|
||||
return { status: kinds.length ? 'valid' : 'none', kinds, dropped }
|
||||
},
|
||||
|
||||
@@ -54,7 +84,10 @@ const v4NaturalDecisionMethods = {
|
||||
const peerBlocker = messages.find(message => {
|
||||
if (message.agentKind === owner) return false
|
||||
const status = message.trace?.context?.taskDecision?.status
|
||||
const contribution = this.v4NaturalMessageContent(message)
|
||||
return status === 'blocked' || status === 'needs-human'
|
||||
|| contribution.partial || explicitTaskFailure(contribution.text)
|
||||
|| explicitTaskFailure(message.trace?.context?.taskDecision?.reason)
|
||||
})
|
||||
const ownerSawBlocker = peerBlocker && ownerMessage
|
||||
&& controller.discussionStyle === 'sequential'
|
||||
@@ -63,12 +96,13 @@ const v4NaturalDecisionMethods = {
|
||||
const peerDecision = peerBlocker.trace?.context?.taskDecision
|
||||
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoPeerBlockerForwarded',
|
||||
`${this.agentLabel(peerBlocker.agentKind)} reported a blocker; the delivery owner will decide how to proceed.`,
|
||||
{ agent: this.agentLabel(peerBlocker.agentKind), reason: peerDecision.reason })
|
||||
{ agent: this.agentLabel(peerBlocker.agentKind), reason: peerDecision?.reason || '' })
|
||||
return { nextKinds: [owner], ownerReview: true }
|
||||
}
|
||||
const nextKinds = this.v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds)
|
||||
if (nextKinds.length) return { nextKinds, ownerReview: false }
|
||||
const decision = ownerMessage?.trace?.context?.taskDecision
|
||||
let decision
|
||||
try { decision = parseLenientTaskDecision(ownerMessage?.trace?.context?.taskDecision) } catch { /* ask for a valid receipt */ }
|
||||
if (decision?.handoffTo && !activeKinds.includes(decision.handoffTo)) {
|
||||
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoHandoffIgnored',
|
||||
`The handoff to ${this.agentLabel(decision.handoffTo)} was ignored because that Agent is not active; ${this.agentLabel(owner)} remains the delivery owner.`,
|
||||
@@ -92,6 +126,16 @@ const v4NaturalDecisionMethods = {
|
||||
return { status: 'partial' }
|
||||
}
|
||||
if (decision.status === 'completed') {
|
||||
const contribution = this.v4NaturalMessageContent(ownerMessage)
|
||||
if (contribution.partial || !contribution.text.trim() || explicitTaskFailure(contribution.text)
|
||||
|| explicitTaskFailure(decision.reason)) {
|
||||
return { nextKinds: [owner], ownerReview: true }
|
||||
}
|
||||
const unresolved = this.v4NaturalUnresolvedBlockers(group, controller, threadRootId, round)
|
||||
if (unresolved.length) {
|
||||
const available = unresolved.filter(kind => activeKinds.includes(kind))
|
||||
return available.length ? { nextKinds: available, ownerReview: true } : { status: 'partial' }
|
||||
}
|
||||
// The owner may not close the task before selected peers had one discussion
|
||||
// round of their own; otherwise a single full round collapses into an
|
||||
// owner-only review and peers never get to challenge the result.
|
||||
@@ -127,6 +171,37 @@ const v4NaturalDecisionMethods = {
|
||||
return { status: 'partial' }
|
||||
},
|
||||
|
||||
v4NaturalUnresolvedBlockers(group, controller, threadRootId, round) {
|
||||
const unresolved = new Set()
|
||||
const messages = this.state().messages.filter(message => (
|
||||
message.groupId === group.id && message.threadRootId === threadRootId
|
||||
&& message.role === 'agent' && message.trace?.phase === 'discussion'
|
||||
&& Number(message.trace?.round) <= round
|
||||
&& this.v4NaturalMessageMatchesBinding(message, controller)
|
||||
)).map((message, index) => ({ message, index })).sort((left, right) => (
|
||||
Number(left.message.trace.round) - Number(right.message.trace.round)
|
||||
|| (Number(left.message.turnSeq) || 0) - (Number(right.message.turnSeq) || 0)
|
||||
|| left.index - right.index
|
||||
))
|
||||
for (const { message } of messages) {
|
||||
const contribution = this.v4NaturalMessageContent(message)
|
||||
if (contribution.partial || explicitTaskFailure(contribution.text)
|
||||
|| explicitTaskFailure(message.trace?.context?.taskDecision?.reason)) {
|
||||
unresolved.add(message.agentKind)
|
||||
continue
|
||||
}
|
||||
let status
|
||||
try { status = parseLenientTaskDecision(message.trace?.context?.taskDecision).status } catch { continue }
|
||||
if (status === 'blocked' || status === 'needs-human') unresolved.add(message.agentKind)
|
||||
else if (status === 'completed') {
|
||||
if (!contribution.partial && contribution.text.trim()) {
|
||||
unresolved.delete(message.agentKind)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...unresolved]
|
||||
},
|
||||
|
||||
v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds) {
|
||||
const current = this.v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round)
|
||||
const selected = new Set(current.flatMap(message => (
|
||||
@@ -134,7 +209,7 @@ const v4NaturalDecisionMethods = {
|
||||
.filter(kind => kind !== message.agentKind)
|
||||
)))
|
||||
if (!selected.size) return []
|
||||
return activeKinds.filter(kind => selected.has(kind))
|
||||
return [...selected]
|
||||
},
|
||||
|
||||
v4NaturalPriorPeerDiscussion(group, controller, threadRootId, round, owner) {
|
||||
@@ -162,7 +237,8 @@ const v4NaturalDecisionMethods = {
|
||||
&& this.v4NaturalMessageMatchesBinding(message, controller)
|
||||
))
|
||||
const previous = earlier.at(-1)
|
||||
return Boolean(previous && !previous.trace?.context?.taskDecision)
|
||||
if (!previous) return false
|
||||
try { parseLenientTaskDecision(previous.trace?.context?.taskDecision); return false } catch { return true }
|
||||
},
|
||||
|
||||
// With activeKinds, an owner who failed or was paused hands ownership to
|
||||
@@ -200,9 +276,19 @@ const v4NaturalDecisionMethods = {
|
||||
|| messageRound > round || !this.v4NaturalMessageMatchesBinding(message, controller)) continue
|
||||
const contribution = this.v4NaturalMessageContent(message)
|
||||
if (contribution.partial) return false
|
||||
const content = contribution.text
|
||||
const text = contribution.text
|
||||
.replace(/@[A-Za-z0-9][A-Za-z0-9_-]*/gu, '')
|
||||
.replace(/\s+/gu, ' ').trim()
|
||||
const files = []
|
||||
for (const artifactId of message.trace?.context?.outcomeRefs?.artifactIds || []) {
|
||||
try {
|
||||
const { artifact, contentHash } = this.v4ArtifactIdentity(artifactId)
|
||||
if (artifact.locationRef?.kind === 'workspace-relative') {
|
||||
files.push([artifact.locationRef.path, contentHash])
|
||||
}
|
||||
} catch { return false }
|
||||
}
|
||||
const content = JSON.stringify([text, message.trace?.context?.taskDecision?.reviewedVersion || '', files.sort()])
|
||||
if (previous.has(message.agentKind) && previous.get(message.agentKind) !== content) return false
|
||||
if (messageRound >= firstRound) {
|
||||
observedRounds.add(messageRound)
|
||||
@@ -226,4 +312,4 @@ const v4NaturalDecisionMethods = {
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = { v4NaturalDecisionMethods }
|
||||
module.exports = { v4NaturalDecisionMethods, explicitTaskFailure, explicitReviewRejection }
|
||||
@@ -27,6 +27,9 @@ const naturalDiscussionMethods = {
|
||||
|
||||
v4NaturalMessageContent(message) {
|
||||
const text = String(message.content || '')
|
||||
if (message.trace?.status && message.trace.status !== 'completed') {
|
||||
return { text, partial: true }
|
||||
}
|
||||
if (text.length < MAX_MESSAGE_TEXT_CHARS && message.trace?.truncated !== true) {
|
||||
return { text, partial: false }
|
||||
}
|
||||
@@ -87,6 +90,12 @@ const naturalDiscussionMethods = {
|
||||
return packNaturalDiscussionTranscript(this.v4NaturalThreadEntries(group, threadRootId, options))
|
||||
},
|
||||
|
||||
v4NaturalFailureStatus(group, controller, threadRootId, result) {
|
||||
const hasEarlierResult = this.v4NaturalThreadEntries(group, threadRootId, { controller })
|
||||
.some(entry => !entry.partial && entry.text.trim())
|
||||
return result.phasePendingMessages?.length || hasEarlierResult ? 'partial' : 'failed'
|
||||
},
|
||||
|
||||
v4NaturalDeliveryPrompt(group, controller, threadRootId, kind, slot, binding, promptBuilder) {
|
||||
const entries = this.v4NaturalThreadEntries(group, threadRootId, { controller })
|
||||
const previousEntries = binding.hasSession && !binding.sessionRotated
|
||||
@@ -104,7 +113,12 @@ const naturalDiscussionMethods = {
|
||||
const unseen = entries.filter(entry => !seen.has([
|
||||
entry.agentKind, entry.phase, entry.id, entry.hash,
|
||||
].join('\u0000')))
|
||||
const packed = packNaturalDiscussionTranscript(unseen, {
|
||||
const fullOpening = controller.discussionStyle === 'agent-led' && controller.currentRound === 2
|
||||
? entries.filter(entry => entry.phase === 'proposal') : null
|
||||
const packed = fullOpening ? {
|
||||
text: fullOpening.map(entry => `Round 1 - @${entry.agentKind} [${entry.id}]\n${entry.text}`).join('\n\n'),
|
||||
completeMessageIds: fullOpening.filter(entry => !entry.partial).map(entry => entry.id),
|
||||
} : packNaturalDiscussionTranscript(unseen, {
|
||||
withEntries: true,
|
||||
prefix: unseen.length < entries.length
|
||||
? 'Previously delivered turns are not repeated here. They were supplied to this native session; ask peers to restate missing evidence if your current context is incomplete. Their omission does not imply agreement or task completion.'
|
||||
@@ -237,9 +251,10 @@ const naturalDiscussionMethods = {
|
||||
activeKinds,
|
||||
transcript: String(options.transcript || ''),
|
||||
humanResponsesText: humanResponses.length ? packNaturalDiscussionTranscript(humanResponses) : '',
|
||||
writerKind: options.writerKind,
|
||||
writerKind: group.allowWrite === true ? kind : '',
|
||||
ownerKind: options.ownerKind,
|
||||
allowRouting: options.allowRouting === true,
|
||||
consensusText: options.consensusText || '',
|
||||
})
|
||||
},
|
||||
|
||||
@@ -336,7 +351,7 @@ const naturalDiscussionMethods = {
|
||||
const failedKinds = removePhaseFailures(dispatch)
|
||||
activeKinds = activeKinds.filter(agentKind => !failedKinds.includes(agentKind))
|
||||
if (!activeKinds.length) {
|
||||
return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
|
||||
return this.v4NaturalFailureStatus(group, controller, threadRootId, dispatch)
|
||||
}
|
||||
}
|
||||
pendingKinds = remainingKinds.filter(agentKind => activeKinds.includes(agentKind))
|
||||
@@ -356,7 +371,7 @@ const naturalDiscussionMethods = {
|
||||
|
||||
async runV4AgentLedDiscussion(group, controller, threadRootId, context, input) {
|
||||
const {
|
||||
targetKinds, activeKinds: initialActiveKinds, writerKind, batchId,
|
||||
targetKinds, activeKinds: initialActiveKinds, batchId,
|
||||
snapshot, snapshotRecord, snapshotHash, slots: initialSlots, receiptRecords,
|
||||
latestFor, runPhase, checkpointPhase, removePhaseFailures, addRoundLimitNotice,
|
||||
} = input
|
||||
@@ -372,7 +387,7 @@ const naturalDiscussionMethods = {
|
||||
const failedKinds = removePhaseFailures(proposal)
|
||||
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
|
||||
checkpointPhase('proposal', [], slots, null)
|
||||
if (!activeKinds.length) return proposal.phasePendingMessages?.length ? 'partial' : 'failed'
|
||||
if (!activeKinds.length) return this.v4NaturalFailureStatus(group, controller, threadRootId, proposal)
|
||||
}
|
||||
}
|
||||
const proposalComplete = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
|
||||
@@ -393,9 +408,9 @@ const naturalDiscussionMethods = {
|
||||
if (this.v4NaturalStopRepeatingDiscussion(
|
||||
group, controller, threadRootId, round, activeKinds,
|
||||
)) return 'partial'
|
||||
const outcome = await this.v4NaturalRoundOutcome(group, controller, threadRootId, round, activeKinds)
|
||||
const outcome = await this.v4NaturalAgentLedOutcome(group, controller, threadRootId, round, activeKinds)
|
||||
if (outcome.status) return outcome.status
|
||||
nextKinds = outcome.nextKinds
|
||||
nextKinds = outcome.nextKinds.length ? outcome.nextKinds : [outcome.fallbackKind]
|
||||
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
|
||||
addRoundLimitNotice()
|
||||
return 'round-limit'
|
||||
@@ -404,20 +419,22 @@ const naturalDiscussionMethods = {
|
||||
}
|
||||
} else {
|
||||
round = 2
|
||||
// The first discussion round is a full participant round: every selected
|
||||
// Agent reacts to the proposal set before the delivery owner reviews and
|
||||
// judges completion, so a group run cannot collapse to owner-only turns.
|
||||
nextKinds = [...activeKinds]
|
||||
nextKinds = [activeKinds[0]]
|
||||
}
|
||||
|
||||
while (!controller.signal.aborted) {
|
||||
controller.currentRound = round
|
||||
const kind = nextKinds[0]
|
||||
const remainingKinds = nextKinds.slice(1)
|
||||
const candidate = this.v4NaturalConsensus(group, controller, threadRootId)
|
||||
const turnWriterKind = !candidate && group.allowWrite === true && this.canWriteWorkspace(kind)
|
||||
? kind : ''
|
||||
const dispatch = await this.runV4Phase(group, controller, threadRootId, context, {
|
||||
phase: 'discussion',
|
||||
activeKinds: nextKinds,
|
||||
activeKinds: [kind],
|
||||
targetKinds,
|
||||
participantKinds: activeKinds,
|
||||
writerKind,
|
||||
writerKind: turnWriterKind,
|
||||
batchId,
|
||||
snapshot,
|
||||
snapshotRecord,
|
||||
@@ -428,10 +445,10 @@ const naturalDiscussionMethods = {
|
||||
coordinationPlan: null,
|
||||
workReceipts: [],
|
||||
operationIds: this.v4NaturalDiscussionOperationIds(
|
||||
controller, slots, nextKinds, round,
|
||||
controller, slots, [kind], round,
|
||||
),
|
||||
pendingKinds: nextKinds,
|
||||
remainingKinds: [],
|
||||
remainingKinds,
|
||||
sessionPolicy: null,
|
||||
resumedGate: input.resumedGate,
|
||||
disableDeliveryPrompt: true,
|
||||
@@ -442,8 +459,8 @@ const naturalDiscussionMethods = {
|
||||
skillHints: context.rootSkillsByKind.get(kind) || [],
|
||||
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(kind) || [],
|
||||
allowRouting: true,
|
||||
ownerKind: this.v4NaturalOwner(group, controller, threadRootId, activeKinds),
|
||||
writerKind,
|
||||
writerKind: turnWriterKind,
|
||||
consensusText: this.v4NaturalConsensusPrompt(group, controller, threadRootId),
|
||||
},
|
||||
),
|
||||
})
|
||||
@@ -452,14 +469,20 @@ const naturalDiscussionMethods = {
|
||||
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
|
||||
const failedKinds = removePhaseFailures(dispatch)
|
||||
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
|
||||
if (!activeKinds.length) return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
|
||||
if (!activeKinds.length) return this.v4NaturalFailureStatus(group, controller, threadRootId, dispatch)
|
||||
}
|
||||
if (this.v4NaturalStopRepeatingDiscussion(
|
||||
group, controller, threadRootId, round, activeKinds,
|
||||
)) return 'partial'
|
||||
const outcome = await this.v4NaturalRoundOutcome(group, controller, threadRootId, round, activeKinds)
|
||||
const outcome = await this.v4NaturalAgentLedOutcome(group, controller, threadRootId, round, activeKinds)
|
||||
if (outcome.status) return outcome.status
|
||||
nextKinds = outcome.nextKinds
|
||||
// A new candidate starts its own confirmation queue. An objection
|
||||
// cancels pending approvals; otherwise preserve outstanding invitations.
|
||||
nextKinds = this.v4NaturalConsensus(group, controller, threadRootId) || candidate
|
||||
? outcome.nextKinds
|
||||
: [...new Set([...outcome.nextKinds, ...remainingKinds])]
|
||||
nextKinds = nextKinds.filter(agentKind => activeKinds.includes(agentKind))
|
||||
if (!nextKinds.length) nextKinds = [outcome.fallbackKind || activeKinds[0]]
|
||||
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
|
||||
addRoundLimitNotice()
|
||||
return 'round-limit'
|
||||
@@ -471,19 +494,20 @@ const naturalDiscussionMethods = {
|
||||
},
|
||||
|
||||
async runV4NaturalDiscussion(group, controller, threadRootId, context, input) {
|
||||
if (input.resume && controller.orchestration?.phase === 'discussion') {
|
||||
if (input.resume && ['proposal', 'discussion'].includes(controller.orchestration?.phase)) {
|
||||
for (const slot of input.slots) {
|
||||
if (slot.permission !== 'workspace-write' || !slot.agentRunId
|
||||
|| slot.commitStatus === 'committed'
|
||||
|| !['running', 'waiting', 'stopped', 'failed'].includes(slot.status)) continue
|
||||
if (this.v4NaturalRecoveredDiscussionResult(group, controller, threadRootId, slot.agentKind, slot)) continue
|
||||
if (this.v4NaturalRecoveredPhaseResult(group, controller, threadRootId,
|
||||
controller.orchestration.phase, slot.agentKind, slot)) continue
|
||||
if (input.resumedGate?.operationId === slot.operationId
|
||||
&& input.resumedGate.agentKind === slot.agentKind
|
||||
&& input.resumedGate.status === 'approved') continue
|
||||
if (!this.requestHumanGate) throw new Error('LOCAL_RUN_RETRY_GATE_INVALID')
|
||||
const binding = {
|
||||
agentRunId: slot.agentRunId, agentKind: slot.agentKind,
|
||||
round: controller.currentRound, phase: 'discussion', slotId: slot.slotId,
|
||||
round: controller.currentRound, phase: controller.orchestration.phase, slotId: slot.slotId,
|
||||
operationId: slot.operationId, snapshotHash: input.snapshotHash,
|
||||
}
|
||||
const request = {
|
||||
@@ -494,7 +518,7 @@ const naturalDiscussionMethods = {
|
||||
const gate = await this.requestHumanGate({
|
||||
type: 'retry', runId: controller.runId, agentRunId: slot.agentRunId,
|
||||
agentKind: slot.agentKind,
|
||||
summary: 'The interrupted discussion writer may already have changed the workspace.',
|
||||
summary: 'The interrupted Agent may already have changed the workspace.',
|
||||
options: [
|
||||
{ optionId: 'retry-once', name: 'Retry once', kind: 'allow_once' },
|
||||
{ optionId: 'cancel-retry', name: 'Do not retry', kind: 'reject_once' },
|
||||
@@ -512,8 +536,10 @@ const naturalDiscussionMethods = {
|
||||
return 'stopped'
|
||||
}
|
||||
if (gate.optionId !== 'retry-once') throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
if (this.completeHumanGateContinuation?.(controller.runId, gate.gateId, 'completed') !== true
|
||||
&& this.hasRunLedger()) throw new Error('LOCAL_RUN_PERSIST_FAILED')
|
||||
input.resumedGate = { ...binding, gateId: gate.gateId, type: 'retry',
|
||||
status: gate.status, optionId: gate.optionId, request, used: false }
|
||||
status: gate.status, optionId: gate.optionId, request, used: true }
|
||||
}
|
||||
}
|
||||
if (input.resumedGate?.type === 'retry'
|
||||
@@ -525,7 +551,7 @@ const naturalDiscussionMethods = {
|
||||
|| slot.operationId !== gate.operationId
|
||||
|| slot.snapshotHash !== gate.snapshotHash
|
||||
|| slot.attempt !== gate.request.attempt) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
if (this.completeHumanGateContinuation?.(controller.runId, gate.gateId, 'completed') !== true
|
||||
if (!gate.used && this.completeHumanGateContinuation?.(controller.runId, gate.gateId, 'completed') !== true
|
||||
&& this.hasRunLedger()) throw new Error('LOCAL_RUN_PERSIST_FAILED')
|
||||
}
|
||||
const style = controller.orchestration?.discussionStyle
|
||||
|
||||
@@ -420,7 +420,8 @@ const recoveryMethods = {
|
||||
// Only explicit sign-in failures reach this point. Mark the Agent as
|
||||
// needing a fresh login; the catalog clears it on the next refresh
|
||||
// after the retry window or as soon as native credentials verify.
|
||||
this.markRuntimeCredential(kind, 'missing')
|
||||
this.markRuntimeCredential(kind, 'missing', { verificationIdentity: error.verificationIdentity,
|
||||
workdir: group.workdir, projectIdentity: error.projectIdentity })
|
||||
this.recordAttempt(group, controller, {
|
||||
agentKind: kind,
|
||||
phase,
|
||||
|
||||
@@ -22,7 +22,9 @@ const roundsMethods = {
|
||||
let totalSuccesses = cursor?.totalSuccesses || 0
|
||||
let consensusReached = false
|
||||
const reportedFailures = new Set()
|
||||
const firstRound = resume ? Math.max(0, controller.currentRound - 1) : 0
|
||||
const currentRound = Number.isSafeInteger(controller.currentRound) && controller.currentRound > 0
|
||||
? controller.currentRound : 1
|
||||
const firstRound = resume ? currentRound - 1 : 0
|
||||
|
||||
for (
|
||||
let round = firstRound;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// V3 task-graph runs. Kept so older runs can be resumed; new runs do not use it.
|
||||
const { createHash } = require('node:crypto')
|
||||
const { canonicalJson } = require('../collaboration/context-pack-records.cjs')
|
||||
const {
|
||||
appendBlackboardEntry,
|
||||
emptyCollaborationState,
|
||||
publicCollaborationText,
|
||||
} = require('../collaboration/collaboration-records.cjs')
|
||||
const { unlimitedReviewContract } = require('./local-workspace-context.cjs')
|
||||
const { explicitTaskFailure, explicitReviewRejection } = require('./local-workspace-auto-runner-natural-decisions.cjs')
|
||||
const {
|
||||
assertTaskGraphDependencies,
|
||||
parseTaskGraphCursor,
|
||||
readyTaskGraphNodes,
|
||||
terminalTaskGraphState,
|
||||
@@ -19,6 +22,113 @@ const {
|
||||
} = require('./local-workspace-auto-runner-support.cjs')
|
||||
|
||||
const taskGraphMethods = {
|
||||
validateTaskGraphRecoveredResults(controller) {
|
||||
const cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
|
||||
const entries = controller.orchestration.collaboration?.entries || []
|
||||
for (const state of cursor.nodeStates) {
|
||||
const node = cursor.graph.nodes.find(candidate => candidate.nodeId === state.nodeId)
|
||||
if (state.status !== 'accepted' && !(node.role === 'human' && state.status === 'rejected')) continue
|
||||
if (node.role === 'human') {
|
||||
const option = node.decisionOptions.find(candidate => candidate.optionId === state.decisionOptionId)
|
||||
const decision = state.entryIds.length === 1
|
||||
? entries.find(entry => entry.entryId === state.entryIds[0]) : null
|
||||
if (!option || option.kind !== (state.status === 'accepted' ? 'allow_once' : 'reject_once')
|
||||
|| decision?.entryType !== 'decision' || decision.lifecycle?.state !== 'active'
|
||||
|| (state.status === 'rejected' && decision.refs?.length !== 0)
|
||||
|| decision.owner?.type !== 'harness' || decision.provenance?.runId !== controller.runId
|
||||
|| decision.provenance.taskId !== controller.taskId
|
||||
|| decision.provenance.agentRunId !== this.taskGraphDecisionAgentRunId(cursor.graph.graphId, node.nodeId)
|
||||
|| decision.subject !== `task:${controller.taskId}:node:${node.nodeId}`
|
||||
|| decision.statement !== `Human selected: ${option.name}`
|
||||
|| decision.refs.some(id => !this.taskGraphHumanResolvableConflicts(controller, node).some(entry => entry.entryId === id))
|
||||
|| (node.terminal && state.status === 'accepted'
|
||||
&& this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration).length > 0)
|
||||
|| (decision.value !== option.optionId && !(decision.value === ''
|
||||
&& node.decisionOptions.filter(candidate => candidate.name === option.name).length === 1))) {
|
||||
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!state.entryIds.length && !node.acceptance.requireConclusion
|
||||
&& !node.acceptance.minArtifactRefs && !node.acceptance.minEvidenceRefs) continue
|
||||
const selected = state.entryIds.map(id => entries.find(entry => entry.entryId === id))
|
||||
if (selected.some(entry => !entry || entry.lifecycle?.state !== 'active')) {
|
||||
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
}
|
||||
const claims = selected.filter(entry => ['claim', 'decision'].includes(entry.entryType)
|
||||
&& entry.owner?.type === 'agent' && entry.owner.agentKind === node.agentKind
|
||||
&& entry.provenance?.runId === controller.runId && entry.provenance.taskId === controller.taskId)
|
||||
if (claims.length > 1 || (node.acceptance.requireConclusion && claims.length !== 1)) {
|
||||
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
}
|
||||
const claim = claims[0]
|
||||
const anchor = claim || selected.find(entry => ['artifact-ref', 'evidence-ref'].includes(entry.entryType)
|
||||
&& entry.owner?.type === 'agent' && entry.owner.agentKind === node.agentKind
|
||||
&& entry.provenance?.runId === controller.runId && entry.provenance.taskId === controller.taskId)
|
||||
if (!anchor?.provenance.agentRunId) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
if (selected.some(entry => entry.provenance?.runId !== controller.runId
|
||||
|| entry.provenance?.taskId !== controller.taskId
|
||||
|| entry.provenance?.agentRunId !== anchor.provenance.agentRunId
|
||||
|| (entry.entryType === 'conflict' ? entry.owner?.type !== 'harness'
|
||||
: !['claim', 'decision', 'artifact-ref', 'evidence-ref'].includes(entry.entryType)
|
||||
|| entry.owner?.type !== 'agent' || entry.owner.agentKind !== node.agentKind
|
||||
|| entry.owner.role !== node.role))) {
|
||||
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
}
|
||||
const subject = node.role === 'primary'
|
||||
? (node.claimKey ? `task:${controller.taskId}:claim:${node.claimKey}` : `task:${controller.taskId}:conclusion`)
|
||||
: `task:${controller.taskId}:node:${node.nodeId}`
|
||||
const conclusionHash = claim?.statement ? createHash('sha256').update(claim.statement).digest('hex') : ''
|
||||
if (anchor.owner.role !== node.role || (claim && (claim.subject !== subject
|
||||
|| claim.entryType !== (node.role === 'arbiter' ? 'decision' : 'claim')))
|
||||
|| state.conclusionHash !== conclusionHash
|
||||
|| canonicalJson(state.artifactIds) !== canonicalJson(anchor.provenance.artifactIds)
|
||||
|| canonicalJson(state.evidenceIds) !== canonicalJson(anchor.provenance.evidenceIds)) {
|
||||
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
}
|
||||
for (const [type, ids] of [['artifact-ref', state.artifactIds], ['evidence-ref', state.evidenceIds]]) {
|
||||
const references = selected.filter(entry => entry.entryType === type)
|
||||
if (references.length > ids.length || references.some((entry, index) => (
|
||||
entry.subject !== `task:${controller.taskId}:${type === 'artifact-ref' ? 'artifact' : 'evidence'}`
|
||||
|| entry.refs.length !== 1 || entry.refs[0] !== ids[index]
|
||||
|| canonicalJson(entry.provenance.artifactIds) !== canonicalJson(state.artifactIds)
|
||||
|| canonicalJson(entry.provenance.evidenceIds) !== canonicalJson(state.evidenceIds)
|
||||
))) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
}
|
||||
const result = { message: { content: claim?.statement || '', trace: {
|
||||
status: 'completed', agentRunId: anchor.provenance.agentRunId,
|
||||
} }, outcomeRefs: { artifactIds: state.artifactIds, evidenceIds: state.evidenceIds } }
|
||||
const refs = this.taskGraphVerifiedOutcomeRefs(node, result, controller)
|
||||
if (canonicalJson(refs.artifactIds) !== canonicalJson(state.artifactIds)
|
||||
|| canonicalJson(refs.evidenceIds) !== canonicalJson(state.evidenceIds)
|
||||
|| !this.taskGraphResultAccepted(node, result, controller, refs)) {
|
||||
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
restoreTaskGraphOutcomeReferences(group, controller) {
|
||||
this.validateTaskGraphRecoveredResults(controller)
|
||||
const cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
|
||||
for (const state of cursor.nodeStates) {
|
||||
if (state.status !== 'accepted') continue
|
||||
const node = cursor.graph.nodes.find(candidate => candidate.nodeId === state.nodeId)
|
||||
if (node.role === 'human') continue
|
||||
const entries = state.entryIds.map(id => controller.orchestration.collaboration.entries.find(entry => entry.entryId === id))
|
||||
const artifacts = entries.filter(entry => entry.entryType === 'artifact-ref')
|
||||
const evidence = entries.filter(entry => entry.entryType === 'evidence-ref')
|
||||
if (artifacts.length === state.artifactIds.length && evidence.length === state.evidenceIds.length) continue
|
||||
const claim = entries.find(entry => ['claim', 'decision'].includes(entry.entryType))
|
||||
const anchor = claim || artifacts[0] || evidence[0]
|
||||
if (!anchor?.provenance?.agentRunId) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
this.applyTaskGraphAgentResult(group, controller, node, {
|
||||
message: { content: claim?.statement || '', trace: { status: 'completed', agentRunId: anchor.provenance.agentRunId } },
|
||||
outcomeRefs: { artifactIds: state.artifactIds, evidenceIds: state.evidenceIds },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
unresolvedTaskGraphConflicts(collaborationInput = null) {
|
||||
const collaboration = collaborationInput || emptyCollaborationState()
|
||||
const resolved = new Set(collaboration.entries
|
||||
@@ -48,9 +158,17 @@ const taskGraphMethods = {
|
||||
const states = new Map(cursor.nodeStates.map(state => [state.nodeId, state]))
|
||||
const selected = node.inputNodeIds.flatMap(nodeId => states.get(nodeId)?.entryIds || [])
|
||||
if (['reviewer', 'arbiter'].includes(node.role)) {
|
||||
selected.push(...this.unresolvedTaskGraphConflicts(
|
||||
controller.orchestration.collaboration,
|
||||
).map(entry => entry.entryId))
|
||||
for (const conflict of this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration)) {
|
||||
selected.push(conflict.entryId, ...conflict.refs)
|
||||
for (const state of cursor.nodeStates) {
|
||||
if (state.status === 'accepted' && state.entryIds.some(id => conflict.refs.includes(id))) {
|
||||
const outcomes = controller.orchestration.collaboration.entries.filter(entry => (
|
||||
state.entryIds.includes(entry.entryId) && ['artifact-ref', 'evidence-ref'].includes(entry.entryType)
|
||||
))
|
||||
selected.push(...outcomes.map(entry => entry.entryId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(selected)]
|
||||
},
|
||||
@@ -65,10 +183,40 @@ const taskGraphMethods = {
|
||||
`Acceptance criteria: ${this.taskGraphAcceptanceCriteria(node).join(' | ')}`,
|
||||
unlimitedReviewContract(unlimitedRounds),
|
||||
collaborationPackage.text,
|
||||
'Meldwork records your completed response as an Artifact and associated Evidence. Do not search for, invent, or output internal Artifact or Evidence IDs.',
|
||||
'Return the requested result directly. Completion is evaluated from durable typed records; do not emit a consensus marker.',
|
||||
].join('\n')
|
||||
},
|
||||
|
||||
taskGraphReviewOutcomeText(entries) {
|
||||
const lines = []
|
||||
let remaining = 24000
|
||||
for (const entry of entries) {
|
||||
if (!['artifact-ref', 'evidence-ref'].includes(entry.entryType)) continue
|
||||
const id = entry.refs?.[0]
|
||||
let label
|
||||
let raw
|
||||
if (entry.entryType === 'artifact-ref') {
|
||||
const identity = this.v4ArtifactIdentity(id)
|
||||
label = `Artifact ${id}`
|
||||
raw = identity.artifact.contentRef?.mediaType === 'text/plain'
|
||||
? identity.content : '[Non-text artifact; content unavailable in this handoff.]'
|
||||
} else {
|
||||
const evidence = this.v4EvidenceRecord(id)
|
||||
label = `Evidence ${id} (${evidence.kind}, ${evidence.level})`
|
||||
raw = evidence.summary
|
||||
}
|
||||
const limit = Math.max(0, Math.min(6000, remaining))
|
||||
if (!limit) { lines.push('[Additional outcome content truncated.]'); break }
|
||||
const content = publicCollaborationText(raw || '', limit + 1)
|
||||
const truncated = content.length > limit
|
||||
const shown = content.slice(0, limit)
|
||||
lines.push(`${label}: ${shown}${truncated ? ' [Content truncated.]' : ''}`)
|
||||
remaining -= shown.length
|
||||
}
|
||||
return lines.length ? `Verified outcome content:\n${lines.join('\n')}\nDo not assume truncated or unavailable content was reviewed.` : ''
|
||||
},
|
||||
|
||||
checkpointTaskGraph(group, controller, cursorInput, options = {}) {
|
||||
let taskGraph = parseTaskGraphCursor(cursorInput)
|
||||
taskGraph = parseTaskGraphCursor({
|
||||
@@ -96,36 +244,150 @@ const taskGraphMethods = {
|
||||
return taskGraph
|
||||
},
|
||||
|
||||
taskGraphResultAccepted(node, result) {
|
||||
taskGraphVerifiedOutcomeRefs(node, result, controller) {
|
||||
const artifacts = new Map()
|
||||
const evidence = new Set()
|
||||
for (const id of new Set(result?.outcomeRefs?.artifactIds || [])) {
|
||||
try {
|
||||
const identity = this.v4ArtifactIdentity(id)
|
||||
const producer = identity.artifact.producedBy
|
||||
if (producer.runId === controller?.runId && producer.agentRunId === result?.message?.trace?.agentRunId
|
||||
&& producer.agentKind === node.agentKind) artifacts.set(id, identity)
|
||||
} catch { /* Missing or invalid outcomes cannot satisfy acceptance. */ }
|
||||
}
|
||||
for (const id of new Set(result?.outcomeRefs?.evidenceIds || [])) {
|
||||
try {
|
||||
const record = this.v4EvidenceRecord(id)
|
||||
const recorder = record.recordedBy || {}
|
||||
const authorized = recorder.kind === 'system' ? recorder.actorId === 'meldwork-main'
|
||||
: recorder.kind === 'agent' && recorder.runId === controller?.runId
|
||||
&& recorder.agentRunId === result?.message?.trace?.agentRunId
|
||||
&& recorder.agentKind === node.agentKind
|
||||
const bound = [...artifacts].some(([artifactId, identity]) => (
|
||||
(record.subject?.type === 'artifact' && record.subject.artifactId === artifactId
|
||||
|| record.refs.some(reference => reference.type === 'artifact' && reference.artifactId === artifactId))
|
||||
&& record.refs.some(reference => reference.type === 'blob'
|
||||
&& reference.contentHash === identity.contentHash
|
||||
&& canonicalJson(reference.contentRef) === canonicalJson(identity.artifact.contentRef))
|
||||
))
|
||||
if (authorized && bound) {
|
||||
evidence.add(id)
|
||||
}
|
||||
} catch { /* Missing or invalid evidence cannot satisfy acceptance. */ }
|
||||
}
|
||||
return { artifactIds: [...artifacts.keys()], evidenceIds: [...evidence] }
|
||||
},
|
||||
|
||||
taskGraphResultAccepted(node, result, controller, verifiedRefs = null) {
|
||||
const conclusion = publicCollaborationText(result?.message?.content || '')
|
||||
const artifactIds = result?.outcomeRefs?.artifactIds || []
|
||||
const evidenceIds = result?.outcomeRefs?.evidenceIds || []
|
||||
return (!node.acceptance.requireConclusion || Boolean(conclusion))
|
||||
&& artifactIds.length >= node.acceptance.minArtifactRefs
|
||||
&& evidenceIds.length >= node.acceptance.minEvidenceRefs
|
||||
const refs = verifiedRefs || this.taskGraphVerifiedOutcomeRefs(node, result, controller)
|
||||
return result?.message?.trace?.status === 'completed'
|
||||
&& !explicitTaskFailure(result?.message?.content)
|
||||
&& (!['reviewer', 'arbiter'].includes(node.role) || !explicitReviewRejection(result?.message?.content))
|
||||
&& (!node.acceptance.requireConclusion
|
||||
|| (Boolean(conclusion) && result.message.trace.truncated !== true))
|
||||
&& refs.artifactIds.length >= node.acceptance.minArtifactRefs
|
||||
&& refs.evidenceIds.length >= node.acceptance.minEvidenceRefs
|
||||
},
|
||||
|
||||
applyTaskGraphAgentResult(group, controller, node, result) {
|
||||
const conflicts = this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration)
|
||||
const entryIds = this.recordCollaborationResult(
|
||||
const verifiedRefs = this.taskGraphVerifiedOutcomeRefs(node, result, controller)
|
||||
const verifiedResult = { ...result, outcomeRefs: verifiedRefs }
|
||||
const conclusion = publicCollaborationText(result?.message?.content || '')
|
||||
let accepted = this.taskGraphResultAccepted(node, result, controller, verifiedRefs)
|
||||
if (node.role === 'arbiter' && conflicts.length && !conclusion) accepted = false
|
||||
const acceptedDecision = node.role === 'arbiter' && accepted
|
||||
const subject = node.role === 'primary'
|
||||
? (node.claimKey ? `task:${controller.taskId}:claim:${node.claimKey}` : `task:${controller.taskId}:conclusion`)
|
||||
: `task:${controller.taskId}:node:${node.nodeId}`
|
||||
const agentRunId = result?.message?.trace?.agentRunId
|
||||
const previous = agentRunId ? controller.orchestration.collaboration.entries.filter(entry => (
|
||||
entry.provenance.runId === controller.runId && entry.provenance.agentRunId === agentRunId
|
||||
)) : []
|
||||
if (previous.length) {
|
||||
const primary = previous.filter(entry => ['claim', 'decision'].includes(entry.entryType))
|
||||
const artifactEntries = previous.filter(entry => entry.entryType === 'artifact-ref')
|
||||
const evidenceEntries = previous.filter(entry => entry.entryType === 'evidence-ref')
|
||||
const conflictEntries = previous.filter(entry => entry.entryType === 'conflict')
|
||||
const matchesRefs = (entries, ids) => (entries.length === ids.length || (entries.length === 1 && ids.length > 1))
|
||||
&& entries.every((entry, index) => entry.refs.length === 1 && entry.refs[0] === ids[index])
|
||||
const validConflicts = conflictEntries.every(entry => {
|
||||
const claim = primary[0]
|
||||
const otherId = entry.refs.find(id => id !== claim?.entryId)
|
||||
const other = controller.orchestration.collaboration.entries.find(candidate => candidate.entryId === otherId)
|
||||
return primary.length === 1 && claim.entryType === 'claim' && entry.owner.type === 'harness'
|
||||
&& entry.lifecycle.state === 'active' && entry.subject === subject && entry.refs.length === 2
|
||||
&& entry.refs.includes(claim.entryId) && other?.entryType === 'claim'
|
||||
&& other.subject === subject && other.value !== claim.value
|
||||
&& other.owner.type === 'agent' && other.owner.agentKind !== node.agentKind
|
||||
&& entry.provenance.taskId === controller.taskId
|
||||
&& canonicalJson(entry.provenance.artifactIds) === canonicalJson(verifiedRefs.artifactIds)
|
||||
&& canonicalJson(entry.provenance.evidenceIds) === canonicalJson(verifiedRefs.evidenceIds)
|
||||
})
|
||||
const validReferences = previous.length === primary.length + artifactEntries.length + evidenceEntries.length + conflictEntries.length
|
||||
&& validConflicts
|
||||
&& matchesRefs(artifactEntries, verifiedRefs.artifactIds)
|
||||
&& matchesRefs(evidenceEntries, verifiedRefs.evidenceIds)
|
||||
&& [...artifactEntries, ...evidenceEntries].every(entry => entry.lifecycle.state === 'active'
|
||||
&& entry.owner.type === 'agent' && entry.owner.agentKind === node.agentKind && entry.owner.role === node.role
|
||||
&& entry.provenance.taskId === controller.taskId
|
||||
&& canonicalJson(entry.provenance.artifactIds) === canonicalJson(verifiedRefs.artifactIds)
|
||||
&& canonicalJson(entry.provenance.evidenceIds) === canonicalJson(verifiedRefs.evidenceIds)
|
||||
&& entry.subject === `task:${controller.taskId}:${entry.entryType === 'artifact-ref' ? 'artifact' : 'evidence'}`)
|
||||
const artifactOnly = !conclusion && !node.acceptance.requireConclusion && primary.length === 0
|
||||
if (!validReferences || (!artifactOnly && (primary.length !== 1 || primary[0].entryType !== (acceptedDecision ? 'decision' : 'claim')
|
||||
|| primary[0].subject !== subject || primary[0].statement !== conclusion
|
||||
|| primary[0].owner.type !== 'agent' || primary[0].owner.agentKind !== node.agentKind
|
||||
|| primary[0].provenance.taskId !== controller.taskId
|
||||
|| canonicalJson(primary[0].provenance.artifactIds) !== canonicalJson(verifiedRefs.artifactIds)
|
||||
|| canonicalJson(primary[0].provenance.evidenceIds) !== canonicalJson(verifiedRefs.evidenceIds)))) {
|
||||
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
}
|
||||
if (artifactEntries.length < verifiedRefs.artifactIds.length
|
||||
|| evidenceEntries.length < verifiedRefs.evidenceIds.length) {
|
||||
let collaboration = controller.orchestration.collaboration
|
||||
for (const [entries, ids] of [[artifactEntries, verifiedRefs.artifactIds], [evidenceEntries, verifiedRefs.evidenceIds]]) {
|
||||
for (const id of ids.slice(entries.length)) {
|
||||
const { entryId, version, recordType, ...template } = entries[0]
|
||||
collaboration = appendBlackboardEntry(collaboration, {
|
||||
...template,
|
||||
refs: [id],
|
||||
lifecycle: { ...template.lifecycle, sequence: (collaboration.entries.at(-1)?.lifecycle.sequence || 0) + 1,
|
||||
recordedAt: Date.now(), supersedesEntryId: null },
|
||||
})
|
||||
previous.push(collaboration.entries.at(-1))
|
||||
}
|
||||
}
|
||||
this.checkpointOrchestration(group, controller, { collaboration })
|
||||
}
|
||||
}
|
||||
const entryIds = previous.length ? previous.map(entry => entry.entryId) : this.recordCollaborationResult(
|
||||
group,
|
||||
controller,
|
||||
controller.orchestration.activeKinds,
|
||||
node.agentKind,
|
||||
result,
|
||||
verifiedResult,
|
||||
{
|
||||
role: node.role,
|
||||
entryType: node.role === 'arbiter' ? 'decision' : 'claim',
|
||||
subject: node.role === 'primary'
|
||||
? `task:${controller.taskId}:conclusion`
|
||||
: `task:${controller.taskId}:node:${node.nodeId}`,
|
||||
refs: node.role === 'arbiter' ? conflicts.map(entry => entry.entryId) : [],
|
||||
audience: {
|
||||
...this.collaborationAudience(node.role),
|
||||
agentKinds: [...new Set(controller.orchestration.taskGraph.graph.nodes
|
||||
.filter(candidate => candidate.inputNodeIds.includes(node.nodeId) && candidate.agentKind)
|
||||
.map(candidate => candidate.agentKind))],
|
||||
},
|
||||
deduplicateByAgentRun: true,
|
||||
allOutcomeRefs: true,
|
||||
entryType: acceptedDecision ? 'decision' : 'claim',
|
||||
subject,
|
||||
refs: acceptedDecision ? conflicts.map(entry => entry.entryId) : [],
|
||||
},
|
||||
)
|
||||
const accepted = this.taskGraphResultAccepted(node, result)
|
||||
const conclusion = publicCollaborationText(result?.message?.content || '')
|
||||
const artifactIds = result?.outcomeRefs?.artifactIds || []
|
||||
const evidenceIds = result?.outcomeRefs?.evidenceIds || []
|
||||
if (accepted && node.terminal && node.role !== 'arbiter'
|
||||
&& this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration).length) {
|
||||
accepted = false
|
||||
}
|
||||
const { artifactIds, evidenceIds } = verifiedRefs
|
||||
let cursor = updateTaskGraphCursor(
|
||||
controller.orchestration.taskGraph,
|
||||
node.nodeId,
|
||||
@@ -163,17 +425,72 @@ const taskGraphMethods = {
|
||||
.find(Boolean) || controller.targetKinds[0]
|
||||
},
|
||||
|
||||
taskGraphHumanResolvableConflicts(controller, node) {
|
||||
const cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
|
||||
const selected = new Set(cursor.nodeStates.filter(state => node.inputNodeIds.includes(state.nodeId))
|
||||
.flatMap(state => state.entryIds))
|
||||
return (controller.orchestration.collaboration?.entries || []).filter(entry => (
|
||||
entry.entryType === 'conflict' && entry.lifecycle.state === 'active'
|
||||
&& entry.refs.every(id => selected.has(id))
|
||||
))
|
||||
},
|
||||
|
||||
taskGraphHumanContextFingerprint(controller, node) {
|
||||
const cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
|
||||
const selected = new Set(cursor.nodeStates.filter(state => node.inputNodeIds.includes(state.nodeId))
|
||||
.flatMap(state => state.entryIds))
|
||||
const entries = controller.orchestration.collaboration?.entries || []
|
||||
return createHash('sha256').update(canonicalJson({
|
||||
graphId: cursor.graph.graphId,
|
||||
nodeId: node.nodeId,
|
||||
inputNodeIds: node.inputNodeIds,
|
||||
entries: entries.filter(entry => selected.has(entry.entryId)),
|
||||
conflicts: this.taskGraphHumanResolvableConflicts(controller, node),
|
||||
})).digest('hex')
|
||||
},
|
||||
|
||||
validateTaskGraphHumanDecisionContext(controller, node, gateId, decision) {
|
||||
if (decision.status !== 'approved' || !this.getHumanGateRequest) return
|
||||
const request = this.getHumanGateRequest(gateId)
|
||||
// Pending gates created by older versions did not contain a fingerprint.
|
||||
if (request.contextFingerprint === undefined) return
|
||||
if (request.contextFingerprint !== this.taskGraphHumanContextFingerprint(controller, node)) {
|
||||
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
}
|
||||
},
|
||||
|
||||
applyTaskGraphHumanDecision(group, controller, node, decision) {
|
||||
const option = node.decisionOptions.find(candidate => candidate.optionId === decision.optionId)
|
||||
if (!option) throw new Error('LOCAL_WORKFLOW_DECISION_INVALID')
|
||||
const accepted = decision.status === 'approved'
|
||||
const conflicts = this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration)
|
||||
if (decision.status !== (option.kind === 'allow_once' ? 'approved' : 'rejected')) {
|
||||
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
}
|
||||
const resolvable = this.taskGraphHumanResolvableConflicts(controller, node)
|
||||
const resolvableIds = new Set(resolvable.map(entry => entry.entryId))
|
||||
const unresolved = this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration)
|
||||
const conflicts = unresolved.filter(entry => resolvableIds.has(entry.entryId))
|
||||
if (accepted && node.terminal && unresolved.some(entry => !resolvableIds.has(entry.entryId))) {
|
||||
throw new Error('LOCAL_WORKFLOW_INPUT_UNAVAILABLE')
|
||||
}
|
||||
let collaboration = controller.orchestration.collaboration
|
||||
collaboration = appendBlackboardEntry(collaboration, {
|
||||
const agentRunId = this.taskGraphDecisionAgentRunId(controller.orchestration.taskGraph.graph.graphId, node.nodeId)
|
||||
const subject = `task:${controller.taskId}:node:${node.nodeId}`
|
||||
const existing = collaboration.entries.filter(entry => entry.entryType === 'decision'
|
||||
&& entry.provenance.agentRunId === agentRunId)
|
||||
const prior = existing[0]
|
||||
if (existing.length > 1 || (prior && (prior.provenance.runId !== controller.runId
|
||||
|| prior.provenance.taskId !== controller.taskId || prior.subject !== subject || prior.owner.type !== 'harness'
|
||||
|| prior.lifecycle.state !== 'active' || prior.statement !== `Human selected: ${option.name}`
|
||||
|| (prior.value !== option.optionId && !(prior.value === ''
|
||||
&& node.decisionOptions.filter(candidate => candidate.name === option.name).length === 1))
|
||||
|| (!accepted && prior.refs.length)
|
||||
|| prior.refs.some(id => !resolvableIds.has(id))))) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
|
||||
if (!prior) collaboration = appendBlackboardEntry(collaboration, {
|
||||
entryType: 'decision',
|
||||
subject: `task:${controller.taskId}:node:${node.nodeId}`,
|
||||
subject,
|
||||
statement: `Human selected: ${option.name}`,
|
||||
value: '',
|
||||
value: option.optionId,
|
||||
owner: { type: 'harness' },
|
||||
audience: { roles: ['primary', 'reviewer', 'arbiter'], agentKinds: [] },
|
||||
lifecycle: {
|
||||
@@ -186,17 +503,14 @@ const taskGraphMethods = {
|
||||
runId: controller.runId,
|
||||
taskId: controller.taskId,
|
||||
round: controller.currentRound,
|
||||
agentRunId: this.taskGraphDecisionAgentRunId(
|
||||
controller.orchestration.taskGraph.graph.graphId,
|
||||
node.nodeId,
|
||||
),
|
||||
agentRunId,
|
||||
artifactIds: [],
|
||||
evidenceIds: [],
|
||||
},
|
||||
refs: conflicts.map(entry => entry.entryId),
|
||||
refs: accepted ? conflicts.map(entry => entry.entryId) : [],
|
||||
})
|
||||
const decisionEntryId = collaboration.entries.at(-1).entryId
|
||||
this.checkpointOrchestration(group, controller, { collaboration })
|
||||
const decisionEntryId = prior?.entryId || collaboration.entries.at(-1).entryId
|
||||
if (!prior) this.checkpointOrchestration(group, controller, { collaboration })
|
||||
let cursor = updateTaskGraphCursor(
|
||||
controller.orchestration.taskGraph,
|
||||
node.nodeId,
|
||||
@@ -227,7 +541,14 @@ const taskGraphMethods = {
|
||||
acceptanceCriteria: this.taskGraphAcceptanceCriteria(node),
|
||||
},
|
||||
)
|
||||
if (collaborationPackage.entries.length !== selectedEntryIds.length) {
|
||||
throw new Error('LOCAL_WORKFLOW_INPUT_UNAVAILABLE')
|
||||
}
|
||||
const isolated = ['reviewer', 'arbiter'].includes(node.role)
|
||||
if (isolated) {
|
||||
const outcomes = this.taskGraphReviewOutcomeText(collaborationPackage.entries)
|
||||
if (outcomes) collaborationPackage.text += `\n${outcomes}`
|
||||
}
|
||||
const attachments = node.role === 'primary'
|
||||
&& !controller.orchestration.attachmentRecipients.includes(node.agentKind)
|
||||
? context.rootAttachments.map(attachment => attachment.path)
|
||||
@@ -249,6 +570,7 @@ const taskGraphMethods = {
|
||||
runtimeInstruction: isolated ? '' : [
|
||||
`Execute task-graph node ${node.nodeId} as ${node.role}.`,
|
||||
`Expected output: ${node.expectedOutput}`,
|
||||
'Meldwork records your completed response as an Artifact and associated Evidence. Do not search for, invent, or output internal Artifact or Evidence IDs.',
|
||||
'Do not emit a consensus marker.',
|
||||
].join('\n'),
|
||||
...(isolated ? {
|
||||
@@ -269,11 +591,29 @@ const taskGraphMethods = {
|
||||
|
||||
async executeTaskGraphHumanNode(group, controller, node) {
|
||||
if (!this.requestHumanGate) throw new Error('LOCAL_WORKFLOW_DECISION_UNAVAILABLE')
|
||||
if (node.terminal) {
|
||||
const resolvable = new Set(this.taskGraphHumanResolvableConflicts(controller, node).map(entry => entry.entryId))
|
||||
if (this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration).some(entry => !resolvable.has(entry.entryId))) {
|
||||
throw new Error('LOCAL_WORKFLOW_INPUT_UNAVAILABLE')
|
||||
}
|
||||
}
|
||||
const anchorKind = this.taskGraphDecisionAnchor(controller, node)
|
||||
const agentRunId = this.taskGraphDecisionAgentRunId(
|
||||
controller.orchestration.taskGraph.graph.graphId,
|
||||
node.nodeId,
|
||||
)
|
||||
const graph = parseTaskGraphCursor(controller.orchestration.taskGraph)
|
||||
const selected = new Set(graph.nodeStates.filter(state => node.inputNodeIds.includes(state.nodeId))
|
||||
.flatMap(state => state.entryIds))
|
||||
const decisionContext = controller.orchestration.collaboration.entries.filter(entry => (
|
||||
selected.has(entry.entryId) && entry.entryType === 'claim' && entry.lifecycle.state === 'active'
|
||||
&& entry.owner.type === 'agent'
|
||||
)).map(entry => {
|
||||
const full = publicCollaborationText(entry.statement)
|
||||
let text = full.slice(0, 2000).trim()
|
||||
if (/[\uD800-\uDBFF]$/.test(text)) text = text.slice(0, -1).trimEnd()
|
||||
return { agentKind: entry.owner.agentKind, text, truncated: text.length < full.length }
|
||||
})
|
||||
let cursor = updateTaskGraphCursor(
|
||||
controller.orchestration.taskGraph,
|
||||
node.nodeId,
|
||||
@@ -291,6 +631,8 @@ const taskGraphMethods = {
|
||||
graphId: cursor.graph.graphId,
|
||||
nodeId: node.nodeId,
|
||||
inputNodeIds: node.inputNodeIds,
|
||||
decisionContext,
|
||||
contextFingerprint: this.taskGraphHumanContextFingerprint(controller, node),
|
||||
},
|
||||
}, {
|
||||
signal: controller.signal,
|
||||
@@ -302,6 +644,7 @@ const taskGraphMethods = {
|
||||
round: controller.currentRound,
|
||||
},
|
||||
})
|
||||
this.validateTaskGraphHumanDecisionContext(controller, node, gate.gateId, gate)
|
||||
const accepted = this.applyTaskGraphHumanDecision(group, controller, node, gate)
|
||||
if (this.completeHumanGateContinuation?.(
|
||||
controller.runId,
|
||||
@@ -314,22 +657,30 @@ const taskGraphMethods = {
|
||||
},
|
||||
|
||||
taskGraphBatch(group, readyNodes) {
|
||||
// A terminal node must not finish the graph while other ready work can
|
||||
// still fail; independent nonterminal nodes may continue in parallel.
|
||||
const nonterminal = readyNodes.filter(node => !node.terminal)
|
||||
const candidates = nonterminal.length ? nonterminal : readyNodes
|
||||
const parallel = []
|
||||
const kinds = new Set()
|
||||
if (group.allowWrite !== true) {
|
||||
for (const node of readyNodes) {
|
||||
for (const node of candidates) {
|
||||
if (!node.parallel || node.role === 'human' || kinds.has(node.agentKind)) continue
|
||||
parallel.push(node)
|
||||
kinds.add(node.agentKind)
|
||||
}
|
||||
}
|
||||
return parallel.length > 1 ? parallel : readyNodes.slice(0, 1)
|
||||
return parallel.length > 1 ? parallel : candidates.slice(0, 1)
|
||||
},
|
||||
|
||||
async runTaskGraph(group, controller, threadRootId, context) {
|
||||
while (!controller.signal.aborted) {
|
||||
let cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
|
||||
this.restoreTaskGraphOutcomeReferences(group, controller)
|
||||
let cursor = assertTaskGraphDependencies(controller.orchestration.taskGraph)
|
||||
const terminalState = terminalTaskGraphState(cursor)
|
||||
if (cursor.terminalState !== terminalState) {
|
||||
cursor = this.checkpointTaskGraph(group, controller, cursor)
|
||||
}
|
||||
if (terminalState !== 'running') {
|
||||
cursor = this.checkpointTaskGraph(group, controller, cursor)
|
||||
break
|
||||
@@ -341,6 +692,11 @@ const taskGraphMethods = {
|
||||
this.checkpointTaskGraph(group, controller, cursor)
|
||||
break
|
||||
}
|
||||
if (readyNodes.every(node => node.terminal)
|
||||
&& cursor.graph.nodes.some(node => !node.terminal
|
||||
&& cursor.nodeStates.find(state => state.nodeId === node.nodeId)?.status !== 'accepted')) {
|
||||
throw new Error('LOCAL_WORKFLOW_INVALID')
|
||||
}
|
||||
const batch = this.taskGraphBatch(group, readyNodes)
|
||||
controller.currentRound += 1
|
||||
if (batch.length === 1 && batch[0].role === 'human') {
|
||||
@@ -360,45 +716,46 @@ const taskGraphMethods = {
|
||||
const currentKind = batch.length === 1 ? batch[0].agentKind : ''
|
||||
cursor = this.checkpointTaskGraph(group, controller, cursor, { currentKind })
|
||||
this.emitChanged()
|
||||
const settled = await Promise.allSettled(batch.map(node => (
|
||||
this.executeTaskGraphAgentNode(
|
||||
group, controller, threadRootId, context, node, batch.length > 1,
|
||||
)
|
||||
)))
|
||||
for (let index = 0; index < batch.length; index += 1) {
|
||||
const node = batch[index]
|
||||
const outcome = settled[index]
|
||||
if (outcome.status === 'fulfilled' && outcome.value?.result) {
|
||||
if (outcome.value.result.outcomeRefs?.artifactIds?.length
|
||||
&& !controller.orchestration.attachmentRecipients.includes(node.agentKind)) {
|
||||
this.checkpointOrchestration(group, controller, {
|
||||
attachmentRecipients: [...controller.orchestration.attachmentRecipients, node.agentKind],
|
||||
})
|
||||
const settled = await Promise.allSettled(batch.map(async node => {
|
||||
let outcome
|
||||
try {
|
||||
outcome = await this.executeTaskGraphAgentNode(
|
||||
group, controller, threadRootId, context, node, batch.length > 1,
|
||||
)
|
||||
} catch (error) { outcome = { error } }
|
||||
try {
|
||||
if (outcome?.result) {
|
||||
if (outcome.result.outcomeRefs?.artifactIds?.length
|
||||
&& !controller.orchestration.attachmentRecipients.includes(node.agentKind)) {
|
||||
this.checkpointOrchestration(group, controller, {
|
||||
attachmentRecipients: [...controller.orchestration.attachmentRecipients, node.agentKind],
|
||||
})
|
||||
}
|
||||
this.applyTaskGraphAgentResult(group, controller, node, outcome.result)
|
||||
this.emitChanged()
|
||||
} else if (!controller.signal.aborted) {
|
||||
const error = outcome?.error || new Error('LOCAL_AGENT_UNKNOWN_FAILURE')
|
||||
this.recordAgentFailure(group.id, node.agentKind, error, threadRootId, new Set())
|
||||
const failed = updateTaskGraphCursor(controller.orchestration.taskGraph, node.nodeId, { status: 'failed' })
|
||||
this.checkpointTaskGraph(group, controller, failed)
|
||||
if (!controller.failedKinds.includes(node.agentKind)) controller.failedKinds.push(node.agentKind)
|
||||
if (circuitBreakerFailure(error)) {
|
||||
controller.stopReason = 'circuit_breaker'
|
||||
controller.abort()
|
||||
} else if (hardBudgetFailure(error)) {
|
||||
controller.stopReason = 'hard_budget'
|
||||
controller.abort()
|
||||
}
|
||||
this.emitChanged()
|
||||
}
|
||||
this.applyTaskGraphAgentResult(group, controller, node, outcome.value.result)
|
||||
continue
|
||||
}
|
||||
const error = outcome.status === 'rejected'
|
||||
? outcome.reason
|
||||
: (outcome.value?.error || new Error('LOCAL_AGENT_UNKNOWN_FAILURE'))
|
||||
if (controller.signal.aborted) continue
|
||||
this.recordAgentFailure(group.id, node.agentKind, error, threadRootId, new Set())
|
||||
cursor = updateTaskGraphCursor(
|
||||
controller.orchestration.taskGraph,
|
||||
node.nodeId,
|
||||
{ status: 'failed' },
|
||||
)
|
||||
this.checkpointTaskGraph(group, controller, cursor)
|
||||
if (!controller.failedKinds.includes(node.agentKind)) {
|
||||
controller.failedKinds.push(node.agentKind)
|
||||
}
|
||||
if (circuitBreakerFailure(error)) {
|
||||
controller.stopReason = 'circuit_breaker'
|
||||
controller.abort()
|
||||
} else if (hardBudgetFailure(error)) {
|
||||
controller.stopReason = 'hard_budget'
|
||||
controller.abort()
|
||||
} catch (error) {
|
||||
return { checkpointError: error }
|
||||
}
|
||||
return outcome
|
||||
}))
|
||||
for (const outcome of settled) {
|
||||
if (outcome.status === 'fulfilled' && outcome.value?.checkpointError) throw outcome.value.checkpointError
|
||||
if (outcome.status === 'rejected') throw outcome.reason
|
||||
}
|
||||
controller.currentKind = ''
|
||||
controller.progress = []
|
||||
|
||||
@@ -57,8 +57,13 @@ const v4DeliveryMethods = {
|
||||
for (const record of receiptRecords) {
|
||||
const receipt = record?.receipt
|
||||
if (receipt?.phase !== 'work' || !dependencies.has(receipt.workItemId)) continue
|
||||
const source = coordinationPlan.assignments.find(item => item.taskId === receipt.workItemId)
|
||||
if (!source || source.ownerKind !== receipt.agentKind) throw new Error('LOCAL_RUN_V4_REFERENCE_INVALID')
|
||||
latestDependencies.set(receipt.workItemId, record)
|
||||
}
|
||||
if ([...dependencies].some(id => !latestDependencies.has(id))) {
|
||||
throw new Error('LOCAL_RUN_V4_REFERENCE_INVALID')
|
||||
}
|
||||
const dependencyArtifacts = [...latestDependencies.values()]
|
||||
.sort((left, right) => String(left.receipt.workItemId)
|
||||
.localeCompare(String(right.receipt.workItemId)))
|
||||
@@ -103,6 +108,8 @@ const v4DeliveryMethods = {
|
||||
`Input refs: ${assignment.inputRefs.length ? assignment.inputRefs.join(', ') : '(none)'}`,
|
||||
`Artifact refs: ${assignment.artifactIds.length ? assignment.artifactIds.join(', ') : '(none)'}`,
|
||||
`Dependencies: ${assignment.dependsOn.length ? assignment.dependsOn.join(', ') : '(none)'}`,
|
||||
...(assignment.acceptance ? [`Required Artifact refs: ${assignment.acceptance.minArtifactRefs}; Evidence refs: ${assignment.acceptance.minEvidenceRefs}`] : []),
|
||||
...(assignment.acceptance?.requiredEvidenceKinds?.length ? [`Required Evidence kinds: ${assignment.acceptance.requiredEvidenceKinds.join(', ')}`] : []),
|
||||
]
|
||||
if (inputReferences.length) {
|
||||
lines.push('MELDWORK_V4_FROZEN_INPUT_REFS_V1')
|
||||
|
||||
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user