Compare commits
591
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8802dcf4dc | ||
|
|
28bb377e66 | ||
|
|
a0fe8d70c2 | ||
|
|
13d0ae66a6 | ||
|
|
4770e5a8e6 | ||
|
|
0bcc2f27c6 | ||
|
|
f3b0fcdc4c | ||
|
|
f10d552fd7 | ||
|
|
8b8756b00e | ||
|
|
ec59cf8065 | ||
|
|
2ffddbd5a0 | ||
|
|
0b3abfad4b | ||
|
|
1d3c56f9fa | ||
|
|
502317db68 | ||
|
|
66516755e6 | ||
|
|
6e3f528f48 | ||
|
|
d70e6b85b6 | ||
|
|
5e71a4ea52 | ||
|
|
0793238d47 | ||
|
|
c8f83533fb | ||
|
|
08e80bcb89 | ||
|
|
39d9a65616 | ||
|
|
a325ca80d8 | ||
|
|
75f08da197 | ||
|
|
b246b7d83b | ||
|
|
f4cb8aa282 | ||
|
|
c15480c452 | ||
|
|
c0b013afa9 | ||
|
|
34d33e0492 | ||
|
|
be15dd05df | ||
|
|
3578e4019b | ||
|
|
ede8e3c555 | ||
|
|
7a1b85ab62 | ||
|
|
490818dfe9 | ||
|
|
1ab8f113a5 | ||
|
|
4f4ea43549 | ||
|
|
4fd67c52dc | ||
|
|
a3b06befbc | ||
|
|
290f39ecb8 | ||
|
|
7fbe7593c8 | ||
|
|
9fb61f8e99 | ||
|
|
2287abe644 | ||
|
|
b3991d473e | ||
|
|
32bd2118af | ||
|
|
f290a2101e | ||
|
|
c8490da527 | ||
|
|
f26806bc55 | ||
|
|
c53e1f465d | ||
|
|
176a556355 | ||
|
|
8609963ef8 | ||
|
|
e625da4c27 | ||
|
|
c68603fea0 | ||
|
|
f57d9a51f4 | ||
|
|
f118a369b0 | ||
|
|
9dc7f64b87 | ||
|
|
b334794168 | ||
|
|
5aaf22782c | ||
|
|
089c5491ec | ||
|
|
7186a69b78 | ||
|
|
9c202c7eae | ||
|
|
2969fb3c21 | ||
|
|
149a2481f4 | ||
|
|
4da2f382ec | ||
|
|
a3a96a6bd4 | ||
|
|
548809f72e | ||
|
|
7568d05434 | ||
|
|
6aaa15be3c | ||
|
|
ac8e41aa5f | ||
|
|
57bc1bcea8 | ||
|
|
f1c5a887b6 | ||
|
|
7c76e4fc03 | ||
|
|
606f712a52 | ||
|
|
dac4f6c029 | ||
|
|
b7a6abb780 | ||
|
|
7dab8df861 | ||
|
|
288212748c | ||
|
|
ef2c3ac163 | ||
|
|
b057c89a70 | ||
|
|
6ddfa59a28 | ||
|
|
721a40b05e | ||
|
|
d04511b8a6 | ||
|
|
f913c95ed1 | ||
|
|
effde76227 | ||
|
|
43f0813acd | ||
|
|
33d8cd7e36 | ||
|
|
cfbe5b9b0d | ||
|
|
6e85983ad4 | ||
|
|
edbb175d4e | ||
|
|
b7bc0acb14 | ||
|
|
48e5d603bc | ||
|
|
12ff9d6138 | ||
|
|
ea18feb0a8 | ||
|
|
c5e3c2ec56 | ||
|
|
92c80f81f9 | ||
|
|
70ed89c6bf | ||
|
|
07b14aa72a | ||
|
|
d245ea4c84 | ||
|
|
9e3a5d6fbd | ||
|
|
33623d09d9 | ||
|
|
b20055a0b5 | ||
|
|
caf81b7984 | ||
|
|
fc05976d33 | ||
|
|
021da02474 | ||
|
|
a5b9e5a13f | ||
|
|
5742239c74 | ||
|
|
3dce49020e | ||
|
|
4ec2635830 | ||
|
|
f319906f29 | ||
|
|
fac92c252e | ||
|
|
9f8c525008 | ||
|
|
207d4dd7e5 | ||
|
|
8db297fe4b | ||
|
|
dc2aec7696 | ||
|
|
9a6b7d4d41 | ||
|
|
404af112b7 | ||
|
|
5947016cc1 | ||
|
|
5425d1565f | ||
|
|
386426bb92 | ||
|
|
1a58e3c3e6 | ||
|
|
3cea671a54 | ||
|
|
9d8b338833 | ||
|
|
ea92e0212b | ||
|
|
f06ea4d9e2 | ||
|
|
a82d945f54 | ||
|
|
6846326445 | ||
|
|
54c2054a5c | ||
|
|
e5bf332b05 | ||
|
|
9ed55978d9 | ||
|
|
7ffbbc4a51 | ||
|
|
2db73a8185 | ||
|
|
a62332be93 | ||
|
|
1083093cbc | ||
|
|
c6ebe307cd | ||
|
|
3ee66d4373 | ||
|
|
a0be395ccc | ||
|
|
93dbd768f7 | ||
|
|
c1a549cd64 | ||
|
|
5a414999ef | ||
|
|
7aa8240629 | ||
|
|
37b9a1dc31 | ||
|
|
2ab8748c4d | ||
|
|
f041275811 | ||
|
|
f486105836 | ||
|
|
e14de2b4c2 | ||
|
|
fe2f3ca92f | ||
|
|
8e4519cacd | ||
|
|
857279e076 | ||
|
|
16abb481e8 | ||
|
|
7ad82bbf0a | ||
|
|
104eb715c4 | ||
|
|
97ea887ea5 | ||
|
|
03838a3430 | ||
|
|
bfeb9f6af0 | ||
|
|
e26f278112 | ||
|
|
0d34150333 | ||
|
|
e6b5938bd8 | ||
|
|
4f95373420 | ||
|
|
afb90009f6 | ||
|
|
9e8b58cbb6 | ||
|
|
6411d26a95 | ||
|
|
31117d1b89 | ||
|
|
e3553fe7a5 | ||
|
|
fe724e96e8 | ||
|
|
067aff179f | ||
|
|
353454abb2 | ||
|
|
96b9cbce02 | ||
|
|
36877d00dc | ||
|
|
a9a97c2746 | ||
|
|
f72979f4a9 | ||
|
|
55d94d3b58 | ||
|
|
bfd0976b31 | ||
|
|
4a33e7e893 | ||
|
|
ee74765383 | ||
|
|
35239259fb | ||
|
|
85bf2dfc8a | ||
|
|
c4f2ab631b | ||
|
|
308e71c783 | ||
|
|
ecce09b355 | ||
|
|
b7aa6bddf5 | ||
|
|
64ad5f22b0 | ||
|
|
c68207ad4b | ||
|
|
4f57967c56 | ||
|
|
1d02ff805d | ||
|
|
4d843cf7a4 | ||
|
|
8560830d3e | ||
|
|
9fbd8addbe | ||
|
|
92195a58a3 | ||
|
|
fb9ff7de73 | ||
|
|
b49bc0ed14 | ||
|
|
5ee80cdb97 | ||
|
|
bf2c0653ed | ||
|
|
e4daddf9cf | ||
|
|
e92309f7c4 | ||
|
|
7a58b0d19a | ||
|
|
78e6f11d72 | ||
|
|
9a8a41a318 | ||
|
|
af8e6a9ccc | ||
|
|
547020f47e | ||
|
|
d5eee82816 | ||
|
|
0d8763b917 | ||
|
|
600404abd0 | ||
|
|
247926d0fa | ||
|
|
9ef2a4e652 | ||
|
|
d4daf9525c | ||
|
|
63a89e68fa | ||
|
|
29b73a7d5e | ||
|
|
3488e11129 | ||
|
|
596bdce3a1 | ||
|
|
0b012788c7 | ||
|
|
58eea98f6c | ||
|
|
e742a6c269 | ||
|
|
b53b84616e | ||
|
|
15a2fea0dc | ||
|
|
05868610f0 | ||
|
|
d8da9a2e9f | ||
|
|
1a6ae856ec | ||
|
|
22649e96ef | ||
|
|
9c6407ae74 | ||
|
|
f68a11f11d | ||
|
|
3f2fc2e5f0 | ||
|
|
abe5129306 | ||
|
|
ef27877628 | ||
|
|
b9b8cc2c77 | ||
|
|
7b7bd556e9 | ||
|
|
6a2e9dd10e | ||
|
|
d534ee242c | ||
|
|
e02fdbdc8f | ||
|
|
ce529c9337 | ||
|
|
42b5004bf8 | ||
|
|
6952b22f45 | ||
|
|
3aa06e32fa | ||
|
|
97fc783cc0 | ||
|
|
3a3cf00072 | ||
|
|
a18b1e5fe4 | ||
|
|
90473284b8 | ||
|
|
b17e030d1f | ||
|
|
03258ca045 | ||
|
|
afd8422580 | ||
|
|
07aa2c883a | ||
|
|
4b3e0e5046 | ||
|
|
5abef59c7c | ||
|
|
a6f69a06ce | ||
|
|
2016e7f6dc | ||
|
|
95986bbfc5 | ||
|
|
322077be89 | ||
|
|
a7a0a97115 | ||
|
|
cbaa8c9bf5 | ||
|
|
0f5ecb609b | ||
|
|
5094c63755 | ||
|
|
4a78e7c1d9 | ||
|
|
0c2a9cb2b3 | ||
|
|
c9d4783968 | ||
|
|
2116122c95 | ||
|
|
4c3450792a | ||
|
|
f55f9bc3a6 | ||
|
|
be001949e4 | ||
|
|
bcd91aca1f | ||
|
|
12e6632692 | ||
|
|
a5111f486b | ||
|
|
d0e6aba319 | ||
|
|
b0b18986b1 | ||
|
|
7a9348f9aa | ||
|
|
6c78db7467 | ||
|
|
b539e15e6d | ||
|
|
abecb0dee1 | ||
|
|
d17f50b9de | ||
|
|
c9426622f0 | ||
|
|
5b01f29f2f | ||
|
|
5efb6210b0 | ||
|
|
113e084a8d | ||
|
|
2734e3e1ce | ||
|
|
a76492e16e | ||
|
|
10417396f1 | ||
|
|
41a3724e9e | ||
|
|
a0cc9b4b51 | ||
|
|
97b6022017 | ||
|
|
45df573d0e | ||
|
|
608edfa309 | ||
|
|
e8ef510d3a | ||
|
|
8c6266f158 | ||
|
|
91f0fb7b11 | ||
|
|
410a63ea9a | ||
|
|
570d2e6756 | ||
|
|
c3ffb9c831 | ||
|
|
58c382efb7 | ||
|
|
3598586bc0 | ||
|
|
e6821176a4 | ||
|
|
504db23823 | ||
|
|
44857449d6 | ||
|
|
29f2f1c813 | ||
|
|
d21f18af04 | ||
|
|
dd604455cc | ||
|
|
26049a158a | ||
|
|
b066a14f0c | ||
|
|
95f9d168f1 | ||
|
|
6d58520f57 | ||
|
|
8984a1c454 | ||
|
|
91090a13b9 | ||
|
|
395712490d | ||
|
|
29c00341fa | ||
|
|
2eef6fdaa2 | ||
|
|
14a2175434 | ||
|
|
94d4b5dcc9 | ||
|
|
5cbf18713a | ||
|
|
78d94380e7 | ||
|
|
973671bdf1 | ||
|
|
4b555515cd | ||
|
|
ec83d8ff53 | ||
|
|
8cd2b0259d | ||
|
|
2d24f74980 | ||
|
|
90278ab2fc | ||
|
|
671a41437d | ||
|
|
1b06d0105a | ||
|
|
18fad57bbe | ||
|
|
658f1e8e34 | ||
|
|
a32608f964 | ||
|
|
4b8d94c24e | ||
|
|
c3ef04988b | ||
|
|
9f1b3e8254 | ||
|
|
76e5a8c4d9 | ||
|
|
1f2fbca4de | ||
|
|
c718b051c2 | ||
|
|
76d54d6df6 | ||
|
|
58a8dddf31 | ||
|
|
6a93f14e0a | ||
|
|
0dc6735da2 | ||
|
|
a36189d31e | ||
|
|
913b7cf9a9 | ||
|
|
e46c4d0d71 | ||
|
|
35f399e2cf | ||
|
|
d52d16dba4 | ||
|
|
6abffce4e5 | ||
|
|
c3a3b59ad2 | ||
|
|
5b0cd561ff | ||
|
|
6b3f2e29bd | ||
|
|
86b78e45d7 | ||
|
|
c2c260b3a8 | ||
|
|
9ff74c852a | ||
|
|
9be59ddfec | ||
|
|
8c00068364 | ||
|
|
a354144412 | ||
|
|
d77fa91c69 | ||
|
|
9eeb0681ff | ||
|
|
9ea527a7c4 | ||
|
|
d525648b45 | ||
|
|
f78f1b83e7 | ||
|
|
75bd518447 | ||
|
|
3a6fa9a00c | ||
|
|
dc43d0d6d4 | ||
|
|
bb69ed76df | ||
|
|
427d0cc1fc | ||
|
|
a26b16b30e | ||
|
|
e0c9b4910d | ||
|
|
1f6010f998 | ||
|
|
d9ba74aac0 | ||
|
|
c118a6a795 | ||
|
|
90070840f1 | ||
|
|
14818775c5 | ||
|
|
3d6c93196a | ||
|
|
dc762dc6e3 | ||
|
|
45a80185f6 | ||
|
|
a1dc997004 | ||
|
|
b525497da8 | ||
|
|
273a3ab5dd | ||
|
|
647bdb251c | ||
|
|
9c59206d2f | ||
|
|
9edc587e96 | ||
|
|
5065e4bfb6 | ||
|
|
db2caf6544 | ||
|
|
ea9e31a59f | ||
|
|
e58b85ea45 | ||
|
|
f3f1174407 | ||
|
|
e3fef0b6d4 | ||
|
|
54535bec11 | ||
|
|
d32bbe009d | ||
|
|
c7236a1844 | ||
|
|
0ea3d9810e | ||
|
|
ce6d5fb538 | ||
|
|
0a063e3ebd | ||
|
|
1e13413f79 | ||
|
|
43882bf959 | ||
|
|
e19c54f77e | ||
|
|
891dde7d03 | ||
|
|
fbc34509f8 | ||
|
|
def6ed4d2f | ||
|
|
7bf8ce79bd | ||
|
|
55c6a09bbc | ||
|
|
ad0cf639c4 | ||
|
|
2778bef5bd | ||
|
|
2f8e136dc0 | ||
|
|
fdbd11e0ea | ||
|
|
d07bf39586 | ||
|
|
eee41a9b45 | ||
|
|
896801634f | ||
|
|
a203572ee3 | ||
|
|
ed6e7e493c | ||
|
|
6c0ba91414 | ||
|
|
a55880ce82 | ||
|
|
ec4a730287 | ||
|
|
19a21b8f7e | ||
|
|
286376df93 | ||
|
|
fa00da3507 | ||
|
|
472d3d321b | ||
|
|
a7ac4a264e | ||
|
|
88cd453db6 | ||
|
|
0b68450709 | ||
|
|
346444ea38 | ||
|
|
54dc8fadb7 | ||
|
|
6fdf6e0678 | ||
|
|
b469bb127a | ||
|
|
c6e810e4d9 | ||
|
|
98d03455b1 | ||
|
|
fad41d4d99 | ||
|
|
b8deec9087 | ||
|
|
dbee2de1d5 | ||
|
|
1a9945f299 | ||
|
|
b92ac4db0f | ||
|
|
3e27af8e21 | ||
|
|
4d13905cb8 | ||
|
|
9a3796c401 | ||
|
|
6bf78f1783 | ||
|
|
5fed80fc0f | ||
|
|
bb68baf0a9 | ||
|
|
18c8e8390c | ||
|
|
657f9ee368 | ||
|
|
1727025f67 | ||
|
|
ae6d9aa16d | ||
|
|
357b0955b1 | ||
|
|
221e42b103 | ||
|
|
715f5346da | ||
|
|
8aee08268d | ||
|
|
6a4744073c | ||
|
|
bcc324cc8f | ||
|
|
f875b1bc87 | ||
|
|
a55bd9bff8 | ||
|
|
cf8dd167a4 | ||
|
|
1dabfa1dc6 | ||
|
|
d82e12d09e | ||
|
|
30f3273a17 | ||
|
|
3e362fb3d1 | ||
|
|
d40a22aeb0 | ||
|
|
516bd5d99c | ||
|
|
9818f7779a | ||
|
|
65a00b497b | ||
|
|
3388df1c63 | ||
|
|
0e856f5a6e | ||
|
|
b29a12abbf | ||
|
|
e08fb484a8 | ||
|
|
65bedd5f8c | ||
|
|
2df3b99e26 | ||
|
|
21c6581975 | ||
|
|
d3584077d7 | ||
|
|
e49ba1ae71 | ||
|
|
3e4a3fb9d9 | ||
|
|
1f1c27d68f | ||
|
|
2c46213257 | ||
|
|
388ae0d37b | ||
|
|
77dc7d30a0 | ||
|
|
aa3c279313 | ||
|
|
f2a3025f41 | ||
|
|
5282a55a54 | ||
|
|
07c5d25d55 | ||
|
|
51dc3df91b | ||
|
|
1b8ca149cb | ||
|
|
e9bbfdd20c | ||
|
|
59978d9c06 | ||
|
|
1f7d8c16bd | ||
|
|
9c14d9a6e1 | ||
|
|
70e03887d4 | ||
|
|
8c25736f39 | ||
|
|
dacf166935 | ||
|
|
fd26152141 | ||
|
|
a53971b146 | ||
|
|
6ac2bbb7cf | ||
|
|
56bb50913b | ||
|
|
5812276f46 | ||
|
|
74baac23a1 | ||
|
|
b31eaec78d | ||
|
|
34c5118e85 | ||
|
|
6e4ea0980f | ||
|
|
54aefaaf60 | ||
|
|
3ce0e001c1 | ||
|
|
077a5c3b30 | ||
|
|
f3567fba71 | ||
|
|
e7837cdc6b | ||
|
|
88e2f8e9e2 | ||
|
|
b131726497 | ||
|
|
86ec9733c0 | ||
|
|
0a90c0350d | ||
|
|
654b740532 | ||
|
|
8a60334978 | ||
|
|
76a6980244 | ||
|
|
fcbbc0bd9a | ||
|
|
31edcc3c5a | ||
|
|
6910bda9c7 | ||
|
|
bfd836064d | ||
|
|
f256d7a43c | ||
|
|
305ccf0984 | ||
|
|
c2c1131079 | ||
|
|
24ea2505a5 | ||
|
|
488411615f | ||
|
|
01c1428b66 | ||
|
|
566e94a31e | ||
|
|
f6a699227e | ||
|
|
185fbb1544 | ||
|
|
5c68e4d9cc | ||
|
|
38e387bcd6 | ||
|
|
276ab52aed | ||
|
|
12435e6e54 | ||
|
|
1d8182bcfb | ||
|
|
4243676739 | ||
|
|
4324fa72f2 | ||
|
|
b6c508acdf | ||
|
|
1d4c51a4d3 | ||
|
|
ef5462a4dc | ||
|
|
bdf3048773 | ||
|
|
e1da6ba356 | ||
|
|
ae309b5846 | ||
|
|
57e23d661d | ||
|
|
9472f4a1d9 | ||
|
|
9ef26055fa | ||
|
|
90e27c4b86 | ||
|
|
d1bd518043 | ||
|
|
bac4fded0d | ||
|
|
82bfddc1c2 | ||
|
|
8cf23ee7cb | ||
|
|
5777ea36e9 | ||
|
|
6eceebd701 | ||
|
|
4b898e9011 | ||
|
|
cb14ae96b3 | ||
|
|
aeb4b2dcaa | ||
|
|
09f9289deb | ||
|
|
bbb14c24dc | ||
|
|
79f4be31d5 | ||
|
|
e2a1be5e93 | ||
|
|
d4eba7fa96 | ||
|
|
bbc2eb111c | ||
|
|
9de722ab34 | ||
|
|
eebd6b2a1c | ||
|
|
181f030350 | ||
|
|
6140e503ec | ||
|
|
9539ae8e40 | ||
|
|
b1bfe6002d | ||
|
|
8e8e3a3ce8 | ||
|
|
132dea9aaa | ||
|
|
5034c332fe | ||
|
|
9f4e748404 | ||
|
|
e0dd800378 | ||
|
|
309c39a8e0 | ||
|
|
39d6caa24d | ||
|
|
0d4bd28a08 | ||
|
|
9264323b29 | ||
|
|
1744880648 | ||
|
|
246f4ebaf5 | ||
|
|
ec5f312fd6 | ||
|
|
3c81741e2e | ||
|
|
afb25ae0e9 | ||
|
|
293c085634 | ||
|
|
f8258576ef | ||
|
|
f57c002ae7 | ||
|
|
b17634ef7d | ||
|
|
e0fd344a26 | ||
|
|
c38e988b14 | ||
|
|
773e76a1c6 | ||
|
|
f4e39a219b | ||
|
|
c170a464e1 | ||
|
|
4665b42bbf | ||
|
|
16273de554 | ||
|
|
aabee99e3f | ||
|
|
9e3a083c27 | ||
|
|
3a0d814276 | ||
|
|
f3ddbb2db0 | ||
|
|
fbdb5e8d4d | ||
|
|
cc7e7bf0e0 | ||
|
|
01a7b20026 | ||
|
|
62541947e7 | ||
|
|
596da1343e | ||
|
|
12c7b6eb89 | ||
|
|
bc3d92ccaf | ||
|
|
61adc87987 | ||
|
|
c515f7c1e5 | ||
|
|
8eae408e28 | ||
|
|
9f3df91584 | ||
|
|
37cccdbc0e | ||
|
|
b6851e641e | ||
|
|
3af7adaad6 | ||
|
|
e02e4a666d | ||
|
|
1f127881c9 | ||
|
|
c52f2b6e05 | ||
|
|
d5c8982c00 | ||
|
|
402429ac2a |
@@ -0,0 +1,34 @@
|
||||
# Release fragments
|
||||
|
||||
普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的
|
||||
`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免
|
||||
并行 PR 争用同一文件。
|
||||
|
||||
文件名使用能唯一定位变更的短名,通常是 PR 号,例如
|
||||
`1234-chat-reply-mentions.md`。文件名必须匹配
|
||||
`^[a-z0-9][a-z0-9._-]*\.md$`,且必须是普通文件,不能是符号链接。本目录顶层
|
||||
只接受 `README.md`、`released/` 和符合该规则的 fragment:fragment 一律平铺在
|
||||
顶层,不接受任何其它子目录,本目录自身也不能被替换成文件或符号链接。其余条目
|
||||
会被 CI 直接拒绝而不是忽略,以免非法条目跳过校验后拖垮下一个 PR。文件格式
|
||||
严格如下:
|
||||
|
||||
```markdown
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat reply mentions** (#1234) — supports mentioning selected members.
|
||||
```
|
||||
|
||||
`category` 只能是 `Added`、`Changed`、`Deprecated`、`Removed`、`Fixed` 或
|
||||
`Security`。正文至少包含一个 Markdown 列表项,且不得包含 `TODO` 或 `TBD`。
|
||||
|
||||
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
|
||||
将未归档 fragments 汇总为唯一的版本章节,并移动到
|
||||
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
|
||||
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
|
||||
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
|
||||
删除或重写已归档文件。
|
||||
|
||||
无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该
|
||||
例外是否成立。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Agent version and extended context passthrough** (Aone 85384225) — adds
|
||||
validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to
|
||||
ordinary non-plugin MCP requests without forwarding it to A2A, OAuth,
|
||||
Discovery, or third-party plugins.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
|
||||
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
|
||||
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
|
||||
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
|
||||
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
|
||||
the first failing folder with its depth and reason, and emit a recovery command that
|
||||
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
|
||||
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
|
||||
quoted so a URL query string or a shell metacharacter cannot change how the copied command
|
||||
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
|
||||
containing metacharacters are not inlined at all: the command carries a placeholder and the
|
||||
original value is shown on a separate line marked as data rather than an executable command.
|
||||
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
|
||||
Remote-controlled folder names and server error text are stripped of ANSI escapes and
|
||||
control characters before they reach the plain-text stderr message. The internal `sortTime`
|
||||
sort key no longer leaks into `drive list --depth` output on any path.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
|
||||
file|folder`, `--start`, and `--end` for client-side filtering by node type
|
||||
and modification time on both the pan and workspace routes. Filtering runs
|
||||
a bounded full scan of the target directory (2000-entry cap, reported via
|
||||
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
|
||||
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
|
||||
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
|
||||
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
|
||||
single-layer pan route now filters the returned page by name pattern; the
|
||||
flag was previously accepted but silently ignored.
|
||||
|
||||
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
|
||||
ranks the filtered entries (folders included when `--type folder` is set)
|
||||
instead of unconditionally dropping folders, so the documented combination
|
||||
returns the most recently modified folders rather than an empty list.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`,
|
||||
`dws drive push`, and `dws drive sync` for file-level comparison and transfer
|
||||
between a local folder and a Drive folder. Differences come from exact MD5 by
|
||||
default or from modification time with `--quick`; `status` is read-only, `pull`
|
||||
and `push` are one-directional with `--if-exists skip|smart|overwrite`, and
|
||||
`sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`.
|
||||
Only regular files are transferred — online documents and shortcuts are skipped,
|
||||
neither side deletes extra files, downloads are staged through a temporary file
|
||||
and committed with an atomic rename, and remote names that would escape
|
||||
`--local-folder` are reported as failures instead of being written. Every command
|
||||
prints a structured summary on stdout and exits non-zero when any item fails.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
|
||||
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
|
||||
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
|
||||
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
|
||||
without re-running a validation scan over documents `json.Marshal` has just
|
||||
produced, cutting roughly a third of the projection work across the full tool
|
||||
set. Untrusted JSON input keeps its existing validation.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
|
||||
activity shortcuts with strict collection validation, cursor handling,
|
||||
write-terminal evidence, safe read-backs where the backend supports them,
|
||||
task-oriented routing, and documented backend
|
||||
boundaries.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
|
||||
record queries on the service's 20-record page boundary so multi-page reads
|
||||
and mutation readbacks no longer report false retryable failures, preserves
|
||||
`totalCount` when supplied, validates `--dry-run` plans before transport,
|
||||
follows active deletion readback continuations before proving absence, and
|
||||
rejects Minutes unshare success until the listening note exists and the
|
||||
service acknowledges the exact task and member targets.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **AI Table server-side statistics** — adds `dws aitable record stats` for
|
||||
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
|
||||
record group-stats` for grouped, distinct, and advanced aggregation through
|
||||
`query_stats`; both commands validate their JSON aggregation contracts before
|
||||
dispatch.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
|
||||
entries with lark-cli where the DingTalk backend supports equivalent
|
||||
semantics, rejects malformed or missing collections instead of returning
|
||||
false empty success, preserves truthful pagination, and requires stable
|
||||
identifiers plus read-back or explicit terminal receipts for writes. Adds
|
||||
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
|
||||
capability review with documented platform boundaries.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
|
||||
`comment resolve`, `comment restore`, and the lightweight
|
||||
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
|
||||
the same `doc-comment` MCP capabilities; batch queries preserve input order
|
||||
for repeated `topicId:commentKey` references, while reaction replies require
|
||||
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
|
||||
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Wait framework capability** — adds the reviewed `Contract.Wait`
|
||||
declaration (`contract.WaitSpec`) with three execution modes: `poll`
|
||||
(cadence-poll the leaf's `WaitPoll` hook), `event` (consume the leaf's
|
||||
`WaitEvents` push stream, correlate events to the accepted resource via
|
||||
`match_field`/`resource_query`, apply the same terminal map), and `auto`
|
||||
(event first, fall back to polling when the stream ends or the
|
||||
subscription fails — one deadline spans both phases). Declared commands
|
||||
must use the `ResultInvoke` dispatcher; mode and hooks are paired at
|
||||
construction (poll↔WaitPoll, event↔WaitEvents, auto↔both; surplus hooks
|
||||
are rejected too). Declared commands register `--wait` /
|
||||
`--wait-timeout` (framework-owned flags that never enter MCP toolArgs);
|
||||
undeclared commands reject the flags as unknown. The wait phase closes
|
||||
the unified envelope exactly once: terminal success → `success`,
|
||||
terminal failure → `failure` with new wire-stable `error.type: "wait"`
|
||||
(exit code 8), timeout → `pending` with `meta.operation.timed_out: true`
|
||||
and the last observed state (exit 0). Deadline exhaustion during a poll,
|
||||
during event consumption, or between polls always closes as timed-out
|
||||
pending, never as a poll/stream failure; a correlated event with an
|
||||
unknown status fails closed exactly like a poll. The capability is
|
||||
projected into the Schema catalog (`wait` key) alongside `dry_run`. No
|
||||
business command declares it yet; approval/export/batch adoption lands
|
||||
separately.
|
||||
@@ -19,3 +19,12 @@
|
||||
|
||||
# Cache directory (optional, defaults to ~/.dws/cache)
|
||||
# DWS_CACHE_DIR=
|
||||
|
||||
# Agent integration metadata (optional; ordinary non-plugin MCP requests only)
|
||||
# DWS_AGENT_PRODUCT=example-agent
|
||||
# DWS_AGENT_HOST=cloud
|
||||
# DWS_AGENT_VER=0.1.5
|
||||
# DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}'
|
||||
# The outer single quotes above are shell syntax and are not part of the value.
|
||||
# DWS_AGENT_EXT is sensitive caller-declared JSON (max 8 KiB); never put real
|
||||
# tokens in committed files or use this metadata alone for authentication.
|
||||
|
||||
@@ -19,8 +19,10 @@ repeat the entire CI suite locally only to fill this checklist: CI expands the
|
||||
selected tier from documentation checks, through affected-package tests, to
|
||||
the complete high-risk suite.
|
||||
|
||||
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] Release fragment added for a user-visible behavior/interface change (otherwise `N/A`):
|
||||
`.changes/<unique-name>.md`; ordinary PRs must not edit `CHANGELOG.md`.
|
||||
- [ ] Release-seal validation (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --content-only "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] Targeted test/check commands and results:
|
||||
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
|
||||
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
|
||||
|
||||
+423
-69
@@ -24,6 +24,7 @@ jobs:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
changelog_only: ${{ steps.classify.outputs.changelog_only }}
|
||||
release_seal_only: ${{ steps.classify.outputs.release_seal_only }}
|
||||
changelog_changed: ${{ steps.classify.outputs.changelog_changed }}
|
||||
docs_only: ${{ steps.classify.outputs.docs_only }}
|
||||
full_suite: ${{ steps.classify.outputs.full_suite }}
|
||||
@@ -39,6 +40,7 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
let changelogOnly = false;
|
||||
let releaseSealOnly = false;
|
||||
let changelogChanged = false;
|
||||
let docsOnly = false;
|
||||
let fullSuite = context.eventName === 'push';
|
||||
@@ -149,8 +151,18 @@ jobs:
|
||||
filename.startsWith('scripts/') ||
|
||||
filename.startsWith('verify/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
// Shortcut declarations feed the live command tree and Schema
|
||||
// assembly. Their reverse dependencies include the expensive
|
||||
// app and generator packages, which must run in separate shards.
|
||||
filename.startsWith('internal/shortcut/') ||
|
||||
filename.startsWith('internal/generator/') ||
|
||||
filename.startsWith('internal/cli/schema') ||
|
||||
// Parameter aliases are reduced against the live command tree.
|
||||
// Their reverse-dependency set is too large for one focused
|
||||
// race job, so use the existing full-suite shards.
|
||||
filename === 'internal/cli/param_concepts.json' ||
|
||||
filename === 'internal/cli/param_concepts.schema.json' ||
|
||||
filename === 'internal/cli/param_aliases_generated.go' ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
filename.startsWith('internal/app/upgrade') ||
|
||||
filename.startsWith('internal/transport/') ||
|
||||
@@ -162,6 +174,43 @@ jobs:
|
||||
filename === 'go.mod' ||
|
||||
filename === 'go.sum'
|
||||
);
|
||||
const isExactReleaseSeal = (candidates) => {
|
||||
const changelog = candidates.filter(
|
||||
({ filename, status, previous_filename }) =>
|
||||
filename === 'CHANGELOG.md' &&
|
||||
status === 'modified' &&
|
||||
!previous_filename
|
||||
);
|
||||
if (changelog.length !== 1 || candidates.length < 2) {
|
||||
return false;
|
||||
}
|
||||
let version = '';
|
||||
return candidates.every((file) => {
|
||||
if (file.filename === 'CHANGELOG.md') {
|
||||
return file.status === 'modified' && !file.previous_filename;
|
||||
}
|
||||
if (
|
||||
file.status !== 'renamed' ||
|
||||
typeof file.filename !== 'string' ||
|
||||
typeof file.previous_filename !== 'string' ||
|
||||
file.additions !== 0 ||
|
||||
file.deletions !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const target = file.filename.match(
|
||||
/^\.changes\/released\/([0-9]+\.[0-9]+\.[0-9]+(?:-beta\.[1-9][0-9]*)?)\/([a-z0-9][a-z0-9._-]*\.md)$/
|
||||
);
|
||||
if (!target || file.previous_filename !== `.changes/${target[2]}`) {
|
||||
return false;
|
||||
}
|
||||
if (version && version !== target[1]) {
|
||||
return false;
|
||||
}
|
||||
version = target[1];
|
||||
return true;
|
||||
});
|
||||
};
|
||||
const classifyFiles = (complete) => {
|
||||
const paths = files.flatMap(({ filename, previous_filename }) =>
|
||||
[filename, previous_filename].filter(
|
||||
@@ -248,19 +297,26 @@ jobs:
|
||||
);
|
||||
}
|
||||
|
||||
changelogOnly =
|
||||
const exactChangelogDiff =
|
||||
files.length === 1 &&
|
||||
files[0].filename === 'CHANGELOG.md' &&
|
||||
files[0].status === 'modified' &&
|
||||
!files[0].previous_filename;
|
||||
releaseSealOnly = isExactReleaseSeal(files);
|
||||
changelogOnly = exactChangelogDiff || releaseSealOnly;
|
||||
changelogChanged = files.some(
|
||||
({ filename, previous_filename }) =>
|
||||
filename === 'CHANGELOG.md' ||
|
||||
previous_filename === 'CHANGELOG.md'
|
||||
);
|
||||
classifyFiles(true);
|
||||
if (releaseSealOnly) {
|
||||
fullSuite = false;
|
||||
}
|
||||
fastPathTrust = changelogOnly
|
||||
? 'exact pull-request revision and synthetic merge policy'
|
||||
? releaseSealOnly
|
||||
? 'exact release-seal fragment archival and synthetic merge policy'
|
||||
: 'exact CHANGELOG-only revision and synthetic merge policy'
|
||||
: docsOnly
|
||||
? 'documentation-only focused admission'
|
||||
: fullSuite
|
||||
@@ -295,7 +351,8 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
files = Array.isArray(comparison.files) ? comparison.files : [];
|
||||
classifyFiles(files.length < 300);
|
||||
const pushFilesComplete = files.length < 300;
|
||||
classifyFiles(pushFilesComplete);
|
||||
const linearFromValidatedTip =
|
||||
comparison.status === 'ahead' &&
|
||||
comparison.merge_base_commit?.sha === expectedBefore &&
|
||||
@@ -307,8 +364,10 @@ jobs:
|
||||
files[0].filename === 'CHANGELOG.md' &&
|
||||
files[0].status === 'modified' &&
|
||||
!files[0].previous_filename;
|
||||
const exactReleaseSealDiff =
|
||||
pushFilesComplete && isExactReleaseSeal(files);
|
||||
|
||||
if (linearFromValidatedTip && exactChangelogDiff) {
|
||||
if (linearFromValidatedTip && (exactChangelogDiff || exactReleaseSealDiff)) {
|
||||
const requiredContexts = [
|
||||
'Lint',
|
||||
'Test',
|
||||
@@ -359,9 +418,15 @@ jobs:
|
||||
|
||||
if (missing.length === 0 && nonSuccess.length === 0) {
|
||||
changelogOnly = true;
|
||||
releaseSealOnly = exactReleaseSealDiff;
|
||||
changelogChanged = true;
|
||||
if (releaseSealOnly) {
|
||||
fullSuite = false;
|
||||
}
|
||||
fastPathTrust =
|
||||
`exact CHANGELOG-only successor of validated ${expectedBefore}`;
|
||||
releaseSealOnly
|
||||
? `exact release-seal successor of validated ${expectedBefore}`
|
||||
: `exact CHANGELOG-only successor of validated ${expectedBefore}`;
|
||||
} else {
|
||||
fastPathTrust =
|
||||
'predecessor Code Admission is not fully successful; ' +
|
||||
@@ -376,6 +441,7 @@ jobs:
|
||||
}
|
||||
|
||||
core.setOutput('changelog_only', String(changelogOnly));
|
||||
core.setOutput('release_seal_only', String(releaseSealOnly));
|
||||
core.setOutput('changelog_changed', String(changelogChanged));
|
||||
core.setOutput('docs_only', String(docsOnly));
|
||||
core.setOutput('full_suite', String(fullSuite));
|
||||
@@ -387,7 +453,8 @@ jobs:
|
||||
await core.summary
|
||||
.addHeading('Code Admission scope')
|
||||
.addRaw(`- Event: \`${context.eventName}\`\n`)
|
||||
.addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`)
|
||||
.addRaw(`- Metadata-only fast path: \`${changelogOnly}\`\n`)
|
||||
.addRaw(`- Release-seal fragments only: \`${releaseSealOnly}\`\n`)
|
||||
.addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`)
|
||||
.addRaw(`- Documentation-only: \`${docsOnly}\`\n`)
|
||||
.addRaw(`- Full suite: \`${fullSuite}\`\n`)
|
||||
@@ -402,7 +469,14 @@ jobs:
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: steps.classify.outputs.changelog_only == 'true'
|
||||
run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
|
||||
env:
|
||||
RELEASE_SEAL_ONLY: ${{ steps.classify.outputs.release_seal_only }}
|
||||
run: |
|
||||
if [ "$RELEASE_SEAL_ONLY" = true ]; then
|
||||
echo "Lint is satisfied by the trusted release-seal fragment Policy path." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Record documentation-only fast path
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only == 'true'
|
||||
@@ -439,11 +513,38 @@ jobs:
|
||||
run: node .github/reviewer-routing.test.js
|
||||
|
||||
test-focused:
|
||||
name: Test (changed packages)
|
||||
name: "Test (focused: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Each shard owns one bounded slice of the impacted set, so no single job
|
||||
# carries internal/app together with every reverse dependency. The shard
|
||||
# list and per-shard execution below mirror test-race, which runs the same
|
||||
# shards at full-suite scope; release-scripts is included because its
|
||||
# dedicated job only runs at full-suite or release-sensitive scope, and
|
||||
# dropping it here would stop testing test/scripts changes entirely.
|
||||
# internal/app is carried by one shard per bounded partition rather than a
|
||||
# single app shard: the partitions used to run end to end inside one job,
|
||||
# where the Schema partition alone owned most of the wall clock. The
|
||||
# app-<partition> names are pinned to the helper's partition set by
|
||||
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
|
||||
# its job silently.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
- smoke
|
||||
- remaining
|
||||
- release-scripts
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -472,36 +573,110 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Test changed packages and reverse dependencies
|
||||
- name: Select impacted packages for shard
|
||||
id: select
|
||||
shell: bash
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Every app partition shard tests the same single internal/app
|
||||
# package, so the impacted-package query uses the base shard name and
|
||||
# the partition only selects which tests run.
|
||||
package_shard="$TEST_SHARD"
|
||||
case "$TEST_SHARD" in
|
||||
app-*) package_shard=app ;;
|
||||
esac
|
||||
package_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$package_output" ]; then
|
||||
echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
echo "affected=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# The package list travels through a file rather than a step output:
|
||||
# reading it with `mapfile < file` has unambiguous line semantics,
|
||||
# whereas a here-string over a multi-line output would append an extra
|
||||
# empty element if the value ever carried a trailing newline, and an
|
||||
# empty element would reach go test as an empty package argument.
|
||||
printf '%s\n' "$package_output" > "$RUNNER_TEMP/focused-shard-packages.txt"
|
||||
echo "affected=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build
|
||||
if: ${{ matrix.shard == 'remaining' && steps.select.outputs.affected == 'true' }}
|
||||
run: make build
|
||||
|
||||
- name: Install archive tooling
|
||||
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Test shard with Race Detection
|
||||
if: ${{ steps.select.outputs.affected == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$package_output" ]; then
|
||||
echo "No buildable Go package is affected by this revision." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
mapfile -t packages < "$RUNNER_TEMP/focused-shard-packages.txt"
|
||||
test "${#packages[@]}" -gt 0
|
||||
for package in "${packages[@]}"; do
|
||||
test -n "$package" || {
|
||||
echo "shard package list contains an empty entry" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
case "$TEST_SHARD" in
|
||||
app-*)
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Each partition is its own
|
||||
# job, so that state is released when the process exits and the
|
||||
# partitions run concurrently instead of end to end. The helper
|
||||
# still verifies that the partition patterns cover every top-level
|
||||
# test exactly once before running the one it was asked for.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
if [ "$TEST_SHARD" = "release-scripts" ]; then
|
||||
# Mirror the dedicated release-contract job: these suites shell out
|
||||
# to archive tooling and are not race-instrumented there.
|
||||
go test -v -count=1 -timeout=10m "${packages[@]}"
|
||||
exit 0
|
||||
fi
|
||||
mapfile -t packages <<< "$package_output"
|
||||
go test -v -race -count=1 -timeout=15m "${packages[@]}"
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
|
||||
# give them a dedicated package timeout on slower hosted runners.
|
||||
timeout_budget=12m
|
||||
if [ "$TEST_SHARD" = "cli" ] ||
|
||||
[ "$TEST_SHARD" = "smoke" ]; then
|
||||
timeout_budget=15m
|
||||
fi
|
||||
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
|
||||
|
||||
test-race:
|
||||
name: "Test (race: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
# cli/smoke shards need headroom beyond go test -timeout for setup + assembly.
|
||||
# internal/app is split across one shard per bounded partition so the
|
||||
# partitions run concurrently and each releases its framework registries
|
||||
# when the process exits; cli/smoke need headroom beyond go test -timeout for
|
||||
# setup + assembly. The app-<partition> names are pinned to the helper's
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
@@ -527,14 +702,35 @@ jobs:
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")"
|
||||
# Every app partition shard tests the same single internal/app
|
||||
# package, so the package query uses the base shard name and the
|
||||
# partition only selects which tests run.
|
||||
package_shard="$TEST_SHARD"
|
||||
case "$TEST_SHARD" in
|
||||
app-*) package_shard=app ;;
|
||||
esac
|
||||
package_output="$(./scripts/ci/test-packages.sh list "$package_shard")"
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race; give
|
||||
# them a dedicated budget so remaining is not SIGTERM'd by OOM/timeout.
|
||||
case "$TEST_SHARD" in
|
||||
app-*)
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Each partition is its own
|
||||
# job, so that state is released when the process exits and the
|
||||
# partitions run concurrently instead of end to end. The helper
|
||||
# still verifies that the partition patterns cover every top-level
|
||||
# test exactly once before running the one it was asked for.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
|
||||
# give them a dedicated package timeout on slower hosted runners.
|
||||
timeout_budget=12m
|
||||
if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then
|
||||
if [ "$TEST_SHARD" = "cli" ] ||
|
||||
[ "$TEST_SHARD" = "smoke" ]; then
|
||||
timeout_budget=15m
|
||||
fi
|
||||
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
|
||||
@@ -631,7 +827,7 @@ jobs:
|
||||
failed=0
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
for shard in \
|
||||
"changed packages:$FOCUSED_RESULT" \
|
||||
"focused shards:$FOCUSED_RESULT" \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
|
||||
@@ -660,7 +856,7 @@ jobs:
|
||||
release_expected=success
|
||||
fi
|
||||
for shard in \
|
||||
"changed packages:$FOCUSED_RESULT:$focused_expected" \
|
||||
"focused shards:$FOCUSED_RESULT:$focused_expected" \
|
||||
"race shards:$RACE_RESULT:$race_expected" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT:success"
|
||||
@@ -831,7 +1027,7 @@ jobs:
|
||||
coverage-current:
|
||||
name: Coverage (current)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -846,10 +1042,6 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -871,41 +1063,33 @@ jobs:
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Run current unit tests with coverage
|
||||
- name: Run scoped unit tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
changed_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
impacted_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
echo "No buildable Go package needs scoped coverage." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
mapfile -t changed_packages <<< "$changed_output"
|
||||
mapfile -t impacted_packages <<< "$impacted_output"
|
||||
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
|
||||
go test -count=1 -p 1 \
|
||||
-coverpkg="$coverpkg" \
|
||||
-coverprofile=coverage.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
else
|
||||
changed_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
impacted_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
echo "No buildable Go package needs scoped coverage." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
mapfile -t changed_packages <<< "$changed_output"
|
||||
mapfile -t impacted_packages <<< "$impacted_output"
|
||||
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
|
||||
go test -count=1 -p 1 \
|
||||
-coverpkg="$coverpkg" \
|
||||
-coverprofile=coverage.txt \
|
||||
-covermode=atomic \
|
||||
"${impacted_packages[@]}"
|
||||
fi
|
||||
"${impacted_packages[@]}"
|
||||
fi
|
||||
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
|
||||
go tool cover -func=coverage.txt
|
||||
@@ -918,6 +1102,66 @@ jobs:
|
||||
path: coverage.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-current-full:
|
||||
name: "Coverage (current: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- cli
|
||||
- generators
|
||||
- helpers
|
||||
- remaining
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
# Each shard keeps -p 1 so the authoritative measurement stays serial
|
||||
# inside one instrumented process group; shards run on isolated runners,
|
||||
# and scripts/ci/test-packages.sh verify proves the shard union equals
|
||||
# the previous single full-suite package set exactly once.
|
||||
- name: Run current shard tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
COVERAGE_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD")"
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \
|
||||
-covermode=atomic \
|
||||
"${packages[@]}"
|
||||
go tool cover -func="coverage-shard-$COVERAGE_SHARD.txt" | tail -n 1
|
||||
|
||||
- name: Upload current shard coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-current-shard-${{ matrix.shard }}
|
||||
path: coverage-shard-${{ matrix.shard }}.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-supporting:
|
||||
name: Coverage (supporting)
|
||||
needs: lint
|
||||
@@ -971,14 +1215,11 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -996,7 +1237,34 @@ jobs:
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
# The merge-base full-suite profile is a pure function of the base
|
||||
# commit. Reuse the profile published by the last green push run of
|
||||
# exactly that commit instead of re-running the whole suite; any key
|
||||
# mismatch falls back to authoritative recomputation. Exact key only,
|
||||
# never prefix fallback: a near-miss profile would compare the
|
||||
# candidate against the wrong commit.
|
||||
- name: Restore cached merge-base coverage profile
|
||||
id: baseline-cache
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Materialize cached merge-base coverage profile
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
cp coverage-cache.txt coverage-base.txt
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Run baseline unit tests with coverage
|
||||
if: steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
@@ -1045,6 +1313,21 @@ jobs:
|
||||
fi
|
||||
)
|
||||
|
||||
- name: Prepare merge-base coverage profile cache
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-base.txt
|
||||
test "$(head -n 1 coverage-base.txt)" = "mode: atomic"
|
||||
cp coverage-base.txt coverage-cache.txt
|
||||
|
||||
- name: Save merge-base coverage profile cache
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Upload baseline coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -1057,6 +1340,7 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- coverage-current
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-darwin
|
||||
@@ -1072,6 +1356,7 @@ jobs:
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
CURRENT_RESULT: ${{ needs.coverage-current.result }}
|
||||
CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
||||
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
@@ -1079,6 +1364,7 @@ jobs:
|
||||
run: |
|
||||
failed=0
|
||||
current_expected=success
|
||||
current_full_expected=skipped
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
native_expected=skipped
|
||||
@@ -1086,6 +1372,8 @@ jobs:
|
||||
current_expected=skipped
|
||||
baseline_expected=skipped
|
||||
elif [ "$FULL_SUITE" = true ]; then
|
||||
current_expected=skipped
|
||||
current_full_expected=success
|
||||
supporting_expected=success
|
||||
fi
|
||||
if [ "$CHANGELOG_ONLY" != true ] &&
|
||||
@@ -1096,6 +1384,7 @@ jobs:
|
||||
|
||||
for profile in \
|
||||
"current:$CURRENT_RESULT:$current_expected" \
|
||||
"current shards:$CURRENT_FULL_RESULT:$current_full_expected" \
|
||||
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected"
|
||||
do
|
||||
@@ -1131,6 +1420,7 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -1154,11 +1444,12 @@ jobs:
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current coverage profile
|
||||
- name: Download current coverage profiles
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
pattern: coverage-current-*
|
||||
merge-multiple: true
|
||||
path: .
|
||||
|
||||
- name: Download supporting coverage profiles
|
||||
@@ -1175,6 +1466,26 @@ jobs:
|
||||
name: coverage-baseline-profile
|
||||
path: .
|
||||
|
||||
# Shard profiles cover disjoint package sets, so their block-level
|
||||
# concatenation is the same candidate profile one serial run produced.
|
||||
# Every expected shard must be present; a missing shard would silently
|
||||
# shrink the scope-matched overall comparison.
|
||||
- name: Assemble full-suite coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test ! -f coverage.txt
|
||||
for shard in app cli generators helpers remaining; do
|
||||
profile="coverage-shard-$shard.txt"
|
||||
test -f "$profile"
|
||||
test "$(head -n 1 "$profile")" = "mode: atomic"
|
||||
done
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
for shard in app cli generators helpers remaining; do
|
||||
tail -n +2 "coverage-shard-$shard.txt" >> coverage.txt
|
||||
done
|
||||
|
||||
- name: Enforce coverage gate
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
env:
|
||||
@@ -1195,6 +1506,26 @@ jobs:
|
||||
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
|
||||
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
# Publish this push's full-suite profile as the merge-base cache for
|
||||
# future PRs whose merge-base is exactly this commit. Saved only after
|
||||
# the gate passed so a broken run never becomes a baseline. Both producer
|
||||
# and consumer use coverage-cache.txt because the cache version includes
|
||||
# the configured path as well as the compression tool.
|
||||
- name: Prepare push coverage profile as merge-base cache
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage.txt
|
||||
test "$(head -n 1 coverage.txt)" = "mode: atomic"
|
||||
cp coverage.txt coverage-cache.txt
|
||||
|
||||
- name: Save push coverage profile as merge-base cache
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Generate coverage report
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
run: |
|
||||
@@ -1256,6 +1587,7 @@ jobs:
|
||||
env:
|
||||
CLASSIFIED_CHANGELOG_CHANGED: ${{ needs.lint.outputs.changelog_changed }}
|
||||
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
|
||||
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
@@ -1282,31 +1614,53 @@ jobs:
|
||||
fi
|
||||
|
||||
mode=--content-only
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
if [ "$CHANGELOG_ONLY" = true ] && [ "$RELEASE_SEAL_ONLY" != true ]; then
|
||||
mode=--fast-path
|
||||
fi
|
||||
./scripts/policy/check-changelog-pr.sh \
|
||||
"$mode" "$PR_BASE_SHA" HEAD
|
||||
|
||||
- name: Validate trusted main CHANGELOG-only push
|
||||
- name: Validate release fragment lifecycle
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
|
||||
|
||||
- name: Validate trusted main metadata-only push
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true'
|
||||
env:
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
PUSH_AFTER_SHA: ${{ github.event.after }}
|
||||
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD)" = "$PUSH_AFTER_SHA" || {
|
||||
echo "checked-out push revision does not match event after SHA" >&2
|
||||
exit 1
|
||||
}
|
||||
mode=--fast-path
|
||||
if [ "$RELEASE_SEAL_ONLY" = true ]; then
|
||||
mode=--content-only
|
||||
fi
|
||||
./scripts/policy/check-changelog-pr.sh \
|
||||
--fast-path "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
|
||||
"$mode" "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
|
||||
if [ "$RELEASE_SEAL_ONLY" = true ]; then
|
||||
./scripts/policy/check-release-fragments.sh \
|
||||
"$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
|
||||
fi
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
env:
|
||||
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
|
||||
run: |
|
||||
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$RELEASE_SEAL_ONLY" = true ]; then
|
||||
echo "Only the trusted release-seal and fragment validators ran; executable sources are unchanged." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Validate scoped policy
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && (needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true')) }}
|
||||
|
||||
@@ -2645,6 +2645,40 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
|
||||
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
# The sealed candidate tag is intentionally visible while its GitHub
|
||||
# authority is checked above. Compatibility must instead discover the
|
||||
# previous delivered stable tag, so hide only this verified candidate
|
||||
# from this isolated runner's local tag namespace.
|
||||
- name: Prepare delivered-stable compatibility ref view
|
||||
if: ${{ matrix.check == 'compatibility' }}
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
|
||||
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
|
||||
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
|
||||
PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}
|
||||
PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }}
|
||||
run: |
|
||||
set -eu
|
||||
test -n "$RELEASE_VERSION"
|
||||
test -n "$RELEASE_COMMIT"
|
||||
test -n "$RELEASE_TAG_OBJECT"
|
||||
test -n "$PREVIOUS_STABLE"
|
||||
test -n "$PREVIOUS_STABLE_COMMIT"
|
||||
test "$RELEASE_VERSION" != "$PREVIOUS_STABLE"
|
||||
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
|
||||
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT"
|
||||
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"
|
||||
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
|
||||
|
||||
git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
if git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"; then
|
||||
echo "sealed candidate tag is still visible to compatibility baseline discovery" >&2
|
||||
exit 2
|
||||
fi
|
||||
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
|
||||
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -2793,7 +2827,7 @@ jobs:
|
||||
fi
|
||||
if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then
|
||||
echo
|
||||
echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish."
|
||||
echo "Plan only: no tag or package was created. Render pending \`.changes/*.md\` fragments into the exact \`CHANGELOG.md\` section, merge the release-seal PR to main, then run publish."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ test/cli_compat/testdata/
|
||||
.gitignore
|
||||
.worktrees/
|
||||
.qoder/
|
||||
_logs/
|
||||
_docs/
|
||||
_output/
|
||||
vendor/
|
||||
|
||||
# Secrets & credentials
|
||||
.env
|
||||
|
||||
+253
@@ -6,6 +6,259 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.59-beta.3] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
|
||||
|
||||
- **AI Table server-side statistics** — adds `dws aitable record stats` for
|
||||
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
|
||||
record group-stats` for grouped, distinct, and advanced aggregation through
|
||||
`query_stats`; both commands validate their JSON aggregation contracts before
|
||||
dispatch.
|
||||
|
||||
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
|
||||
|
||||
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
|
||||
entries with lark-cli where the DingTalk backend supports equivalent
|
||||
semantics, rejects malformed or missing collections instead of returning
|
||||
false empty success, preserves truthful pagination, and requires stable
|
||||
identifiers plus read-back or explicit terminal receipts for writes. Adds
|
||||
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
|
||||
capability review with documented platform boundaries.
|
||||
|
||||
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
|
||||
`comment resolve`, `comment restore`, and the lightweight
|
||||
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
|
||||
the same `doc-comment` MCP capabilities; batch queries preserve input order
|
||||
for repeated `topicId:commentKey` references, while reaction replies require
|
||||
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
|
||||
|
||||
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
|
||||
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
|
||||
|
||||
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
|
||||
record queries on the service's 20-record page boundary so multi-page reads
|
||||
and mutation readbacks no longer report false retryable failures, preserves
|
||||
`totalCount` when supplied, validates `--dry-run` plans before transport,
|
||||
follows active deletion readback continuations before proving absence, and
|
||||
rejects Minutes unshare success until the listening note exists and the
|
||||
service acknowledges the exact task and member targets.
|
||||
|
||||
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
|
||||
|
||||
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
|
||||
|
||||
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
|
||||
|
||||
|
||||
## [1.0.59-beta.2] - 2026-08-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
|
||||
|
||||
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
|
||||
|
||||
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
|
||||
activity shortcuts with strict collection validation, cursor handling,
|
||||
write-terminal evidence, safe read-backs where the backend supports them,
|
||||
task-oriented routing, and documented backend
|
||||
boundaries.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
|
||||
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
|
||||
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
|
||||
|
||||
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
|
||||
without re-running a validation scan over documents `json.Marshal` has just
|
||||
produced, cutting roughly a third of the projection work across the full tool
|
||||
set. Untrusted JSON input keeps its existing validation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
|
||||
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
|
||||
|
||||
|
||||
## [1.0.59-beta.1] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
|
||||
file|folder`, `--start`, and `--end` for client-side filtering by node type
|
||||
and modification time on both the pan and workspace routes. Filtering runs
|
||||
a bounded full scan of the target directory (2000-entry cap, reported via
|
||||
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
|
||||
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
|
||||
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
|
||||
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
|
||||
|
||||
- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`,
|
||||
`dws drive push`, and `dws drive sync` for file-level comparison and transfer
|
||||
between a local folder and a Drive folder. Differences come from exact MD5 by
|
||||
default or from modification time with `--quick`; `status` is read-only, `pull`
|
||||
and `push` are one-directional with `--if-exists skip|smart|overwrite`, and
|
||||
`sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`.
|
||||
Only regular files are transferred — online documents and shortcuts are skipped,
|
||||
neither side deletes extra files, downloads are staged through a temporary file
|
||||
and committed with an atomic rename, and remote names that would escape
|
||||
`--local-folder` are reported as failures instead of being written. Every command
|
||||
prints a structured summary on stdout and exits non-zero when any item fails.
|
||||
|
||||
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
|
||||
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
|
||||
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
|
||||
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
|
||||
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
|
||||
the first failing folder with its depth and reason, and emit a recovery command that
|
||||
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
|
||||
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
|
||||
quoted so a URL query string or a shell metacharacter cannot change how the copied command
|
||||
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
|
||||
containing metacharacters are not inlined at all: the command carries a placeholder and the
|
||||
original value is shown on a separate line marked as data rather than an executable command.
|
||||
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
|
||||
Remote-controlled folder names and server error text are stripped of ANSI escapes and
|
||||
control characters before they reach the plain-text stderr message. The internal `sortTime`
|
||||
sort key no longer leaks into `drive list --depth` output on any path.
|
||||
|
||||
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
|
||||
single-layer pan route now filters the returned page by name pattern; the
|
||||
flag was previously accepted but silently ignored.
|
||||
|
||||
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
|
||||
ranks the filtered entries (folders included when `--type folder` is set)
|
||||
instead of unconditionally dropping folders, so the documented combination
|
||||
returns the most recently modified folders rather than an empty list.
|
||||
|
||||
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
|
||||
|
||||
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
|
||||
|
||||
|
||||
## [1.0.58] - 2026-08-13
|
||||
|
||||
This release promotes the sealed `v1.0.58-beta.6` contents to stable.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Expanded collaborative workflows** — adds full AI Table, Sheet, Minutes,
|
||||
approval-event, Drive-comment, document export, and CSV workflow support,
|
||||
including safer validation, explicit confirmation for writes, and
|
||||
machine-readable completion receipts.
|
||||
- **More capable Chat operations** — adds robot image/file messages, toolbar
|
||||
management, streaming-card mentions, automatic pagination controls, and
|
||||
clearer post-send ID, Markdown-image, paging, and result-shape guidance.
|
||||
- **Reliable Agent and CLI contracts** — expands Agent-visible Chat and
|
||||
Minutes commands, aligns bundled skills, improves schema/result envelopes,
|
||||
and hardens parameter, pagination, runtime-token, and write-result
|
||||
verification so ambiguous or incomplete operations fail closed.
|
||||
- **Multi-skill install and upgrade** — makes the multi-skill layout the
|
||||
default for fresh installs and upgrades while preserving an explicit legacy
|
||||
mono option.
|
||||
- **Safer release delivery** — strengthens release-equivalent compatibility,
|
||||
sealing, package verification, and evaluation-dispatch checks for more
|
||||
reliable cross-platform releases.
|
||||
|
||||
## [1.0.58-beta.6] - 2026-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- **npm package verification for multi-skill installs** (#991) — aligns the
|
||||
release verifier with the installer’s concrete Agent skill-root selection,
|
||||
preventing valid multi-skill package layouts from failing release delivery.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release-seal CI classification** (#987) — recognizes the reviewed
|
||||
CHANGELOG-and-fragment archival shape while retaining release-contract and
|
||||
lifecycle validation, reducing unrelated CI work for release-seal PRs.
|
||||
|
||||
## [1.0.58-beta.5] - 2026-08-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Agent version and extended context passthrough** (Aone 85384225) — adds
|
||||
validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to
|
||||
ordinary non-plugin MCP requests without forwarding it to A2A, OAuth,
|
||||
Discovery, or third-party plugins.
|
||||
|
||||
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
|
||||
|
||||
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
|
||||
|
||||
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
|
||||
|
||||
|
||||
## [1.0.58-beta.4] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-skill installation and upgrade** — fresh installs, `dws skill setup`,
|
||||
and `dws upgrade` now use the multi-skill layout by default. Existing mono
|
||||
installations migrate during upgrade; mono remains an explicit legacy option.
|
||||
- **Native streaming-card mentions** — `dws chat message send-card` now accepts
|
||||
`--at-open-dingtalk-ids` and `--at-all` for group cards and forwards them to
|
||||
`create_and_send_card`, matching the existing shortcut behavior without
|
||||
changing single-chat card creation.
|
||||
- **Expanded Minutes workflows** — 27 public Minutes shortcuts now cover
|
||||
upload, download, export, recording, analysis, sharing, and recovery flows;
|
||||
every write command keeps an explicit confirmation requirement.
|
||||
- **Chat command discovery** — 30 existing typed Chat commands are now
|
||||
available in the runtime Schema and Agent catalog, with sensitive writes
|
||||
carrying their required confirmation metadata.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat read results** — typed commands and shortcuts now expose a consistent
|
||||
top-level `messages` list with stable `messageId` and `text` fields while
|
||||
retaining existing response envelopes and fields.
|
||||
- **Wiki feed results** — Wiki feed list output now formats time fields and
|
||||
trims excess fields. Its `--limit` default is 10 and maximum is 20.
|
||||
- **Developer command results** — the `dev` and selected `devapp` commands now
|
||||
use the unified result envelope for consistent success, pending, partial,
|
||||
and failure reporting.
|
||||
- **Evaluation dispatch hardening** — `/eval` now uses a verifiable polling
|
||||
relay instead of direct access from the hosted runner, binding the workflow,
|
||||
comment, PR head, parameters, and result provenance.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Streaming-card update acknowledgement** — accepts the pre-production
|
||||
`success: true` response from `update_streaming_card` as affirmative write
|
||||
evidence while preserving explicit negative, conflicting, and bizId-drift
|
||||
failures, so Agents do not repeat an update that the service already applied.
|
||||
- **Text input bounds** — literal input, stdin, and `@file` inputs now all
|
||||
enforce the same byte limit; file reads validate the opened descriptor and
|
||||
cannot exceed the limit after a path replacement or file growth.
|
||||
- **Evaluation PR comments** — restores `/eval` PR conversation comments with
|
||||
the least required pull-request write permission and actionable GitHub 403
|
||||
diagnostics.
|
||||
|
||||
## [1.0.58-beta.3] - 2026-08-11
|
||||
|
||||
### Added
|
||||
|
||||
+4
-1
@@ -83,7 +83,10 @@ coverage is additionally selected for platform-sensitive code.
|
||||
change.
|
||||
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
|
||||
installer surfaces change (run `make package` first).
|
||||
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
7. Update docs and add one `.changes/<unique-name>.md` release fragment for
|
||||
behavior/interface changes. Do not edit `CHANGELOG.md` in an ordinary PR;
|
||||
the release-seal workflow renders and archives fragments into the versioned
|
||||
changelog section.
|
||||
|
||||
## Submission Flow
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.58-beta.3"
|
||||
version "1.0.59-beta.3"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "29b4fb9e081f36a699933c0919fe7530544f62de3e27c785d27626a575a2efc2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a6b9c4ef212c533e02b414bd9c3be0b8d1874d4af983a7896072825bdfec1033"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "ae3a9ebe151702fd05dee0c56d778a20789d98c390cf8c0a0b2ec695b57b5ec3"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "544b480701e9ec9ec5366467ad4c855fca06dea887e3d577ff50e4b3eeb13ac2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-skills.zip"
|
||||
sha256 "322f1840442ff183ad4b6d4f2a2b38825ff9f96a3fcde06ba57b8f80647468ae"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
|
||||
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.57"
|
||||
version "1.0.58"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
|
||||
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
|
||||
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
|
||||
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
|
||||
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
|
||||
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
|
||||
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
|
||||
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
|
||||
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
|
||||
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -18,7 +18,7 @@ help:
|
||||
@printf "Available targets:\n"
|
||||
@printf " make build - Build the dws CLI binary\n"
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\n"
|
||||
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
|
||||
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
|
||||
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
|
||||
@@ -70,15 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
|
||||
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
- **Quick install** (one-liner above): non-interactive, installs `mono`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
|
||||
- **Quick install** (one-liner above): non-interactive, installs `multi`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — review the listed paths and confirm interactively.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -208,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
|
||||
The upgrade process follows a two-phase atomic flow to ensure consistency:
|
||||
|
||||
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
|
||||
|
||||
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
|
||||
|
||||
@@ -391,19 +393,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
|
||||
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
```bash
|
||||
# Install skills into current project (defaults to mono)
|
||||
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
@@ -413,22 +415,31 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
# Interactive: prompts for mode + target agents
|
||||
dws skill setup
|
||||
|
||||
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
|
||||
dws skill setup --mode mono --target all --yes
|
||||
# Preview the exact directories that mono setup would back up and replace
|
||||
dws skill setup --mode mono --target all --dry-run
|
||||
|
||||
# Install multi skills to a single agent home
|
||||
dws skill setup --mode multi --target cursor --yes
|
||||
# Run interactively and confirm the listed directories
|
||||
dws skill setup --mode mono --target all
|
||||
|
||||
# Point at a local source tree (e.g. a fork or work-in-progress)
|
||||
# Preview, then install multi skills to a single agent home with interactive confirmation
|
||||
dws skill setup --mode multi --target cursor --dry-run
|
||||
dws skill setup --mode multi --target cursor
|
||||
|
||||
# Point at a local source tree (e.g. a fork or work-in-progress), preview first
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
| Flag | Values | Description |
|
||||
|------|--------|-------------|
|
||||
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home, including ZCode at `~/.zcode/skills` |
|
||||
| `--source` | path | Local source directory (overrides bundled skills) |
|
||||
| `--yes` | — | Skip confirmation prompts |
|
||||
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
|
||||
|
||||
> The setup command can remove the opposite-mode layout (`dws/` for multi, DWS-managed multi Skills for mono) and stale managed Skills not in the bundle. DWS records ownership, installer version, source, and content digest centrally in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Exact official names shipped before the centralized state remain a frozen migration list. A `dingtalk-*` prefix alone never authorizes cleanup, so other same-prefix market/user Skills are preserved. Every removal is previewed before confirmation and preserved under `~/.dws/skill-backups/<timestamp>/`; a directory that cannot be backed up is never removed. In a non-interactive shell, first run `--dry-run` and inspect its output; only then may the caller explicitly choose the scripting-only confirmation bypass.
|
||||
|
||||
After a multi setup or upgrade, DWS stores the official bundle snapshot and centralized ownership metadata in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Every upgrade installs and overwrites the complete bundled Skill set from that release. Deleting or excluding a bundled Skill is not sticky: the next upgrade restores it. `dws upgrade --force` additionally allows reinstalling the current CLI version when no newer version is available.
|
||||
|
||||
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
|
||||
|
||||
@@ -471,7 +482,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance events.
|
||||
|
||||
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
|
||||
|
||||
@@ -519,12 +530,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# Listen for all six public OA approval events in one process
|
||||
# Listen for all seven public OA approval events in one process
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
@@ -726,7 +738,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
- `conference` (video meetings)
|
||||
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
|
||||
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back after interactive confirmation
|
||||
|
||||
</details>
|
||||
|
||||
@@ -775,6 +787,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
|
||||
## Reference & Docs
|
||||
|
||||
- [International DingTalk (`.io`) guide](./docs/international-region-guide.md) — international login, domestic/international profile switching, isolated testing, and troubleshooting
|
||||
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
|
||||
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
|
||||
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
|
||||
|
||||
+34
-21
@@ -70,15 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
|
||||
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),核对列出的路径后交互确认。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -205,7 +207,7 @@ bash verify-all-channels.sh
|
||||
升级过程采用两阶段原子流程,确保一致性:
|
||||
|
||||
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
|
||||
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
|
||||
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包平铺到已检测到的具体 Agent 目录(例如 `~/.codex/skills/dingtalk-chat`、`~/.claude/skills/dingtalk-chat`)。只有未检测到具体 Agent 时才使用 `~/.agents/skills`;检测到具体 Agent 后会备份迁走旧的 DWS 通用副本,避免同一 Skill 被重复发现。
|
||||
|
||||
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
|
||||
|
||||
@@ -385,19 +387,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
|
||||
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目(默认 mono)
|
||||
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
> 安装器优先使用检测到的具体 Agent 根目录(如 `$HOME/.codex/skills/`);仅在未检测到具体 Agent 时回退到 `.agents/skills/`。multi 为按产品平铺,mono 为 `dws/` 子目录。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
@@ -407,22 +409,31 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
# 交互式:提示选模式 + 目标 Agent
|
||||
dws skill setup
|
||||
|
||||
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
|
||||
dws skill setup --mode mono --target all --yes
|
||||
# 先预览 mono setup 将备份和替换的精确目录
|
||||
dws skill setup --mode mono --target all --dry-run
|
||||
|
||||
# 只装到某一个 Agent home
|
||||
dws skill setup --mode multi --target cursor --yes
|
||||
# 交互执行并确认列出的目录
|
||||
dws skill setup --mode mono --target all
|
||||
|
||||
# 指定本地源目录(比如 fork 或正在改的版本)
|
||||
# 先预览,再交互确认装到某一个 Agent home
|
||||
dws skill setup --mode multi --target cursor --dry-run
|
||||
dws skill setup --mode multi --target cursor
|
||||
|
||||
# 指定本地源目录(比如 fork 或正在改的版本),先预览
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | 安装目标;`all` 表示铺到检测到的具体 Agent home(ZCode 为 `~/.zcode/skills`),仅在未检测到具体 Agent 时回退到 `~/.agents/skills` |
|
||||
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
|
||||
| `--yes` | — | 跳过确认提示 |
|
||||
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
|
||||
|
||||
> setup 命令可能移除对面模式残留(装 multi 删 `dws/`,装 mono 清理统一状态中登记或属于状态上线前精确官方名称集合的 multi Skill)以及不在 bundle 内的过期受管 Skill。DWS 在 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)集中记录所有权、安装版本、来源和内容摘要。仅有 `dingtalk-*` 前缀不能触发清理,因此其他同前缀市场/用户 Skill 会保留。所有删除都会先列入确认预览,并备份到 `~/.dws/skill-backups/<时间戳>/`;备份失败的目录会保留原样、绝不删除。非交互环境应先用 `--dry-run` 核对输出,再由调用方显式决定是否使用仅供脚本的确认跳过参数。
|
||||
|
||||
multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权元数据写入 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)。每次 upgrade 都会安装并覆盖该版本的全部预制 Skill;手工删除或通过 setup 排除预制 Skill 不会永久保留,下次 upgrade 会恢复。`dws upgrade --force` 还允许在没有新版本时重装当前 CLI 版本。
|
||||
|
||||
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
|
||||
|
||||
@@ -465,7 +476,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及七个 OA 审批任务/实例事件。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
@@ -513,12 +524,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# 一个进程监听全部六个公开 OA 审批事件
|
||||
# 一个进程监听全部七个公开 OA 审批事件
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
@@ -715,7 +727,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
<summary>即将推出</summary>
|
||||
|
||||
- `conference`(视频会议)
|
||||
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
|
||||
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 交互确认后可切回单 skill
|
||||
|
||||
</details>
|
||||
|
||||
@@ -766,6 +778,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
|
||||
## 参考与文档
|
||||
|
||||
- [国际版(`.io`)使用手册](./docs/international-region-guide.zh-CN.md) — 国际版登录、国内/国际 profile 切换、隔离验证与排障
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
|
||||
|
||||
+637
-17
@@ -3,6 +3,7 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const childProcess = require("child_process");
|
||||
@@ -16,6 +17,7 @@ const AGENT_DIRS = [
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".zcode/skills",
|
||||
".github/skills",
|
||||
".windsurf/skills",
|
||||
".augment/skills",
|
||||
@@ -45,6 +47,58 @@ function ensureCleanDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// backupStamp returns the UTC timestamp used for backup directory names,
|
||||
// matching the shell installers' `date -u +%Y%m%d-%H%M%S` layout.
|
||||
function backupStamp() {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||||
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
|
||||
// <stamp>/<rel-or-basename> instead of destroying it (non-interactive
|
||||
// installs cannot confirm, so removals must stay reversible). Missing paths
|
||||
// are a no-op success. On any backup failure the directory is left in place
|
||||
// and false is returned so callers skip that target rather than silently
|
||||
// deleting data.
|
||||
function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
const rel = path.relative(homeDir, dir);
|
||||
const name =
|
||||
rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||
? rel.split(path.sep).join("-")
|
||||
: path.basename(dir);
|
||||
const stamp = backupStamp();
|
||||
const backupRoot = path.join(homeDir, ".dws", "skill-backups");
|
||||
let targetRoot = path.join(backupRoot, stamp);
|
||||
let target = path.join(targetRoot, name);
|
||||
for (let i = 1; fs.existsSync(target); i++) {
|
||||
if (i > 1000) {
|
||||
console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`);
|
||||
return false;
|
||||
}
|
||||
targetRoot = path.join(backupRoot, `${stamp}-${i}`);
|
||||
target = path.join(targetRoot, name);
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
renameFn(dir, target);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
if (backups) {
|
||||
backups.push({ original: dir, backup: target });
|
||||
}
|
||||
console.log(` × 已备份并移除 ${dir} → ${target}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function findBinary(root) {
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
@@ -117,47 +171,587 @@ function copyChildren(srcDir, destDir) {
|
||||
}
|
||||
}
|
||||
|
||||
// publishCacheAtomically prepares a complete sibling tree before replacing a
|
||||
// cache. If copying or publishing fails, the previous cache stays available.
|
||||
// copyFn is injectable so the failure contract can be tested without relying
|
||||
// on platform-specific permission behavior.
|
||||
function publishCacheAtomically(sourceDir, cacheDir, copyFn = copyChildren) {
|
||||
const cacheParent = path.dirname(cacheDir);
|
||||
const cacheName = path.basename(cacheDir);
|
||||
fs.mkdirSync(cacheParent, { recursive: true });
|
||||
|
||||
const stagedDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.tmp-`));
|
||||
let rollbackDir = "";
|
||||
let published = false;
|
||||
try {
|
||||
copyFn(sourceDir, stagedDir);
|
||||
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
rollbackDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.old-`));
|
||||
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
||||
fs.renameSync(cacheDir, rollbackDir);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(stagedDir, cacheDir);
|
||||
published = true;
|
||||
} catch (publishErr) {
|
||||
if (rollbackDir) {
|
||||
try {
|
||||
fs.renameSync(rollbackDir, cacheDir);
|
||||
rollbackDir = "";
|
||||
} catch (restoreErr) {
|
||||
throw new Error(
|
||||
`failed to publish cache ${cacheDir}: ${publishErr.message}; ` +
|
||||
`failed to restore previous cache from ${rollbackDir}: ${restoreErr.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw publishErr;
|
||||
}
|
||||
|
||||
if (rollbackDir) {
|
||||
try {
|
||||
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
||||
} catch (cleanupErr) {
|
||||
console.warn(
|
||||
`⚠️ New cache is active, but old cache cleanup failed at ${rollbackDir}: ${cleanupErr.message}`,
|
||||
);
|
||||
}
|
||||
rollbackDir = "";
|
||||
}
|
||||
} finally {
|
||||
if (!published) {
|
||||
fs.rmSync(stagedDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installSkillsToHomes(skillRoot) {
|
||||
const homeDir = os.homedir();
|
||||
const managedNames = readManagedSkillNames(homeDir);
|
||||
let installed = 0;
|
||||
let attempted = 0;
|
||||
let failed = 0;
|
||||
|
||||
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
|
||||
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
|
||||
);
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
publishManagedMonoSkillSetAtomically(homeDir, skillRoot, baseDir, victims);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 跳过 ${baseDir}(mono 集合发布失败,已回滚): ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
if (index === 0 && specificAgentDirs.length > 0) {
|
||||
return;
|
||||
}
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
const destDir = path.join(baseDir, "dws");
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
copyChildren(skillRoot, destDir);
|
||||
installed += 1;
|
||||
attempted += 1;
|
||||
if (installToBase(baseDir)) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (installed === 0) {
|
||||
copyChildren(skillRoot, path.join(homeDir, ".agents", "skills", "dws"));
|
||||
if (specificAgentDirs.length > 0 && installed > 0) {
|
||||
try {
|
||||
retireGenericSkillRoot(homeDir, managedNames);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempted === 0) {
|
||||
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (installed === 0) {
|
||||
throw new Error("未安装任何 mono Skill:所有检测到的 Agent 目标均失败");
|
||||
}
|
||||
if (failed > 0) {
|
||||
throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`);
|
||||
}
|
||||
fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true });
|
||||
}
|
||||
|
||||
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
|
||||
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
|
||||
// bundle carries at least one product skill (a subdir with SKILL.md). An
|
||||
// empty or corrupt multi/ tree must never select the multi branch nor refresh
|
||||
// the multi cache — installing it would wipe existing skills and lay down
|
||||
// nothing.
|
||||
function multiTreeHasSkills(dir) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
|
||||
}
|
||||
|
||||
const MANAGED_SKILL_DIGEST_SCOPE = "skill-directory-v1";
|
||||
// Frozen exact names shipped before centralized ownership metadata. Retired
|
||||
// names stay here so old installs can be migrated without treating every
|
||||
// dingtalk-* directory as DWS-owned.
|
||||
const LEGACY_OFFICIAL_MULTI_SKILLS = new Set([
|
||||
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
|
||||
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
|
||||
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
|
||||
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
|
||||
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
|
||||
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
|
||||
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
|
||||
"dingtalk-todo", "dingtalk-wiki", "dws-shared",
|
||||
]);
|
||||
|
||||
function skillStateDir(homeDir) {
|
||||
return (process.env.DWS_CONFIG_DIR || "").trim() || path.join(homeDir, ".dws");
|
||||
}
|
||||
|
||||
function readManagedSkillNames(homeDir) {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(path.join(skillStateDir(homeDir), "skills-state.json"), "utf8"));
|
||||
return new Set((state.managed_skills || []).map((record) => record.name).filter(Boolean));
|
||||
} catch (_) {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function isManagedMultiSkillDir(dir, managedNames) {
|
||||
const name = path.basename(dir);
|
||||
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
|
||||
}
|
||||
|
||||
function retireGenericSkillRoot(homeDir, managedNames) {
|
||||
const baseDir = path.join(homeDir, ".agents", "skills");
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
const backups = [];
|
||||
try {
|
||||
for (const victim of victims) {
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const restoreErrors = [];
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
|
||||
fs.renameSync(backups[i].backup, backups[i].original);
|
||||
} catch (restoreErr) {
|
||||
restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`);
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function skillDirectoryDigest(dir) {
|
||||
const files = [];
|
||||
const visit = (current, prefix) => {
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(full, rel);
|
||||
} else {
|
||||
files.push({ rel, full });
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(dir, "");
|
||||
files.sort((a, b) => Buffer.from(a.rel).compare(Buffer.from(b.rel)));
|
||||
const hash = crypto.createHash("sha256");
|
||||
for (const file of files) {
|
||||
hash.update(file.rel, "utf8");
|
||||
hash.update(Buffer.from([0]));
|
||||
hash.update(fs.readFileSync(file.full));
|
||||
hash.update(Buffer.from([0]));
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
// Publish a complete multi-skill set as one transaction. The entire new set
|
||||
// is staged before any Agent-visible directory moves. If a later backup or
|
||||
// publish fails, every partial publication is removed and all old directories
|
||||
// are restored from their exact backup paths.
|
||||
function publishManagedMultiSkillSetAtomically(
|
||||
homeDir,
|
||||
multiRoot,
|
||||
baseDir,
|
||||
skills,
|
||||
victims,
|
||||
options = {},
|
||||
) {
|
||||
const copyFn = options.copyFn || copyChildren;
|
||||
const renameFn = options.renameFn || fs.renameSync;
|
||||
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-"));
|
||||
const staged = [];
|
||||
const backups = [];
|
||||
const published = [];
|
||||
|
||||
const restore = () => {
|
||||
const restoreErrors = [];
|
||||
for (let i = published.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
removeFn(published[i]);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
const item = backups[i];
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(item.original), { recursive: true });
|
||||
renameFn(item.backup, item.original);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(restoreErrors.join("; "));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (const name of skills) {
|
||||
const stagedDir = path.join(stageRoot, name);
|
||||
copyFn(path.join(multiRoot, name), stagedDir);
|
||||
staged.push({ staged: stagedDir, dest: path.join(baseDir, name) });
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
for (const victim of victims) {
|
||||
const normalized = path.resolve(victim);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of staged) {
|
||||
renameFn(item.staged, item.dest);
|
||||
published.push(item.dest);
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
restore();
|
||||
} catch (restoreErr) {
|
||||
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
removeFn(stageRoot);
|
||||
}
|
||||
}
|
||||
|
||||
// Publish mono plus every mutually-exclusive managed multi victim as one
|
||||
// transaction. The complete dws/ tree is staged before any live directory is
|
||||
// moved; a later backup or publish failure restores the exact previous set.
|
||||
function publishManagedMonoSkillSetAtomically(
|
||||
homeDir,
|
||||
monoRoot,
|
||||
baseDir,
|
||||
victims,
|
||||
options = {},
|
||||
) {
|
||||
const copyFn = options.copyFn || copyChildren;
|
||||
const renameFn = options.renameFn || fs.renameSync;
|
||||
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-"));
|
||||
const stagedDir = path.join(stageRoot, "dws");
|
||||
const destDir = path.join(baseDir, "dws");
|
||||
const backups = [];
|
||||
const published = [];
|
||||
|
||||
const restore = () => {
|
||||
const restoreErrors = [];
|
||||
for (let i = published.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
removeFn(published[i]);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
const item = backups[i];
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(item.original), { recursive: true });
|
||||
renameFn(item.backup, item.original);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(restoreErrors.join("; "));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
copyFn(monoRoot, stagedDir);
|
||||
|
||||
const seen = new Set();
|
||||
for (const victim of victims) {
|
||||
const normalized = path.resolve(victim);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
|
||||
published.push(destDir);
|
||||
renameFn(stagedDir, destDir);
|
||||
} catch (err) {
|
||||
try {
|
||||
restore();
|
||||
} catch (restoreErr) {
|
||||
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
removeFn(stageRoot);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSkillsState(homeDir, multiRoot, skills) {
|
||||
const version = process.env.npm_package_version || process.env.DWS_PACKAGE_VERSION || "unknown";
|
||||
const managedSkills = [...skills].sort().map((name) => ({
|
||||
name,
|
||||
version,
|
||||
source: "npm-postinstall",
|
||||
digest: skillDirectoryDigest(path.join(multiRoot, name)),
|
||||
digest_scope: MANAGED_SKILL_DIGEST_SCOPE,
|
||||
}));
|
||||
const state = {
|
||||
version,
|
||||
official_skills: [...skills].sort(),
|
||||
updated_skills: [...skills].sort(),
|
||||
managed_skills: managedSkills,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
const stateDir = skillStateDir(homeDir);
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const stage = fs.mkdtempSync(path.join(stateDir, ".skills-state.tmp-"));
|
||||
const stagedFile = path.join(stage, "skills-state.json");
|
||||
const statePath = path.join(stateDir, "skills-state.json");
|
||||
const rollbackPath = path.join(stage, "skills-state.previous.json");
|
||||
let movedPrevious = false;
|
||||
let preserveRecovery = false;
|
||||
try {
|
||||
fs.writeFileSync(stagedFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
if (fs.existsSync(statePath)) {
|
||||
fs.renameSync(statePath, rollbackPath);
|
||||
movedPrevious = true;
|
||||
}
|
||||
try {
|
||||
fs.renameSync(stagedFile, statePath);
|
||||
} catch (err) {
|
||||
if (movedPrevious && !fs.existsSync(statePath)) {
|
||||
try {
|
||||
fs.renameSync(rollbackPath, statePath);
|
||||
movedPrevious = false;
|
||||
} catch (restoreErr) {
|
||||
preserveRecovery = true;
|
||||
throw new Error(
|
||||
`publish skills state failed: ${err.message}; restore also failed: ${restoreErr.message}; previous state retained at ${rollbackPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (!preserveRecovery) {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
|
||||
// every product skill becomes a sibling directory of the agent home. Mutual
|
||||
// exclusion: the mono leftover (dws/) and stale, proven DWS-managed skills not
|
||||
// present in the new bundle are removed first.
|
||||
function installMultiSkillsToHomes(multiRoot) {
|
||||
const homeDir = os.homedir();
|
||||
const skills = fs
|
||||
.readdirSync(multiRoot, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
|
||||
.map((e) => e.name);
|
||||
if (skills.length === 0) {
|
||||
throw new Error(`no product skills found under ${multiRoot}`);
|
||||
}
|
||||
const skillSet = new Set(skills);
|
||||
const managedNames = readManagedSkillNames(homeDir);
|
||||
let installed = 0;
|
||||
let attempted = 0;
|
||||
let failed = 0;
|
||||
|
||||
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
|
||||
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
|
||||
);
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
// Mutual exclusion: include the mono leftover and stale managed skills in
|
||||
// the same transaction as every replaced bundled skill.
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
|
||||
!skillSet.has(entry.name)
|
||||
) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
for (const name of skills) {
|
||||
victims.push(path.join(baseDir, name));
|
||||
}
|
||||
try {
|
||||
publishManagedMultiSkillSetAtomically(homeDir, multiRoot, baseDir, skills, victims);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 跳过 ${baseDir}(multi 集合发布失败,已回滚): ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
if (index === 0 && specificAgentDirs.length > 0) {
|
||||
return;
|
||||
}
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
attempted += 1;
|
||||
if (installToBase(baseDir)) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (specificAgentDirs.length > 0 && installed > 0) {
|
||||
try {
|
||||
retireGenericSkillRoot(homeDir, managedNames);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempted === 0) {
|
||||
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (installed === 0) {
|
||||
throw new Error("未安装任何 multi Skill:所有检测到的 Agent 目标均失败");
|
||||
}
|
||||
if (failed > 0) {
|
||||
throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`);
|
||||
}
|
||||
writeSkillsState(homeDir, multiRoot, skills);
|
||||
}
|
||||
|
||||
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
|
||||
// wins; multi is the default. The --skill-mode flag accepts both the space
|
||||
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
|
||||
function resolveSkillMode() {
|
||||
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
|
||||
if (raw === "mono" || raw === "multi") {
|
||||
return raw;
|
||||
}
|
||||
if (raw !== "") {
|
||||
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
let fromFlag;
|
||||
const flagIndex = process.argv.indexOf("--skill-mode");
|
||||
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
|
||||
fromFlag = process.argv[flagIndex + 1];
|
||||
} else {
|
||||
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
|
||||
if (equalsArg) {
|
||||
fromFlag = equalsArg.slice("--skill-mode=".length);
|
||||
}
|
||||
}
|
||||
if (fromFlag !== undefined) {
|
||||
const mode = fromFlag.trim().toLowerCase();
|
||||
if (mode === "mono" || mode === "multi") {
|
||||
return mode;
|
||||
}
|
||||
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
return "multi";
|
||||
}
|
||||
|
||||
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
|
||||
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
|
||||
// can fall back to a user-local cache when --source is not provided. mono is
|
||||
// already installed into agent homes by installSkillsToHomes; the cache is
|
||||
// purely a source-of-truth for the setup command.
|
||||
// can fall back to a user-local cache when --source is not provided. A cache
|
||||
// is only refreshed when the new bundle actually carries that tree — an
|
||||
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
|
||||
// good cache.
|
||||
function cacheUserSkills(extractedSkillsRoot) {
|
||||
const cacheBase = path.join(os.homedir(), ".dws", "skills");
|
||||
|
||||
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
|
||||
? path.join(extractedSkillsRoot, "mono")
|
||||
: extractedSkillsRoot;
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
fs.rmSync(monoCache, { recursive: true, force: true });
|
||||
copyChildren(monoSource, monoCache);
|
||||
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
publishCacheAtomically(monoSource, monoCache);
|
||||
}
|
||||
|
||||
const multiSource = path.join(extractedSkillsRoot, "multi");
|
||||
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
|
||||
if (multiTreeHasSkills(multiSource)) {
|
||||
const multiCache = path.join(cacheBase, "multi");
|
||||
fs.rmSync(multiCache, { recursive: true, force: true });
|
||||
copyChildren(multiSource, multiCache);
|
||||
publishCacheAtomically(multiSource, multiCache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,8 +785,34 @@ function main() {
|
||||
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
|
||||
? path.join(skillsStaging, "mono")
|
||||
: skillsStaging;
|
||||
installSkillsToHomes(monoRoot);
|
||||
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
|
||||
// multi-only zip monoRoot would degrade to the staging root and copy the
|
||||
// whole bundle (multi/ included) into a dws/ directory — skip instead.
|
||||
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
|
||||
const multiRoot = path.join(skillsStaging, "multi");
|
||||
const skillMode = resolveSkillMode();
|
||||
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
|
||||
console.log(`Skill mode: multi — installing per-product skills`);
|
||||
installMultiSkillsToHomes(multiRoot);
|
||||
} else {
|
||||
if (skillMode === "multi") {
|
||||
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
|
||||
}
|
||||
if (monoHasSkill) {
|
||||
installSkillsToHomes(monoRoot);
|
||||
} else {
|
||||
console.log("mono skill tree not found in bundle; skipping skill install.");
|
||||
}
|
||||
}
|
||||
cacheUserSkills(skillsStaging);
|
||||
}
|
||||
|
||||
main();
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
publishCacheAtomically,
|
||||
publishManagedMonoSkillSetAtomically,
|
||||
publishManagedMultiSkillSetAtomically,
|
||||
};
|
||||
|
||||
+66
-2
@@ -15,12 +15,76 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
var exit = os.Exit
|
||||
var (
|
||||
appExecute = app.ExecuteWithTelemetry
|
||||
resolveTelemetryIdentity = app.ResolveTelemetryIdentity
|
||||
trackRun = func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
clitrack.New(cfg).Run(execute, exitCode)
|
||||
}
|
||||
)
|
||||
|
||||
// trackedExitError tells clitrack that the command failed without asking it to
|
||||
// print the error a second time. The already-rendered message is published via
|
||||
// ExtraFields c5, while app.Execute remains the sole owner of presentation.
|
||||
type trackedExitError struct{}
|
||||
|
||||
func (trackedExitError) Error() string { return "" }
|
||||
|
||||
func trackerConfig(identity app.TelemetryIdentity, commandPath, errorMessage *string) clitrack.Config {
|
||||
return clitrack.Config{
|
||||
PID: "wcCRwZ",
|
||||
App: "dws",
|
||||
Version: app.RawVersion(),
|
||||
UID: identity.UserID,
|
||||
Username: identity.UserName,
|
||||
NoCommandLine: true,
|
||||
NoCwd: true,
|
||||
NoAutomaticDimensions: true,
|
||||
ExtraFields: func() map[string]string {
|
||||
fields := map[string]string{"c9": *commandPath}
|
||||
if identity.CorpID != "" {
|
||||
fields["c10"] = identity.CorpID
|
||||
}
|
||||
if *errorMessage != "" {
|
||||
fields["c5"] = *errorMessage
|
||||
}
|
||||
return fields
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func telemetryOptedOut() bool {
|
||||
return strings.TrimSpace(os.Getenv("DO_NOT_TRACK")) != ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
exit(app.Execute())
|
||||
optedOut := telemetryOptedOut()
|
||||
identity := app.TelemetryIdentity{}
|
||||
if !optedOut {
|
||||
identity = resolveTelemetryIdentity(os.Args[1:])
|
||||
}
|
||||
exitCode := 0
|
||||
commandPath := "dws"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(identity, &commandPath, &errorMessage)
|
||||
if optedOut {
|
||||
cfg.PID = ""
|
||||
}
|
||||
trackRun(
|
||||
cfg,
|
||||
func() error {
|
||||
exitCode, commandPath, errorMessage = appExecute()
|
||||
if exitCode != 0 {
|
||||
return trackedExitError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(error) int { return exitCode },
|
||||
)
|
||||
}
|
||||
|
||||
+206
-13
@@ -1,27 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) {
|
||||
previousExit := exit
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
exit = previousExit
|
||||
os.Args = previousArgs
|
||||
})
|
||||
func TestCrossPlatformCoverageMainRunsThroughCLITracker(t *testing.T) {
|
||||
for _, wantCode := range []int{0, 1, 3, 5} {
|
||||
t.Run(fmt.Sprintf("exit_%d", wantCode), func(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "")
|
||||
wantError := ""
|
||||
if wantCode != 0 {
|
||||
wantError = "synthetic failure"
|
||||
}
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "read", "--profile", "corp-a"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func(args []string) app.TelemetryIdentity {
|
||||
if strings.Join(args, " ") != "sheet read --profile corp-a" {
|
||||
t.Fatalf("telemetry identity args = %#v", args)
|
||||
}
|
||||
return app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return wantCode, "sheet read", wantError })
|
||||
called := false
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
called = true
|
||||
if cfg.PID != "wcCRwZ" || cfg.App != "dws" {
|
||||
t.Fatalf("tracker identity = PID %q App %q", cfg.PID, cfg.App)
|
||||
}
|
||||
if cfg.Version != app.RawVersion() {
|
||||
t.Fatalf("tracker Version = %q, want %q", cfg.Version, app.RawVersion())
|
||||
}
|
||||
if !cfg.NoCommandLine || !cfg.NoCwd || !cfg.NoAutomaticDimensions || cfg.CaptureOutput {
|
||||
t.Fatalf("tracker privacy config = NoCommandLine %v NoCwd %v NoAutomaticDimensions %v CaptureOutput %v", cfg.NoCommandLine, cfg.NoCwd, cfg.NoAutomaticDimensions, cfg.CaptureOutput)
|
||||
}
|
||||
if cfg.Env != "" || cfg.EventID != "" || cfg.Endpoint != "" || cfg.FlushTimeout != 0 || cfg.OutputMaxLen != 0 {
|
||||
t.Fatalf("tracker SDK defaults were overridden: %#v", cfg)
|
||||
}
|
||||
if cfg.UID != "user-1" || cfg.Username != "Alice" || cfg.UserType != "" {
|
||||
t.Fatalf("tracker user identity = UID %q Username %q UserType %q", cfg.UID, cfg.Username, cfg.UserType)
|
||||
}
|
||||
|
||||
err := execute()
|
||||
if wantCode == 0 && err != nil {
|
||||
t.Fatalf("successful tracked execute error = %v", err)
|
||||
}
|
||||
if wantCode != 0 && (err == nil || err.Error() != "") {
|
||||
t.Fatalf("failed tracked execute error = %#v, want empty sentinel", err)
|
||||
}
|
||||
if gotCode := exitCode(err); gotCode != wantCode {
|
||||
t.Fatalf("tracked exit code = %d, want %d", gotCode, wantCode)
|
||||
}
|
||||
fields := cfg.ExtraFields()
|
||||
if fields["c9"] != "sheet read" || fields["c10"] != "corp-1" || fields["c5"] != wantError {
|
||||
t.Fatalf("tracker extra fields = %#v, want command path, corp ID, and error %q", fields, wantError)
|
||||
}
|
||||
if (wantError == "" && len(fields) != 2) || (wantError != "" && len(fields) != 3) {
|
||||
t.Fatalf("tracker extra field count = %d for error %q", len(fields), wantError)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called {
|
||||
t.Fatalf("trackRun was not called for exit code %d", wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerConfigOmitsEmptyOrganization(t *testing.T) {
|
||||
commandPath := "version"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{}, &commandPath, &errorMessage)
|
||||
if cfg.UID != "" {
|
||||
t.Fatalf("empty identity UID = %q", cfg.UID)
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
t.Fatalf("empty identity Username = %q", cfg.Username)
|
||||
}
|
||||
if fields := cfg.ExtraFields(); len(fields) != 1 || fields["c9"] != "version" {
|
||||
t.Fatalf("empty organization fields = %#v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDefaultTrackRunNoopTracker(t *testing.T) {
|
||||
called := false
|
||||
code := -1
|
||||
exit = func(value int) {
|
||||
trackRun(clitrack.Config{}, func() error {
|
||||
called = true
|
||||
code = value
|
||||
return nil
|
||||
}, nil)
|
||||
if !called {
|
||||
t.Fatal("default tracker did not execute callback")
|
||||
}
|
||||
os.Args = []string{"dws", "version"}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMainRespectsDoNotTrack(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "1")
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "version"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func([]string) app.TelemetryIdentity {
|
||||
t.Fatal("DO_NOT_TRACK must skip telemetry identity reads")
|
||||
return app.TelemetryIdentity{}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return 0, "version", "" })
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
if cfg.PID != "" || cfg.UID != "" || cfg.Username != "" {
|
||||
t.Fatalf("opted-out tracker config = %#v", cfg)
|
||||
}
|
||||
if err := execute(); err != nil {
|
||||
t.Fatalf("opted-out execution failed: %v", err)
|
||||
}
|
||||
if code := exitCode(nil); code != 0 {
|
||||
t.Fatalf("opted-out exit code = %d, want 0", code)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called || code != 0 {
|
||||
t.Fatalf("main exit = called %v, code %d", called, code)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerPayloadUsesReviewedFieldWhitelist(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "sheet", "read", "--access-token", "must-not-leak"}
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
t.Setenv("TERM_SESSION_ID", "stable-session")
|
||||
t.Setenv("TMUX_PANE", "%42")
|
||||
t.Setenv("LANG", "zh_CN.UTF-8")
|
||||
t.Setenv("LC_ALL", "zh_CN.UTF-8")
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
requestBody := make(chan []byte, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
requestBody <- body
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
commandPath := "sheet read"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}, &commandPath, &errorMessage)
|
||||
cfg.Endpoint = server.URL
|
||||
cfg.FlushTimeout = time.Second
|
||||
clitrack.New(cfg).Run(func() error { return nil }, nil)
|
||||
|
||||
var body []byte
|
||||
select {
|
||||
case body = <-requestBody:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for telemetry request")
|
||||
}
|
||||
var envelope map[string]string
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("decode telemetry request %q: %v", body, err)
|
||||
}
|
||||
decoded, err := url.QueryUnescape(envelope["gokey"])
|
||||
if err != nil {
|
||||
t.Fatalf("decode gokey: %v", err)
|
||||
}
|
||||
globalFields, err := url.ParseQuery(decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("parse global telemetry fields: %v", err)
|
||||
}
|
||||
eventFields, err := url.ParseQuery(globalFields.Get("msg"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse event telemetry fields: %v", err)
|
||||
}
|
||||
|
||||
assertTelemetryKeys(t, globalFields, []string{"app_name", "app_version", "env", "msg", "pid", "platform", "uid", "username", "version"})
|
||||
assertTelemetryKeys(t, eventFields, []string{"c1", "c10", "c3", "c4", "c9", "p1", "p4", "ts", "type"})
|
||||
for key, want := range map[string]string{
|
||||
"app_name": "dws", "app_version": app.RawVersion(), "env": "prod", "pid": "wcCRwZ",
|
||||
"platform": "cli", "uid": "user-1", "username": "Alice", "version": app.RawVersion(),
|
||||
} {
|
||||
if got := globalFields.Get(key); got != want {
|
||||
t.Fatalf("global telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"type": "event", "p1": "cli.exec", "p4": "SYS", "c1": "dws", "c3": "0", "c9": "sheet read", "c10": "corp-1",
|
||||
} {
|
||||
if got := eventFields.Get(key); got != want {
|
||||
t.Fatalf("event telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} {
|
||||
if globalFields.Has(key) {
|
||||
t.Fatalf("global telemetry leaked %s: %q", key, decoded)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"c2", "c5", "c6", "c7", "c8"} {
|
||||
if eventFields.Has(key) {
|
||||
t.Fatalf("event telemetry leaked %s: %q", key, globalFields.Get("msg"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTelemetryKeys(t *testing.T, fields url.Values, want []string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
got = append(got, key)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("telemetry keys = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
+29
-10
@@ -48,8 +48,12 @@ It then runs:
|
||||
--fast-path "$PR_BASE_SHA" HEAD
|
||||
```
|
||||
|
||||
Because the verified PR diff contains only `CHANGELOG.md`, the validator and
|
||||
its policy dependencies in that merge tree are byte-for-byte the current base
|
||||
The exact fast path remains limited to historic one-file maintenance. A
|
||||
release-seal PR uses `--content-only`, which permits the generated
|
||||
`CHANGELOG.md` change together with archival moves from `.changes/` to
|
||||
`.changes/released/`; it receives the normal scoped admission instead of this
|
||||
fast path. Ordinary PRs must not modify `CHANGELOG.md`; they add a standalone
|
||||
release fragment instead. The validator and its policy dependencies in that merge tree are byte-for-byte the current base
|
||||
versions. Validation targets the synthetic merge tree, not the feature-branch
|
||||
tree, so a stale branch cannot supply an older validator or combine with newer
|
||||
base notes into an invalid final CHANGELOG.
|
||||
@@ -79,10 +83,12 @@ to the complete main admission suite. A source change can therefore never
|
||||
inherit the CHANGELOG-only result.
|
||||
|
||||
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
|
||||
content contract in `Policy` with `--content-only`. That mode permits the
|
||||
second file but still rejects invalid dates or versions, missing bullets,
|
||||
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
|
||||
Adding a second file therefore cannot bypass CHANGELOG validation.
|
||||
content contract in `Policy` with `--content-only`. That mode accepts only
|
||||
fragment archival moves (`.changes/<name>.md` to
|
||||
`.changes/released/<version>/<name>.md`) alongside the changelog; source and
|
||||
documentation changes are rejected. It still rejects invalid dates or
|
||||
versions, missing bullets, placeholder `TODO`/`TBD`, unmanaged-section
|
||||
changes, and unsafe tree modes.
|
||||
|
||||
## Risk tiers and downstream boundaries
|
||||
|
||||
@@ -184,19 +190,32 @@ Schema,并让 candidate 对两份历史 contract 独立执行检查;它只
|
||||
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
|
||||
放宽其他 Schema 历史字段。
|
||||
|
||||
For an exact CHANGELOG-only branch:
|
||||
For a release-seal branch that archives rendered fragments:
|
||||
|
||||
```sh
|
||||
base_ref=$(git merge-base HEAD origin/main)
|
||||
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
|
||||
./scripts/policy/check-changelog-pr.sh --content-only "$base_ref" HEAD
|
||||
```
|
||||
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. For a
|
||||
standard PR, CI derives changed packages and their reverse-dependency test
|
||||
closure, then generates candidate and merge-base profiles with the same test
|
||||
scope and `coverpkg`. High-risk and protected-main runs use the complete
|
||||
profiles. Supporting and (when platform-selected) native profiles are
|
||||
generated before the aggregate `Coverage` context evaluates them. The
|
||||
profiles. The complete candidate profile is produced by disjoint per-shard
|
||||
helper jobs (`scripts/ci/test-packages.sh list-coverage`, kept serial with
|
||||
`-p 1` inside each shard; `verify` proves the shard union equals the
|
||||
full-suite scope exactly once) and concatenated in the aggregate job before
|
||||
enforcement. The complete merge-base profile is restored from an exact-key
|
||||
cache written by the last green `main` push of that same commit (key:
|
||||
merge-base SHA plus resolved Go version); any miss falls back to recomputing
|
||||
it in a merge-base worktree. The trusted `main` producer and PR consumer use
|
||||
the same dedicated cache profile path because GitHub includes that path in the
|
||||
cache version; the runtime-facing candidate and baseline filenames remain
|
||||
separate. Near-miss reuse is forbidden — the caches carry no prefix restore
|
||||
keys, because a neighbouring commit's profile would compare the candidate
|
||||
against the wrong baseline. Supporting and (when
|
||||
platform-selected) native profiles are generated before the aggregate
|
||||
`Coverage` context evaluates them. The
|
||||
aggregate and native gates require 100% coverage for changed executable Go
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance,
|
||||
scope-matched merge-base non-regression check. Candidate and baseline profiles
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLI flag 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
|
||||
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
||||
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
||||
@@ -41,7 +41,7 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
|
||||
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
@@ -50,7 +50,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||
|---|---|---|
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||
|
||||
@@ -122,7 +122,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||
|
||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
|
||||
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
@@ -145,7 +145,7 @@ legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interfa
|
||||
`required` / `cli_required` 或重写 constraint;
|
||||
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
|
||||
`required_when` 必须完全一致;
|
||||
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
|
||||
- `required` / `cli_required` 必须在 rename 前后完全一致,升高或降低都失败;
|
||||
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
|
||||
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
|
||||
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="DWS Drive Shortcut 与 lark-cli 的业务能力、真实数据 E2E 证据和平台边界分析。">
|
||||
<title>Drive Shortcut 能力全景|业务评审版</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--paper: #f4f6f2; --surface: #fffefa; --ink: #17251f; --muted: #66746d;
|
||||
--line: #dce2dc; --forest: #154f3d; --green: #17765a; --mint: #dff4e8;
|
||||
--blue: #265f86; --blue-soft: #e7f1f7; --amber: #8c5a09; --amber-soft: #fff2cf;
|
||||
--red: #a43b32; --red-soft: #fde9e5; --shadow: 0 14px 40px rgba(28, 48, 38, .08);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; color: var(--ink); background: var(--paper); font: 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
code { padding: .12rem .38rem; border: 1px solid #d6e0da; border-radius: 6px; color: #174f3e; background: #f1f7f3; font: 600 .88em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
|
||||
.wrap { width: min(1180px, calc(100% - 40px)); margin: auto; }
|
||||
.hero { position: relative; overflow: hidden; padding: 64px 0 52px; color: #f7fff9; background: linear-gradient(125deg, #102b22 0%, #154c3c 57%, #1c6b54 100%); }
|
||||
.hero::after { position: absolute; inset: -180px -100px auto auto; width: 540px; height: 540px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; box-shadow: 0 0 0 76px rgba(255,255,255,.035), 0 0 0 152px rgba(255,255,255,.025); content: ""; }
|
||||
.hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); gap: 32px; align-items: end; }
|
||||
.eyebrow, .section-kicker { margin: 0 0 9px; color: #9fd6bd; font-size: 11px; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
|
||||
h1 { margin: 0; font-size: clamp(40px, 6vw, 68px); line-height: 1.03; letter-spacing: -.045em; }
|
||||
.subtitle { max-width: 760px; margin: 19px 0 0; color: #d3e9de; font-size: 17px; }
|
||||
.meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 21px; }
|
||||
.meta-pill { padding: 6px 10px; border: 1px solid rgba(255,255,255,.18); border-radius: 999px; color: #d5e9df; background: rgba(255,255,255,.07); font-size: 11px; font-weight: 750; }
|
||||
.meta-pill.good { color: #bff2d5; border-color: rgba(139,232,179,.38); }
|
||||
.hero-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.hero-stat { padding: 17px 16px; border: 1px solid rgba(255,255,255,.17); border-radius: 15px; background: rgba(255,255,255,.075); backdrop-filter: blur(8px); }
|
||||
.hero-stat strong { display: block; font-size: 30px; line-height: 1; }
|
||||
.hero-stat span { display: block; margin-top: 7px; color: #cce2d7; font-size: 11px; }
|
||||
.nav { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--line); background: rgba(255,254,250,.94); backdrop-filter: blur(12px); }
|
||||
.nav .wrap { display: flex; overflow-x: auto; }
|
||||
.nav a { flex: 0 0 auto; padding: 14px 15px; color: #5b6c64; font-size: 12px; font-weight: 800; }
|
||||
.nav a:hover { color: var(--forest); background: #eaf3ee; }
|
||||
main { padding: 38px 0 74px; }
|
||||
section { margin-top: 50px; scroll-margin-top: 72px; }
|
||||
section:first-child { margin-top: 0; }
|
||||
.section-head { display: flex; justify-content: space-between; gap: 28px; align-items: end; margin-bottom: 19px; }
|
||||
h2 { margin: 0; font-size: clamp(25px, 3.2vw, 36px); line-height: 1.16; letter-spacing: -.025em; }
|
||||
h3 { margin: 0 0 7px; font-size: 18px; }
|
||||
.section-desc { max-width: 660px; margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.callout { padding: 19px 21px; border: 1px solid #bdd8cb; border-left: 4px solid var(--green); border-radius: 13px; background: #ecf7f1; box-shadow: 0 6px 20px rgba(28,48,38,.04); }
|
||||
.callout strong { color: #13513d; }
|
||||
.callout.warn { border-color: #ead29a; border-left-color: #b67508; background: #fff8e7; }
|
||||
.callout.warn strong { color: #784b00; }
|
||||
.callout.danger { border-color: #e9b7b1; border-left-color: var(--red); background: var(--red-soft); }
|
||||
.score-grid, .domain-grid, .evidence-grid, .review-grid { display: grid; gap: 13px; }
|
||||
.score-grid { grid-template-columns: repeat(4, 1fr); margin-top: 16px; }
|
||||
.score, .domain-card, .evidence-card, .review-card { border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.score { padding: 19px; }
|
||||
.score strong { display: block; color: var(--forest); font-size: 29px; line-height: 1; }
|
||||
.score span { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; }
|
||||
.domain-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.domain-card { position: relative; padding: 21px; overflow: hidden; }
|
||||
.domain-card .number { position: absolute; top: 12px; right: 17px; color: #d5e8de; font: 800 42px/1 ui-monospace, monospace; }
|
||||
.domain-card p { min-height: 64px; margin: 8px 0 12px; color: var(--muted); font-size: 13px; }
|
||||
.domain-card small { color: var(--green); font-weight: 800; }
|
||||
.compare { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.compare-top { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.compare-column { padding: 21px; border-right: 1px solid var(--line); }
|
||||
.compare-column:last-child { border-right: 0; }
|
||||
.compare-column p, .compare-column li { color: var(--muted); font-size: 13px; }
|
||||
.compare-column ul { margin: 9px 0 0; padding-left: 18px; }
|
||||
.compare-column.covered { border-top: 5px solid var(--green); }
|
||||
.compare-column.partial { border-top: 5px solid #c78b22; }
|
||||
.compare-column.gap { border-top: 5px solid var(--red); }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
||||
th { color: #617069; background: #f7f8f5; font-size: 11px; font-weight: 900; letter-spacing: .03em; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: #f8fbf8; }
|
||||
.verdict, .badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; font-size: 10px; font-weight: 900; white-space: nowrap; }
|
||||
.v-covered, .badge.read { color: #116045; background: var(--mint); }
|
||||
.v-ahead, .badge.smart { color: #20577c; background: var(--blue-soft); }
|
||||
.v-partial, .badge.write { color: #7b510a; background: var(--amber-soft); }
|
||||
.v-gap, .badge.high { color: #8f3028; background: var(--red-soft); }
|
||||
.truth-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 14px; }
|
||||
.truth-card { padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.truth-step { display: grid; grid-template-columns: 30px 1fr; gap: 11px; margin-top: 13px; }
|
||||
.truth-step b { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 50%; color: #fff; background: var(--forest); font-size: 12px; }
|
||||
.truth-step strong, .truth-step span { display: block; }
|
||||
.truth-step span { color: var(--muted); font-size: 12px; }
|
||||
.toolbar { display: grid; grid-template-columns: minmax(260px, 1fr) 180px 180px auto; gap: 10px; align-items: center; margin: 18px 0; padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
|
||||
input, select { width: 100%; min-height: 42px; padding: 9px 11px; border: 1px solid #ccd7d0; border-radius: 9px; color: var(--ink); background: #fff; font: inherit; }
|
||||
input:focus, select:focus { outline: 3px solid rgba(23,118,90,.13); border-color: var(--green); }
|
||||
.result-count { color: var(--muted); font-size: 12px; text-align: right; white-space: nowrap; }
|
||||
.catalog { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.shortcut-row { display: grid; grid-template-columns: 215px minmax(0, 1fr) 200px; gap: 16px; align-items: center; padding: 14px 17px; border-bottom: 1px solid var(--line); }
|
||||
.shortcut-row:last-child { border-bottom: 0; }
|
||||
.shortcut-row:hover { background: #f8fbf8; }
|
||||
.command code { font-size: 12px; }
|
||||
.row-main p { margin: 0; font-size: 13px; }
|
||||
.row-main small { color: var(--muted); }
|
||||
.badges { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
|
||||
.hidden-row { display: none; }
|
||||
.evidence-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.evidence-card { padding: 19px; }
|
||||
.evidence-card strong { display: block; color: var(--forest); font-size: 23px; }
|
||||
.evidence-card p { margin: 7px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.timeline { margin-top: 15px; border-left: 2px solid #bdd7ca; }
|
||||
.event { position: relative; padding: 0 0 17px 22px; }
|
||||
.event::before { position: absolute; left: -7px; top: 5px; width: 12px; height: 12px; border: 3px solid var(--paper); border-radius: 50%; background: var(--green); content: ""; }
|
||||
.event b { display: block; }
|
||||
.event span { color: var(--muted); font-size: 12px; }
|
||||
.review-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.review-card { position: relative; padding: 20px; }
|
||||
.review-card .review-num { color: #b8d1c4; font: 800 12px/1 ui-monospace, monospace; letter-spacing: .1em; }
|
||||
.review-card p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
|
||||
footer { margin-top: 52px; padding: 23px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
|
||||
@media (max-width: 900px) { .hero-grid, .truth-grid { grid-template-columns: 1fr; } .score-grid, .domain-grid, .evidence-grid { grid-template-columns: repeat(2, 1fr); } .review-grid { grid-template-columns: 1fr 1fr; } .shortcut-row { grid-template-columns: 180px 1fr; } .badges { grid-column: 1 / -1; justify-content: flex-start; } }
|
||||
@media (max-width: 620px) { .wrap { width: min(100% - 24px, 1180px); } .hero { padding: 44px 0 38px; } .hero-stats, .score-grid, .domain-grid, .evidence-grid, .review-grid, .compare-top { grid-template-columns: 1fr; } .compare-column { border-right: 0; border-bottom: 1px solid var(--line); } .toolbar { grid-template-columns: 1fr; } .result-count { text-align: left; } .shortcut-row { grid-template-columns: 1fr; } }
|
||||
@media print { body { background: #fff; } .hero { color: var(--ink); background: #fff; border-bottom: 2px solid var(--ink); } .subtitle, .meta-pill, .hero-stat span { color: #425249; } .hero-stat { border-color: #aebbb3; } .nav, .toolbar { display: none; } .score, .domain-card, .compare, .truth-card, .catalog, .evidence-card, .review-card { box-shadow: none; break-inside: avoid; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div class="wrap hero-grid">
|
||||
<div>
|
||||
<p class="eyebrow">Business Review · Drive</p>
|
||||
<h1>Drive Shortcut<br>能力全景</h1>
|
||||
<p class="subtitle">从 lark-cli 对齐出发,但不止于命令名:逐项审查输入、校验、多步编排、失败语义、真实字节和平台边界。</p>
|
||||
<div class="meta-row">
|
||||
<span class="meta-pill good">真实账号 E2E 已执行</span>
|
||||
<span class="meta-pill">28 个公开入口</span>
|
||||
<span class="meta-pill">统一 Result / Pagination</span>
|
||||
<span class="meta-pill">报告已移除 PII / 凭证 / 业务正文</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stats" aria-label="关键统计">
|
||||
<div class="hero-stat"><strong>38</strong><span>lark-cli Drive 逐项审查</span></div>
|
||||
<div class="hero-stat"><strong>26</strong><span>已覆盖或跨产品路由</span></div>
|
||||
<div class="hero-stat"><strong>7</strong><span>部分对齐,边界已公开</span></div>
|
||||
<div class="hero-stat"><strong>5</strong><span>客观不可对齐能力</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="nav"><div class="wrap"><a href="#overview">全景</a><a href="#compare">Lark 对齐</a><a href="#ahead">超越项</a><a href="#truth">真实语义</a><a href="#catalog">完整目录</a><a href="#e2e">E2E</a><a href="#review">评审</a></div></nav>
|
||||
|
||||
<main class="wrap">
|
||||
<section id="overview">
|
||||
<div class="section-head"><div><p class="section-kicker">Executive summary</p><h2>28 个公开入口,覆盖文件完整生命周期</h2></div><p class="section-desc">另有 <code>+publish-set</code> 已实现契约和读回逻辑,但真实普通文件与在线文档均被服务端拒绝,因此保持 unavailable,不进入 Agent 公开目录。</p></div>
|
||||
<div class="callout"><strong>结论:</strong>Drive 已从 9 个偏原子入口扩展为 28 个可发现 Shortcut。它不仅补齐 Lark 的核心文件、版本和状态任务,还通过严格响应合同、真实落盘、写后读回、回收恢复和个人收藏形成更可审计的钉盘工作流。</div>
|
||||
<div class="score-grid">
|
||||
<article class="score"><strong>29</strong><span>已审查注册项(含 1 unavailable)</span></article>
|
||||
<article class="score"><strong>25</strong><span>公开主能力 / 语义适配</span></article>
|
||||
<article class="score"><strong>3</strong><span>公开兼容入口</span></article>
|
||||
<article class="score"><strong>3</strong><span>高风险写入口,均需确认</span></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-head"><div><p class="section-kicker">Capability map</p><h2>四个业务域</h2></div><p class="section-desc">目录按用户任务组织;兼容命令不重复计为新增能力。</p></div>
|
||||
<div class="domain-grid">
|
||||
<article class="domain-card"><span class="number">09</span><h3>发现与检查</h3><p>严格目录分页、搜索、最近访问,以及元数据、统计和封面聚合检查。</p><small>+list · +search · +recent · +inspect</small></article>
|
||||
<article class="domain-card"><span class="number">09</span><h3>文件生命周期</h3><p>创建目录、上传下载、快捷方式、在线对象复制、移动重命名、删除与恢复。</p><small>+upload · +download · +rename · +recycle-restore</small></article>
|
||||
<article class="domain-card"><span class="number">06</span><h3>个人与公开状态</h3><p>回收站清单、收藏闭环和互联网公开状态的独立安全域。</p><small>+star-list · +star-add · +publish-get</small></article>
|
||||
<article class="domain-card"><span class="number">04</span><h3>历史版本</h3><p>版本列表、精确定位、真实字节下载与高风险回滚读回。</p><small>+version-history · +version-download · +version-revert</small></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="compare">
|
||||
<div class="section-head"><div><p class="section-kicker">Lark alignment</p><h2>对齐业务语义,不追求同名率</h2></div><p class="section-desc">38 项逐项核对。评论、导入导出和成员权限在 DWS 由更成熟的 Doc 或原子权限入口承接,不在 Drive 再复制一套。</p></div>
|
||||
<div class="compare">
|
||||
<div class="compare-top">
|
||||
<article class="compare-column covered"><h3>26 · 已覆盖 / 路由</h3><p>上传下载、目录与快捷方式、版本、移动删除、状态、搜索、评论、导入导出和成员任务均有真实入口。</p></article>
|
||||
<article class="compare-column partial"><h3>7 · 部分对齐</h3><p>预览、resolve/reaction、push/pull、权限申请与 setting 受对象模型或接口粒度约束,明确保留有限语义。</p></article>
|
||||
<article class="compare-column gap"><h3>5 · 客观缺口</h3><p>删除评论恢复、普通文件版本删除、安全标签读写、可靠双向目录同步缺少必要下层接口。</p></article>
|
||||
</div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Lark 任务组</th><th>DWS 主路径</th><th>结论</th><th>关键差异与交付决定</th></tr></thead><tbody>
|
||||
<tr><td>upload / folder / shortcut / download</td><td><code>drive +upload</code> 等</td><td><span class="verdict v-ahead">增强</span></td><td>工作目录边界、OSS PUT、严格 commit、no-clobber、原子落盘、非零字节和读回验证。</td></tr>
|
||||
<tr><td>preview / cover</td><td><code>drive +cover</code></td><td><span class="verdict v-partial">部分</span></td><td>封面/缩略图可读;没有等价的服务端多格式预览转换,不扩大宣称。</td></tr>
|
||||
<tr><td>comments / replies</td><td><code>doc +comment-*</code> / <code>doc +review</code></td><td><span class="verdict v-covered">路由</span></td><td>评论归在线文档协作域;独立 resolve、reaction identity 与删除后恢复仍受接口限制。</td></tr>
|
||||
<tr><td>export / import / task result</td><td><code>doc +export</code> / <code>doc +import</code></td><td><span class="verdict v-ahead">增强</span></td><td>提交、轮询、恢复、安全下载形成类型化闭环,不保留泛化下划线命令。</td></tr>
|
||||
<tr><td>version history / get / revert</td><td><code>drive +version-*</code></td><td><span class="verdict v-ahead">增强</span></td><td>严格分页、精确版本、历史字节落盘、回滚前预检与终态读回;历史版本删除无接口。</td></tr>
|
||||
<tr><td>status / inspect</td><td><code>drive +inspect</code></td><td><span class="verdict v-ahead">超越</span></td><td>元数据为必达结果,统计、公开状态和封面按需 fan-out;可选失败为 partial_success。</td></tr>
|
||||
<tr><td>push / pull / sync</td><td><code>+upload</code> / <code>+download</code> 单文件</td><td><span class="verdict v-partial">部分</span></td><td>不在缺少稳定 hash、rename/delete journal 和冲突向量时制造危险目录同步。</td></tr>
|
||||
<tr><td>member / permission</td><td><code>doc +access-*</code> / <code>drive permission</code></td><td><span class="verdict v-covered">路由</span></td><td>协作者权限与互联网公开是两个安全域;申请权限需真实上下文,未伪装为通用 Shortcut。</td></tr>
|
||||
<tr><td>secure labels</td><td>无等价</td><td><span class="verdict v-gap">缺口</span></td><td>当前 DWS/钉钉下层没有 Drive 安全标签目录和写入接口,不能用普通权限代替。</td></tr>
|
||||
<tr><td>search</td><td><code>drive +search</code> / <code>doc +search</code></td><td><span class="verdict v-ahead">增强</span></td><td>文件与在线文档按域路由;文件搜索严格验证数组、过滤和分页。</td></tr>
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
<div class="callout warn" style="margin-top:14px"><strong>普通文件 copy 边界:</strong>钉钉现有复制接口对普通文件产生 <code>.dlink</code>,不是字节独立副本。因此 <code>+copy</code> 只接受在线对象;普通文件快捷入口用 <code>+create-shortcut</code>,独立副本使用 <code>+download</code> 后 <code>+upload</code>。</div>
|
||||
</section>
|
||||
|
||||
<section id="ahead">
|
||||
<div class="section-head"><div><p class="section-kicker">Beyond parity</p><h2>DWS 可主推的八个差异化点</h2></div><p class="section-desc">价值来自正确性与完整闭环,而不是额外注册同义命令。</p></div>
|
||||
<div class="domain-grid">
|
||||
<article class="domain-card"><h3>严格目录语义</h3><p><code>+list</code> / <code>+recent</code> 只有服务端明确返回数组时才接受空集合。</p><small>缺字段 ≠ 空目录</small></article>
|
||||
<article class="domain-card"><h3>聚合检查</h3><p><code>+inspect</code> 一次汇总身份、统计、公开状态和封面,并保留局部失败。</p><small>partial_success 可审计</small></article>
|
||||
<article class="domain-card"><h3>真实文件传输</h3><p>上传执行完整事务;下载验证受控路径、覆盖策略、原子发布和字节。</p><small>不是只返回临时 URL</small></article>
|
||||
<article class="domain-card"><h3>回收恢复闭环</h3><p>从 <code>recycleItemId</code> 恢复后读取真实节点,证明资源确实回到可访问状态。</p><small>恢复后读回</small></article>
|
||||
<article class="domain-card"><h3>个人收藏闭环</h3><p>收藏、列表、取消收藏覆盖完整用户偏好过程,并保留分页。</p><small>add → list → remove</small></article>
|
||||
<article class="domain-card"><h3>版本真实字节</h3><p>除元数据外可下载任意已知历史版本,并用本地字节核验回滚结果。</p><small>version-download</small></article>
|
||||
<article class="domain-card"><h3>重命名终态</h3><p>处理服务端扩展名规则,再读取节点确认最终名称,避免重复扩展名。</p><small>write → read-back</small></article>
|
||||
<article class="domain-card"><h3>公开域诚实降级</h3><p>查询和关闭可验证;开启在 eligible 节点闭环完成前保持 unavailable。</p><small>不把 notSupported 当成功</small></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="truth">
|
||||
<div class="section-head"><div><p class="section-kicker">Truthful execution</p><h2>空数组不再是“看起来成功”</h2></div><p class="section-desc">合法业务空集合可以成功,但必须先证明响应结构、元素类型和分页语义成立。内部错误、缺字段与坏投影必须失败。</p></div>
|
||||
<div class="truth-grid">
|
||||
<article class="truth-card"><h3>四层成功证据</h3>
|
||||
<div class="truth-step"><b>1</b><div><strong>传输成功</strong><span>进程成功,MCP / HTTP 没有显式错误。</span></div></div>
|
||||
<div class="truth-step"><b>2</b><div><strong>响应合同</strong><span>对象、数组、success 标志和元素类型与命令声明一致。</span></div></div>
|
||||
<div class="truth-step"><b>3</b><div><strong>业务终态</strong><span>写命令必须获得新 ID、终态证据或后续元数据读回。</span></div></div>
|
||||
<div class="truth-step"><b>4</b><div><strong>产物校验</strong><span>下载必须落盘、非零字节;关键链路比较大小和 SHA-256。</span></div></div>
|
||||
</article>
|
||||
<article class="truth-card"><h3>明确失败的情况</h3>
|
||||
<ul><li>空响应、缺少预期集合字段或集合类型错误。</li><li>集合存在坏元素,不能投影时静默丢弃。</li><li><code>success=false</code>、写响应没有 ID 或读回不一致。</li><li>inspect 的可选分支失败却返回整体 success。</li><li>下载得到空文件、越界路径或覆盖既有文件。</li><li>普通文件 copy 返回快捷链接却声称独立副本。</li></ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="catalog">
|
||||
<div class="section-head"><div><p class="section-kicker">Full catalog</p><h2>28 个公开 Shortcut 完整目录</h2></div><p class="section-desc">16 个只读、9 个普通写、3 个高风险写;3 个历史入口保留兼容但不作为新 Agent 主路径。</p></div>
|
||||
<div class="toolbar"><input id="q" type="search" placeholder="搜索命令或用途,例如 版本、回收、+inspect…" aria-label="搜索 Shortcut"><select id="domain"><option value="all">全部业务域</option><option value="discover">发现与检查</option><option value="lifecycle">文件生命周期</option><option value="personal">个人与公开</option><option value="version">历史版本</option></select><select id="risk"><option value="all">全部风险</option><option value="read">只读</option><option value="write">普通写</option><option value="high">高风险写</option></select><span id="result-count" class="result-count">显示 28 / 28</span></div>
|
||||
<div class="catalog">
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +list</code></div><div class="row-main"><p>严格分页列出目录,保留游标,区分显式空目录和畸形响应。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +inspect</code></div><div class="row-main"><p>聚合元数据与可选统计、公开状态、封面;局部失败如实报告。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +info</code></div><div class="row-main"><p>历史元数据兼容入口;新场景优先使用 +inspect。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search</code></div><div class="row-main"><p>按关键词、类型、扩展名、创建人、时间和分页搜索钉盘文件。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +find-file</code></div><div class="row-main"><p>历史文件搜索兼容入口;新场景优先使用 +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search-docs</code></div><div class="row-main"><p>历史跨域搜索入口;新的在线文档搜索路由 doc +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +recent</code></div><div class="row-main"><p>读取最近访问或编辑列表,支持创建人筛选并保留分页。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +stats</code></div><div class="row-main"><p>读取访问、编辑、评论、点赞、预览和下载统计。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +cover</code></div><div class="row-main"><p>读取封面或缩略图;不宣称服务端多格式预览。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +upload</code></div><div class="row-main"><p>上传凭证、OSS PUT、严格提交和远端元数据读回的一体化事务。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="read"><div class="command"><code>dws drive +download</code></div><div class="row-main"><p>安全落盘、no-clobber、原子发布并验证非零字节。</p><small>文件生命周期</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-folder</code></div><div class="row-main"><p>创建文件夹后要求新 ID,并读回名称验证。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-shortcut</code></div><div class="row-main"><p>创建快捷方式并读回,明确区别于独立副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +copy</code></div><div class="row-main"><p>复制在线对象;普通文件预检拒绝,避免把 .dlink 当副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +move</code></div><div class="row-main"><p>移动到指定文件夹或知识库位置,语义与 copy/shortcut 消歧。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +rename</code></div><div class="row-main"><p>重命名后读取真实节点,验证最终名称和扩展名。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="high"><div class="command"><code>dws drive +delete</code></div><div class="row-main"><p>将确认过的节点移入回收站,要求 success=true 终态证据。</p><small>文件生命周期</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +recycle-restore</code></div><div class="row-main"><p>按回收项 ID 恢复,并读回恢复后的节点。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +recycle-list</code></div><div class="row-main"><p>严格分页列出回收项并稳定投影 recycleItemId。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +star-list</code></div><div class="row-main"><p>严格分页列出当前用户收藏并保留游标。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-add</code></div><div class="row-main"><p>以幂等用户偏好语义收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-remove</code></div><div class="row-main"><p>以幂等用户偏好语义取消收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +publish-get</code></div><div class="row-main"><p>只读查询互联网公开状态,不沿用错误的写风险标签。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="high"><div class="command"><code>dws drive +publish-unset</code></div><div class="row-main"><p>关闭互联网公开并读回验证外链状态。</p><small>个人与公开</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-history</code></div><div class="row-main"><p>严格分页列出普通文件历史版本。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-get</code></div><div class="row-main"><p>按正整数版本号精确匹配,零命中显式失败。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-download</code></div><div class="row-main"><p>预检版本后安全下载历史字节,要求非零产物。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="high"><div class="command"><code>dws drive +version-revert</code></div><div class="row-main"><p>验证版本存在后回滚,并读取当前节点终态。</p><small>历史版本</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
</div>
|
||||
<div class="callout warn" style="margin-top:14px"><strong>未公开入口:</strong><code>+publish-set</code> 的安全契约和 set→get 读回代码已存在,但真实后端返回 <code>operation.notSupported</code>。在找到 eligible 节点并完成 set→get→unset 闭环前,不进入公开 Agent catalog。</div>
|
||||
</section>
|
||||
|
||||
<section id="e2e">
|
||||
<div class="section-head"><div><p class="section-kicker">Real-data E2E</p><h2>真实数据验证,不用空结果证明成功</h2></div><p class="section-desc">测试在隔离目录创建临时资源,覆盖读取、写入、下载、版本、收藏、回收和清理。资源 ID、账号、URL、上传凭证、绝对路径和业务正文均未进入报告。</p></div>
|
||||
<div class="evidence-grid">
|
||||
<article class="evidence-card"><strong>39,838 B</strong><p>真实文件上传后下载字节数;与源文件 SHA-256 完全一致。</p></article>
|
||||
<article class="evidence-card"><strong>2 versions</strong><p>覆盖写入生成两个版本;精确查询、历史下载和回滚全部读回。</p></article>
|
||||
<article class="evidence-card"><strong>4 / 5</strong><p>隔离夹具中搜索命中 4 项、最近列表命中 5 项,证明非空投影链路。</p></article>
|
||||
<article class="evidence-card"><strong>0 remain</strong><p>测试结束后隔离根目录无残留;临时资源进入回收站并完成本地清理。</p></article>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="event"><b>创建与发现</b><span>创建两个隔离目录并读回;+list 命中真实节点,由此发现并修复 dentryId 与 32 字符 fileId 混用。</span></div>
|
||||
<div class="event"><b>上传与下载</b><span>真实 OSS 上传、远端元数据读回、下载、no-clobber 二次路径、大小与 SHA-256 一致性全部通过。</span></div>
|
||||
<div class="event"><b>检查与个人状态</b><span>+inspect(含 stats / publish / cover)、+stats、+cover、收藏 add→list→remove 通过。</span></div>
|
||||
<div class="event"><b>版本闭环</b><span>覆盖文件产生两个版本;history/get/download/revert 通过,回滚后最新字节与原始内容一致。</span></div>
|
||||
<div class="event"><b>复制、移动与命名</b><span>在线文档 copy 通过;普通文件 .dlink 被修正为预检拒绝;move 往返、rename 扩展名规范化通过。</span></div>
|
||||
<div class="event"><b>删除与恢复</b><span>delete→recycle-list→recycle-restore 通过,真实回收响应字段已按后端形态修正。</span></div>
|
||||
<div class="event"><b>平台负向证据</b><span>publish-set 对普通文件和在线文档均明确返回不支持,因此保持 unavailable;没有把失败改写成空对象成功。</span></div>
|
||||
<div class="event"><b>清理</b><span>隔离目录进入回收站,根目录残留计数为零;本地下载产物删除。</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="review">
|
||||
<div class="section-head"><div><p class="section-kicker">Review prompts</p><h2>建议业务评审重点确认</h2></div><p class="section-desc">这些是需要接受的产品边界,不是被空结果遮蔽的实现问题。</p></div>
|
||||
<div class="review-grid">
|
||||
<article class="review-card"><span class="review-num">01</span><h3>是否接受 26 / 7 / 5 结论?</h3><p>按用户任务计覆盖、部分与缺口,不用同名命令数量代替语义保真。</p></article>
|
||||
<article class="review-card"><span class="review-num">02</span><h3>普通文件 copy 是否足够清晰?</h3><p>服务端无法提供原子独立副本;快捷方式与下载后上传两条替代路径已明确。</p></article>
|
||||
<article class="review-card"><span class="review-num">03</span><h3>是否拒绝不可靠目录 sync?</h3><p>缺稳定 hash、删除/重命名日志和冲突向量时,不发布可能覆盖数据的双向同步。</p></article>
|
||||
<article class="review-card"><span class="review-num">04</span><h3>跨产品路由是否合理?</h3><p>评论、导入导出和协作者权限优先复用 Doc 成熟入口,不在 Drive 制造同义表面。</p></article>
|
||||
<article class="review-card"><span class="review-num">05</span><h3>publish-set 是否继续 unavailable?</h3><p>建议维持,直到真实 eligible 节点完成开启、查询、关闭的可恢复闭环。</p></article>
|
||||
<article class="review-card"><span class="review-num">06</span><h3>下一批后端解锁优先级?</h3><p>建议依次评估普通文件原子 copy、同步所需版本信号、安全标签和评论恢复接口。</p></article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer><div class="wrap">依据:DWS 最终 Shortcut catalog / Schema、Drive 实现与测试、真实账号 E2E、lark-cli Drive registrations 与实现。范围仅含 Drive Shortcut 及必要跨产品路由;不包含原子命令总表。所有业务标识、凭证、签名 URL、用户信息和正文均已脱敏。</div></footer>
|
||||
<script>
|
||||
const q = document.querySelector('#q');
|
||||
const domain = document.querySelector('#domain');
|
||||
const risk = document.querySelector('#risk');
|
||||
const rows = [...document.querySelectorAll('[data-tool]')];
|
||||
const count = document.querySelector('#result-count');
|
||||
function filterTools() {
|
||||
const needle = q.value.trim().toLocaleLowerCase('zh-CN');
|
||||
let visible = 0;
|
||||
rows.forEach((row) => {
|
||||
const show = (!needle || row.textContent.toLocaleLowerCase('zh-CN').includes(needle)) && (domain.value === 'all' || row.dataset.domain === domain.value) && (risk.value === 'all' || row.dataset.risk === risk.value);
|
||||
row.classList.toggle('hidden-row', !show);
|
||||
if (show) visible += 1;
|
||||
});
|
||||
count.textContent = `显示 ${visible} / ${rows.length}`;
|
||||
}
|
||||
[q, domain, risk].forEach((control) => control.addEventListener('input', filterTools));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
# Drive Shortcut 对齐与超越 Lark CLI
|
||||
|
||||
## 目标与判定口径
|
||||
|
||||
本轮以 Lark CLI `drive` 的 38 个 shortcut 为对照,但不把“同名命令数量”当完成标准。对齐按用户任务判定:
|
||||
|
||||
1. `drive +...` 有更稳定的 Agent 主入口时,提供 Shortcut,并发布 Selection、Safety、Result 与 Pagination。
|
||||
2. 钉钉已经在其他产品提供更成熟入口时,Skill 明确跨产品路由,不在 Drive 重复实现。
|
||||
3. 只有原子能力且 Shortcut 不增加校验、编排或投影价值时,保留 Runtime Schema leaf,不制造同义别名。
|
||||
4. 下层接口不存在或无法满足相同语义时,明确记录 gap;不得用空数组、空对象或只返回任务提交结果伪装完成。
|
||||
|
||||
成功判定统一为:进程成功 + 统一结果 `ok=true/outcome=success` + 必要业务字段 + 真实数据读回或本地字节校验。服务端显式返回空数组可以是合法业务空结果;空响应、缺少数组、数组类型错误、坏元素、`success=false`、写入缺少终态证据都必须失败。
|
||||
|
||||
## Lark 38 项映射
|
||||
|
||||
| Lark Drive shortcut | DWS 路由 | 结论与原因 |
|
||||
|---|---|---|
|
||||
| `+upload` | `drive +upload` | 对齐并增强:工作目录边界、OSS PUT、严格 commit、元数据读回。 |
|
||||
| `+create-folder` | `drive +create-folder` | 对齐并增强:要求新 fileId 和名称读回。 |
|
||||
| `+create-shortcut` | `drive +create-shortcut` | 对齐并增强:明确 shortcut≠copy,创建后读回。 |
|
||||
| `+download` | `drive +download` | 对齐并增强:真实落盘、no-clobber、原子发布、非零字节。 |
|
||||
| `+preview` | `drive +cover`(有限) | 不完全对齐:钉钉当前只提供封面/缩略图读取,没有等价的服务端多格式预览转换接口。 |
|
||||
| `+cover` | `drive +cover` | 对齐:严格读取封面/缩略图对象。 |
|
||||
| `+add-comment` | `doc +comment-create` | 用户任务对齐;评论归在线文档协作域,Drive 不复制一套。 |
|
||||
| `+list-comments` | `doc +comment-list` | 用户任务对齐;Doc 已有类型、状态与分页。 |
|
||||
| `+batch-query-comments` | `doc +review` / `doc +comment-list` | 超越:可聚合未解决评论与确定性正文上下文;跨文档批量仍由调用方按节点编排。 |
|
||||
| `+resolve-comment` | `doc +comment-update`(有限) | 部分对齐:DWS 可更新评论,但当前下层未声明独立 resolve 状态接口。 |
|
||||
| `+restore-comment` | 无等价 | gap:钉钉当前下层未暴露恢复已删除评论的等价能力。 |
|
||||
| `+add-reply` | `doc +comment-reply` | 对齐。 |
|
||||
| `+list-replies` | `doc +comment-list` | 用户任务对齐:评论列表返回回复上下文;无独立 Drive reply 目录。 |
|
||||
| `+update-reply` | `doc +comment-update` | 对齐到评论/回复统一更新语义。 |
|
||||
| `+delete-reply` | `doc +comment-delete` | 对齐到评论/回复统一删除语义,高风险确认。 |
|
||||
| `+react-reply` | `doc +comment-reply`(有限) | 部分对齐:支持表情回复;不声称拥有 Lark 的独立 reaction identity。 |
|
||||
| `+export` | `doc +export` | 用户任务对齐并增强:提交、轮询、安全下载一体化。 |
|
||||
| `+export-download` | `doc +export` / `doc +export-get` | 超越:常规一体化,`+export-get` 仅作中断恢复。 |
|
||||
| `+import` | `doc +import` | 用户任务对齐并增强:转换白名单、上传 fallback、轮询终态。 |
|
||||
| `+version-history` | `drive +version-history` | 对齐并增强:严格空结果与分页。 |
|
||||
| `+version-get` | `drive +version-get` | 对齐并增强:精确版本号,零命中失败。 |
|
||||
| `+version-revert` | `drive +version-revert` | 对齐并增强:版本预检、高风险确认、节点读回。 |
|
||||
| `+version-delete` | 无等价 | gap:钉钉当前普通文件版本接口没有删除历史版本能力。 |
|
||||
| `+move` | `drive +move` | 对齐;与 copy/shortcut 明确消歧并发布确认。 |
|
||||
| `+delete` | `drive +delete` | 对齐;移入回收站、高风险确认、终态证据。 |
|
||||
| `+status` | `drive +inspect` | 超越:远端身份、统计、公开状态和封面按需聚合;不伪装成本地同步状态。 |
|
||||
| `+push` | `drive +upload`(单文件) | 部分对齐:单文件上传可靠;没有可靠的目录 diff、冲突和远端删除传播语义,因此不提供同名批量 push。 |
|
||||
| `+pull` | `drive +download`(单文件) | 部分对齐:单文件下载可靠;目录级增量拉取需稳定路径、hash 与冲突策略,当前接口不完整。 |
|
||||
| `+sync` | 无等价 | gap:在缺少稳定远端内容 hash、rename/delete journal 和冲突版本向量时,双向同步会有数据覆盖风险。 |
|
||||
| `+task_result` | `doc +export-get` / 导入任务恢复入口 | 用户任务对齐;DWS 按任务所属产品提供类型化恢复入口,不保留 Lark 的下划线泛化命令。 |
|
||||
| `+apply-permission` | `drive permission apply` raw leaf | 下层能力存在但未提升为 Shortcut:需要真实申请上下文和权限夹具,无法在通用 E2E 中安全创建。 |
|
||||
| `+member-add` | `doc +access-grant` | 用户任务对齐并增强:解析接收人、批量 ledger、首次写入前停止。 |
|
||||
| `+member-list` | `drive permission list` / `doc +inspect --include-permissions` | 对齐;常规 Agent 场景优先 Doc 聚合检查。 |
|
||||
| `+permission-get-setting` | `drive permission list` + `drive +publish-get` | 部分对齐:协作者与互联网公开是两个独立安全域,没有一个与 Lark setting 完全同构的钉钉接口。 |
|
||||
| `+secure-label-list` | 无等价 | gap:当前 DWS/钉钉下层没有可声明的 Drive 安全标签目录接口。 |
|
||||
| `+secure-label-update` | 无等价 | gap:没有安全标签写接口,不能用普通权限或公开状态替代。 |
|
||||
| `+search` | `drive +search` | 对齐并增强:过滤、严格数组和分页;在线文档搜索路由 `doc +search`。 |
|
||||
| `+inspect` | `drive +inspect` | 对齐并增强:必达元数据 + 可选聚合,部分失败不伪装成功。 |
|
||||
|
||||
## DWS 超出 Lark Drive 的可挖掘能力
|
||||
|
||||
- `+list`:严格目录分页,而不是把缺字段当空目录。
|
||||
- `+recent`:最近访问/编辑与创建人筛选。
|
||||
- `+stats`:阅读、编辑、评论、点赞、预览和下载统计。
|
||||
- `+recycle-list` / `+recycle-restore`:显式回收项身份与恢复后读回。
|
||||
- `+star-list` / `+star-add` / `+star-remove`:个人收藏完整闭环。
|
||||
- `+publish-get` / `+publish-unset`:互联网公开独立安全域与关闭后读回;`+publish-set` 保留为 unavailable 诊断入口。
|
||||
- `+version-download`:历史版本真实字节下载与本地 artifact 校验。
|
||||
- `+rename`:写后读回验证。
|
||||
|
||||
普通钉盘文件的独立 `copy` 是额外确认出的部分 gap:钉钉当前 `doc/copy_document` 对该对象会生成 `.dlink`,不是字节独立副本。`drive +copy` 因此只接受在线对象;普通文件需要快捷入口时用 `+create-shortcut`,需要独立副本时用 `+download` 后 `+upload`。这不是完整的服务端原子 copy,对大文件也不能宣称完全等价。
|
||||
|
||||
互联网公开开启也是账号/对象能力 gap:真实普通文件与在线文档夹具都由服务端返回 `operation.notSupported`。`+publish-get` 与关闭语义可验证,但 `+publish-set` 在找到 eligible 节点完成 set→get→unset 闭环前保持 `unavailable` 且不进入公开 Agent catalog。
|
||||
|
||||
## 端到端门禁
|
||||
|
||||
每个公开 Drive shortcut 必须至少覆盖:
|
||||
|
||||
- Cobra 参数、静态确认、Shortcut Execute、MCP 调度和最终输出;
|
||||
- 明确业务空集合、空响应、缺字段、错误类型、坏元素、`success=false`;
|
||||
- 写入的 ID/终态证据与读回不一致;
|
||||
- 下载的本地路径边界、no-clobber、真实字节数;
|
||||
- 真实账号数据:读命令必须命中已知非空夹具或明确验证合法空集合;写命令必须创建隔离资源、读回、必要时下载比对字节并清理。
|
||||
|
||||
发布前运行 `make build`、完整 Go 测试、Schema 生成/漂移/策略检查,并保存不含账号业务内容的结构化 E2E 汇总。
|
||||
@@ -92,6 +92,7 @@ command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷
|
||||
| `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) |
|
||||
| `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) |
|
||||
| `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) |
|
||||
| `Input` | 额外取值来源:`@path` 读文件 / `-` 读 stdin,在 required/enum/约束/`Validate` 之前原地解析 | 否(今日:能力由作者写进 `Usage` / `SchemaDescription` 文案,是已声明事实而非推断;不另造 flag。目标形态收敛为类型化投影字段,见 RFC §5.3) |
|
||||
|
||||
#### 1.2.2 编排 / 执行字段(不算声明)
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# International DingTalk (`.io`) Guide
|
||||
|
||||
This guide explains how to log in to the international DingTalk region and run DWS commands against `*.dingtalk.io` services.
|
||||
|
||||
## Region behavior
|
||||
|
||||
- `dws auth login --intl` creates or refreshes an international login using the `.io` login, OAuth, and MCP services.
|
||||
- Omitting `--intl` keeps the existing domestic `.com` behavior.
|
||||
- `--intl` is a login option, not a global option for business commands. After login, commands such as `contact`, `calendar`, and `doc` derive the region from the selected Token/profile.
|
||||
- Each new Token records its login region. Switching profiles therefore switches the official DingTalk gateway region automatically.
|
||||
- `--international` is a compatibility alias. Prefer `--intl` in new scripts.
|
||||
|
||||
For the complete Chinese guide, see [DWS 国际版(DingTalk `.io`)使用手册](./international-region-guide.zh-CN.md).
|
||||
|
||||
## Check availability
|
||||
|
||||
```bash
|
||||
dws auth login --help
|
||||
```
|
||||
|
||||
The help output must include `--intl` and `--international`.
|
||||
|
||||
When validating a source checkout, build it first and use `./dws` so an older binary on `PATH` is not invoked accidentally:
|
||||
|
||||
```bash
|
||||
make build
|
||||
./dws auth login --help
|
||||
```
|
||||
|
||||
## Log in
|
||||
|
||||
Browser login:
|
||||
|
||||
```bash
|
||||
dws auth login --intl
|
||||
```
|
||||
|
||||
Device flow for SSH, containers, and headless environments:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --device
|
||||
```
|
||||
|
||||
User OAuth with custom application credentials:
|
||||
|
||||
```bash
|
||||
dws auth login --intl \
|
||||
--client-id <APP_KEY> \
|
||||
--client-secret <APP_SECRET>
|
||||
```
|
||||
|
||||
This mode still requires the user to complete OAuth authorization in a browser; it is not a userless `client_credentials` login. The application must be configured on the international developer platform with the required callback and permissions. Never commit an AppSecret to source control or include it in logs.
|
||||
|
||||
## Verify the login
|
||||
|
||||
```bash
|
||||
dws auth status --format json
|
||||
dws profile list --format json
|
||||
dws contact user get-self
|
||||
```
|
||||
|
||||
The last command is a read-only smoke check. If the organization has not enabled CLI access, an organization administrator must enable it or approve the access request on the international developer platform.
|
||||
|
||||
## Use domestic and international profiles together
|
||||
|
||||
```bash
|
||||
# Domestic (.com)
|
||||
dws auth login
|
||||
|
||||
# International (.io)
|
||||
dws auth login --intl
|
||||
|
||||
# Find the stable profile selectors
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
Persistently switch profiles:
|
||||
|
||||
```bash
|
||||
dws profile switch <corpId>:<userId>
|
||||
```
|
||||
|
||||
Toggle back to the previous profile:
|
||||
|
||||
```bash
|
||||
dws profile switch -
|
||||
```
|
||||
|
||||
Select a profile for one command without changing the default:
|
||||
|
||||
```bash
|
||||
dws --profile <corpId>:<userId> contact user get-self
|
||||
```
|
||||
|
||||
Do not add `--intl` to business commands. DWS routes official endpoints from the selected profile's Token region.
|
||||
|
||||
## Isolated smoke testing
|
||||
|
||||
Use a separate configuration directory to avoid changing the normal `~/.dws` login state:
|
||||
|
||||
```bash
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
|
||||
```
|
||||
|
||||
Use the same `DWS_CONFIG_DIR` for every command. Use `./dws` for a source build and `dws` for an installed release.
|
||||
|
||||
## Pre-release overrides (maintainers only)
|
||||
|
||||
Normal international users need only `--intl`; they should not set `--pre-url` or `--mcp-url`.
|
||||
|
||||
Maintainers can test the pre-release login/MCP pair with:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
```
|
||||
|
||||
A corresponding `pre-mcp.*` URL is also accepted, and DWS derives the paired `pre-login.*` / `pre-mcp.*` bases. `--mcp-url` explicitly overrides the MCP base URL for that login.
|
||||
|
||||
Pre-release services may require internal network access or allowlisted accounts. `--pre-url` is intended primarily for the MCP-managed credential flow. Do not combine it with direct custom `--client-id/--client-secret` mode unless the pre-release API contract explicitly supports that combination.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### The browser still opens a `.com` page
|
||||
|
||||
1. Run `dws auth login --help` and confirm `--intl` is present.
|
||||
2. For a source checkout, use `./dws` instead of an older installed binary.
|
||||
3. Confirm the executed command is `dws auth login --intl`.
|
||||
|
||||
### A business command appears to use the wrong region
|
||||
|
||||
Run `dws profile list --format json`, then switch with the exact `<corpId>:<userId>` selector or use the global `--profile` option. For a legacy Token created before region metadata existed, reauthorize it with `dws auth login --intl` for an international account or `dws auth login` for a domestic account.
|
||||
|
||||
### Login succeeds but the command reports missing permission
|
||||
|
||||
This normally means the organization has not enabled CLI access or the application lacks a required permission. It does not by itself indicate a region-routing failure.
|
||||
|
||||
### Should I edit `~/.dws/mcp_url` manually?
|
||||
|
||||
No. Normal users should establish the login with `dws auth login` or `dws auth login --intl`. DWS then routes official endpoints from the selected Token/profile. Manual configuration is reserved for maintainers who explicitly control the target environment.
|
||||
|
||||
## Command reference
|
||||
|
||||
| Scenario | Command |
|
||||
|---|---|
|
||||
| Domestic browser login | `dws auth login` |
|
||||
| International browser login | `dws auth login --intl` |
|
||||
| International device login | `dws auth login --intl --device` |
|
||||
| Check auth state | `dws auth status --format json` |
|
||||
| List profiles | `dws profile list --format json` |
|
||||
| Persistently switch profile | `dws profile switch <corpId>:<userId>` |
|
||||
| Toggle to previous profile | `dws profile switch -` |
|
||||
| Select a profile once | `dws --profile <corpId>:<userId> <command>` |
|
||||
@@ -0,0 +1,185 @@
|
||||
# DWS 国际版(DingTalk `.io`)使用手册
|
||||
|
||||
本手册适用于使用钉钉国际版账号登录并调用国际站服务的用户。
|
||||
|
||||
## 核心规则
|
||||
|
||||
- `dws auth login --intl` 创建或刷新国际版登录,使用 `*.dingtalk.io` 登录、鉴权和 MCP 服务。
|
||||
- 不传 `--intl` 时仍使用国内钉钉 `*.dingtalk.com`,原有链路保持不变。
|
||||
- `--intl` 只用于登录命令。登录完成后,`contact`、`calendar`、`doc` 等业务命令不需要再传该参数。
|
||||
- 每个 Token 会记录登录区域。执行业务命令时,DWS 根据当前或 `--profile` 指定的账号自动选择 `.com` 或 `.io` 网关。
|
||||
- `--international` 是 `--intl` 的兼容别名;新脚本推荐使用较短的 `--intl`。
|
||||
|
||||
## 确认当前版本支持国际版
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
dws auth login --help
|
||||
```
|
||||
|
||||
帮助中应包含:
|
||||
|
||||
```text
|
||||
--intl
|
||||
--international
|
||||
```
|
||||
|
||||
从源码分支验证时,先在仓库根目录构建,并始终使用本次构建的 `./dws`,避免误用系统中已安装的旧版本:
|
||||
|
||||
```bash
|
||||
make build
|
||||
./dws auth login --help
|
||||
```
|
||||
|
||||
## 国际版登录
|
||||
|
||||
### 浏览器登录
|
||||
|
||||
```bash
|
||||
dws auth login --intl
|
||||
```
|
||||
|
||||
DWS 会打开国际版登录页面。完成扫码或账号授权后,登录结果会保存为本机 profile。
|
||||
|
||||
### 设备码登录
|
||||
|
||||
适用于 SSH、容器或没有可用浏览器的环境:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --device
|
||||
```
|
||||
|
||||
按照终端提示,在另一台可打开浏览器的设备上完成授权。
|
||||
|
||||
### 使用自有应用凭证完成用户 OAuth
|
||||
|
||||
```bash
|
||||
dws auth login --intl \
|
||||
--client-id <APP_KEY> \
|
||||
--client-secret <APP_SECRET>
|
||||
```
|
||||
|
||||
该模式仍然需要用户在浏览器中完成 OAuth 授权,不是无用户授权的 `client_credentials` 登录。应用必须在国际版开放平台正确配置回调地址和所需权限。不要在命令历史、日志或 PR 中提交真实的 AppSecret。
|
||||
|
||||
## 验证登录和业务调用
|
||||
|
||||
查看当前登录状态:
|
||||
|
||||
```bash
|
||||
dws auth status --format json
|
||||
```
|
||||
|
||||
列出本机全部账号并找到当前 profile:
|
||||
|
||||
```bash
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
执行一个只读命令验证国际链路,例如:
|
||||
|
||||
```bash
|
||||
dws contact user get-self
|
||||
```
|
||||
|
||||
登录状态正常但业务命令提示组织未开通 CLI 时,需要由国际版组织管理员在国际版开发者平台开启 CLI 访问或完成授权审批。
|
||||
|
||||
## 国内版和国际版账号并存
|
||||
|
||||
可以在同一台机器上分别登录国内版和国际版账号:
|
||||
|
||||
```bash
|
||||
# 国内版(.com)
|
||||
dws auth login
|
||||
|
||||
# 国际版(.io)
|
||||
dws auth login --intl
|
||||
|
||||
# 查看稳定的 profile 选择器
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
持久切换账号:
|
||||
|
||||
```bash
|
||||
dws profile switch <corpId>:<userId>
|
||||
```
|
||||
|
||||
切回上一个账号:
|
||||
|
||||
```bash
|
||||
dws profile switch -
|
||||
```
|
||||
|
||||
只为单次命令指定账号,不修改默认账号:
|
||||
|
||||
```bash
|
||||
dws --profile <corpId>:<userId> contact user get-self
|
||||
```
|
||||
|
||||
DWS 会按照选中 profile 的 Token 区域自动选择 `.com` 或 `.io`,不需要在业务命令上追加 `--intl`。
|
||||
|
||||
## 使用独立配置目录进行验证
|
||||
|
||||
如果不希望测试登录影响日常使用的 `~/.dws`,可以指定独立配置目录:
|
||||
|
||||
```bash
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
|
||||
```
|
||||
|
||||
请在三条命令中使用同一个 `DWS_CONFIG_DIR`。验证源码分支时使用 `./dws`;验证已安装版本时可改为 `dws`。
|
||||
|
||||
## 预发参数(仅维护者)
|
||||
|
||||
普通国际版用户只需要 `--intl`,不要配置 `--pre-url` 或 `--mcp-url`。
|
||||
|
||||
维护者验证预发登录/MCP 链路时可以使用:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
```
|
||||
|
||||
也可以传入对应的 `pre-mcp.*` 地址;DWS 会推导配套的 `pre-login.*` / `pre-mcp.*` 地址。`--mcp-url` 用于显式覆盖本次登录的 MCP base URL。
|
||||
|
||||
预发环境可能只对内网或特定测试账号开放。`--pre-url` 主要服务于 MCP 托管凭证登录流程;除非预发 API 契约已经明确支持,否则不要把它与自有 `--client-id/--client-secret` 直连模式组合使用。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 仍然打开 `.com` 登录页面
|
||||
|
||||
1. 运行 `dws auth login --help`,确认当前二进制包含 `--intl`。
|
||||
2. 从源码验证时使用 `./dws`,不要误用 PATH 中的旧版本。
|
||||
3. 确认实际执行的是 `dws auth login --intl`,而不是普通 `dws auth login`。
|
||||
|
||||
### 业务命令似乎使用了错误区域
|
||||
|
||||
先检查当前账号:
|
||||
|
||||
```bash
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
然后使用精确的 `<corpId>:<userId>` 切换或通过全局 `--profile` 单次指定。对于在区域字段引入前生成的历史 Token,建议使用正确的登录方式重新授权:国际账号执行 `dws auth login --intl`,国内账号执行 `dws auth login`。
|
||||
|
||||
### 登录成功但提示没有权限
|
||||
|
||||
这通常是组织 CLI 准入或应用授权问题,不代表区域路由失败。请确认目标组织已开启 CLI 访问,并且当前应用拥有命令所需权限。
|
||||
|
||||
### 是否需要手工修改 `~/.dws/mcp_url`
|
||||
|
||||
不需要。正常使用应通过 `dws auth login` 或 `dws auth login --intl` 建立登录态;业务命令会根据选中的 Token/profile 自动路由。手工修改配置只适用于明确了解目标环境的维护者调试场景。
|
||||
|
||||
## 命令速查
|
||||
|
||||
| 场景 | 命令 |
|
||||
|---|---|
|
||||
| 国内版浏览器登录 | `dws auth login` |
|
||||
| 国际版浏览器登录 | `dws auth login --intl` |
|
||||
| 国际版设备码登录 | `dws auth login --intl --device` |
|
||||
| 查看登录状态 | `dws auth status --format json` |
|
||||
| 查看所有账号 | `dws profile list --format json` |
|
||||
| 持久切换账号 | `dws profile switch <corpId>:<userId>` |
|
||||
| 切回上一个账号 | `dws profile switch -` |
|
||||
| 单次指定账号 | `dws --profile <corpId>:<userId> <command>` |
|
||||
@@ -7,6 +7,8 @@
|
||||
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
|
||||
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent as `x-dws-agent-product` (for example `qwenwork`) for downstream logs/BI and used as the IM `clawType` display label when `--ai-tag` is enabled. `--ai-tag` defaults to `true`, so a configured Product changes the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls send an empty `clawType`, while shortcut calls omit the argument. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values omit the Header and use the edition's IM display default. This client never uses Product to change the separate HTTP `claw-type` PAT/routing label. / 可选、由调用方声明的 Agent 产品标识,经校验后作为 `x-dws-agent-product` 发送,并用于 IM 小尾巴;`--ai-tag` 默认为 `true`,因此配置 Product 后默认会改变展示标签。使用 `--ai-tag=false` 时,原生 `chat message send` / `reply` 发送空的 `clawType`,shortcut 调用则省略该参数。未设置时省略请求头且 IM 使用发行版默认值;本客户端不会用 Product 修改独立的 HTTP `claw-type` |
|
||||
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`) for downstream logs/BI. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. This client does not use Host for PAT, authentication, Discovery, or MCP endpoint selection. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送给下游日志/BI;本客户端不使用该值进行 PAT、鉴权、Discovery 或 MCP 端点选择,未设置时省略 |
|
||||
| `DWS_AGENT_VER` | Optional caller-declared Agent version / 可选、由调用方声明的 Agent 版本。After trimming surrounding ASCII spaces/tabs, the value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9._+-]*$`; a non-empty valid value is sent as `x-dws-agent-ver`, while unset or empty values omit the Header. / 去除首尾 ASCII 空格和 Tab 后,值不得超过 64 字节且必须匹配上述格式;合法非空值通过 `x-dws-agent-ver` 发送,未设置或空值则省略请求头 |
|
||||
| `DWS_AGENT_EXT` | Optional caller-declared Agent extended context / 可选、由调用方声明的 Agent 扩展上下文。The value must be a UTF-8 JSON object no larger than 8 KiB, is compacted before being sent as the sensitive `x-dws-agent-ext` Header, and may use the recommended keys `umt`, `miniwua`, and `ua`; unknown keys remain supported. Unset or empty values omit the Header. / 值必须是 UTF-8 JSON 对象且不得超过 8 KiB,压缩后通过敏感请求头 `x-dws-agent-ext` 发送;推荐使用 `umt`、`miniwua`、`ua`,同时允许未知扩展键。未设置或空值则省略请求头 |
|
||||
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
|
||||
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
|
||||
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
|
||||
@@ -14,6 +16,47 @@
|
||||
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
|
||||
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
|
||||
|
||||
### Agent Version and Extended Context / Agent 版本与扩展上下文
|
||||
|
||||
`DWS_AGENT_VER` and `DWS_AGENT_EXT` are sent only on the CLI's ordinary,
|
||||
non-plugin MCP requests. They do not change the standard HTTP `User-Agent` or
|
||||
the separate `X-Cli-Version` that identifies the DWS CLI version, and they are
|
||||
not forwarded to A2A, OAuth, Discovery, or third-party plugin requests.
|
||||
|
||||
`DWS_AGENT_EXT` is one JSON-object Header rather than a set of Headers. The
|
||||
recommended keys are `umt`, `miniwua`, and `ua`, but the open-source CLI keeps
|
||||
the object extensible and does not enforce a key allowlist. For example, using
|
||||
fictional, redacted values:
|
||||
|
||||
```bash
|
||||
DWS_AGENT_VER=0.1.5
|
||||
DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}'
|
||||
```
|
||||
|
||||
The shell's outer single quotes group the JSON and are not part of the
|
||||
environment-variable value. The CLI trims surrounding ASCII spaces/tabs,
|
||||
omits either Header when its value is empty, and compacts EXT to a single-line
|
||||
JSON object. A representative current payload is about 657 bytes, well below
|
||||
the 8 KiB limit; integrations must still enforce the limit because values can
|
||||
grow. EXT may contain sensitive device or runtime signals: the CLI masks it in
|
||||
configuration and logs, and removes it on a cross-host redirect.
|
||||
|
||||
Both values are declared by the caller and are therefore forgeable. They can
|
||||
support compatibility checks, diagnostics, and observability, but they are not
|
||||
credentials or attestations and must never be sufficient on their own to
|
||||
authenticate a caller or authorize access.
|
||||
|
||||
`DWS_AGENT_VER` 与 `DWS_AGENT_EXT` 仅随 CLI 发起的普通非插件 MCP 请求发送,不会
|
||||
改变标准 HTTP `User-Agent`,也不会覆盖标识 DWS CLI 自身版本的 `X-Cli-Version`;
|
||||
二者不会进入 A2A、OAuth、Discovery 或第三方插件请求。EXT 使用单个 JSON 对象
|
||||
请求头,不拆成多个子请求头;推荐键为 `umt`、`miniwua`、`ua`,但开源 CLI 不限制
|
||||
扩展键。Shell 示例中的外层单引号只用于保护 JSON,不属于环境变量值。当前典型负载
|
||||
约为 657 字节,远低于 8 KiB 上限,但集成方仍须遵守大小限制。EXT 可能包含敏感的
|
||||
设备或运行时信号,配置展示和日志会对其脱敏,跨主机重定向时也会移除该请求头。
|
||||
|
||||
这两个值都由调用方自行声明,可以被伪造;它们可用于兼容性判断、诊断和可观测性,
|
||||
但不是凭据或可信证明,不能单独用于身份认证或访问授权。
|
||||
|
||||
### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type`
|
||||
|
||||
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation
|
||||
|
||||
+13
-2
@@ -17,7 +17,8 @@
|
||||
|
||||
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
|
||||
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
|
||||
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
|
||||
3. workflow summary 会给出唯一的下一版本。运行 `prepare-changelog.sh` 将已合入的
|
||||
release fragments 汇总成对应的精确 `CHANGELOG.md` 章节,并通过唯一的 release-seal PR 合入 `main`。
|
||||
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
@@ -94,7 +95,8 @@ main 上的候选代码 + beta CHANGELOG
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
|
||||
如果 CHANGELOG 尚不存在,该命令会从 `.changes/*.md` 生成 beta 章节并归档已消费的
|
||||
fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1
|
||||
@@ -132,6 +134,15 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
|
||||
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
|
||||
|
||||
### Release fragments
|
||||
|
||||
普通 PR 不修改 `CHANGELOG.md` 的 `Unreleased` 区域。需要面向用户发布说明的改动在
|
||||
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
|
||||
[`.changes/README.md`](../.changes/README.md)。预发封板时
|
||||
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
|
||||
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
|
||||
@@ -292,7 +292,7 @@ Definition(仅声明;不可编译)
|
||||
|
||||
下列字段**是**框架声明面(经 `corecmd.New` 生效并嵌入 `dws.schema.*`):
|
||||
|
||||
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面)
|
||||
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面;`Input` 是取值来源声明,经 `corecmd.New` 生效但**不**嵌入 `dws.schema.*`,能力靠 `Usage` 文案声明,见 §5.3)
|
||||
- `Constraints`
|
||||
- **非空** `Risk`(空值 = 运行时当只读确认,且**不**嵌入 `dws.schema.risk`)
|
||||
- `ConstParams`(载荷声明;不上用户 flag 表)
|
||||
@@ -360,6 +360,7 @@ Definition(仅声明;不可编译)
|
||||
| | `idempotency` | 评审源(或未来 Contract) | reviewed metadata | 今日非框架声明;不得推断 |
|
||||
| | `effect_source` / provenance | 组装派生物 | resolver 写入 `FieldProvenance` | 派生,不手写 |
|
||||
| **DryRun** | `preview_kind`, `remote_reads` | 评审源 | `schema_dry_run_capabilities`(正能力声明) | 否;无条目 ≠ 推断「不支持」之外的假能力 |
|
||||
| **Wait** | `mode`(`poll`/`event`/`auto`), `poll_command`, `status_query`, `terminal`(状态→success/failure), `pending_values`, `event_key`/`match_field`/`resource_query`(event/auto), `default_timeout_secs` | **声明**(`ContractDecl.Wait` 正能力声明,且必须搭配 ResultInvoke dispatcher + 按模式的 hook:poll↔`WaitPoll`、event↔`WaitEvents`、auto↔两者,构造期配对校验,多余 hook 同样拒绝) | 声明后注册 `--wait`/`--wait-timeout`(框架 flag,不进 toolArgs);Schema 投影 `wait` 键;auto = 事件优先、流终止/订阅失败回退轮询,一个 deadline 覆盖两阶段并传入 `WaitPoll`/`WaitEvents`(及 `Command().Context()`);仅 pending 初始结果进入等待,success/failure/partial 原样返回 | 否;未声明命令传 `--wait` = unknown flag。终态失败经统一信封 `error.type: "wait"`(rc=8),超时保持 pending + `meta.operation.timed_out`(rc=0);轮询间/轮询中/事件消费中超时一律按 pending 关闭 |
|
||||
| **Interface** | `interface_mode`, `interface_ref`, `availability`, `reason` | 评审源 | MCP meta + agent metadata 解析 | 否;与 CLI Identity 分离 |
|
||||
| **Selection** | `agent_summary`, `use_when`, `avoid_when`, `examples`, `prerequisites`, `tips`, `workflow_refs`, … | 声明(`ContractDecl.Selection` / `ProductDecl`) | `ContractDecl` / `ProductDecl`(`schema_hints/` 已退役) | 可声明;声明载荷**不得携带** `Reviewed`(旧路径专用),携带即组装报错 |
|
||||
| **FieldProvenance** | 各字段 winner / candidates | 组装派生物 | Schema 组装器 | 派生;须与 delivered value 一致 |
|
||||
@@ -673,6 +674,52 @@ func (k Key[T]) Declare(opts ...FlagOption[T]) FlagSpec
|
||||
- 构造时拒绝 `InputSourceInvalid`。
|
||||
- 当前没有任何 Shortcut 或 Leaf 声明 `Input`,因此 M1 增加能力且零上线表面变化。让现有命令采用它属于 §9 下的用户可见变更。
|
||||
|
||||
`Input` 的框架能力今日已在 `corecmd` 落地(声明即执行的过渡形态,语义与上文目标一致),使用指南:
|
||||
|
||||
**今日声明形态**:`FlagSpec.Input []string`,源常量 `corecmd.InputFile`(`"file"`)/ `corecmd.InputStdin`(`"stdin"`)。`helpers.LeafFlag` 是 `corecmd.FlagSpec` 别名,直接可用;`shortcut.Flag.Input` 同形声明,经 `FromShortcut` 映射到 `FlagSpec`。
|
||||
|
||||
```go
|
||||
// LeafSpec / helpers
|
||||
Flags: []helpers.LeafFlag{
|
||||
{
|
||||
Name: "content",
|
||||
Usage: "文档内容(支持 @文件路径 或 - 读 stdin)",
|
||||
Bind: "content",
|
||||
Input: []string{corecmd.InputFile, corecmd.InputStdin},
|
||||
},
|
||||
}
|
||||
|
||||
// shortcut
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "markdown", Desc: "Markdown 内容(支持 @文件路径 或 -)",
|
||||
Input: []string{"file", "stdin"}},
|
||||
}
|
||||
```
|
||||
|
||||
**运行时语义**(`resolveInputFlags`,在 `runDeclaredPreflight` 内、required/enum/约束/Validate 之前执行,原地改写 cobra flag 值):
|
||||
|
||||
- `--flag @path`:文件内容替换取值;`--flag -`:stdin 内容替换取值。
|
||||
- `--flag @@value`:转义为字面 `@value`,不做来源解析。
|
||||
- 只解析显式 CLI token(主名或别名);EnvVar 回落与注册默认值透传不解析。
|
||||
- 内容前置剥离 UTF-8 BOM;`Trim` 等既有语义照常作用于解析后的值。
|
||||
- 读取失败、源不支持、`@` 后空路径都是类型化校验错误(退出码 3);同时声明两种源而文件读取失败时附 stdin 引导 hint。
|
||||
|
||||
**作者守则**:
|
||||
|
||||
- 声明即全部能力:required/enum/约束/Validate 校验的已是解析后的真实内容,`Execute`/`Invoke` 无需任何额外代码。
|
||||
- `Usage`/`Desc` 必须写明支持 `@路径`/`-`;框架不自动改写 help 文案,今日也不向 Schema 投影(新增投影字段须先过 homology 评审,避免 catalog drift)。
|
||||
- `user_required` 确认的写命令若声明 `InputStdin`:stdin 在校验阶段被消费,交互确认将 fail-closed 为 `confirmation_required`,此类调用必须显式 `--yes`(或 `--dry-run`)。
|
||||
- **声明前先确认取值空间不会被前缀吃掉**:声明 `InputFile` 后,任何以 `@` 开头的合法值都会被当成文件路径(本产品尤其常见的是 at 提及类取值,如 `--at-user @zhangsan` 会报读取文件失败),用户只能改用 `@@` 转义;声明 `InputStdin` 后字面值 `-` 不可达(与 curl 等约定一致)。若该 flag 的正常取值可能命中这两种形态,就不要声明对应来源。
|
||||
- 声明在构造期校验(fail-closed panic):仅限 `KindString`;源值必须是 `file`/`stdin` 且不重复。
|
||||
|
||||
**今日实现与目标形态的差异**(迁移到本节目标 `FlagSpec` 时收敛):
|
||||
|
||||
| 维度 | 今日 | 目标 |
|
||||
|---|---|---|
|
||||
| 源类型 | `[]string` 常量 | 类型化 `InputSource` |
|
||||
| 路径边界 | 直接本地文件 IO | 复用 §5.5.2 本地文件 effect 边界 |
|
||||
| Schema 投影 | 无(靠作者在 Usage 声明) | 声明即最终源,随 Catalog 透传 |
|
||||
|
||||
核心 FlagSpec 故意没有:
|
||||
|
||||
- `Bind`;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# RFC:DWS 预制 Skill 安装、升级与模式迁移
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| 状态 | Accepted / as implemented |
|
||||
| 生效范围 | DWS CLI、升级器、npm 与平台安装脚本 |
|
||||
| 事实源 | 本 RFC 与当前代码;两者冲突时以代码和测试为准 |
|
||||
| 关联合同 | [Skill 内容框架](skill-content-framework.md)、[Mono↔Multi 内容质检](skill-mono-multi-qa.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
DWS 同时通过 CLI、升级器、npm、Shell 和 PowerShell 分发预制 Skill。multi
|
||||
成为默认布局后,所有入口必须对安装集合、模式互斥、失败退出、缓存发布和目录
|
||||
所有权保持一致。此前分散的调研、迁移计划、阶段性 roadmap 和 rollout 文档容易
|
||||
相互冲突;本 RFC 将最终行为收敛为一个长期合同。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 新装与升级默认使用 multi 布局,mono 在兼容期内保留显式 opt-in。
|
||||
- 每次升级使用当前版本的官方清单全量覆盖预制 Skill。
|
||||
- 删除或替换任何目录前先创建可恢复备份,备份失败不修改该 Agent 目标。
|
||||
- 只清理能够证明由 DWS 管理的目录,不通过名称前缀推断所有权。
|
||||
- 所有安装入口对部分失败返回非零状态,不误报整体成功。
|
||||
- 安装预览、确认和实际执行使用同一份计划。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不建设独立的 `dws skill mode status|set|rollback` 产品面。
|
||||
- 不持久化用户对预制 Skill 的本地删除或排除意图。
|
||||
- 不提供跨所有 Agent 目标的事务式回滚。
|
||||
- 不把市场 Skill 纳入预制 Skill 的升级和清理范围。
|
||||
|
||||
## 3. 业内调研
|
||||
|
||||
对主流 CLI 与 Agent Skill 分发方式的公开实现进行归纳后,可以得到以下共性:
|
||||
|
||||
| 观察 | 对 DWS 的启示 |
|
||||
|---|---|
|
||||
| 多个产品能力通常以同级 Skill 目录安装,由 Agent 按目录发现 | multi 使用平铺的产品 Skill,并保留一个共享 Skill 承载公共协议 |
|
||||
| CLI 本体安装和 Agent Skill 安装是两个生命周期 | DWS 可以在 CLI 安装、setup 和 upgrade 中触发 Skill 同步,但二者的失败与状态必须分别报告 |
|
||||
| 生态安装器通常天然采用 multi,不提供 mono/multi 状态机 | DWS 的模式切换保持为重新执行 setup,不新增长期驻留的 mode lifecycle |
|
||||
| 市场 Skill 与 CLI 预制 Skill 可能落在同一 Agent 根目录 | 必须使用统一所有权元数据识别受管目录,名称前缀不能作为删除依据 |
|
||||
| 多 Skill 更新常以新清单刷新官方集合 | DWS 使用当前 bundle 官方清单全量覆盖,新增 Skill 自动加入,本地删除不视为持久化排除 |
|
||||
| 制品可能需要同时服务无运行时依赖、离线和多镜像环境 | DWS 保留 embed、zip 和平台安装脚本,不把单一生态包管理器设为唯一入口 |
|
||||
| 中断的复制和原地覆盖容易破坏最后一个可用版本 | 缓存与 Go upgrade 的 Agent 目标采用 staging publish;发布失败自动恢复该目标的完整旧集合 |
|
||||
| Agent 通常以 `SKILL.md` 为入口,其他文件按引用或工具规则按需读取 | 安装元数据使用不被内容引用的隐藏文件,并保证其内容不包含 Agent 指令 |
|
||||
|
||||
本节只保留可复用的工程结论,不记录具体产品、仓库、版本或逐项能力对照,也不构成
|
||||
DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约束和本 RFC 的行为合同为准。
|
||||
|
||||
## 4. 布局合同
|
||||
|
||||
| 模式 | Agent 目录布局 | 选择方式 |
|
||||
|---|---|---|
|
||||
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
|
||||
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
|
||||
|
||||
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
|
||||
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
|
||||
不可恢复的删除。
|
||||
|
||||
## 5. 官方集合与升级策略
|
||||
|
||||
当前版本 bundle 中的 multi 目录清单是升级集合的唯一权威来源。普通 upgrade 和
|
||||
`--force` 都安装并覆盖该版本的全部官方预制 Skill:
|
||||
|
||||
- 本地删除的预制 Skill 会在下一次升级恢复;
|
||||
- setup 时通过 `--exclude` 暂时排除的 Skill 会在下一次升级恢复;
|
||||
- 新版本新增的官方 Skill 会自动安装;
|
||||
- 用户对预制 Skill 的本地修改会被官方版本覆盖;
|
||||
- `dingtalk-shared` 始终随官方集合安装。
|
||||
|
||||
`~/.dws/skills-state.json`(设置 `DWS_CONFIG_DIR` 时位于该目录)不参与安装集合
|
||||
求解,也不保存排除策略。它既记录结果快照,也集中记录 multi Skill 的所有权和
|
||||
provenance,供安全清理、诊断与后续迁移使用。
|
||||
|
||||
## 6. 目录所有权
|
||||
|
||||
每次 multi setup 或 upgrade 全部成功后,DWS 在统一的
|
||||
`~/.dws/skills-state.json` 中写入:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v0.2.14",
|
||||
"official_skills": ["dingtalk-aitable"],
|
||||
"updated_skills": ["dingtalk-aitable"],
|
||||
"managed_skills": [
|
||||
{
|
||||
"name": "dingtalk-aitable",
|
||||
"version": "v0.2.14",
|
||||
"source": "dws-upgrade",
|
||||
"digest": "sha256:<64 个十六进制字符>",
|
||||
"digest_scope": "skill-directory-v1"
|
||||
}
|
||||
],
|
||||
"updated_at": "2026-08-11T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
每条 `managed_skills` 记录代表一个由 DWS 管理的官方 Skill。`version` 记录安装该
|
||||
副本的 DWS/发布包版本,`source` 记录安装入口,`digest` 是对 bundle 中 Skill 目录
|
||||
全部普通文件按相对路径排序后计算的内容摘要。摘要用于诊断和来源追踪,不作为后续
|
||||
升级的完整性门禁;用户修改 Skill 内容后,DWS 仍保有明确管理权并能在下一次升级时
|
||||
覆盖恢复。
|
||||
|
||||
清理 stale Skill 或切换到 mono 时,只接受以下所有权证据:
|
||||
|
||||
1. Skill 名称存在于统一状态的 `managed_skills` 中;
|
||||
2. 统一状态上线前曾发布过的官方 Skill 精确名称集合。
|
||||
|
||||
历史集合是冻结的迁移清单,包含 `dws-shared` 以及已退役、折叠或仍在发布的旧官方
|
||||
目录名。仅有 `dingtalk-*` 前缀不构成所有权证据。因此,市场或用户创建的
|
||||
`dingtalk-custom` 等非官方精确名称目录不会被迁走。
|
||||
|
||||
### 6.1 对 Agent 的影响
|
||||
|
||||
Skill 目录内不再放置 DWS 所有权文件,也不增加非通用 frontmatter 字段。支持的
|
||||
Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agent Skill 目录之外,
|
||||
不会成为提示词上下文或影响 Agent 行为。
|
||||
|
||||
## 7. Setup:Plan → Confirm → Execute
|
||||
|
||||
`dws skill setup` 分为三个阶段:
|
||||
|
||||
1. **Plan**:只读计算目标、安装集合以及所有待备份路径;
|
||||
2. **Confirm**:`--dry-run` 和交互确认渲染同一份计划;
|
||||
3. **Execute**:确认后严格执行计划中的备份和安装。
|
||||
|
||||
安全要求:
|
||||
|
||||
- 非交互环境未传 `--yes` 时拒绝执行;
|
||||
- 用户拒绝确认时必须零文件写入;
|
||||
- 备份失败时跳过整个 Agent 目标,不开始铺设相反布局;
|
||||
- 同一目标先完成所有必要备份,再复制新集合;
|
||||
- multi Skill 必须在同级 staging 中完成复制,再原子发布到正式目录;
|
||||
- 任意 `skipped > 0` 都返回非零退出码,并且不写入完整成功快照;
|
||||
- 一个 Agent 目标失败不阻止其他目标尝试,但最终结果仍为失败。
|
||||
|
||||
## 8. Upgrade 与恢复语义
|
||||
|
||||
升级器对每个 Agent 目标执行:
|
||||
|
||||
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
|
||||
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
|
||||
|
||||
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
|
||||
2. 在目标文件系统的 staging 中复制完整新集合;
|
||||
3. staging 全部成功后,才将旧集合移入备份目录;
|
||||
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
|
||||
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
|
||||
|
||||
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
|
||||
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
|
||||
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
|
||||
“不提供跨所有 Agent 目标的事务式回滚”非目标保持一致。
|
||||
|
||||
## 9. 备份合同
|
||||
|
||||
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
|
||||
- 主要操作:同一文件系统内使用 rename 移动;
|
||||
- 失败语义:备份失败时原目录保持不变,目标安装失败;
|
||||
- 可见性:计划和执行日志显示原路径与备份路径;
|
||||
- 保留策略:自动修剪,仅保留最近 5 批。
|
||||
|
||||
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
|
||||
`dws skill setup --mode mono`。
|
||||
|
||||
## 10. 缓存与制品
|
||||
|
||||
发布制品和二进制内嵌内容同时携带 mono 与 multi 源树。`~/.dws/skills/` 只是
|
||||
setup 在未显式指定 `--source` 时的本地回退缓存。
|
||||
|
||||
缓存刷新必须采用同级 staging + publish:
|
||||
|
||||
1. 在 staging 中完整复制并验证新树;
|
||||
2. 发布前保留旧缓存;
|
||||
3. 通过 rename 发布新缓存;
|
||||
4. 复制或发布失败时保留或恢复旧缓存;
|
||||
5. 空、缺失或损坏的 bundle 不能擦除有效缓存。
|
||||
|
||||
## 11. 安装入口一致性
|
||||
|
||||
以下入口都遵守本 RFC:
|
||||
|
||||
| 入口 | 默认模式 | 失败合同 |
|
||||
|---|---|---|
|
||||
| `dws skill setup` | multi | 部分失败返回非零;不写完整成功状态 |
|
||||
| `dws upgrade` | bundle 含 multi 时安装 multi | 目标失败返回失败;下次全量重试 |
|
||||
| `scripts/install.sh` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
|
||||
|
||||
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
|
||||
|
||||
## 12. 验收与回归门禁
|
||||
|
||||
合入和后续修改至少覆盖:
|
||||
|
||||
- mono → multi、multi → mono 互斥切换;
|
||||
- 状态上线前的官方 multi 目录切换 mono 时能够被精确迁移;
|
||||
- 未登记的同前缀市场/用户 Skill 在刷新和切换后仍存在;
|
||||
- 统一状态中登记的过期官方 Skill 被备份并移除;
|
||||
- 备份、复制、统一状态写入、缓存 publish 故障注入;
|
||||
- 非交互确认拒绝与显式 `--yes`;
|
||||
- 部分失败返回非零且不写错误状态快照;
|
||||
- 复制失败不留下 Agent 可见的残缺官方目录;
|
||||
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
|
||||
- Windows、macOS、Linux 的路径和覆盖率门禁;
|
||||
- npm、Shell、PowerShell 与包管理器安装冒烟。
|
||||
|
||||
## 13. 后续演进
|
||||
|
||||
- 收敛各安装入口中的 Agent home 清单,减少跨语言复制;
|
||||
- 如确有运维需求,可单独设计备份查看和显式恢复命令;
|
||||
- mono 的物理删除必须作为独立变更,在 multi 内容、安装入口和迁移回归稳定后推进;
|
||||
- `managed_skills` 字段若演进,必须同步更新所有安装入口和跨平台回归。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,7 +102,7 @@
|
||||
<tr><td><code>minutes +latest-minutes</code></td><td>列妙记→取最新一条详情</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>minutes +action-items</code></td><td>列妙记→取最新→取其待办</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>wiki +wiki-new-doc --space <名></code></td><td>按名搜知识空间→建文档(跨 doc server 路由)</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +doc-append --doc --text</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +doc-append --doc --content</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +share-doc --to <名> --url</code></td><td>解析人→把文档链接私信 TA(跨服务)</td><td class="c ok">编译/挂载</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -56,13 +56,13 @@
|
||||
|
||||
| shortcut | 多步/智能逻辑 | 验证 |
|
||||
|----------|--------------|------|
|
||||
| `chat +dm --to <姓名> --text` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
|
||||
| `chat +dm --to <姓名> --content` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
|
||||
| `contact +lookup --name <姓名>` | 搜人→解析 userId→取完整资料 | ✅ **真机端到端** |
|
||||
| `todo +assign --to <姓名> --task` | 解析人→建待办并把 TA 设为执行人 | ✅ dry-run 真机 |
|
||||
| `chat +send-to-group --group <群名> --text` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
|
||||
| `chat +send-to-group --group <群名> --content` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
|
||||
| `calendar +book --title --start --end [--with <姓名CSV>]` | 建日程→按名加参与者→**失败回滚删日程**(对标 lark `calendar +create`) | ✅ dry-run 真机 |
|
||||
| `calendar +free --who <姓名> --start --end` | 解析人→查其时段忙闲 | ✅ **真机端到端**(解析 202397→查忙闲) |
|
||||
| `chat +broadcast --to <姓名CSV> --text` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
|
||||
| `chat +broadcast --to <姓名CSV> --content` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
|
||||
| `minutes +latest-minutes` | 列妙记→取最新一条详情 | ✅ 编译/挂载 |
|
||||
| `chat +group-members --group <群名>` | 按群名搜群→列群成员 | ✅ 编译/挂载 |
|
||||
| `contact +org --name <姓名>` | 解析人→取详情拿 deptId→查部门详情 | ✅ **真机端到端**(3 步:董鑫阳→模型算法/16人) |
|
||||
@@ -76,7 +76,7 @@
|
||||
| `todo +todo-done --task <关键词>` | 列我的待办→按标题匹配→标记完成 | ✅ 编译/挂载 |
|
||||
| `calendar +reschedule --event <id>` | 查日程详情→改时间(查→改机械多步) | ✅ 编译/挂载 |
|
||||
| `wiki +wiki-new-doc --space <名>` | 按名搜知识空间→在其下建文档(跨 doc server 路由) | ✅ 编译/挂载 |
|
||||
| `doc +doc-append --doc --text` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
|
||||
| `doc +doc-append --doc --content` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
|
||||
| `minutes +action-items` | 列妙记→取最新→取其待办事项 | ✅ 编译/挂载 |
|
||||
| `minutes +detail --id <taskUuid>` | 一条命令聚合听记 basic/summary/keywords/transcript/todos,partial-failure 容错 | ✅ 全量测试 |
|
||||
| `minutes +replace-batch --id --pair "原文=>替换"…` | 多组批量替换文字,去重校验+逐组结果聚合 | ✅ 全量测试 |
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
|
||||
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
|
||||
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
|
||||
> 安装、升级与模式迁移见
|
||||
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
|
||||
|
||||
## 1. 两棵内容树
|
||||
|
||||
@@ -87,18 +88,7 @@ skills/mono/
|
||||
|
||||
质检可断言源树形状;**不**断言安装器默认解压哪棵。
|
||||
|
||||
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
|
||||
|
||||
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|
||||
|---|---|---|
|
||||
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
|
||||
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
|
||||
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
|
||||
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
|
||||
|
||||
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
|
||||
|
||||
## 7. 变更流程
|
||||
## 6. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
|
||||
> 安装、升级与模式迁移见
|
||||
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
@@ -70,7 +72,3 @@ paired_files:
|
||||
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
|
||||
|
||||
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
|
||||
|
||||
## 4. 与悟空
|
||||
|
||||
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
|
||||
|
||||
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
|
||||
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
|
||||
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
|
||||
>
|
||||
> 对照仓:
|
||||
>
|
||||
> | 仓 | 路径 | 基线 |
|
||||
> |---|---|---|
|
||||
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
|
||||
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
|
||||
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
|
||||
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
|
||||
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
|
||||
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
|
||||
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
|
||||
|
||||
### 0.1 IN SCOPE
|
||||
|
||||
| 类别 | 包含 |
|
||||
|---|---|
|
||||
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
|
||||
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
|
||||
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
|
||||
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
|
||||
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
|
||||
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
|
||||
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
|
||||
|
||||
### 0.2 OUT OF SCOPE
|
||||
|
||||
| 类别 | 去向 |
|
||||
|---|---|
|
||||
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
|
||||
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
|
||||
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
|
||||
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
|
||||
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
|
||||
| 把质检做成「改安装默认值」的后门 | 拒绝 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 内容现状盘点
|
||||
|
||||
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
|
||||
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
|
||||
│ └── best_practices/…
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
### 1.2 DWS `skills/multi`(内容主体)
|
||||
|
||||
```text
|
||||
skills/multi/
|
||||
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
|
||||
└── dingtalk-*/ # 19 产品 + 各 references、scripts
|
||||
```
|
||||
|
||||
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
|
||||
|
||||
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
|
||||
|
||||
### 1.4 Zip 内容布局合同
|
||||
|
||||
| Zip 路径 | 内容含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 副本(兼容) |
|
||||
| `<root>/mono/` | 显式 mono 内容源 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
|
||||
|
||||
### 1.5 现有 DWS skill 内容质检资产(复用清单)
|
||||
|
||||
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|
||||
|---|---|---|
|
||||
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
|
||||
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
|
||||
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
|
||||
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
|
||||
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
|
||||
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
|
||||
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
|
||||
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
|
||||
|
||||
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
|
||||
|
||||
### 1.6 悟空侧类比质检
|
||||
|
||||
| 悟空 | 说明 | 本分支 |
|
||||
|---|---|---|
|
||||
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
|
||||
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
|
||||
|
||||
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
|
||||
|
||||
---
|
||||
|
||||
## 2. Diff(内容组织 + 质检视角)
|
||||
|
||||
### 2.1 已同构
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
|
||||
|
||||
### 2.2 分叉与已知内容风险(质检要盯的)
|
||||
|
||||
| 风险 ID | 现象(线索) | 质检类型 |
|
||||
|---|---|---|
|
||||
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
|
||||
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
|
||||
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
|
||||
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
|
||||
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
|
||||
|
||||
### 2.3 Reject
|
||||
|
||||
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals / Non-goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
|
||||
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
|
||||
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
|
||||
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
|
||||
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
|
||||
|
||||
### 3.2 Non-goals
|
||||
|
||||
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
|
||||
|
||||
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
|
||||
|
||||
### Phase 0 — 方案冻结(本文)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 本文件;§7(含质检轨)勾选 |
|
||||
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
|
||||
|
||||
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
|
||||
| **触达** | `docs/skill-content-framework.md` |
|
||||
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
|
||||
|
||||
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
|
||||
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
|
||||
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
|
||||
|
||||
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | G1–G4 自动门禁 |
|
||||
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
|
||||
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
|
||||
|
||||
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
|
||||
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
|
||||
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
|
||||
|
||||
### 延期登记(非本分支)
|
||||
|
||||
| 主题 | 载体 |
|
||||
|---|---|
|
||||
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
|
||||
| skillhome / 安装面 bootstrap | 行为分支 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Port / Adapt / Reject
|
||||
|
||||
| 项 | 决策 | 说明 |
|
||||
|---|---|---|
|
||||
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
|
||||
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
|
||||
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
|
||||
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
|
||||
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
|
||||
| 行为 cherry-pick | **Defer** | 另分支 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 `402429ac` / `d5c8982c`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 本分支 cherry-pick? | **否** |
|
||||
| 质检是否替代行为翻转? | **否** |
|
||||
| 行为分支 | 另开;可与内容/质检并行 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 批准清单(请重新勾选)
|
||||
|
||||
**范围**
|
||||
|
||||
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
|
||||
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
|
||||
- [x] 取消产品与悟空客户端链路仍拒绝
|
||||
|
||||
**内容框架 Phase**
|
||||
|
||||
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
|
||||
|
||||
**质检轨 Phase**
|
||||
|
||||
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
|
||||
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
|
||||
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
|
||||
|
||||
**可选**
|
||||
|
||||
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
|
||||
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
|
||||
|
||||
**Follow-up 知悉**
|
||||
|
||||
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步
|
||||
|
||||
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
|
||||
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
|
||||
安装默认 multi 等行为仍走 **另一分支**。
|
||||
|
||||
---
|
||||
|
||||
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
|
||||
@@ -0,0 +1,55 @@
|
||||
# OA Attachment Download URL Output Design
|
||||
|
||||
## Goal
|
||||
|
||||
Keep the existing command and MCP request unchanged while making the returned
|
||||
OSS signed URL directly copyable from JSON output:
|
||||
|
||||
```text
|
||||
dws oa approval attachment download-url
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
Only `oa approval attachment download-url` changes. The other OA attachment
|
||||
commands and the global JSON formatter retain their current behavior.
|
||||
|
||||
## Design
|
||||
|
||||
The command continues to invoke MCP server `oa`, tool
|
||||
`get_attachment_download_url`, with the same arguments. Its leaf declaration
|
||||
provides a command-specific `Call` callback that invokes the existing MCP
|
||||
dispatcher with HTML escaping disabled when the selected output format is
|
||||
JSON. This preserves literal `&` separators in `result.downloadUri` instead of
|
||||
rendering them as `\u0026`.
|
||||
|
||||
For `raw`, `table`, and other non-JSON formats, the callback uses the existing
|
||||
escaped dispatcher behavior so their current rendering remains unchanged.
|
||||
|
||||
The change does not alter the URL, decode or re-sign it, download the file, or
|
||||
change global JSON serialization.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Authentication, MCP transport, gateway, PAT, and business errors continue
|
||||
through the existing dispatcher and retain their current classification and
|
||||
output behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
Add a `TestCrossPlatformCoverage*` regression test that executes the real Cobra
|
||||
leaf in explicit JSON mode with a fake MCP result containing a signed URL. It
|
||||
must verify:
|
||||
|
||||
- the request still targets `oa/get_attachment_download_url`;
|
||||
- the exact request arguments remain unchanged, including omission of the
|
||||
optional boolean when the flag was not supplied;
|
||||
- stdout contains literal `&OSSAccessKeyId=` and `&Signature=`;
|
||||
- stdout contains no `\u0026` escape.
|
||||
|
||||
The fake caller must report JSON format (or the command must be executed with
|
||||
`--format json`) so the test fails against the current escaped JSON path rather
|
||||
than accidentally exercising raw MCP text output.
|
||||
|
||||
Run the focused OA attachment tests, format modified Go files, and rebuild the
|
||||
CLI. No commit is created.
|
||||
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DWS Wiki Shortcut 全景评审</title>
|
||||
<style>
|
||||
:root{--ink:#14213d;--muted:#5c677d;--line:#dbe4f0;--paper:#fff;--bg:#f3f7fb;--blue:#1769e0;--cyan:#00a6a6;--green:#178746;--amber:#a45b00;--red:#b42318;--shadow:0 14px 34px rgba(20,33,61,.08)}
|
||||
*{box-sizing:border-box}body{margin:0;overflow-x:hidden;background:linear-gradient(150deg,#edf5ff 0,#f8fbff 45%,#eef8f5 100%);color:var(--ink);font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}
|
||||
main,.card,.two>*{min-width:0}main{width:min(1180px,calc(100% - 32px));margin:28px auto 72px}.hero,.card{background:rgba(255,255,255,.96);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow)}
|
||||
.hero{padding:38px;background:radial-gradient(circle at 95% 0,#dff8f3,transparent 36%),linear-gradient(135deg,#fff,#f5f9ff)}h1{font-size:34px;line-height:1.2;margin:0 0 10px}.lead{font-size:17px;color:var(--muted);max-width:900px}.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:20px}.pill{border:1px solid #cbd9ea;border-radius:999px;padding:5px 11px;background:#fff;font-size:13px}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}.metric{padding:20px}.metric b{display:block;font-size:31px;color:var(--blue)}.metric span{color:var(--muted)}
|
||||
section{margin-top:22px}.card{padding:26px}h2{font-size:23px;margin:0 0 14px}h3{font-size:17px;margin:22px 0 8px}.callout{border-left:4px solid var(--blue);background:#f2f7ff;padding:14px 16px;border-radius:8px}.warn{border-color:var(--amber);background:#fff8eb}.ok{border-color:var(--green);background:#effbf4}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid var(--line);padding:11px 9px}th{color:#41516b;background:#f7f9fc;position:sticky;top:0}code{background:#edf2f8;border-radius:5px;padding:2px 5px;color:#24466e}.tag{display:inline-block;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:650;white-space:nowrap}.full{background:#e6f6ec;color:#116436}.partial{background:#fff0d5;color:#875000}.extra{background:#e8f1ff;color:#1854a5}.fixed{background:#f1eaff;color:#6338a5}
|
||||
.toolbar{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0}.toolbar input,.toolbar select{border:1px solid #bdcada;border-radius:10px;padding:9px 11px;background:#fff;min-width:min(220px,100%);max-width:100%;flex:1 1 220px}.matrix{max-height:620px;overflow:auto;border:1px solid var(--line);border-radius:12px}.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}.small{color:var(--muted);font-size:13px}ul{padding-left:20px}.footer{color:var(--muted);text-align:center;margin-top:22px}@media(max-width:850px){.grid,.two{grid-template-columns:1fr 1fr}.hero{padding:25px}}@media(max-width:560px){.grid,.two{grid-template-columns:1fr}main{width:min(100% - 18px,1180px)}.card{padding:18px}h1{font-size:28px}}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<header class="hero">
|
||||
<h1>DWS Wiki Shortcut 全景评审</h1>
|
||||
<p class="lead">以 13 项成熟 Wiki 用户任务为基线,重新审视 DWS 的空间、成员、节点与动态能力。本次不是按命令名凑数:每个入口都要求真实业务证据,缺失数组、畸形响应、空确认或读回不一致一律失败。</p>
|
||||
<div class="meta"><span class="pill">评审日期 2026-08-14</span><span class="pill">独立 worktree / 独立分支</span><span class="pill">真实组织数据 E2E 28/28</span><span class="pill">报告已去标识化</span></div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card metric"><b>20</b><span>公开 Wiki Shortcuts</span></div>
|
||||
<div class="card metric"><b>13/13</b><span>基线用户任务有对应路径</span></div>
|
||||
<div class="card metric"><b>7</b><span>DWS 额外场景</span></div>
|
||||
<div class="card metric"><b>20/20</b><span>真实数据能力已触达</span></div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>结论先行</h2>
|
||||
<div class="callout ok"><strong>DWS 已形成比“API 快捷别名”更完整的 Wiki 任务层。</strong> 基线中的 13 个用户任务均有对应入口;DWS 还提供空间搜索/详情/唯一解析、成员角色更新、库内节点搜索、协作动态和按空间名新建文档。创建、复制、移动等关键写能力从“请求发出”升级为“终态 + ID + 读回”成功标准。</div>
|
||||
<div class="callout warn" style="margin-top:12px"><strong>能力边界必须诚实表达。</strong> DingTalk 成员接口不提供游标,单次真实上限是 50,因此不能实现成员 <code>--page-all</code>;成员身份只接受同组织可用的 userId,无法提供 email/open_id 等多种身份模式;节点创建也没有等价的 origin/shortcut 模式。这些差异保留为明确边界,而不是用本地循环或空结果伪装。</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>13 项基线任务逐条映射</h2>
|
||||
<div class="matrix"><table><thead><tr><th>基线任务</th><th>DWS 主入口</th><th>结论</th><th>DWS 视角与边界</th></tr></thead><tbody>
|
||||
<tr><td><code>+space-list</code></td><td><code>wiki +space-list</code></td><td><span class="tag full">完整对齐</span></td><td>严格空集合、游标续传、自动翻页、停滞检测;支持组织/我的知识库。</td></tr>
|
||||
<tr><td><code>+space-create</code></td><td><code>wiki +space-create</code></td><td><span class="tag full">超过</span></td><td>公开真实 32 字符名称上限;创建后按 workspaceId 读回。</td></tr>
|
||||
<tr><td><code>+delete-space</code></td><td><code>wiki +delete-space</code></td><td><span class="tag full">超过</span></td><td>预读目标、高风险确认、只接受 <code>success=true</code>;兼容 <code>+space-delete</code>。</td></tr>
|
||||
<tr><td><code>+member-add</code></td><td><code>wiki +member-add</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 1–30 个 userId 与四种角色;以写接口终态作为成功证据,不把最多 50 条的名单误作精确读回。</td></tr>
|
||||
<tr><td><code>+member-list</code></td><td><code>wiki +member-list</code></td><td><span class="tag partial">任务对齐</span></td><td>严格成员数组、角色过滤、真实上限 50;后端无游标,不能提供诚实的 page-all。</td></tr>
|
||||
<tr><td><code>+member-remove</code></td><td><code>wiki +member-remove</code></td><td><span class="tag partial">任务对齐</span></td><td>支持批量 userId;只接受写接口明确终态,并公开无法进行精确成员读回的边界。</td></tr>
|
||||
<tr><td><code>+node-list</code></td><td><code>wiki +node-list</code></td><td><span class="tag full">完整对齐</span></td><td>正确跨域路由 doc/list_nodes,严格空目录、分页与自动翻页。</td></tr>
|
||||
<tr><td><code>+node-get</code></td><td><code>wiki +node-get</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 DingTalk 节点 ID/在线文档 URL 并返回文档域元数据;不接受跨平台专用的 token/type 组合。</td></tr>
|
||||
<tr><td><code>+node-create</code></td><td><code>wiki +node-create</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 adoc/axls/able/appt/adraw/amind/folder 并读回;无 origin/shortcut 等价接口。</td></tr>
|
||||
<tr><td><code>+node-copy</code></td><td><code>wiki +node-copy</code></td><td><span class="tag full">超过</span></td><td>确认后要求新 nodeId 并读取副本;底层面向在线节点,不把 .dlink 当独立副本。</td></tr>
|
||||
<tr><td><code>+move</code></td><td><code>wiki +move</code></td><td><span class="tag partial">任务对齐</span></td><td>同一入口支持 Wiki 内移动和“我的文档”在线节点入 Wiki,读回 workspace/folder;底层接口没有 apply 权限迁移开关。</td></tr>
|
||||
<tr><td><code>+move-to-drive</code></td><td><code>wiki +move-to-drive</code></td><td><span class="tag full">超过</span></td><td>DWS 当前接口同步完成并读回 workspace 变化,无需暴露异步 task 轮询。</td></tr>
|
||||
<tr><td><code>+node-delete</code></td><td><code>wiki +node-delete</code></td><td><span class="tag full">超过</span></td><td>预读并核对 workspace,高风险确认,要求删除终态。</td></tr>
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>DWS 可挖掘的 7 个额外场景</h2>
|
||||
<div class="two">
|
||||
<div><h3>定位与创建链</h3><ul><li><code>+space-search</code>:严格关键词搜索。</li><li><code>+space-get</code>:空间详情与 workspaceId 证据。</li><li><code>+resolve-space</code>:唯一命中直出 ID,多命中拒绝猜测。</li><li><code>+wiki-new-doc</code>:空间名解析 → 创建 → 文档读回。</li></ul></div>
|
||||
<div><h3>治理与巡检链</h3><ul><li><code>+member-update</code>:角色变更终态与不可精确读回声明。</li><li><code>+node-search</code>:库内关键词/扩展名搜索,严格零命中。</li><li><code>+feed-list</code>:知识库动态时间线与服务端 exclude-file 过滤。</li></ul></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>隐藏问题与修复</h2>
|
||||
<table><thead><tr><th>原问题</th><th>错误风险</th><th>本次修复</th></tr></thead><tbody>
|
||||
<tr><td>5 个旧 Wiki Shortcut 可直接执行,但只有 1 个进入公开目录。</td><td>Help、Schema、Skill 发现链与运行面漂移。</td><td><span class="tag fixed">20 项统一评审</span> 全部具备 Contract/Safety/Result 与语义目录记录。</td></tr>
|
||||
<tr><td>列表投影找不到数组或遇到坏元素时返回空 slice。</td><td>把内部错误、字段漂移误报为“没有数据”。</td><td><span class="tag fixed">失败关闭</span> 只有响应中真实存在的 <code>[]</code> 才是合法空集合。</td></tr>
|
||||
<tr><td>节点列表 Shortcut 调错 Wiki MCP 服务。</td><td>真实后端 <code>success=false</code>,Mock/静态检查看不出。</td><td><span class="tag fixed">跨域路由</span> 明确调用 doc/list_nodes,并纳入真实 E2E。</td></tr>
|
||||
<tr><td>成员帮助宣称最大 200。</td><td>真实接口超过 50 直接参数错误。</td><td><span class="tag fixed">真实上限</span> Shortcut 与原子 Help 均改为 50,并在本地提前拒绝。</td></tr>
|
||||
<tr><td>成员写操作从最多 50 条、不可分页的名单推断成员存在或缺失。</td><td>目标在截断部分时会误报写失败,或把未验证的移除报告为已读回。</td><td><span class="tag fixed">终态证据</span> 只接受写接口 <code>success=true</code>,并在结果中明确 <code>readbackAvailable=false</code>。</td></tr>
|
||||
<tr><td>空间搜索的稳定工作流属性名与实际请求属性名不同。</td><td>直接改写已发布的 <code>query/limit</code> 会造成无版本 Schema 破坏;继续隐式转换又会让审计者误以为请求同名透传。</td><td><span class="tag fixed">显式复合适配</span> 最终 Schema 保留兼容属性并明确声明转换为 <code>keyword/pageSize</code>;回归测试同时锁定最终交付和精确请求参数。</td></tr>
|
||||
<tr><td>知识库名称帮助宣称最大 100。</td><td>真实接口超过 32 失败。</td><td><span class="tag fixed">真实上限</span> Help 与 Shortcut 校验统一为 32。</td></tr>
|
||||
<tr><td>复制/移动/创建只把无异常视为成功。</td><td>空确认、未知远端效果或移动未到目标仍可能被接受。</td><td><span class="tag fixed">读回证明</span> 在后端具备精确查询能力时检查 success、业务 ID、workspace/folder 等最终状态。</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>真实数据 E2E 证据矩阵</h2>
|
||||
<p class="small">28 项业务断言全部通过。测试使用一次性空知识库、临时在线文档与一名同组织内部测试成员;所有对象在 finally 清理。报告不保存对象 ID、成员身份、组织信息、URL、trace 或原始响应。</p>
|
||||
<div class="toolbar"><input id="q" placeholder="筛选命令或证据"><select id="g"><option value="">全部分组</option><option>空间</option><option>成员</option><option>节点</option><option>动态</option></select></div>
|
||||
<div class="matrix"><table id="catalog"><thead><tr><th>分组</th><th>Shortcut</th><th>实际业务断言</th><th>状态</th></tr></thead><tbody>
|
||||
<tr><td>空间</td><td><code>+space-list</code></td><td>真实 count、hasMore、nextCursor;自动翻页返回两页结果。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-search</code></td><td>等待搜索索引后命中一次性 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-get</code></td><td>读回 workspaceId 与创建结果一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+resolve-space</code></td><td>唯一名称解析为同一 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-create</code></td><td>success=true、workspaceId 非空、详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+delete-space</code></td><td>目标预读、确认、success=true;兼容别名执行 finally 清理。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-list</code></td><td>真实 owner 条目与显式 members 数组,limit=50。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-add</code></td><td>命令只报告写终态;一次性小规模空间另行确认名单完整且角色为 READER。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-update</code></td><td>命令只报告写终态;一次性小规模空间另行确认角色变为 EDITOR。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-remove</code></td><td>命令只报告写终态;一次性小规模空间另行确认完整名单中不存在该 userId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-list</code></td><td>空库返回真实 nodes:[];有数据时验证游标与自动翻页。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-get</code></td><td>读回 nodeId 与请求一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-search</code></td><td>等待索引后按标题命中真实 nodeId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-create</code></td><td>分别创建 folder/adoc,均取得 nodeId 和元数据读回。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-copy</code></td><td>取得不同的新 nodeId,副本元数据可读。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+move</code></td><td>读回 workspaceId 与 folderId 均等于目标;兼容 +node-move。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+move-to-drive</code></td><td>移动后读回 workspace 发生变化,再通过 +move 移回。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-delete</code></td><td>目标预读与 workspace 核对后收到 success=true。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+wiki-new-doc</code></td><td>按唯一空间名创建,nodeId 与文档详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>动态</td><td><code>+feed-list</code></td><td>创建/移动操作后返回真实 feeds 数组,缺字段不会被接受。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
</tbody></table></div>
|
||||
<p class="small">可复跑入口:<code>make build</code> 后设置临时 <code>DWS_WIKI_E2E_MEMBER_ID</code>,在交互终端运行 <code>./scripts/dev/wiki-shortcut-e2e.py</code>。脚本只输出能力标签,不输出业务对象;受保护操作及最终清理均由命令逐项获取终端确认,非交互环境会在创建测试数据前拒绝运行。</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>成功判定与发布门</h2>
|
||||
<div class="two"><div><h3>运行时证据层</h3><ol><li>传输/MCP 调用成功。</li><li>响应契约存在且类型正确。</li><li>写操作必须有 <code>success=true</code>;创建类操作还必须有业务 ID。</li><li>后端具备精确查询时必须读回;不具备时明确发布不可读回,而非从截断集合推断。</li><li>集合只有显式数组才允许为空。</li></ol></div><div><h3>交付门</h3><ol><li>20/20 语义目录与注册面精确覆盖。</li><li>Contract、Safety、Result、统一输出完整。</li><li>生成漂移、Schema、确认真值、全量 Go 测试。</li><li>独立真实数据 E2E 与 finally 清理。</li><li>diff PII/密钥/本地绝对路径扫描。</li></ol></div></div>
|
||||
</section>
|
||||
<p class="footer">DWS Wiki Shortcut business review · sanitized engineering artifact</p>
|
||||
</main>
|
||||
<script>
|
||||
const q=document.querySelector('#q'),g=document.querySelector('#g'),rows=[...document.querySelectorAll('#catalog tbody tr')];
|
||||
function filter(){const text=q.value.trim().toLowerCase(),group=g.value;rows.forEach(r=>{const okText=!text||r.textContent.toLowerCase().includes(text),okGroup=!group||r.children[0].textContent===group;r.style.display=okText&&okGroup?'':'none'})}q.addEventListener('input',filter);g.addEventListener('change',filter);
|
||||
</script></body></html>
|
||||
@@ -2,6 +2,8 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
|
||||
go 1.25.9
|
||||
|
||||
replace gitlab.alibaba-inc.com/aes/aem-go-sdk => ./third_party/aem-go-sdk
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
@@ -14,9 +16,11 @@ require (
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/yuin/goldmark v1.8.5
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/text v0.35.0
|
||||
|
||||
@@ -88,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1 h1:5WwR5TV6A12taXMH7SggT8yCMMJMF9jWE7Wj+4AuHck=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
|
||||
@@ -65,12 +65,16 @@ func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *te
|
||||
token := "token-a"
|
||||
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||
calls.Add(1)
|
||||
return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: token,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
LoginRegion: string(authpkg.LoginRegionInternational),
|
||||
}, nil
|
||||
})
|
||||
|
||||
manager := NewTokenManager()
|
||||
first, err := manager.Get(context.Background(), configDir, "")
|
||||
if err != nil || first.AccessToken != "token-a" {
|
||||
if err != nil || first.AccessToken != "token-a" || first.LoginRegion != authpkg.LoginRegionInternational || !first.LoginRegionKnown {
|
||||
t.Fatalf("first token = %#v, %v", first, err)
|
||||
}
|
||||
second, err := manager.Get(context.Background(), configDir, "")
|
||||
|
||||
@@ -41,9 +41,11 @@ type accessTokenSnapshotGetter interface {
|
||||
// AccessTokenSnapshot is the minimal bearer view needed by the process cache.
|
||||
// Refresh-token material never leaves the auth package.
|
||||
type AccessTokenSnapshot struct {
|
||||
AccessToken string
|
||||
ExpiresAt time.Time
|
||||
Source string
|
||||
AccessToken string
|
||||
ExpiresAt time.Time
|
||||
Source string
|
||||
LoginRegion authpkg.LoginRegion
|
||||
LoginRegionKnown bool
|
||||
}
|
||||
|
||||
type tokenManagerKey struct {
|
||||
@@ -223,9 +225,11 @@ func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile s
|
||||
data, err := snapshotProvider.GetTokenSnapshot(ctx)
|
||||
if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" {
|
||||
return AccessTokenSnapshot{
|
||||
AccessToken: strings.TrimSpace(data.AccessToken),
|
||||
ExpiresAt: data.ExpiresAt,
|
||||
Source: "oauth",
|
||||
AccessToken: strings.TrimSpace(data.AccessToken),
|
||||
ExpiresAt: data.ExpiresAt,
|
||||
Source: "oauth",
|
||||
LoginRegion: authpkg.LoginRegion(strings.TrimSpace(data.LoginRegion)),
|
||||
LoginRegionKnown: true,
|
||||
}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestResolveIdentityHeadersOmitsAbsentOrInvalidAgentHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
const invalidValue = "DO_NOT_ECHO"
|
||||
t.Setenv(envDWSAgentHost, invalidValue)
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
const (
|
||||
envDWSAgentVersion = "DWS_AGENT_VER"
|
||||
envDWSAgentExt = "DWS_AGENT_EXT"
|
||||
maxAgentVersionBytes = 64
|
||||
maxAgentExtensionBytes = 8 * 1024
|
||||
)
|
||||
|
||||
var agentVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]*$`)
|
||||
|
||||
type agentMetadataSnapshot struct {
|
||||
version string
|
||||
ext string
|
||||
versionErr error
|
||||
extErr error
|
||||
}
|
||||
|
||||
type agentMetadataSnapshotContextKey struct{}
|
||||
|
||||
func (snapshot agentMetadataSnapshot) validationError() error {
|
||||
if snapshot.versionErr != nil {
|
||||
return snapshot.versionErr
|
||||
}
|
||||
return snapshot.extErr
|
||||
}
|
||||
|
||||
func contextWithAgentMetadataSnapshot(ctx context.Context, snapshot agentMetadataSnapshot) context.Context {
|
||||
return context.WithValue(ctx, agentMetadataSnapshotContextKey{}, snapshot)
|
||||
}
|
||||
|
||||
func agentMetadataSnapshotFromContext(ctx context.Context) (agentMetadataSnapshot, bool) {
|
||||
if ctx == nil {
|
||||
return agentMetadataSnapshot{}, false
|
||||
}
|
||||
snapshot, ok := ctx.Value(agentMetadataSnapshotContextKey{}).(agentMetadataSnapshot)
|
||||
return snapshot, ok
|
||||
}
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: envDWSAgentVersion,
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "调用 DWS 的 Agent 版本;仅作为 x-dws-agent-ver 透传到非插件 MCP 请求",
|
||||
Example: "1.2.3-beta.1+build.7",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: envDWSAgentExt,
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "调用 DWS 的 Agent 扩展上下文 JSON;仅作为 x-dws-agent-ext 透传到非插件 MCP 请求",
|
||||
Example: `{"umt":"<token>","miniwua":"<token>","ua":"agent/1.0"}`,
|
||||
Sensitive: true,
|
||||
})
|
||||
}
|
||||
|
||||
// parseAgentVersion normalizes and validates the caller-declared Agent
|
||||
// version. Only surrounding ASCII spaces and tabs are trimmed. An unset or
|
||||
// ASCII-whitespace-only value means "do not emit".
|
||||
func parseAgentVersion(raw string) (string, error) {
|
||||
value := strings.Trim(raw, " \t")
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(value) > maxAgentVersionBytes || !agentVersionPattern.MatchString(value) {
|
||||
return "", invalidAgentVersionError()
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// parseAgentExt validates one generic JSON object and returns its compact
|
||||
// one-line representation. Raw control characters other than horizontal tab
|
||||
// are rejected before JSON parsing; escaped JSON control characters remain
|
||||
// valid because they are safe on the HTTP header wire.
|
||||
func parseAgentExt(raw string) (string, error) {
|
||||
if len(raw) > maxAgentExtensionBytes || !utf8.ValidString(raw) {
|
||||
return "", invalidAgentExtError()
|
||||
}
|
||||
for _, r := range raw {
|
||||
if unicode.IsControl(r) && r != '\t' {
|
||||
return "", invalidAgentExtError()
|
||||
}
|
||||
}
|
||||
|
||||
value := strings.Trim(raw, " \t")
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, []byte(value)); err != nil {
|
||||
return "", invalidAgentExtError()
|
||||
}
|
||||
compactBytes := compact.Bytes()
|
||||
if len(compactBytes) > maxAgentExtensionBytes || len(compactBytes) < 2 || compactBytes[0] != '{' {
|
||||
return "", invalidAgentExtError()
|
||||
}
|
||||
return compact.String(), nil
|
||||
}
|
||||
|
||||
func invalidAgentVersionError() error {
|
||||
return apperrors.NewValidation(
|
||||
"DWS_AGENT_VER must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9._+-]*$",
|
||||
apperrors.WithReason("invalid_agent_version"),
|
||||
)
|
||||
}
|
||||
|
||||
func invalidAgentExtError() error {
|
||||
return apperrors.NewValidation(
|
||||
"DWS_AGENT_EXT must be a UTF-8 JSON object of at most 8192 bytes without raw control characters",
|
||||
apperrors.WithReason("invalid_agent_ext"),
|
||||
)
|
||||
}
|
||||
|
||||
// readAgentMetadataSnapshot reads both environment variables from one
|
||||
// os.Environ snapshot, then parses them once. Normal CLI execution retains the
|
||||
// validated result through the invocation so hooks and transport observe the
|
||||
// same pair even in an embedding process that mutates its environment.
|
||||
func readAgentMetadataSnapshot() agentMetadataSnapshot {
|
||||
var rawVersion, rawExt string
|
||||
for _, entry := range os.Environ() {
|
||||
key, value, _ := strings.Cut(entry, "=")
|
||||
switch key {
|
||||
case envDWSAgentVersion:
|
||||
rawVersion = value
|
||||
case envDWSAgentExt:
|
||||
rawExt = value
|
||||
}
|
||||
}
|
||||
version, versionErr := parseAgentVersion(rawVersion)
|
||||
ext, extErr := parseAgentExt(rawExt)
|
||||
return agentMetadataSnapshot{
|
||||
version: version,
|
||||
ext: ext,
|
||||
versionErr: versionErr,
|
||||
extErr: extErr,
|
||||
}
|
||||
}
|
||||
|
||||
// removeAgentMetadataHeaders removes every case variant so edition or
|
||||
// credential hooks cannot smuggle MCP-only metadata into shared transports.
|
||||
func removeAgentMetadataHeaders(headers map[string]string) {
|
||||
for key := range headers {
|
||||
if strings.EqualFold(key, transport.HeaderAgentVersion) ||
|
||||
strings.EqualFold(key, transport.HeaderAgentExt) {
|
||||
delete(headers, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyAgentMetadataHeaders applies validated environment values as the final
|
||||
// authority for non-plugin MCP requests. Invalid values are omitted on
|
||||
// library paths that bypass root validation; normal CLI execution rejects
|
||||
// them before hooks or network access.
|
||||
func applyAgentMetadataHeaders(headers map[string]string) map[string]string {
|
||||
return applyAgentMetadataSnapshot(headers, readAgentMetadataSnapshot())
|
||||
}
|
||||
|
||||
func applyAgentMetadataSnapshot(headers map[string]string, snapshot agentMetadataSnapshot) map[string]string {
|
||||
removeAgentMetadataHeaders(headers)
|
||||
|
||||
if (snapshot.versionErr != nil || snapshot.version == "") && (snapshot.extErr != nil || snapshot.ext == "") {
|
||||
return headers
|
||||
}
|
||||
if headers == nil {
|
||||
headers = make(map[string]string)
|
||||
}
|
||||
if snapshot.versionErr == nil && snapshot.version != "" {
|
||||
headers[transport.HeaderAgentVersion] = snapshot.version
|
||||
}
|
||||
if snapshot.extErr == nil && snapshot.ext != "" {
|
||||
headers[transport.HeaderAgentExt] = snapshot.ext
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// resolveMCPRequestHeaders adds Agent version and extension metadata only to
|
||||
// the built-in DingTalk MCP request path. Shared identity consumers (notably
|
||||
// A2A) continue to use resolveIdentityHeaders and never receive these fields.
|
||||
func resolveMCPRequestHeaders() map[string]string {
|
||||
return resolveMCPRequestHeadersWithSnapshot(readAgentMetadataSnapshot())
|
||||
}
|
||||
|
||||
func resolveMCPRequestHeadersWithSnapshot(snapshot agentMetadataSnapshot) map[string]string {
|
||||
return applyAgentMetadataSnapshot(resolveIdentityHeaders(), snapshot)
|
||||
}
|
||||
|
||||
// resolveMCPRequestHeadersForInvocation resolves one immutable Header snapshot
|
||||
// for an invocation. The helper-only mcp-meta server performs endpoint
|
||||
// discovery rather than an ordinary MCP product call, so caller-declared
|
||||
// Agent metadata must not cross that boundary.
|
||||
func resolveMCPRequestHeadersForInvocation(invocation executor.Invocation, snapshots ...agentMetadataSnapshot) map[string]string {
|
||||
headers := resolveIdentityHeaders()
|
||||
if strings.EqualFold(strings.TrimSpace(invocation.CanonicalProduct), mcpMetaServerID) {
|
||||
return headers
|
||||
}
|
||||
snapshot := readAgentMetadataSnapshot()
|
||||
if len(snapshots) > 0 {
|
||||
snapshot = snapshots[0]
|
||||
}
|
||||
return applyAgentMetadataSnapshot(headers, snapshot)
|
||||
}
|
||||
|
||||
// pluginRequestHeaders returns a private, sanitized copy of plugin-owned
|
||||
// Headers. Third-party plugins never receive DWS-owned Agent metadata, even if
|
||||
// their manifest tries to declare the reserved Header names itself.
|
||||
func pluginRequestHeaders(pluginAuth *PluginAuth) map[string]string {
|
||||
if pluginAuth == nil || len(pluginAuth.ExtraHeaders) == 0 {
|
||||
return nil
|
||||
}
|
||||
headers := make(map[string]string, len(pluginAuth.ExtraHeaders))
|
||||
for key, value := range pluginAuth.ExtraHeaders {
|
||||
headers[key] = value
|
||||
}
|
||||
removeAgentMetadataHeaders(headers)
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return headers
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
outputpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageParseAgentVersion(t *testing.T) {
|
||||
var nilContext context.Context
|
||||
if _, ok := agentMetadataSnapshotFromContext(nilContext); ok {
|
||||
t.Fatal("nil context unexpectedly contained Agent metadata")
|
||||
}
|
||||
wantSnapshot := agentMetadataSnapshot{version: "context-version", ext: "{}"}
|
||||
if got, ok := agentMetadataSnapshotFromContext(contextWithAgentMetadataSnapshot(context.Background(), wantSnapshot)); !ok || got != wantSnapshot {
|
||||
t.Fatalf("context Agent metadata = %#v, %v; want %#v", got, ok, wantSnapshot)
|
||||
}
|
||||
|
||||
valid := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "unset", raw: "", want: ""},
|
||||
{name: "ASCII whitespace only", raw: " \t ", want: ""},
|
||||
{name: "semantic version", raw: "1.2.3", want: "1.2.3"},
|
||||
{name: "pre-release and build", raw: " v1.2.3-rc.1+build_7 ", want: "v1.2.3-rc.1+build_7"},
|
||||
{name: "maximum length", raw: strings.Repeat("a", maxAgentVersionBytes), want: strings.Repeat("a", maxAgentVersionBytes)},
|
||||
}
|
||||
for _, tc := range valid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAgentVersion(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAgentVersion() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("parseAgentVersion() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "leading punctuation", raw: "-1.2.3"},
|
||||
{name: "internal space", raw: "1.2 3"},
|
||||
{name: "slash", raw: "1.2/3"},
|
||||
{name: "line feed", raw: "1.2.3\n"},
|
||||
{name: "carriage return", raw: "1.2.3\r"},
|
||||
{name: "NUL", raw: "1.2\x003"},
|
||||
{name: "Unicode", raw: "版本1"},
|
||||
{name: "too long", raw: strings.Repeat("a", maxAgentVersionBytes+1)},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAgentVersion(tc.raw)
|
||||
if err == nil || got != "" {
|
||||
t.Fatalf("parseAgentVersion(%q) = %q, %v; want validation error", tc.raw, got, err)
|
||||
}
|
||||
assertAgentMetadataValidationError(t, err, "invalid_agent_version", tc.raw)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParseAgentExt(t *testing.T) {
|
||||
boundary := `{"x":"` + strings.Repeat("a", maxAgentExtensionBytes-8) + `"}`
|
||||
if len(boundary) != maxAgentExtensionBytes {
|
||||
t.Fatalf("invalid boundary fixture size: %d", len(boundary))
|
||||
}
|
||||
|
||||
valid := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "unset", raw: "", want: ""},
|
||||
{name: "ASCII whitespace only", raw: " \t ", want: ""},
|
||||
{name: "empty object", raw: "{}", want: "{}"},
|
||||
{name: "compact generic object", raw: " \t{ \"umt\": \"masked\",\t \"nested\": { \"ok\": true }, \"unknown\": [1, 2] }\t ", want: `{"umt":"masked","nested":{"ok":true},"unknown":[1,2]}`},
|
||||
{name: "Unicode value", raw: `{"ua":"千问办公/1.0"}`, want: `{"ua":"千问办公/1.0"}`},
|
||||
{name: "escaped control remains safe", raw: `{"ua":"line\nnext"}`, want: `{"ua":"line\nnext"}`},
|
||||
{name: "maximum length", raw: boundary, want: boundary},
|
||||
}
|
||||
for _, tc := range valid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAgentExt(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAgentExt() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("parseAgentExt() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalidUTF8 := string([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'})
|
||||
invalid := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "too long raw input", raw: strings.Repeat(" ", maxAgentExtensionBytes+1)},
|
||||
{name: "invalid UTF-8", raw: invalidUTF8},
|
||||
{name: "array", raw: `[]`},
|
||||
{name: "string", raw: `"value"`},
|
||||
{name: "number", raw: `1`},
|
||||
{name: "boolean", raw: `true`},
|
||||
{name: "null", raw: `null`},
|
||||
{name: "malformed object", raw: `{"secret":"DO_NOT_ECHO"`},
|
||||
{name: "trailing value", raw: `{} {}`},
|
||||
{name: "line feed", raw: "{\n}"},
|
||||
{name: "carriage return", raw: "{\r}"},
|
||||
{name: "NUL", raw: "{\x00}"},
|
||||
{name: "vertical tab", raw: "{\v}"},
|
||||
{name: "form feed", raw: "{\f}"},
|
||||
{name: "DEL", raw: "{\x7f}"},
|
||||
{name: "C1 control", raw: "{\u0085}"},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAgentExt(tc.raw)
|
||||
if err == nil || got != "" {
|
||||
t.Fatalf("parseAgentExt() = %q, %v; want validation error", got, err)
|
||||
}
|
||||
assertAgentMetadataValidationError(t, err, "invalid_agent_ext", tc.raw)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertAgentMetadataValidationError(t *testing.T, err error, reason, raw string) {
|
||||
t.Helper()
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != reason {
|
||||
t.Fatalf("error = category %q reason %q, want validation/%s", appErr.Category, appErr.Reason, reason)
|
||||
}
|
||||
if strings.Contains(raw, "DO_NOT_ECHO") && strings.Contains(err.Error(), "DO_NOT_ECHO") {
|
||||
t.Fatalf("error must not echo invalid value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgentMetadataConfigRegistrationAndMasking(t *testing.T) {
|
||||
items := configmeta.All()
|
||||
var versionItem, extItem *configmeta.ConfigItem
|
||||
for i := range items {
|
||||
switch items[i].Name {
|
||||
case envDWSAgentVersion:
|
||||
versionItem = &items[i]
|
||||
case envDWSAgentExt:
|
||||
extItem = &items[i]
|
||||
}
|
||||
}
|
||||
if versionItem == nil || extItem == nil {
|
||||
t.Fatalf("Agent metadata config registration missing: version=%v ext=%v", versionItem != nil, extItem != nil)
|
||||
}
|
||||
if versionItem.Category != configmeta.CategoryExternal || versionItem.Sensitive {
|
||||
t.Fatalf("version config metadata = %#v", *versionItem)
|
||||
}
|
||||
if extItem.Category != configmeta.CategoryExternal || !extItem.Sensitive {
|
||||
t.Fatalf("extension config metadata = %#v", *extItem)
|
||||
}
|
||||
|
||||
const canary = `{"umt":"SENSITIVE_CANARY"}`
|
||||
t.Setenv(envDWSAgentExt, canary)
|
||||
got, ok := configmeta.Resolve(envDWSAgentExt)
|
||||
if !ok || got == "" || strings.Contains(got, "SENSITIVE_CANARY") || got == canary {
|
||||
t.Fatalf("sensitive extension was not masked: value=%q ok=%v", got, ok)
|
||||
}
|
||||
|
||||
t.Setenv(envDWSAgentVersion, "9.8.7")
|
||||
command := newConfigListCommand()
|
||||
var output strings.Builder
|
||||
command.SetOut(&output)
|
||||
command.SetArgs([]string{"--category", string(configmeta.CategoryExternal), "--show-values", "--json"})
|
||||
if err := command.Execute(); err != nil {
|
||||
t.Fatalf("config list failed: %v", err)
|
||||
}
|
||||
rawOutput := output.String()
|
||||
if !json.Valid([]byte(rawOutput)) {
|
||||
t.Fatalf("config list emitted invalid JSON: %q", rawOutput)
|
||||
}
|
||||
if !strings.Contains(rawOutput, envDWSAgentVersion) || !strings.Contains(rawOutput, envDWSAgentExt) {
|
||||
t.Fatalf("config list omitted Agent metadata variables: %s", rawOutput)
|
||||
}
|
||||
if strings.Contains(rawOutput, "SENSITIVE_CANARY") || strings.Contains(rawOutput, canary) {
|
||||
t.Fatalf("config list leaked Agent extension: %s", rawOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveMCPRequestHeadersScopesAndFinalizesAgentMetadata(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSAgentHost, "")
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
t.Setenv(envDWSAgentVersion, " 1.2.3-rc.1 ")
|
||||
t.Setenv(envDWSAgentExt, " { \"umt\": \"masked\", \"unknown\": true } ")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers["X-Dws-Agent-Ver"] = "merge-must-not-win"
|
||||
headers["X-Dws-Agent-Ext"] = `{"source":"merge"}`
|
||||
return headers
|
||||
},
|
||||
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||
headers[transport.HeaderAgentVersion] = "credential-must-not-win"
|
||||
headers[transport.HeaderAgentExt] = `{"source":"credential"}`
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
for name, headers := range map[string]map[string]string{
|
||||
"shared identity": resolveIdentityHeaders(),
|
||||
"A2A export": MCPIdentityHeaders(),
|
||||
} {
|
||||
if hasHeaderFold(headers, transport.HeaderAgentVersion) || hasHeaderFold(headers, transport.HeaderAgentExt) {
|
||||
t.Fatalf("%s leaked MCP-only metadata: %#v", name, headers)
|
||||
}
|
||||
}
|
||||
|
||||
headers := resolveMCPRequestHeaders()
|
||||
if got := headers[transport.HeaderAgentVersion]; got != "1.2.3-rc.1" {
|
||||
t.Fatalf("%s = %q, want 1.2.3-rc.1", transport.HeaderAgentVersion, got)
|
||||
}
|
||||
if got := headers[transport.HeaderAgentExt]; got != `{"umt":"masked","unknown":true}` {
|
||||
t.Fatalf("%s = %q", transport.HeaderAgentExt, got)
|
||||
}
|
||||
if got := headers[transport.HeaderVersion]; got != version {
|
||||
t.Fatalf("%s = %q, want CLI version %q", transport.HeaderVersion, got, version)
|
||||
}
|
||||
if _, ok := headers["User-Agent"]; ok {
|
||||
t.Fatal("Agent extension must not create or replace the standard User-Agent header")
|
||||
}
|
||||
for _, key := range []string{"umt", "miniwua", "ua", "x-dws-agent-umt", "x-dws-agent-miniwua", "x-dws-agent-ua"} {
|
||||
if hasHeaderFold(headers, key) {
|
||||
t.Fatalf("Agent extension was split into an extra header %q: %#v", key, headers)
|
||||
}
|
||||
}
|
||||
|
||||
// Library paths are best-effort: one invalid value is omitted without
|
||||
// suppressing the other valid field or preserving hook-injected values.
|
||||
t.Setenv(envDWSAgentExt, `{"secret":"DO_NOT_ECHO"`)
|
||||
headers = resolveMCPRequestHeaders()
|
||||
if got := headers[transport.HeaderAgentVersion]; got != "1.2.3-rc.1" {
|
||||
t.Fatalf("valid version was suppressed: %q", got)
|
||||
}
|
||||
if hasHeaderFold(headers, transport.HeaderAgentExt) {
|
||||
t.Fatalf("invalid extension or hook value leaked: %#v", headers)
|
||||
}
|
||||
|
||||
// Exercise the nil-map and empty-input library paths. An absent environment
|
||||
// must not allocate a map, while an EXT-only value must allocate one and
|
||||
// remain a single compact Header.
|
||||
t.Setenv(envDWSAgentVersion, "")
|
||||
t.Setenv(envDWSAgentExt, "")
|
||||
if got := applyAgentMetadataHeaders(nil); got != nil {
|
||||
t.Fatalf("empty metadata allocated headers: %#v", got)
|
||||
}
|
||||
t.Setenv(envDWSAgentExt, " { } ")
|
||||
headers = applyAgentMetadataHeaders(nil)
|
||||
if got := headers[transport.HeaderAgentExt]; got != "{}" {
|
||||
t.Fatalf("EXT-only metadata = %q, want {}", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRootRejectsInvalidAgentMetadataBeforeEditionHook(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
value string
|
||||
reason string
|
||||
}{
|
||||
{name: "version", env: envDWSAgentVersion, value: "DO_NOT ECHO", reason: "invalid_agent_version"},
|
||||
{name: "extension", env: envDWSAgentExt, value: `{"secret":"DO_NOT_ECHO"`, reason: "invalid_agent_ext"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSAgentHost, "")
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
t.Setenv(envDWSAgentVersion, "")
|
||||
t.Setenv(envDWSAgentExt, "")
|
||||
t.Setenv(tc.env, tc.value)
|
||||
|
||||
headerHookCalled := false
|
||||
afterHookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headerHookCalled = true
|
||||
return headers
|
||||
},
|
||||
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||
headerHookCalled = true
|
||||
return headers
|
||||
},
|
||||
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
|
||||
afterHookCalled = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("root command accepted invalid %s", tc.env)
|
||||
}
|
||||
if headerHookCalled || afterHookCalled {
|
||||
t.Fatalf("edition hook ran before %s validation", tc.env)
|
||||
}
|
||||
assertAgentMetadataValidationError(t, err, tc.reason, tc.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgentMetadataProcessEntryValidationPrecedesRootConstruction(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{name: "default JSON", args: []string{"version"}, want: true},
|
||||
{name: "long JSON", args: []string{"version", "--format", "JSON"}, want: true},
|
||||
{name: "long table", args: []string{"--format=table", "version"}, want: false},
|
||||
{name: "short attached JSON", args: []string{"version", "-fjson"}, want: true},
|
||||
{name: "short table", args: []string{"version", "-f", "table"}, want: false},
|
||||
{name: "last wins", args: []string{"--format", "table", "version", "-f=json"}, want: true},
|
||||
{name: "terminator", args: []string{"version", "--format", "table", "--", "--format", "json"}, want: false},
|
||||
{name: "missing value", args: []string{"version", "--format"}, want: false},
|
||||
} {
|
||||
t.Run("presentation/"+tc.name, func(t *testing.T) {
|
||||
if got := processArgsRequestJSON(tc.args); got != tc.want {
|
||||
t.Fatalf("processArgsRequestJSON(%q) = %v, want %v", tc.args, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSAgentHost, "")
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
t.Setenv(envDWSAgentVersion, "")
|
||||
sensitiveRaw := "{\"umt\":\"must-not-leak\"}\n"
|
||||
t.Setenv(envDWSAgentExt, sensitiveRaw)
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
extensionHookCalls := 0
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "presentation-test",
|
||||
RegisterExtraCommands: func(*cobra.Command, edition.ToolCaller) {
|
||||
extensionHookCalls++
|
||||
},
|
||||
VisibleProducts: func() []string {
|
||||
extensionHookCalls++
|
||||
return nil
|
||||
},
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
extensionHookCalls++
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "version"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
rootConstructed := false
|
||||
preParseCalled := false
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
rootConstructed = true
|
||||
return &cobra.Command{Use: "dws"}
|
||||
})
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error {
|
||||
preParseCalled = true
|
||||
return nil
|
||||
})
|
||||
|
||||
stderrFile, err := os.CreateTemp(t.TempDir(), "agent-metadata-stderr-*")
|
||||
if err != nil {
|
||||
t.Fatalf("create stderr capture: %v", err)
|
||||
}
|
||||
oldStderr := os.Stderr
|
||||
os.Stderr = stderrFile
|
||||
t.Cleanup(func() {
|
||||
os.Stderr = oldStderr
|
||||
_ = stderrFile.Close()
|
||||
})
|
||||
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("process entry accepted invalid Agent metadata")
|
||||
}
|
||||
if rootConstructed || preParseCalled {
|
||||
t.Fatalf("invalid Agent metadata reached root hooks: constructed=%v preParse=%v", rootConstructed, preParseCalled)
|
||||
}
|
||||
if extensionHookCalls != 0 {
|
||||
t.Fatalf("invalid Agent metadata executed %d extension hooks", extensionHookCalls)
|
||||
}
|
||||
if err := stderrFile.Sync(); err != nil {
|
||||
t.Fatalf("sync stderr capture: %v", err)
|
||||
}
|
||||
stderrOutput, err := os.ReadFile(stderrFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("read stderr capture: %v", err)
|
||||
}
|
||||
if strings.Contains(string(stderrOutput), "must-not-leak") || strings.Contains(string(stderrOutput), sensitiveRaw) {
|
||||
t.Fatalf("process validation error leaked raw EXT: %q", stderrOutput)
|
||||
}
|
||||
if !json.Valid(stderrOutput) || !strings.Contains(string(stderrOutput), `"reason": "invalid_agent_ext"`) {
|
||||
t.Fatalf("default JSON error presentation = %q", stderrOutput)
|
||||
}
|
||||
|
||||
stdoutFile, err := os.CreateTemp(t.TempDir(), "agent-metadata-stdout-*")
|
||||
if err != nil {
|
||||
t.Fatalf("create stdout capture: %v", err)
|
||||
}
|
||||
oldStdout := os.Stdout
|
||||
os.Stdout = stdoutFile
|
||||
t.Cleanup(func() {
|
||||
os.Stdout = oldStdout
|
||||
_ = stdoutFile.Close()
|
||||
})
|
||||
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"drive", "+list", "--format", "json"})
|
||||
if err := stdoutFile.Sync(); err != nil {
|
||||
t.Fatalf("sync stdout capture: %v", err)
|
||||
}
|
||||
unifiedOutput, err := os.ReadFile(stdoutFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("read stdout capture: %v", err)
|
||||
}
|
||||
if !json.Valid(unifiedOutput) || !strings.Contains(string(unifiedOutput), `"outcome": "failure"`) ||
|
||||
!strings.Contains(string(unifiedOutput), `"subtype": "invalid_agent_ext"`) {
|
||||
t.Fatalf("unified JSON error presentation = %q", unifiedOutput)
|
||||
}
|
||||
if extensionHookCalls != 0 {
|
||||
t.Fatalf("presentation-only root executed %d extension hooks", extensionHookCalls)
|
||||
}
|
||||
|
||||
if err := stderrFile.Truncate(0); err != nil {
|
||||
t.Fatalf("truncate fallback stderr capture: %v", err)
|
||||
}
|
||||
if _, err := stderrFile.Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatalf("rewind fallback stderr capture: %v", err)
|
||||
}
|
||||
testseam.Swap(t, &rootEmitResult, func(*cobra.Command, outputpkg.CommandResult) (int, error) {
|
||||
return 0, errors.New("injected result emission failure")
|
||||
})
|
||||
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"drive", "+list", "--format", "json"})
|
||||
if err := stderrFile.Sync(); err != nil {
|
||||
t.Fatalf("sync fallback stderr capture: %v", err)
|
||||
}
|
||||
fallbackOutput, err := os.ReadFile(stderrFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("read fallback stderr capture: %v", err)
|
||||
}
|
||||
if !json.Valid(fallbackOutput) || !strings.Contains(string(fallbackOutput), `"reason": "invalid_agent_ext"`) ||
|
||||
strings.Contains(string(fallbackOutput), "must-not-leak") {
|
||||
t.Fatalf("fallback validation error presentation = %q", fallbackOutput)
|
||||
}
|
||||
|
||||
if err := stderrFile.Truncate(0); err != nil {
|
||||
t.Fatalf("truncate stderr capture: %v", err)
|
||||
}
|
||||
if _, err := stderrFile.Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatalf("rewind stderr capture: %v", err)
|
||||
}
|
||||
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"version", "--format", "table"})
|
||||
if err := stderrFile.Sync(); err != nil {
|
||||
t.Fatalf("sync human stderr capture: %v", err)
|
||||
}
|
||||
humanOutput, err := os.ReadFile(stderrFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("read human stderr capture: %v", err)
|
||||
}
|
||||
if json.Valid(humanOutput) || !strings.Contains(string(humanOutput), "DWS_AGENT_EXT") ||
|
||||
strings.Contains(string(humanOutput), "must-not-leak") {
|
||||
t.Fatalf("human validation error presentation = %q", humanOutput)
|
||||
}
|
||||
|
||||
var capturedRunner *runtimeRunner
|
||||
testseam.Swap(t, &rootNewCommandRunnerWithFlags, func(flags *GlobalFlags) executor.Runner {
|
||||
capturedRunner = newCommandRunnerWithFlags(flags).(*runtimeRunner)
|
||||
return capturedRunner
|
||||
})
|
||||
cachedSnapshot := agentMetadataSnapshot{version: "9.8.7", ext: `{"ua":"cached"}`}
|
||||
_ = newRootCommandWithMode(
|
||||
contextWithAgentMetadataSnapshot(context.Background(), cachedSnapshot),
|
||||
nil,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
if capturedRunner == nil || capturedRunner.agentMetadata == nil || *capturedRunner.agentMetadata != cachedSnapshot {
|
||||
t.Fatalf("root runner Agent metadata = %#v, want %#v", capturedRunner, cachedSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgentMetadataExcludedFromServiceDiscovery(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSAgentHost, "")
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
t.Setenv(envDWSAgentVersion, "3.0.0")
|
||||
t.Setenv(envDWSAgentExt, `{"umt":"test-value"}`)
|
||||
|
||||
headers := resolveMCPRequestHeadersForInvocation(executor.Invocation{
|
||||
CanonicalProduct: mcpMetaServerID,
|
||||
Tool: mcpMetaURLTool,
|
||||
})
|
||||
if hasHeaderFold(headers, transport.HeaderAgentVersion) || hasHeaderFold(headers, transport.HeaderAgentExt) {
|
||||
t.Fatalf("service-discovery request leaked Agent metadata: %#v", headers)
|
||||
}
|
||||
|
||||
headers = resolveMCPRequestHeadersForInvocation(executor.Invocation{CanonicalProduct: "doc", Tool: "read"})
|
||||
if headers[transport.HeaderAgentVersion] != "3.0.0" || headers[transport.HeaderAgentExt] == "" {
|
||||
t.Fatalf("ordinary MCP request omitted Agent metadata: %#v", headers)
|
||||
}
|
||||
cached := agentMetadataSnapshot{version: "3.1.0", ext: "{}"}
|
||||
headers = resolveMCPRequestHeadersForInvocation(executor.Invocation{CanonicalProduct: "doc", Tool: "read"}, cached)
|
||||
if headers[transport.HeaderAgentVersion] != "3.1.0" || headers[transport.HeaderAgentExt] != "{}" {
|
||||
t.Fatalf("ordinary MCP request ignored its validated snapshot: %#v", headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSAgentHost, "")
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
t.Setenv(envDWSAgentVersion, "2.0.0")
|
||||
t.Setenv(envDWSAgentExt, `{"ua":"test-agent/2.0"}`)
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{})
|
||||
|
||||
pluginAuthMu.Lock()
|
||||
oldPluginRegistry := pluginAuthRegistry
|
||||
pluginAuthRegistry = make(map[string]*PluginAuth)
|
||||
pluginAuthMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
pluginAuthMu.Lock()
|
||||
pluginAuthRegistry = oldPluginRegistry
|
||||
pluginAuthMu.Unlock()
|
||||
})
|
||||
dynamicMu.Lock()
|
||||
oldDynamicEndpoints := dynamicEndpoints
|
||||
oldDynamicProducts := dynamicProducts
|
||||
oldDynamicAliases := dynamicAliases
|
||||
oldDynamicToolEndpoints := dynamicToolEndpoints
|
||||
dynamicEndpoints = nil
|
||||
dynamicProducts = nil
|
||||
dynamicAliases = nil
|
||||
dynamicToolEndpoints = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = oldDynamicEndpoints
|
||||
dynamicProducts = oldDynamicProducts
|
||||
dynamicAliases = oldDynamicAliases
|
||||
dynamicToolEndpoints = oldDynamicToolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
testseam.Swap(t, &runnerPreflightDocDownload, func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||
return nil
|
||||
})
|
||||
type capturedRequest struct {
|
||||
headers map[string]string
|
||||
token string
|
||||
}
|
||||
var captured []capturedRequest
|
||||
testseam.Swap(t, &runnerCallTool, func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
||||
copyHeaders := make(map[string]string, len(client.ExtraHeaders))
|
||||
for key, value := range client.ExtraHeaders {
|
||||
copyHeaders[key] = value
|
||||
}
|
||||
captured = append(captured, capturedRequest{headers: copyHeaders, token: client.AuthToken})
|
||||
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
|
||||
})
|
||||
|
||||
created := newCommandRunnerWithFlags(&GlobalFlags{}).(*runtimeRunner)
|
||||
if hasHeaderFold(created.transport.ExtraHeaders, transport.HeaderAgentVersion) ||
|
||||
hasHeaderFold(created.transport.ExtraHeaders, transport.HeaderAgentExt) {
|
||||
t.Fatalf("new runner resolved Agent metadata before invocation validation: %#v", created.transport.ExtraHeaders)
|
||||
}
|
||||
|
||||
// runSingle must not cache ambient MCP metadata on the shared base transport.
|
||||
// Use mock mode to exercise the path without authentication or network I/O.
|
||||
t.Setenv(envDWSAgentVersion, "2.0.1")
|
||||
refreshRunner := &runtimeRunner{
|
||||
transport: transport.NewClient(nil),
|
||||
globalFlags: &GlobalFlags{Mock: true},
|
||||
auditSink: audit.NopSink{},
|
||||
}
|
||||
refreshInvocation := executor.Invocation{CanonicalProduct: "refresh", Tool: "tool", Params: map[string]any{}}
|
||||
if _, err := refreshRunner.runSingle(context.Background(), refreshInvocation, false); err != nil {
|
||||
t.Fatalf("mock runSingle failed: %v", err)
|
||||
}
|
||||
if hasHeaderFold(refreshRunner.transport.ExtraHeaders, transport.HeaderAgentVersion) {
|
||||
t.Fatalf("runSingle mutated the shared transport Header map: %#v", refreshRunner.transport.ExtraHeaders)
|
||||
}
|
||||
t.Setenv(envDWSAgentVersion, "2.0.0")
|
||||
|
||||
r := &runtimeRunner{
|
||||
transport: transport.NewClient(nil),
|
||||
globalFlags: &GlobalFlags{Token: "test-token"},
|
||||
auditSink: audit.NopSink{},
|
||||
agentMetadata: &agentMetadataSnapshot{
|
||||
version: "2.0.0",
|
||||
ext: `{"ua":"test-agent/2.0"}`,
|
||||
},
|
||||
}
|
||||
builtIn := executor.Invocation{CanonicalProduct: "built-in", Tool: "tool", Params: map[string]any{}}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://example.test", builtIn); err != nil {
|
||||
t.Fatalf("built-in invocation failed: %v", err)
|
||||
}
|
||||
|
||||
pluginDescriptor := mcptypes.ServerDescriptor{
|
||||
Key: "third-party",
|
||||
Endpoint: "https://plugin.example.test",
|
||||
CLI: mcptypes.CLIOverlay{ID: "third-party"},
|
||||
AuthHeaders: map[string]string{
|
||||
"X-Plugin": "yes",
|
||||
"X-Dws-Agent-Ver": "plugin-must-not-forge-version",
|
||||
"X-Dws-Agent-Ext": `{"source":"plugin"}`,
|
||||
},
|
||||
}
|
||||
registerPluginHTTPServer(pluginDescriptor)
|
||||
registeredPlugin, pluginOwned := LookupPluginAuth("third-party")
|
||||
if !pluginOwned || registeredPlugin == nil || registeredPlugin.Token != "" {
|
||||
t.Fatalf("anonymous HTTP plugin ownership = %#v, %v", registeredPlugin, pluginOwned)
|
||||
}
|
||||
registerPluginHTTPServer(mcptypes.ServerDescriptor{
|
||||
Key: "anonymous-empty",
|
||||
Endpoint: "https://anonymous.example.test",
|
||||
CLI: mcptypes.CLIOverlay{ID: "anonymous-empty"},
|
||||
})
|
||||
if emptyPlugin, owned := LookupPluginAuth("anonymous-empty"); !owned || emptyPlugin == nil || emptyPlugin.Token != "" || len(emptyPlugin.ExtraHeaders) != 0 {
|
||||
t.Fatalf("headerless HTTP plugin ownership = %#v, %v", emptyPlugin, owned)
|
||||
}
|
||||
originalPluginHeaders := maps.Clone(registeredPlugin.ExtraHeaders)
|
||||
pluginInvocation := executor.Invocation{CanonicalProduct: "third-party", Tool: "tool", Params: map[string]any{}}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://plugin.example.test", pluginInvocation); err != nil {
|
||||
t.Fatalf("plugin invocation failed: %v", err)
|
||||
}
|
||||
|
||||
if len(captured) != 2 {
|
||||
t.Fatalf("captured %d calls, want 2", len(captured))
|
||||
}
|
||||
if captured[0].headers[transport.HeaderAgentVersion] != "2.0.0" || captured[0].headers[transport.HeaderAgentExt] != `{"ua":"test-agent/2.0"}` {
|
||||
t.Fatalf("built-in MCP metadata = %#v", captured[0].headers)
|
||||
}
|
||||
if hasHeaderFold(captured[1].headers, transport.HeaderAgentVersion) || hasHeaderFold(captured[1].headers, transport.HeaderAgentExt) {
|
||||
t.Fatalf("plugin request leaked Agent metadata: %#v", captured[1].headers)
|
||||
}
|
||||
if got := captured[1].headers["X-Plugin"]; got != "yes" {
|
||||
t.Fatalf("plugin-owned header = %q, want yes", got)
|
||||
}
|
||||
if captured[1].token != "" {
|
||||
t.Fatalf("anonymous plugin unexpectedly received default OAuth token")
|
||||
}
|
||||
if !maps.Equal(registeredPlugin.ExtraHeaders, originalPluginHeaders) {
|
||||
t.Fatalf("plugin Header sanitization mutated registry state: got %#v want %#v", registeredPlugin.ExtraHeaders, originalPluginHeaders)
|
||||
}
|
||||
if got := pluginRequestHeaders(nil); got != nil {
|
||||
t.Fatalf("nil plugin auth produced Headers: %#v", got)
|
||||
}
|
||||
if got := pluginRequestHeaders(&PluginAuth{ExtraHeaders: map[string]string{
|
||||
"X-DWS-AGENT-VER": "forged",
|
||||
"X-DWS-AGENT-EXT": `{"forged":true}`,
|
||||
}}); got != nil {
|
||||
t.Fatalf("reserved-only plugin Headers survived sanitization: %#v", got)
|
||||
}
|
||||
|
||||
// Keep the execution-boundary auth guard independently testable: even if a
|
||||
// future token provider returns an empty token without an error, built-in MCP
|
||||
// calls must fail before preflight or transport while anonymous plugins remain
|
||||
// valid above.
|
||||
resolveCalled := false
|
||||
testseam.Swap(t, &runnerResolveAuthSnapshot, func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
|
||||
resolveCalled = true
|
||||
return AccessTokenSnapshot{}, nil
|
||||
})
|
||||
callsBefore := len(captured)
|
||||
unauthenticated := &runtimeRunner{
|
||||
transport: transport.NewClient(nil),
|
||||
globalFlags: &GlobalFlags{},
|
||||
auditSink: audit.NopSink{},
|
||||
}
|
||||
if _, err := unauthenticated.executeInvocation(context.Background(), "https://example.test", executor.Invocation{CanonicalProduct: "built-in-unauthenticated", Tool: "tool"}); err == nil || !isAuthError(err) {
|
||||
t.Fatalf("unauthenticated built-in request = %v, want auth error", err)
|
||||
}
|
||||
if !resolveCalled {
|
||||
t.Fatal("unauthenticated request did not exercise the token resolver")
|
||||
}
|
||||
if len(captured) != callsBefore {
|
||||
t.Fatalf("unauthenticated built-in request reached transport: calls %d -> %d", callsBefore, len(captured))
|
||||
}
|
||||
}
|
||||
|
||||
func hasHeaderFold(headers map[string]string, want string) bool {
|
||||
for key := range headers {
|
||||
if strings.EqualFold(key, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func TestApplyAgentProductHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
const invalidValue = "DO_NOT ECHO"
|
||||
t.Setenv(agentproduct.EnvName, invalidValue)
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -48,8 +50,24 @@ type authLoginConfig struct {
|
||||
TargetCorpID string
|
||||
HistoryProfileSelector string
|
||||
HistoryProfileSelectorExplicit bool
|
||||
International bool
|
||||
PreURL string
|
||||
MCPURL string
|
||||
}
|
||||
|
||||
type authLoginEndpointOverrides struct {
|
||||
LoginURL string
|
||||
MCPURL string
|
||||
}
|
||||
|
||||
type authLoginMCPPersistence uint8
|
||||
|
||||
const (
|
||||
authLoginMCPUseDefault authLoginMCPPersistence = iota
|
||||
authLoginMCPUseManagedRegion
|
||||
authLoginMCPUseExplicitOverride
|
||||
)
|
||||
|
||||
type authLoginGuideAction string
|
||||
|
||||
const (
|
||||
@@ -103,12 +121,17 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
支持的登录方式:
|
||||
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
|
||||
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
|
||||
- 自有应用 OAuth (--client-id/--client-secret): 使用指定应用完成用户授权
|
||||
- 直接提供 Token (--token): 跳过授权,使用已有 token
|
||||
|
||||
不支持的登录方式:
|
||||
- 邮箱/密码登录
|
||||
- 手机号/验证码登录
|
||||
- 应用凭证 (AppKey/AppSecret) 直接登录
|
||||
- 无用户授权的纯应用凭证 (client_credentials) 登录
|
||||
|
||||
区域:
|
||||
- 默认使用国内钉钉 .com 登录与服务端点
|
||||
- --intl(或 --international)使用国际版 .io 登录;后续业务命令按所选 profile 自动路由
|
||||
|
||||
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
@@ -116,6 +139,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
示例:
|
||||
dws auth login # 本机登录并新增/刷新一个组织 profile
|
||||
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
|
||||
dws auth login --intl # 使用钉钉国际版 .io 登录入口
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
dws auth login --intl --pre-url https://pre-mcp.dingtalk.io
|
||||
dws auth login --recommend # 无交互批量授权服务端推荐权限
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
|
||||
@@ -126,6 +152,22 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var preOverrides authLoginEndpointOverrides
|
||||
if cfg.PreURL != "" {
|
||||
var err error
|
||||
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restoreLoginBaseURL := authpkg.PushLoginBaseURLOverride(preOverrides.LoginURL)
|
||||
defer restoreLoginBaseURL()
|
||||
}
|
||||
mcpBaseURL, mcpPersistence, err := authLoginMCPBaseURLForConfig(cfg, preOverrides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restoreMCPBaseURL := authpkg.PushMCPBaseURLOverride(mcpBaseURL)
|
||||
defer restoreMCPBaseURL()
|
||||
configDir := defaultConfigDir()
|
||||
var tokenData *authpkg.TokenData
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
@@ -139,6 +181,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
AccessToken: cfg.Token,
|
||||
ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
|
||||
}
|
||||
if cfg.International {
|
||||
tokenData.LoginRegion = string(authpkg.LoginRegionInternational)
|
||||
}
|
||||
if err := authSaveTokenData(configDir, tokenData); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
|
||||
}
|
||||
@@ -149,6 +194,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
if cfg.International {
|
||||
provider.SetLoginRegion(authpkg.LoginRegionInternational)
|
||||
}
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
@@ -167,6 +215,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.TargetCorpID = cfg.TargetCorpID
|
||||
if cfg.International {
|
||||
provider.LoginRegion = authpkg.LoginRegionInternational
|
||||
}
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
@@ -180,6 +231,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if tokenData != nil {
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, mcpBaseURL, mcpPersistence); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist MCP URL: %v", err))
|
||||
}
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
@@ -278,6 +334,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
}
|
||||
cmd.Flags().String("token", "", "Access token")
|
||||
cmd.Flags().Bool("device", false, "Use device authorization flow")
|
||||
cmd.Flags().Bool("intl", false, "Use DingTalk international (.io) login and service endpoints")
|
||||
cmd.Flags().Bool("international", false, "Use DingTalk international (.io) login and service endpoints")
|
||||
cmd.Flags().String("pre-url", "", "Override pre-release login/MCP base URL for this login")
|
||||
cmd.Flags().String("mcp-url", "", "Override MCP base URL for this login")
|
||||
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
|
||||
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
|
||||
// Hidden compatibility flags
|
||||
@@ -967,6 +1027,7 @@ func newAuthResetCommand() *cobra.Command {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
|
||||
}
|
||||
_ = authRemove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = authRemove(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
|
||||
_ = authRemove(filepath.Join(configDir, "token"))
|
||||
_ = authDeleteAppConfig(configDir)
|
||||
ResetRuntimeTokenCache()
|
||||
@@ -1225,6 +1286,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --device")
|
||||
}
|
||||
intl, err := cmd.Flags().GetBool("intl")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --intl")
|
||||
}
|
||||
international, err := cmd.Flags().GetBool("international")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --international")
|
||||
}
|
||||
force, err := cmd.Flags().GetBool("force")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --force")
|
||||
@@ -1233,6 +1302,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
|
||||
}
|
||||
preURL, err := cmd.Flags().GetString("pre-url")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --pre-url")
|
||||
}
|
||||
mcpURL, err := cmd.Flags().GetString("mcp-url")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --mcp-url")
|
||||
}
|
||||
yes := false
|
||||
profileSelector := ""
|
||||
if cmd.Root() != nil {
|
||||
@@ -1266,9 +1343,172 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
TargetCorpID: targetCorpID,
|
||||
HistoryProfileSelector: historyProfileSelector,
|
||||
HistoryProfileSelectorExplicit: historyProfileSelectorExplicit,
|
||||
International: intl || international,
|
||||
PreURL: strings.TrimSpace(preURL),
|
||||
MCPURL: strings.TrimSpace(mcpURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func authLoginEndpointOverridesForPreURL(raw string) (authLoginEndpointOverrides, error) {
|
||||
parsed, normalized, err := normalizeAuthLoginBaseURL(raw, "--pre-url")
|
||||
if err != nil {
|
||||
return authLoginEndpointOverrides{}, err
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
switch {
|
||||
case strings.HasPrefix(host, "pre-login."):
|
||||
return authLoginEndpointOverrides{
|
||||
LoginURL: normalized,
|
||||
MCPURL: authLoginURLWithHost(parsed, "pre-mcp."+strings.TrimPrefix(host, "pre-login.")),
|
||||
}, nil
|
||||
case strings.HasPrefix(host, "pre-mcp."):
|
||||
return authLoginEndpointOverrides{
|
||||
LoginURL: authLoginURLWithHost(parsed, "pre-login."+strings.TrimPrefix(host, "pre-mcp.")),
|
||||
MCPURL: normalized,
|
||||
}, nil
|
||||
default:
|
||||
return authLoginEndpointOverrides{}, apperrors.NewValidation("--pre-url must be a pre-login.* or pre-mcp.* URL")
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginMCPBaseURLForConfig(cfg authLoginConfig, preOverrides authLoginEndpointOverrides) (string, authLoginMCPPersistence, error) {
|
||||
if cfg.MCPURL != "" {
|
||||
_, normalized, err := normalizeAuthLoginBaseURL(cfg.MCPURL, "--mcp-url")
|
||||
if err != nil {
|
||||
return "", authLoginMCPUseDefault, err
|
||||
}
|
||||
return normalized, authLoginMCPUseExplicitOverride, nil
|
||||
}
|
||||
if cfg.PreURL != "" {
|
||||
if preOverrides.MCPURL == "" {
|
||||
var err error
|
||||
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
|
||||
if err != nil {
|
||||
return "", authLoginMCPUseDefault, err
|
||||
}
|
||||
}
|
||||
return preOverrides.MCPURL, authLoginMCPUseExplicitOverride, nil
|
||||
}
|
||||
if cfg.International {
|
||||
return authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion, nil
|
||||
}
|
||||
return authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault, nil
|
||||
}
|
||||
|
||||
func persistAuthLoginMCPBaseURL(configDir, mcpBaseURL string, persistence authLoginMCPPersistence) error {
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
|
||||
|
||||
switch persistence {
|
||||
case authLoginMCPUseExplicitOverride:
|
||||
if err := removeAuthLoginManagedMCPRegion(managedRegionPath); err != nil {
|
||||
return fmt.Errorf("clear managed MCP region: %w", err)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("save explicit MCP URL: %w", err)
|
||||
}
|
||||
return nil
|
||||
case authLoginMCPUseManagedRegion:
|
||||
managedURL, managedErr := authReadFile(managedRegionPath)
|
||||
if managedErr != nil && !os.IsNotExist(managedErr) {
|
||||
return fmt.Errorf("read managed MCP region: %w", managedErr)
|
||||
}
|
||||
currentURL, currentErr := authReadFile(mcpURLPath)
|
||||
switch {
|
||||
case currentErr == nil && os.IsNotExist(managedErr):
|
||||
return nil
|
||||
case currentErr == nil && strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)):
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
case currentErr != nil && !os.IsNotExist(currentErr):
|
||||
return fmt.Errorf("read MCP URL: %w", currentErr)
|
||||
}
|
||||
if err := authAtomicWrite(managedRegionPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("save managed MCP region: %w", err)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
_ = authRemove(managedRegionPath)
|
||||
return fmt.Errorf("save managed MCP URL: %w", err)
|
||||
}
|
||||
return nil
|
||||
case authLoginMCPUseDefault:
|
||||
managedURL, err := authReadFile(managedRegionPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read managed MCP region: %w", err)
|
||||
}
|
||||
currentURL, err := authReadFile(mcpURLPath)
|
||||
if os.IsNotExist(err) {
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read MCP URL: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)) {
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("restore default MCP URL: %w", err)
|
||||
}
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported MCP persistence mode %d", persistence)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAuthLoginManagedMCPRegion(path string) error {
|
||||
if err := authRemove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAuthLoginBaseURL(raw, flagName string) (*url.URL, string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " cannot be empty")
|
||||
}
|
||||
if !strings.Contains(value, "://") {
|
||||
value = "https://" + value
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return nil, "", apperrors.NewValidation(fmt.Sprintf("invalid %s: %v", flagName, err))
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must use http or https")
|
||||
}
|
||||
if parsed.Hostname() == "" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must include a host")
|
||||
}
|
||||
if parsed.Scheme == "http" && !isAuthLoginLoopbackHost(parsed.Hostname()) {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must use HTTPS, except for a loopback HTTP test endpoint")
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
return parsed, strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func isAuthLoginLoopbackHost(host string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(host), "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimSpace(host))
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func authLoginURLWithHost(parsed *url.URL, host string) string {
|
||||
copyURL := *parsed
|
||||
if port := parsed.Port(); port != "" {
|
||||
copyURL.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
copyURL.Host = host
|
||||
}
|
||||
return strings.TrimRight(copyURL.String(), "/")
|
||||
}
|
||||
|
||||
func authLoginForcesAuthorization(_ authLoginConfig) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -214,7 +215,8 @@ func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
oldSave := authSaveTokenData
|
||||
oldDevice := authDeviceLogin
|
||||
oldOAuth := authOAuthLogin
|
||||
@@ -255,6 +257,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) {
|
||||
t.Fatalf("json token login = %q, %v", out, err)
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://example.com"}); err == nil {
|
||||
t.Fatal("invalid pre-release host should fail")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "mcp-url": "http://remote.example.com"}); err == nil {
|
||||
t.Fatal("remote plaintext MCP URL should fail")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://pre-login.dingtalk.io"}); err != nil {
|
||||
t.Fatalf("pre-release token login = %v", err)
|
||||
}
|
||||
|
||||
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("device")
|
||||
@@ -271,6 +282,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) {
|
||||
if provider.LoginRegion != authpkg.LoginRegionInternational {
|
||||
t.Errorf("device login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "intl": "true"}); err != nil {
|
||||
t.Fatalf("international device login = %v", err)
|
||||
}
|
||||
|
||||
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("oauth")
|
||||
@@ -291,6 +311,25 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") {
|
||||
t.Fatalf("oauth success = %q, %v", out, err)
|
||||
}
|
||||
authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) {
|
||||
if provider.LoginRegion != authpkg.LoginRegionInternational {
|
||||
t.Errorf("OAuth login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"intl": "true"}); err != nil {
|
||||
t.Fatalf("international OAuth login = %v", err)
|
||||
}
|
||||
|
||||
blockedConfigDir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(blockedConfigDir, "mcp_url"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", blockedConfigDir)
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "intl": "true"}); err == nil || !strings.Contains(err.Error(), "failed to persist MCP URL") {
|
||||
t.Fatalf("MCP URL persist failure = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
|
||||
return errors.New("recommend")
|
||||
@@ -1418,7 +1457,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
|
||||
authRemove = func(string) error { removed++; return errors.New("ignored") }
|
||||
authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") }
|
||||
edition.Override(&edition.Hooks{})
|
||||
if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") {
|
||||
if err := reset.RunE(reset, nil); err != nil || removed != 4 || !strings.Contains(out.String(), "重新登录") {
|
||||
t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed)
|
||||
}
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||
|
||||
@@ -33,6 +33,8 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -1033,8 +1035,12 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
@@ -1061,6 +1067,560 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) {
|
||||
for _, flag := range []string{"intl", "international"} {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set(flag, "true"); err != nil {
|
||||
t.Fatalf("set %s: %v", flag, err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if !cfg.International {
|
||||
t.Fatalf("International = false for --%s, want true", flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
}{
|
||||
{
|
||||
name: "missing intl",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing international",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing pre url",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
cmd.Flags().Bool("international", false, "")
|
||||
cmd.Flags().Bool("force", false, "")
|
||||
cmd.Flags().Bool("recommend", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing mcp url",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
cmd.Flags().Bool("international", false, "")
|
||||
cmd.Flags().Bool("force", false, "")
|
||||
cmd.Flags().Bool("recommend", false, "")
|
||||
cmd.Flags().String("pre-url", "", "")
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "login"}
|
||||
tc.setup(cmd)
|
||||
if _, err := resolveAuthLoginConfig(cmd); err == nil {
|
||||
t.Fatal("resolveAuthLoginConfig succeeded with an incomplete flag set")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsMCPURL(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set("mcp-url", " https://pre-mcp.dingtalk.io/ "); err != nil {
|
||||
t.Fatalf("set mcp-url: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if cfg.MCPURL != "https://pre-mcp.dingtalk.io/" {
|
||||
t.Fatalf("MCPURL = %q, want trimmed flag value", cfg.MCPURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsPreURL(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set("pre-url", " pre-login.dingtalk.io "); err != nil {
|
||||
t.Fatalf("set pre-url: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if cfg.PreURL != "pre-login.dingtalk.io" {
|
||||
t.Fatalf("PreURL = %q, want trimmed flag value", cfg.PreURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginEndpointOverridesForPreURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantLogin string
|
||||
wantMCP string
|
||||
}{
|
||||
{
|
||||
name: "pre login",
|
||||
raw: "https://pre-login.dingtalk.io/",
|
||||
wantLogin: "https://pre-login.dingtalk.io",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
{
|
||||
name: "pre mcp",
|
||||
raw: "pre-mcp.dingtalk.io",
|
||||
wantLogin: "https://pre-login.dingtalk.io",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
{
|
||||
name: "pre login with port",
|
||||
raw: "https://pre-login.dingtalk.io:8443/path/",
|
||||
wantLogin: "https://pre-login.dingtalk.io:8443/path",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io:8443/path",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := authLoginEndpointOverridesForPreURL(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("authLoginEndpointOverridesForPreURL error = %v", err)
|
||||
}
|
||||
if got.LoginURL != tc.wantLogin || got.MCPURL != tc.wantMCP {
|
||||
t.Fatalf("overrides = %#v, want login %q mcp %q", got, tc.wantLogin, tc.wantMCP)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, raw := range []string{"https://example.com", "http://pre-login.example.com"} {
|
||||
if _, err := authLoginEndpointOverridesForPreURL(raw); err == nil {
|
||||
t.Fatalf("authLoginEndpointOverridesForPreURL(%q) succeeded", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginMCPBaseURLForConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg authLoginConfig
|
||||
preOverride authLoginEndpointOverrides
|
||||
wantURL string
|
||||
wantPersistence authLoginMCPPersistence
|
||||
}{
|
||||
{
|
||||
name: "default center login uses com and resets only managed region",
|
||||
cfg: authLoginConfig{},
|
||||
wantURL: authpkg.DefaultMCPBaseURL,
|
||||
wantPersistence: authLoginMCPUseDefault,
|
||||
},
|
||||
{
|
||||
name: "international login persists managed io",
|
||||
cfg: authLoginConfig{International: true},
|
||||
wantURL: authpkg.InternationalMCPBaseURL,
|
||||
wantPersistence: authLoginMCPUseManagedRegion,
|
||||
},
|
||||
{
|
||||
name: "pre login persists mapped pre mcp",
|
||||
cfg: authLoginConfig{PreURL: "pre-login.dingtalk.io"},
|
||||
preOverride: authLoginEndpointOverrides{
|
||||
LoginURL: "https://pre-login.dingtalk.io",
|
||||
MCPURL: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
wantURL: "https://pre-mcp.dingtalk.io",
|
||||
wantPersistence: authLoginMCPUseExplicitOverride,
|
||||
},
|
||||
{
|
||||
name: "explicit mcp url wins over pre url",
|
||||
cfg: authLoginConfig{
|
||||
PreURL: "pre-login.dingtalk.io",
|
||||
MCPURL: " https://custom-mcp.example.com/ ",
|
||||
},
|
||||
preOverride: authLoginEndpointOverrides{
|
||||
LoginURL: "https://pre-login.dingtalk.io",
|
||||
MCPURL: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
wantURL: "https://custom-mcp.example.com",
|
||||
wantPersistence: authLoginMCPUseExplicitOverride,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotURL, gotPersistence, err := authLoginMCPBaseURLForConfig(tc.cfg, tc.preOverride)
|
||||
if err != nil {
|
||||
t.Fatalf("authLoginMCPBaseURLForConfig error = %v", err)
|
||||
}
|
||||
if gotURL != tc.wantURL || gotPersistence != tc.wantPersistence {
|
||||
t.Fatalf("got url=%q persistence=%v, want url=%q persistence=%v", gotURL, gotPersistence, tc.wantURL, tc.wantPersistence)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, cfg := range []authLoginConfig{
|
||||
{MCPURL: "http://remote.example.com"},
|
||||
{PreURL: "https://example.com"},
|
||||
} {
|
||||
if _, _, err := authLoginMCPBaseURLForConfig(cfg, authLoginEndpointOverrides{}); err == nil {
|
||||
t.Fatalf("authLoginMCPBaseURLForConfig(%#v) succeeded", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURL(t *testing.T) {
|
||||
t.Run("persists selected io mcp url", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if string(data) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("mcp_url = %q, want %q", string(data), authpkg.InternationalMCPBaseURL)
|
||||
}
|
||||
managed, err := os.ReadFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
|
||||
if err != nil || string(managed) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("managed MCP region = %q, %v", string(managed), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("center login preserves previous persisted override", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
const customURL = "https://custom-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if string(data) != customURL {
|
||||
t.Fatalf("mcp_url = %q, want preserved override %q", string(data), customURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("international login preserves an unmanaged explicit override", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("unmanaged override acquired a managed marker: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("center login resets a managed international URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != authpkg.DefaultMCPBaseURL {
|
||||
t.Fatalf("mcp_url = %q, %v; want domestic default", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("managed region marker remains after domestic login: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit override clears region ownership", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const customURL = "https://custom-mcp.example.com"
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, customURL, authLoginMCPUseExplicitOverride); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale marker never deletes a different custom URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://custom.example.com"), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName), []byte(authpkg.InternationalMCPBaseURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != "https://custom.example.com" {
|
||||
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("international login clears a stale marker without changing a custom URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(managedRegionPath, []byte(authpkg.DefaultMCPBaseURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(managedRegionPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("stale managed marker remains: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURLErrors(t *testing.T) {
|
||||
fail := errors.New("persist failure")
|
||||
|
||||
t.Run("explicit marker cleanup", func(t *testing.T) {
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
|
||||
t.Fatalf("explicit marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit URL write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
|
||||
t.Fatalf("explicit URL write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed marker write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed marker read", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed MCP URL read", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, fail
|
||||
})
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed MCP URL read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed stale marker cleanup", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.DefaultMCPBaseURL), nil
|
||||
}
|
||||
return []byte("https://private-mcp.example.com"), nil
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed stale marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed URL write cleans marker", func(t *testing.T) {
|
||||
writes := 0
|
||||
removed := false
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error {
|
||||
writes++
|
||||
if writes == 2 {
|
||||
return fail
|
||||
}
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { removed = true; return nil })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) || !removed {
|
||||
t.Fatalf("managed URL write error = %v, marker removed=%v", err, removed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default managed marker read", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing MCP URL cleans marker", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.InternationalMCPBaseURL), nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { return nil })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatalf("missing MCP URL cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCP URL read", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.InternationalMCPBaseURL), nil
|
||||
}
|
||||
return nil, fail
|
||||
})
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("MCP URL read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default URL write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("default URL write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("final marker cleanup", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return nil })
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("final marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPPersistence(255)); err == nil {
|
||||
t.Fatal("unsupported MCP persistence mode succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNormalizeAuthLoginBaseURLTransportSecurity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantURL string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "https remote", raw: "https://pre-mcp.example.com/path/?secret=drop#fragment", wantURL: "https://pre-mcp.example.com/path"},
|
||||
{name: "http localhost", raw: "http://localhost:8080/", wantURL: "http://localhost:8080"},
|
||||
{name: "http IPv4 loopback", raw: "http://127.0.0.1:8080", wantURL: "http://127.0.0.1:8080"},
|
||||
{name: "http IPv6 loopback", raw: "http://[::1]:8080", wantURL: "http://[::1]:8080"},
|
||||
{name: "http remote", raw: "http://pre-mcp.example.com", wantErr: "must use HTTPS"},
|
||||
{name: "empty", raw: " ", wantErr: "cannot be empty"},
|
||||
{name: "invalid URL", raw: "https://%", wantErr: "invalid --mcp-url"},
|
||||
{name: "invalid scheme", raw: "ftp://pre-mcp.example.com", wantErr: "must use http or https"},
|
||||
{name: "missing host", raw: "https:///path", wantErr: "must include a host"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, got, err := normalizeAuthLoginBaseURL(tc.raw, "--mcp-url")
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("normalizeAuthLoginBaseURL error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeAuthLoginBaseURL error = %v", err)
|
||||
}
|
||||
if got != tc.wantURL {
|
||||
t.Fatalf("normalized URL = %q, want %q", got, tc.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
|
||||
if !authLoginForcesAuthorization(authLoginConfig{}) {
|
||||
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
|
||||
|
||||
@@ -15,11 +15,10 @@ package app
|
||||
|
||||
import "sync"
|
||||
|
||||
// PluginAuth holds authentication credentials for a plugin-owned
|
||||
// streamable-http MCP server. Each server is keyed by its canonical
|
||||
// product ID (CLI.ID) so that different servers can use independent
|
||||
// tokens without interfering with each other or with the default
|
||||
// DingTalk OAuth token.
|
||||
// PluginAuth marks ownership of a plugin-owned streamable-http MCP server and
|
||||
// holds its optional authentication credentials. Every accepted HTTP plugin,
|
||||
// including an anonymous one, has a non-nil record keyed by canonical product
|
||||
// ID (CLI.ID) so execution never falls back to built-in DingTalk OAuth.
|
||||
type PluginAuth struct {
|
||||
// Token is the Bearer token extracted from the plugin's
|
||||
// "Authorization" header (e.g. a third-party API key).
|
||||
@@ -39,27 +38,25 @@ var (
|
||||
pluginAuthRegistry = make(map[string]*PluginAuth)
|
||||
)
|
||||
|
||||
// RegisterPluginAuth stores authentication credentials for a plugin
|
||||
// server keyed by its canonical product ID. The runner looks up these
|
||||
// credentials at execution time to inject the correct Bearer token
|
||||
// instead of the default DingTalk OAuth token.
|
||||
// RegisterPluginAuth stores ownership and optional authentication credentials
|
||||
// for a plugin server keyed by its canonical product ID.
|
||||
func RegisterPluginAuth(productID string, auth *PluginAuth) {
|
||||
pluginAuthMu.Lock()
|
||||
defer pluginAuthMu.Unlock()
|
||||
pluginAuthRegistry[productID] = auth
|
||||
}
|
||||
|
||||
// ClearPluginAuth removes credentials for a plugin product. Registration uses
|
||||
// this before applying an accepted descriptor so a descriptor without custom
|
||||
// auth cannot inherit stale credentials from an earlier root construction.
|
||||
// ClearPluginAuth removes the ownership and credential record for a plugin
|
||||
// product.
|
||||
func ClearPluginAuth(productID string) {
|
||||
pluginAuthMu.Lock()
|
||||
defer pluginAuthMu.Unlock()
|
||||
delete(pluginAuthRegistry, productID)
|
||||
}
|
||||
|
||||
// LookupPluginAuth returns the authentication credentials registered
|
||||
// for the given product ID, or nil if none exists.
|
||||
// LookupPluginAuth returns plugin ownership and optional authentication
|
||||
// credentials for the product ID. The bool denotes ownership, not whether a
|
||||
// Bearer token is present.
|
||||
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
|
||||
pluginAuthMu.RLock()
|
||||
defer pluginAuthMu.RUnlock()
|
||||
|
||||
@@ -33,8 +33,10 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -325,6 +327,29 @@ func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
region authpkg.LoginRegion
|
||||
want string
|
||||
}{
|
||||
{raw: "%", want: "%"},
|
||||
{raw: "https://dingtalk.io/path", want: "https://dingtalk.com/path"},
|
||||
{raw: "https://mcp.dingtalk.com:8443/path", region: authpkg.LoginRegionInternational, want: "https://mcp.dingtalk.io:8443/path"},
|
||||
} {
|
||||
if got := mcpBaseURLForLoginRegion(tc.raw, tc.region); got != tc.want {
|
||||
t.Fatalf("mcpBaseURLForLoginRegion(%q, %q) = %q, want %q", tc.raw, tc.region, got, tc.want)
|
||||
}
|
||||
}
|
||||
if hasDirectRuntimeEndpointOverride("") {
|
||||
t.Fatal("blank product unexpectedly has an endpoint override")
|
||||
}
|
||||
t.Setenv("DINGTALK_COVERAGE_PRODUCT_MCP_URL", "https://override.test")
|
||||
if !hasDirectRuntimeEndpointOverride("coverage-product") {
|
||||
t.Fatal("configured product endpoint override was not detected")
|
||||
}
|
||||
if got := activeDingTalkGatewayEndpointWithBase("https://mcp-gw.dingtalk.com/server/contact", "%"); got != "https://mcp-gw.dingtalk.com/server/contact" {
|
||||
t.Fatalf("invalid gateway base rewrote endpoint to %q", got)
|
||||
}
|
||||
server := mcptypes.ServerDescriptor{
|
||||
Endpoint: "https://one.test",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
@@ -912,12 +937,21 @@ func TestCrossPlatformCoverageAuthCommandPureCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
oldInteractive := authLoginInteractiveTerminal
|
||||
authLoginInteractiveTerminal = func() bool { return false }
|
||||
t.Cleanup(func() { authLoginInteractiveTerminal = oldInteractive; authpkg.SetRuntimeProfile("") })
|
||||
for _, format := range []string{"table", "json"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
for _, tc := range []struct {
|
||||
format string
|
||||
international bool
|
||||
}{
|
||||
{format: "table"},
|
||||
{format: "json", international: true},
|
||||
} {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
@@ -928,16 +962,90 @@ func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
|
||||
root.SetOut(&output)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"login", "--token", "manual-token", "--yes"}
|
||||
if format == "json" {
|
||||
if tc.international {
|
||||
args = append(args, "--intl")
|
||||
}
|
||||
if tc.format == "json" {
|
||||
args = append(args, "--format", "json")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil || output.Len() == 0 {
|
||||
t.Fatalf("token login = %q %v", output.String(), err)
|
||||
}
|
||||
data, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData error = %v", err)
|
||||
}
|
||||
wantRegion := ""
|
||||
if tc.international {
|
||||
wantRegion = string(authpkg.LoginRegionInternational)
|
||||
}
|
||||
if data.LoginRegion != wantRegion {
|
||||
t.Fatalf("LoginRegion = %q, want %q", data.LoginRegion, wantRegion)
|
||||
}
|
||||
if tc.international {
|
||||
snapshot, err := resolveAccessTokenSnapshotFromDir(context.Background(), configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAccessTokenSnapshotFromDir error = %v", err)
|
||||
}
|
||||
gotEndpoint := activeDingTalkGatewayEndpointForLoginRegion(
|
||||
"https://mcp-gw.dingtalk.com/server/contact",
|
||||
snapshot.LoginRegion,
|
||||
)
|
||||
if wantEndpoint := "https://mcp-gw.dingtalk.io/server/contact"; gotEndpoint != wantEndpoint {
|
||||
t.Fatalf("international manual-token endpoint = %q, want %q", gotEndpoint, wantEndpoint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("international then domestic login restores domestic MCP URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
runLogin := func(international bool) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
root.AddCommand(newAuthLoginCommand(nil))
|
||||
args := []string{"login", "--token", "manual-token", "--yes"}
|
||||
if international {
|
||||
args = append(args, "--intl")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("international=%v login error = %v", international, err)
|
||||
}
|
||||
}
|
||||
|
||||
runLogin(true)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("international mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
runLogin(false)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.DefaultMCPBaseURL {
|
||||
t.Fatalf("domestic mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("managed MCP region marker remains: %v", err)
|
||||
}
|
||||
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runLogin(true)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url after international login = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("explicit mcp_url acquired a managed marker: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, hidden := range []bool{false, true} {
|
||||
old := edition.Get()
|
||||
edition.Override(&edition.Hooks{HideAuthLogin: hidden})
|
||||
@@ -2033,6 +2141,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, readable, err := skillstate.Read(home)
|
||||
if err != nil || !readable || len(state.OfficialSkills) != 3 || len(state.UpdatedSkills) != 2 {
|
||||
t.Fatalf("setup state = %#v, readable=%v, err=%v", state, readable, err)
|
||||
}
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
|
||||
t.Fatalf("multi dry run = %q, %v", output, err)
|
||||
}
|
||||
@@ -2048,8 +2160,11 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
t.Fatalf("invalid setup %#v succeeded", args)
|
||||
}
|
||||
}
|
||||
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("default mono setup: %v", err)
|
||||
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("mono setup: %v", err)
|
||||
}
|
||||
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
|
||||
t.Fatalf("default mode should be multi: %q, %v", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2082,7 +2197,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing multi source succeeded")
|
||||
}
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
|
||||
t.Fatalf("default setup mode = %q, %v", mode, err)
|
||||
}
|
||||
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
|
||||
@@ -2117,7 +2232,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
for _, tc := range []struct{ path, mode string }{{"", skillSetupModeMono}, {mono, skillSetupModeMono}, {filepath.Dir(multi), skillSetupModeMulti}, {root, "bad"}} {
|
||||
_ = isSkillSourceRoot(tc.path, tc.mode)
|
||||
}
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
setTestHome(t, t.TempDir())
|
||||
for _, tc := range []struct{ target, mode string }{{"agents", skillSetupModeMono}, {"agents", skillSetupModeMulti}, {"all", skillSetupModeMono}, {"missing", skillSetupModeMono}} {
|
||||
_, _ = resolveSkillSetupTargets(tc.target, tc.mode)
|
||||
}
|
||||
@@ -2125,8 +2240,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
_ = agentHomeForMode("base", skillSetupModeMulti)
|
||||
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
|
||||
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
|
||||
_ = mutualExclusionVictims(root, mode)
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
|
||||
_, _ = mutualExclusionVictims(root, mode)
|
||||
}
|
||||
if isCharDevice(nil) || isInteractiveTerminal() {
|
||||
t.Fatal("test process unexpectedly interactive")
|
||||
@@ -2134,17 +2249,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
|
||||
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
multiDest := filepath.Join(t.TempDir(), "agent")
|
||||
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
|
||||
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
|
||||
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
|
||||
|
||||
badParent := filepath.Join(t.TempDir(), "file")
|
||||
_ = os.WriteFile(badParent, []byte("x"), 0o600)
|
||||
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
|
||||
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
|
||||
t.Fatal("copy missing directory succeeded")
|
||||
}
|
||||
|
||||
@@ -61,11 +61,16 @@ func appRPCServer(t *testing.T, initOK, listOK bool) *httptest.Server {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePluginAuthCoverage(t *testing.T) {
|
||||
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
|
||||
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
|
||||
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "none"})
|
||||
if got, ok := LookupPluginAuth("cli"); !ok || got == nil || got.Token != "token" {
|
||||
t.Fatalf("registered plugin auth = %#v, %v", got, ok)
|
||||
fallback := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
|
||||
if fallback == nil || fallback.Token != "token" || len(fallback.TrustedDomains) != 0 {
|
||||
t.Fatalf("fallback plugin auth = %#v", fallback)
|
||||
}
|
||||
got := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
|
||||
if got == nil || got.Token != "token" || got.ExtraHeaders["X"] != "Y" || len(got.TrustedDomains) != 2 {
|
||||
t.Fatalf("plugin auth = %#v", got)
|
||||
}
|
||||
if anonymous := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "none"}); anonymous == nil || anonymous.Token != "" {
|
||||
t.Fatalf("anonymous plugin ownership = %#v", anonymous)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,20 @@ func defaultPATMCPEndpoint() string {
|
||||
|
||||
func defaultPATGatewayBaseURL() string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
return mcpGatewayBaseURL(raw)
|
||||
}
|
||||
|
||||
func defaultPATGatewayBaseURLForLoginRegion(region authpkg.LoginRegion) string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
if override := authpkg.MCPBaseURLOverride(); override != "" {
|
||||
raw = override
|
||||
} else {
|
||||
raw = mcpBaseURLForLoginRegion(raw, region)
|
||||
}
|
||||
return mcpGatewayBaseURL(raw)
|
||||
}
|
||||
|
||||
func mcpGatewayBaseURL(raw string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.TrimRight(raw, "/")
|
||||
@@ -100,6 +114,33 @@ func defaultPATGatewayBaseURL() string {
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
func mcpBaseURLForLoginRegion(raw string, region authpkg.LoginRegion) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return raw
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
fromSuffix, toSuffix := ".dingtalk.io", ".dingtalk.com"
|
||||
if region.IsInternational() {
|
||||
fromSuffix, toSuffix = toSuffix, fromSuffix
|
||||
}
|
||||
bareFrom := strings.TrimPrefix(fromSuffix, ".")
|
||||
if host != bareFrom && !strings.HasSuffix(host, fromSuffix) {
|
||||
return raw
|
||||
}
|
||||
if host == bareFrom {
|
||||
host = strings.TrimPrefix(toSuffix, ".")
|
||||
} else {
|
||||
host = strings.TrimSuffix(host, fromSuffix) + toSuffix
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
parsed.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
parsed.Host = host
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// SetDynamicServers injects server data discovered from servers.json.
|
||||
// All product endpoints are resolved dynamically from this data.
|
||||
func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
@@ -131,7 +172,7 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
@@ -262,6 +303,19 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func hasDirectRuntimeEndpointOverride(productID string) bool {
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := productEndpointOverride(candidate); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func editionServerEndpoint(productID string) (string, bool) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
if productID == "" {
|
||||
@@ -282,7 +336,7 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
|
||||
return "", false
|
||||
}
|
||||
for _, server := range fn() {
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
|
||||
if endpoint == "" {
|
||||
continue
|
||||
}
|
||||
@@ -298,6 +352,46 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
|
||||
return "", false
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpoint(endpoint string) string {
|
||||
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURL())
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpointForLoginRegion(endpoint string, region authpkg.LoginRegion) string {
|
||||
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURLForLoginRegion(region))
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpointWithBase(endpoint, gatewayBaseURL string) string {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return endpoint
|
||||
}
|
||||
if !isDingTalkMCPGatewayHost(parsed.Hostname()) {
|
||||
return endpoint
|
||||
}
|
||||
base, err := url.Parse(gatewayBaseURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return endpoint
|
||||
}
|
||||
parsed.Scheme = base.Scheme
|
||||
parsed.Host = base.Host
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
func isDingTalkMCPGatewayHost(host string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(host)) {
|
||||
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isDingTalkMCPGatewayEndpoint(endpoint string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(endpoint))
|
||||
return err == nil && isDingTalkMCPGatewayHost(parsed.Hostname())
|
||||
}
|
||||
|
||||
// DirectRuntimeProductIDs returns product IDs that should stay visible for
|
||||
// direct runtime execution. Dynamic products come from MCP discovery/plugin
|
||||
// registration; built-in helper products such as devapp resolve their endpoint
|
||||
|
||||
@@ -403,7 +403,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
|
||||
UseWhen: []string{
|
||||
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听七个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
|
||||
},
|
||||
@@ -1220,7 +1220,8 @@ func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
editionName := editionNameOrDefault()
|
||||
clientIDHash := dwsevent.ClientIDHash(clientID)
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
|
||||
return nil
|
||||
|
||||
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount != 22 {
|
||||
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
|
||||
if publicCount != 23 {
|
||||
t.Fatalf("public personal events = %d, want 23 (16 IM + 7 OA)", publicCount)
|
||||
}
|
||||
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
|
||||
if !ruleTypes[ruleType] {
|
||||
|
||||
@@ -1220,7 +1220,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
}
|
||||
|
||||
busState := "personal bus stopped"
|
||||
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
busState = "personal bus is not running"
|
||||
} else {
|
||||
|
||||
@@ -61,6 +61,13 @@ func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceCC,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
@@ -161,6 +168,7 @@ func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceCC,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -414,6 +422,7 @@ func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceCC,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
|
||||
@@ -82,6 +82,46 @@ func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestionsChatFromExplainsBothMeanings(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
search := &cobra.Command{Use: "+search-msg", Run: func(*cobra.Command, []string) {}}
|
||||
search.Flags().String("sender", "", "sender target")
|
||||
search.Flags().String("start", "", "start time")
|
||||
root.AddCommand(chat)
|
||||
chat.AddCommand(search)
|
||||
|
||||
orig := fmt.Errorf("unknown flag: --from")
|
||||
err := flagErrorWithSuggestions(search, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != "ambiguous_flag" || !strings.Contains(ae.Hint, "--sender") || !strings.Contains(ae.Hint, "--start") {
|
||||
t.Fatalf("structured error = reason %q hint %q", ae.Reason, ae.Hint)
|
||||
}
|
||||
if !strings.HasSuffix(ae.Message, "See 'dws chat +search-msg --help' for usage.") {
|
||||
t.Fatalf("Message = %q", ae.Message)
|
||||
}
|
||||
|
||||
for _, flag := range []string{"from-file", "from-user"} {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
err := flagErrorWithSuggestions(search, fmt.Errorf("unknown flag: --%s", flag))
|
||||
var structured *apperrors.Error
|
||||
if stderrors.As(err, &structured) && structured.Reason == "ambiguous_flag" {
|
||||
t.Fatalf("--%s incorrectly used --from ambiguity handling: %#v", flag, structured)
|
||||
}
|
||||
if strings.Contains(err.Error(), "--from 在消息查询中含义不明确") {
|
||||
t.Fatalf("--%s incorrectly received --from ambiguity hint: %v", flag, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag: --"+flag) {
|
||||
t.Fatalf("error = %q, want original flag --%s", err, flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误,
|
||||
// 如 missing required flag / ambiguous shorthand)也带尾部 See '<cmd> --help' for usage.
|
||||
// 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。
|
||||
|
||||
@@ -268,7 +268,7 @@ type frameworkFailWriter struct{}
|
||||
|
||||
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
@@ -470,6 +470,118 @@ func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteDeterministicInterruptionBranches(t *testing.T) {
|
||||
install := func(t *testing.T, state *processSignalState, stdout, stderr io.Writer) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootInstallProcessSignalContext, func(ctx context.Context, _ *output.ResultStore) (context.Context, *processSignalState, func()) {
|
||||
return ctx, state, func() {}
|
||||
})
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
return cmd
|
||||
})
|
||||
}
|
||||
interrupted := func(primaryCompleted bool) *processSignalState {
|
||||
return &processSignalState{
|
||||
interruption: &processInterruption{signal: os.Interrupt},
|
||||
primaryCompletedAtSignal: primaryCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("preparse interruption emits unified failure", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("preparse failed") })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) {
|
||||
t.Fatal("preparse failure reached command execution")
|
||||
return nil, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("preparse interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption before emission becomes primary error", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil })
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("pre-emission interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("late hook error preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(true), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, errors.New("late hook failed")
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary != "late hook failed" {
|
||||
t.Fatalf("late hook result = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption after emission preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary == "" {
|
||||
t.Fatalf("post-emission interruption = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure replaces unobservable result", func(t *testing.T) {
|
||||
var original bytes.Buffer
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{file: file, original: &original, finished: true}))
|
||||
publicationErr := newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
if _, handled, emitErr := emitOutputPublicationFailure(cmd, publicationErr); !handled || emitErr != nil {
|
||||
t.Fatalf("precondition publication failure = handled %v error %v unified %v state %v", handled, emitErr, output.UsesUnifiedResult(cmd), outputSinkForCommand(cmd) != nil)
|
||||
}
|
||||
return cmd, publicationErr
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 5 || summary == "" {
|
||||
t.Fatalf("publication failure = code %d summary %q output %q", code, summary, original.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type frameworkPanicWriter struct{}
|
||||
|
||||
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
package app
|
||||
|
||||
// MCPIdentityHeaders returns the same header map used for MCP HTTP requests
|
||||
// (agent identity, env trace headers, edition MergeHeaders). Intended for
|
||||
// non-MCP transports such as the A2A gateway client.
|
||||
// MCPIdentityHeaders returns the shared identity header map used by non-MCP
|
||||
// transports such as the A2A gateway client. MCP-only Agent version and
|
||||
// extension metadata are intentionally excluded.
|
||||
func MCPIdentityHeaders() map[string]string {
|
||||
return resolveIdentityHeaders()
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut, true); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
|
||||
@@ -51,7 +51,40 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
return `{"success":true,"result":{"events":[],"hasMore":false,"nextCursor":""}}`
|
||||
case "get_calendar_detail":
|
||||
return c.paramAliasCalendarDetailResponse()
|
||||
case "get_calendar_participants":
|
||||
return `{"success":true,"result":{"participants":[{"userId":"fixture-user","displayName":"Fixture User"},{"userId":"user-2","displayName":"User Two"}]}}`
|
||||
case "search_calendar":
|
||||
return `{"success":true,"result":{"calendars":[]}}`
|
||||
case "search_rooms":
|
||||
return `{"success":true,"result":{"rooms":[]}}`
|
||||
case "query_available_meeting_room":
|
||||
return `{"success":true,"result":{"rooms":[],"hasMore":false}}`
|
||||
case "list_meeting_room_groups":
|
||||
return `{"success":true,"result":{"groups":[]}}`
|
||||
case "query_busy_status":
|
||||
return `{"success":true,"result":[]}`
|
||||
case "list_suggested_event_times":
|
||||
return `{"success":true,"result":{"recommendEventTimes":[]}}`
|
||||
case "create_calendar_event":
|
||||
return `{"success":true,"result":{"eventId":"event-1"}}`
|
||||
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
|
||||
return `{"success":true}`
|
||||
case "respond":
|
||||
status := "accepted"
|
||||
if call := c.lastParamAliasCall(); call != nil {
|
||||
if value, ok := call.args["responseStatus"].(string); ok && value != "" {
|
||||
status = value
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"responseStatus": status}})
|
||||
return string(encoded)
|
||||
case "get_current_user_profile":
|
||||
return `{"success":true,"result":{"userId":"user-1","name":"Fixture Current User"}}`
|
||||
case "query_records":
|
||||
return `{"success":true,"status":"success","error":{},"data":{}}`
|
||||
case "search_mail_users":
|
||||
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
|
||||
case "search_dept_by_keyword":
|
||||
@@ -63,11 +96,52 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "revert_doc_version":
|
||||
return `{"version":3}`
|
||||
return `{"revertedToVersion":3}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "list_workflows":
|
||||
return `{"workflows":[]}`
|
||||
case "create_document":
|
||||
return `{"nodeId":"fixture-node"}`
|
||||
case "list_files":
|
||||
return `{"success":true,"result":{"files":[],"hasMore":false}}`
|
||||
case "list_recycle_items":
|
||||
return `{"success":true,"result":{"recycleItems":[{"recycleItemId":"recycle-1","originalName":"Fixture Node"}],"hasMore":false}}`
|
||||
case "get_star_list":
|
||||
return `{"success":true,"result":{"starList":[],"hasMore":false}}`
|
||||
case "list_file_versions":
|
||||
return `{"success":true,"result":{"versions":[{"version":3,"name":"Fixture Version"}],"hasMore":false}}`
|
||||
case "get_file_info":
|
||||
name := "Fixture Node"
|
||||
for index := len(c.calls) - 2; index >= 0; index-- {
|
||||
call := c.calls[index]
|
||||
switch call.tool {
|
||||
case "create_folder":
|
||||
if value, ok := call.args["name"].(string); ok {
|
||||
name = value
|
||||
}
|
||||
index = -1
|
||||
case "rename_document":
|
||||
if value, ok := call.args["newName"].(string); ok {
|
||||
name = value
|
||||
}
|
||||
index = -1
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"fileId": "node-1", "name": name}})
|
||||
return string(encoded)
|
||||
case "get_cover", "get_node_stats":
|
||||
return `{"success":true,"result":{"nodeId":"node-1"}}`
|
||||
case "get_file_publish_status":
|
||||
return `{"success":true,"result":{"fileId":"node-1","published":false}}`
|
||||
case "create_folder", "create_shortcut":
|
||||
return `{"success":true,"fileId":"node-1"}`
|
||||
case "delete_document", "mark_star", "unmark_star", "restore_recycle_item", "rename_document", "revert_file_version":
|
||||
return `{"success":true,"fileId":"node-1"}`
|
||||
case "set_file_publish":
|
||||
return `{"success":true}`
|
||||
case "download_file", "download_file_version":
|
||||
return `{"success":true,"result":{"downloadUrl":"http://invalid.test/fixture.bin","fileName":"fixture.bin"}}`
|
||||
case "get_document_content":
|
||||
for index := len(c.calls) - 2; index >= 0; index-- {
|
||||
call := c.calls[index]
|
||||
@@ -84,6 +158,41 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
}
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) lastParamAliasCall() *paramAliasToolCall {
|
||||
if len(c.calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &c.calls[len(c.calls)-1]
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) paramAliasCalendarDetailResponse() string {
|
||||
event := map[string]any{
|
||||
"eventId": "event-1",
|
||||
"summary": "Fixture Meeting",
|
||||
"description": "fixture description",
|
||||
"startDateTime": "2026-03-10T09:00:00+08:00",
|
||||
"endDateTime": "2026-03-10T10:00:00+08:00",
|
||||
}
|
||||
for _, call := range c.calls {
|
||||
switch call.tool {
|
||||
case "create_calendar_event", "update_calendar_event":
|
||||
for _, key := range []string{"eventId", "summary", "description", "startDateTime", "endDateTime", "timeZone", "location", "freeBusy"} {
|
||||
if value, ok := call.args[key]; ok {
|
||||
event[key] = value
|
||||
}
|
||||
}
|
||||
case "respond":
|
||||
if value, ok := call.args["responseStatus"]; ok {
|
||||
event["responseStatus"] = value
|
||||
}
|
||||
case "delete_calendar_event":
|
||||
event["status"] = "cancelled"
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": event})
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func (*paramAliasCaptureCaller) Format() string { return "json" }
|
||||
func (*paramAliasCaptureCaller) DryRun() bool { return false }
|
||||
func (*paramAliasCaptureCaller) Fields() string { return "" }
|
||||
@@ -322,9 +431,9 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
"--to-user", "D-recipient",
|
||||
"--to-user", appFixtureCurrentDOpenID,
|
||||
"--text", "hello alias",
|
||||
"--uuid", "alias-e2e",
|
||||
"--idempotency-key", "alias-e2e",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat write alias E2E error = %v", err)
|
||||
@@ -336,7 +445,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
t.Fatalf("chat calls = %#v", caller.calls)
|
||||
}
|
||||
payload := caller.calls[0].args
|
||||
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
|
||||
if payload["receiverOpenDingTalkId"] != appFixtureCurrentDOpenID || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
|
||||
t.Fatalf("chat payload identity fields = %#v", payload)
|
||||
}
|
||||
content, _ := payload["content"].(string)
|
||||
@@ -350,6 +459,29 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageSendLegacyUUIDAliasFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
_, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
"--group", "fixture-conversation",
|
||||
"--text", "hello legacy uuid",
|
||||
"--uuid", "legacy-alias-e2e",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat message send legacy uuid error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" {
|
||||
t.Fatalf("chat calls = %#v", caller.calls)
|
||||
}
|
||||
payload := caller.calls[0].args
|
||||
if payload["uuid"] != "legacy-alias-e2e" || payload["openConversationId"] != "fixture-conversation" {
|
||||
t.Fatalf("chat legacy uuid payload = %#v", payload)
|
||||
}
|
||||
if _, exists := payload["idempotency-key"]; exists {
|
||||
t.Fatalf("chat payload leaked CLI-only idempotency-key: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -417,7 +549,11 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo
|
||||
if err != nil {
|
||||
t.Fatalf("alias execution failed: %v", err)
|
||||
}
|
||||
if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
if alias == "open-conversation-id" {
|
||||
if ctx == nil || len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
@@ -627,11 +763,11 @@ func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryR
|
||||
tool: "send_personal_message",
|
||||
canonicalArgs: []string{
|
||||
"--dry-run", "chat", "message", "send",
|
||||
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
"--user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
},
|
||||
aliasArgs: []string{
|
||||
"--dry-run", "chat", "message", "send",
|
||||
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
"--to-user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
},
|
||||
wantCorrections: 1,
|
||||
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -79,13 +79,15 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
|
||||
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
|
||||
t.Fatalf("timed out authorization = %v, %v", ok, err)
|
||||
}
|
||||
patAuthorizationTimeout = 5 * time.Millisecond
|
||||
patAuthorizationTimeout = time.Second
|
||||
patAuthorizationPollInterval = time.Millisecond
|
||||
pollCtx, pollCancel := context.WithCancel(context.Background())
|
||||
patResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||
pollCancel()
|
||||
return "", authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
out.Reset()
|
||||
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
|
||||
if ok, err := WaitForPatAuthorization(pollCtx, "", &out); ok || !errors.Is(err, context.Canceled) || !strings.Contains(out.String(), "等待授权中") {
|
||||
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
)
|
||||
|
||||
@@ -218,8 +219,16 @@ func TestPatScopeError_Error(t *testing.T) {
|
||||
// /cli/oauth/device/poll?flowId=<fid> with the given status sequence.
|
||||
// It also writes the server URL into a temp DWS_CONFIG_DIR/mcp_url so that
|
||||
// GetMCPBaseURL() returns the test server address.
|
||||
func stubPATPollAccessToken(t *testing.T) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &patResolveAccessToken, func(context.Context, string, string) (string, error) {
|
||||
return "", authpkg.ErrTokenDataNotFound
|
||||
})
|
||||
}
|
||||
|
||||
func setupPollServer(t *testing.T, statuses []authpkg.DevicePollResponse) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
stubPATPollAccessToken(t)
|
||||
var callCount atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -381,6 +390,7 @@ func TestPollPatDeviceFlow_ServerErrorFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) {
|
||||
stubPATPollAccessToken(t)
|
||||
// When server returns 302 (SSO redirect), poll should continue until real response.
|
||||
var callCount int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -540,6 +550,7 @@ func (m *mockRunner) Run(ctx context.Context, inv executor.Invocation) (executor
|
||||
// It responds to device poll requests with the given status after the first poll.
|
||||
func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
stubPATPollAccessToken(t)
|
||||
var pollCount atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -66,6 +66,32 @@ func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePreparseProfileFlagUsesLastOccurrence(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{name: "space then equals", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "equals then space", args: []string{"--profile=corp-a", "version", "--profile", "corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "last multi", args: []string{"--profile=corp-a", "--profile", "corp-b,", "corp-c", "version"}, want: "corp-b,corp-c", valid: true},
|
||||
{name: "empty equals clears earlier", args: []string{"--profile=corp-a", "version", "--profile="}},
|
||||
{name: "missing value clears earlier", args: []string{"--profile=corp-a", "version", "--profile"}},
|
||||
{name: "next flag is not profile value", args: []string{"--profile=corp-a", "--profile", "--debug", "version"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := preparseProfileFlag(tc.args); got != tc.want {
|
||||
t.Fatalf("preparseProfileFlag(%#v) = %q, want %q", tc.args, got, tc.want)
|
||||
}
|
||||
_, specified, valid := preparseProfileSelection(tc.args)
|
||||
if !specified || valid != tc.valid {
|
||||
t.Fatalf("preparseProfileSelection(%#v) = specified %v valid %v, want true/%v", tc.args, specified, valid, tc.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
+264
-47
@@ -79,10 +79,20 @@ var (
|
||||
rootPluginSyncSkills = plugin.SyncSkills
|
||||
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
|
||||
rootEmitResult = output.EmitResult
|
||||
rootInstallProcessSignalContext = installProcessSignalContext
|
||||
)
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() (exitCode int) {
|
||||
func Execute() int {
|
||||
exitCode, _, _ := ExecuteWithTelemetry()
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// ExecuteWithTelemetry runs the root command and additionally returns a
|
||||
// privacy-safe command path and error summary for the official CLI entrypoint.
|
||||
func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage string) {
|
||||
commandPath = "dws"
|
||||
var (
|
||||
root *cobra.Command
|
||||
executed *cobra.Command
|
||||
@@ -90,12 +100,16 @@ func Execute() (exitCode int) {
|
||||
)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errorMessage = "internal panic"
|
||||
target := executed
|
||||
if target == nil && root != nil {
|
||||
if found, _, err := root.Find(os.Args[1:]); err == nil {
|
||||
target = found
|
||||
}
|
||||
}
|
||||
if target != nil {
|
||||
commandPath = telemetryCommandPath(target)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
exitCode = code
|
||||
if target != nil {
|
||||
@@ -120,6 +134,7 @@ func Execute() (exitCode int) {
|
||||
CloseFileLogger()
|
||||
if executed != nil {
|
||||
if err := closeOutputSink(executed); err != nil {
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
@@ -136,6 +151,18 @@ func Execute() (exitCode int) {
|
||||
restoreArgs := rootNormalizeProcessProfileArgs()
|
||||
defer restoreArgs()
|
||||
|
||||
// Validate MCP Agent metadata before constructing the command tree.
|
||||
// Construction may invoke edition registration/static-server hooks and load
|
||||
// plugin PreParse handlers, so PersistentPreRunE alone is too late for the
|
||||
// process entry point. Retain this exact pair for the eventual invocation.
|
||||
agentMetadata := readAgentMetadataSnapshot()
|
||||
if err := agentMetadata.validationError(); err != nil {
|
||||
emitEarlyAgentMetadataValidationError(err, os.Args[1:])
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
|
||||
timing := NewTimingCollector()
|
||||
defer func() {
|
||||
rootStopAllStdioClients() // Ensure child processes are terminated on exit
|
||||
@@ -147,15 +174,17 @@ func Execute() (exitCode int) {
|
||||
|
||||
// Attach timing collector to context for use by child components
|
||||
ctx := WithTimingCollector(context.Background(), timing)
|
||||
ctx = contextWithAgentMetadataSnapshot(ctx, agentMetadata)
|
||||
ctx, resultStore = output.WithResultStore(ctx)
|
||||
var signalState *processSignalState
|
||||
var stopSignals func()
|
||||
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
|
||||
ctx, signalState, stopSignals = rootInstallProcessSignalContext(ctx, resultStore)
|
||||
defer stopSignals()
|
||||
|
||||
initStart := time.Now()
|
||||
engine := newPipelineEngine()
|
||||
root = rootNewRootCommandWithEngine(ctx, engine)
|
||||
commandPath = telemetryCommandPath(root)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
@@ -170,15 +199,23 @@ func Execute() (exitCode int) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(target, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
commandPath = telemetryCommandPathForArgs(root, os.Args[1:])
|
||||
|
||||
var err error
|
||||
executed, err = rootExecuteCommand(root)
|
||||
if executed != nil {
|
||||
commandPath = telemetryCommandPath(executed)
|
||||
}
|
||||
// PersistentPostRunE normally commits or aborts the transactional output
|
||||
// sink. Finalize once more at the process boundary so custom execution
|
||||
// seams, embedding callers, or future hook changes cannot leave publication
|
||||
@@ -210,7 +247,9 @@ func Execute() (exitCode int) {
|
||||
// successfully emitted result into a contradictory 130/143 process
|
||||
// status; likewise, a failed publication must retain its internal
|
||||
// error code instead of being relabelled as cancellation.
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(interrupted)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
}
|
||||
var publicationErr *outputPublicationError
|
||||
@@ -221,20 +260,26 @@ func Execute() (exitCode int) {
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
commandPath = telemetryCommandPath(root)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if stderrors.As(err, &publicationErr) {
|
||||
errorMessage = telemetryErrorSummary(publicationErr)
|
||||
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
|
||||
if emitErr == nil {
|
||||
return failureCode
|
||||
exitCode = failureCode
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
return apperrors.ExitCode(publicationErr)
|
||||
exitCode = apperrors.ExitCode(publicationErr)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
var raw apperrors.RawStderrError
|
||||
@@ -242,7 +287,9 @@ func Execute() (exitCode int) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(executed, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
@@ -252,12 +299,106 @@ func Execute() (exitCode int) {
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(resultStore); emitted {
|
||||
return code
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
return 0
|
||||
return
|
||||
}
|
||||
|
||||
func telemetryCommandPath(command *cobra.Command) string {
|
||||
if command == nil {
|
||||
return "dws"
|
||||
}
|
||||
path := strings.TrimSpace(command.CommandPath())
|
||||
root := command.Root()
|
||||
rootName := strings.TrimSpace(root.Name())
|
||||
if path == rootName {
|
||||
return rootName
|
||||
}
|
||||
if rootName != "" {
|
||||
path = strings.TrimSpace(strings.TrimPrefix(path, rootName+" "))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func telemetryCommandPathForArgs(root *cobra.Command, args []string) string {
|
||||
if root == nil {
|
||||
return "dws"
|
||||
}
|
||||
command, _, err := root.Find(args)
|
||||
if err != nil || command == nil {
|
||||
return telemetryCommandPath(root)
|
||||
}
|
||||
return telemetryCommandPath(command)
|
||||
}
|
||||
|
||||
// emitEarlyAgentMetadataValidationError preserves each built-in command's
|
||||
// legacy-vs-unified output contract without running extension hooks. The
|
||||
// presentation-only tree contains reviewed open-source commands and flags but
|
||||
// deliberately omits edition registration, plugin loading, and visibility
|
||||
// hooks; callers therefore still fail before any external hook executes.
|
||||
func emitEarlyAgentMetadataValidationError(err error, args []string) {
|
||||
format := processArgsFormat(args)
|
||||
presentationRoot := newRootPresentationCommand()
|
||||
_ = presentationRoot.PersistentFlags().Set("format", format)
|
||||
if target, _, findErr := presentationRoot.Find(args); findErr == nil && target != nil && output.UsesUnifiedResult(target) {
|
||||
target.SetOut(os.Stdout)
|
||||
target.SetErr(os.Stderr)
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
if _, emitErr := rootEmitResult(target, result); emitErr == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
_ = apperrors.PrintJSON(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
_ = apperrors.PrintHumanAt(os.Stderr, err, apperrors.VerbosityNormal)
|
||||
}
|
||||
|
||||
// processArgsRequestJSON preserves the CLI's machine-readable error contract
|
||||
// for validation that must occur before Cobra and its presentation flags exist.
|
||||
// The global format defaults to JSON; an explicit non-JSON format switches to
|
||||
// the human diagnostic path. Last occurrence wins, matching pflag semantics.
|
||||
func processArgsRequestJSON(args []string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(processArgsFormat(args)), "json")
|
||||
}
|
||||
|
||||
func processArgsFormat(args []string) string {
|
||||
format := "json"
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
if arg == "--" {
|
||||
break
|
||||
}
|
||||
if value, ok := strings.CutPrefix(arg, "--format="); ok {
|
||||
format = value
|
||||
continue
|
||||
}
|
||||
if value, ok := strings.CutPrefix(arg, "-f="); ok {
|
||||
format = value
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(arg, "-f") && len(arg) > len("-f") {
|
||||
format = strings.TrimPrefix(arg, "-f")
|
||||
continue
|
||||
}
|
||||
if arg != "--format" && arg != "-f" {
|
||||
continue
|
||||
}
|
||||
if index+1 >= len(args) {
|
||||
format = ""
|
||||
break
|
||||
}
|
||||
index++
|
||||
format = args[index]
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
// errorInfoFromExecutionError projects the repository error model into the unified
|
||||
@@ -435,6 +576,22 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
|
||||
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
|
||||
msgWithTail := errMsg + tail
|
||||
if flag, ok := unknownFlagName(errMsg); ok && flag == "from" {
|
||||
switch cmd.CommandPath() {
|
||||
case "dws chat +search-msg", "dws chat +chat-messages":
|
||||
return apperrors.NewValidation(
|
||||
msgWithTail,
|
||||
apperrors.WithHint("--from 在消息查询中含义不明确:按发送者过滤请使用 --sender <姓名|userId|openDingTalkId>;指定时间起点请使用 --start <RFC3339>"),
|
||||
apperrors.WithReason("ambiguous_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(
|
||||
"Use --sender <姓名|userId|openDingTalkId> to filter by sender",
|
||||
"Use --start <RFC3339> together with --end <RFC3339> to set a time range",
|
||||
),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
if flag, protection, ok := reviewedFlagProtection(cmd, errMsg); ok {
|
||||
hint := fmt.Sprintf("Parameter --%s is blocked from automatic normalization on %q; choose an explicit flag from --help.", flag, cmd.CommandPath())
|
||||
reason := "blocked_flag"
|
||||
@@ -503,15 +660,10 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
|
||||
if cmd == nil {
|
||||
return "", "", false
|
||||
}
|
||||
const prefix = "unknown flag: --"
|
||||
idx := strings.Index(errMsg, prefix)
|
||||
if idx < 0 {
|
||||
flag, ok := unknownFlagName(errMsg)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
|
||||
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
|
||||
flag = flag[:i]
|
||||
}
|
||||
entry, ok := cli.LookupParamAlias(cmd.CommandPath())
|
||||
if !ok {
|
||||
return "", "", false
|
||||
@@ -526,6 +678,19 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func unknownFlagName(errMsg string) (string, bool) {
|
||||
const prefix = "unknown flag: --"
|
||||
idx := strings.Index(errMsg, prefix)
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
|
||||
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
|
||||
flag = flag[:i]
|
||||
}
|
||||
return flag, flag != ""
|
||||
}
|
||||
|
||||
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
|
||||
var raw apperrors.RawStderrError
|
||||
if stderrors.As(err, &raw) {
|
||||
@@ -633,12 +798,25 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
}
|
||||
|
||||
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool) *cobra.Command {
|
||||
return newRootCommandWithMode(rootCtx, engine, loadRuntimeExtensions, declarationOnly, false)
|
||||
}
|
||||
|
||||
func newRootPresentationCommand() *cobra.Command {
|
||||
return newRootCommandWithMode(context.Background(), nil, false, true, true)
|
||||
}
|
||||
|
||||
func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool, presentationOnly bool) *cobra.Command {
|
||||
if rootCtx == nil {
|
||||
rootCtx = context.Background()
|
||||
}
|
||||
flags := &GlobalFlags{}
|
||||
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
|
||||
runner := rootNewCommandRunnerWithFlags(flags)
|
||||
if snapshot, ok := agentMetadataSnapshotFromContext(rootCtx); ok {
|
||||
if runtime, ok := runner.(*runtimeRunner); ok {
|
||||
runtime.agentMetadata = &snapshot
|
||||
}
|
||||
}
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
@@ -672,15 +850,34 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
// --output sink instead opens at Run entry (after Cobra's own
|
||||
// validation), so validation failures still cannot strand a
|
||||
// temporary file.
|
||||
// Validate caller-provided identity labels before any edition hook
|
||||
// or command network activity can run. Header-only library callers
|
||||
// use the best-effort path in resolveIdentityHeaders instead.
|
||||
// Validate caller-provided identity and MCP metadata before command
|
||||
// execution hooks or network activity. The process entry point additionally
|
||||
// validates Agent metadata before command-tree construction; direct Cobra
|
||||
// embedding retains this execution-boundary guard.
|
||||
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
|
||||
return err
|
||||
}
|
||||
agentMetadata, cached := agentMetadataSnapshotFromContext(cmd.Context())
|
||||
if !cached {
|
||||
agentMetadata = readAgentMetadataSnapshot()
|
||||
}
|
||||
if err := agentMetadata.validationError(); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime, ok := runner.(*runtimeRunner); ok {
|
||||
// Retain the exact validated pair for this command execution so a
|
||||
// concurrently mutating embedding environment cannot change what is
|
||||
// later applied after edition and credential hooks.
|
||||
runtime.agentMetadata = &agentMetadata
|
||||
}
|
||||
if shouldDetectNestedSkillLayout(cmd) {
|
||||
if found, err := detectNestedMultiSkillLayout(); err == nil && found {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ 检测到旧升级器留下的嵌套 Skill;请运行 dws skill setup --mode multi 查看迁移计划并确认")
|
||||
}
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile(flags.Profile)
|
||||
// Apply OAuth credential overrides from CLI flags (highest priority).
|
||||
@@ -772,10 +969,12 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
// PAT authorization commands (open-source core)
|
||||
pat.RegisterCommands(root, patCaller)
|
||||
|
||||
if fn := edition.Get().RegisterExtraCommands; fn != nil {
|
||||
caller := newToolCallerAdapter(runner, flags)
|
||||
fn(root, caller)
|
||||
deduplicateCommands(root)
|
||||
if !presentationOnly {
|
||||
if fn := edition.Get().RegisterExtraCommands; fn != nil {
|
||||
caller := newToolCallerAdapter(runner, flags)
|
||||
fn(root, caller)
|
||||
deduplicateCommands(root)
|
||||
}
|
||||
}
|
||||
if loadRuntimeExtensions {
|
||||
// Resolve plugins only after the complete distribution command tree is
|
||||
@@ -786,7 +985,9 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
addPluginCommandsSafe(root, pluginCmds)
|
||||
}
|
||||
}
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
if !presentationOnly {
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
}
|
||||
configureRootHelp(root)
|
||||
// Set custom flag error handler for better UX
|
||||
root.SetFlagErrorFunc(flagErrorWithSuggestions)
|
||||
@@ -823,17 +1024,36 @@ func installReviewedFlagProtectionHandlers(root *cobra.Command) {
|
||||
}
|
||||
|
||||
func preparseProfileFlag(args []string) string {
|
||||
profile, _, valid := preparseProfileSelection(args)
|
||||
if !valid {
|
||||
return ""
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func preparseProfileSelection(args []string) (profile string, specified, valid bool) {
|
||||
args, _ = normalizeProfileFlagArgs(args)
|
||||
valid = true
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--profile" && i+1 < len(args):
|
||||
return strings.TrimSpace(args[i+1])
|
||||
case arg == "--profile":
|
||||
specified = true
|
||||
if i+1 >= len(args) || strings.HasPrefix(strings.TrimSpace(args[i+1]), "-") {
|
||||
profile = ""
|
||||
valid = false
|
||||
continue
|
||||
}
|
||||
profile = strings.TrimSpace(args[i+1])
|
||||
valid = profile != ""
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
specified = true
|
||||
profile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
valid = profile != ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return profile, specified, valid
|
||||
}
|
||||
|
||||
func normalizeProcessProfileArgs() func() {
|
||||
@@ -1750,17 +1970,16 @@ func distributionRootOwns(root *cobra.Command, name string) bool {
|
||||
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
|
||||
AppendDynamicServer(srv)
|
||||
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
|
||||
ClearPluginAuth(productID)
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
}
|
||||
// Register ownership for every accepted HTTP plugin, including anonymous
|
||||
// plugins. Execution must never fall back to the built-in DingTalk OAuth or
|
||||
// Agent-metadata path merely because a plugin has no Authorization Header.
|
||||
RegisterPluginAuth(productID, pluginAuthFromServerDescriptor(srv))
|
||||
}
|
||||
|
||||
// registerPluginAuthFromHeaders extracts authentication credentials from
|
||||
// a server descriptor's AuthHeaders and registers them in the global
|
||||
// PluginAuth registry. The runner uses this registry at execution time
|
||||
// to inject the correct Bearer token for third-party MCP servers.
|
||||
func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
||||
// pluginAuthFromServerDescriptor extracts plugin-owned credentials and custom
|
||||
// Headers. A non-nil result also acts as the HTTP plugin ownership marker for
|
||||
// anonymous plugins.
|
||||
func pluginAuthFromServerDescriptor(srv mcptypes.ServerDescriptor) *PluginAuth {
|
||||
authToken := ""
|
||||
extraHeaders := make(map[string]string)
|
||||
for key, value := range srv.AuthHeaders {
|
||||
@@ -1771,20 +1990,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
||||
extraHeaders[key] = value
|
||||
}
|
||||
}
|
||||
if authToken == "" {
|
||||
return
|
||||
}
|
||||
var trustedDomains []string
|
||||
if parsed, err := url.Parse(srv.Endpoint); err == nil {
|
||||
host := parsed.Hostname()
|
||||
trustedDomains = []string{host, "*." + host}
|
||||
if host != "" {
|
||||
trustedDomains = []string{host, "*." + host}
|
||||
}
|
||||
}
|
||||
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
|
||||
RegisterPluginAuth(productID, &PluginAuth{
|
||||
return &PluginAuth{
|
||||
Token: authToken,
|
||||
ExtraHeaders: extraHeaders,
|
||||
TrustedDomains: trustedDomains,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newPipelineEngine creates and configures the pipeline engine with
|
||||
|
||||
@@ -37,29 +37,64 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
var executedLeaf *cobra.Command
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
executedLeaf = &cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}
|
||||
sheet.AddCommand(executedLeaf)
|
||||
root.AddCommand(sheet)
|
||||
return root
|
||||
}
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return executedLeaf, nil }
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 0 || commandPath != "sheet read" || errorMessage != "" {
|
||||
t.Fatalf("successful ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
}
|
||||
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("pre-parse conflict returned zero")
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "alias/canonical conflict" {
|
||||
t.Fatalf("pre-parse conflict = code %d error %q", code, errorMessage)
|
||||
}
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
|
||||
wantErr := errors.New("unknown command missing")
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("failed Execute returned zero")
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "unknown command" {
|
||||
t.Fatalf("failed ExecuteWithTelemetry = code %d error %q", code, errorMessage)
|
||||
}
|
||||
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") }
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("panic Execute code = %d", code)
|
||||
os.Args = []string{"dws", "sheet", "read"}
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 5 || commandPath != "sheet read" || errorMessage != "internal panic" {
|
||||
t.Fatalf("panic ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryCommandPath(t *testing.T) {
|
||||
if got := telemetryCommandPath(nil); got != "dws" {
|
||||
t.Fatalf("nil command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(nil, nil); got != "dws" {
|
||||
t.Fatalf("nil root command path = %q, want dws", got)
|
||||
}
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
read := &cobra.Command{Use: "read <range>"}
|
||||
sheet.AddCommand(read)
|
||||
root.AddCommand(sheet)
|
||||
if got := telemetryCommandPath(root); got != "dws" {
|
||||
t.Fatalf("root command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPath(read); got != "sheet read" {
|
||||
t.Fatalf("leaf command path = %q, want sheet read", got)
|
||||
}
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
read.Aliases = []string{"get"}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"--profile", "corp-a", "sheet", "get", "A1:B2"}); got != "sheet read" {
|
||||
t.Fatalf("pre-execution command path = %q, want sheet read", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"missing"}); got != "dws" {
|
||||
t.Fatalf("unknown pre-execution command path = %q, want dws", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
@@ -13,6 +14,12 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// feedbackFormURL points at the DingTalk Notable form collecting dws CLI
|
||||
// user-experience feedback. The source parameter tags submissions that
|
||||
// originated from the CLI help output so they can be told apart from
|
||||
// responses arriving through other channels.
|
||||
const feedbackFormURL = "https://alidocs.dingtalk.com/notable/share/form/v01eLbnj1bw1ELb0laN_dv19yqvsgs3oebp3pcjys_1qX0QQ0?source=dws-cli"
|
||||
|
||||
func configureRootHelp(root *cobra.Command) {
|
||||
if root == nil {
|
||||
return
|
||||
@@ -101,6 +108,29 @@ func renderRootHelp(root *cobra.Command) {
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, tui.Dim(long))
|
||||
}
|
||||
|
||||
// Keep the feedback entry last: everything above it is operational guidance
|
||||
// an agent acts on, while the survey is addressed to human readers who
|
||||
// scroll to the end.
|
||||
_, _ = fmt.Fprintln(w)
|
||||
renderRootFeedback(w)
|
||||
}
|
||||
|
||||
// renderRootFeedback prints the user-experience survey entry. The URL occupies
|
||||
// its own line and is never wrapped or padded through a tabwriter: it is longer
|
||||
// than the help rule width, and breaking it would stop terminals from
|
||||
// recognizing it as a clickable hyperlink. Soft wrapping performed by the
|
||||
// terminal itself keeps the link intact.
|
||||
//
|
||||
// The label is intentionally not routed through i18n. Everything surrounding it
|
||||
// in this listing — service descriptions, utility descriptions, global flag
|
||||
// usage — is hardcoded Chinese, so translating this one line would render it in
|
||||
// English on any host whose LANG is not zh_*, leaving a single English line
|
||||
// inside an otherwise Chinese screen.
|
||||
func renderRootFeedback(w io.Writer) {
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Feedback:"))
|
||||
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.Dim("使用体验反馈问卷(1 分钟)"))
|
||||
_, _ = fmt.Fprintf(w, " %s\n", tui.Cyan(feedbackFormURL))
|
||||
}
|
||||
|
||||
func renderRootGlobalFlags(root *cobra.Command) {
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -51,6 +53,49 @@ func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpShowsFeedbackEntry(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("root help: %v\n%s", err, out.String())
|
||||
}
|
||||
help := out.String()
|
||||
// The label stays Chinese regardless of the host locale: the rest of this
|
||||
// listing is hardcoded Chinese, so a translated label would show up as a
|
||||
// lone English line on any host whose LANG is not zh_*.
|
||||
for _, want := range []string{"Feedback:", "使用体验反馈问卷", feedbackFormURL} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
// The form URL is longer than the help rule width; it must stay on a
|
||||
// single unbroken line so terminals keep recognizing it as a hyperlink.
|
||||
if !strings.Contains(help, "\n "+feedbackFormURL+"\n") {
|
||||
t.Fatalf("feedback URL must occupy one unwrapped line:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
// The feedback entry is deliberately root-only: this CLI is driven mostly by
|
||||
// AI agents, and repeating a survey link in every subcommand help would be
|
||||
// pure context noise. Guard the boundary so a future refactor cannot move the
|
||||
// rendering into the shared subcommand help path unnoticed.
|
||||
func TestSubcommandHelpOmitsFeedbackEntry(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"chat", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat help: %v\n%s", err, out.String())
|
||||
}
|
||||
if help := out.String(); strings.Contains(help, feedbackFormURL) {
|
||||
t.Fatalf("subcommand help must not carry the feedback URL:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
@@ -92,8 +137,8 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
}{
|
||||
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command := NewRootCommand()
|
||||
command.SilenceErrors = true
|
||||
@@ -219,7 +264,7 @@ func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *t
|
||||
}
|
||||
|
||||
got := output.String() + "\n" + err.Error()
|
||||
for _, want := range []string{"已下线", "chat message send --msg-type file --file-path"} {
|
||||
for _, want := range []string{"已下线", "chat message send --msg-type file --file"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("chat media upload migration output missing %q:\n%s", want, got)
|
||||
}
|
||||
@@ -369,6 +414,20 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
|
||||
t.Fatalf("chat message send missing --%s", flag)
|
||||
}
|
||||
}
|
||||
idempotencyKey := send.Flags().Lookup("idempotency-key")
|
||||
if idempotencyKey == nil {
|
||||
t.Fatal("chat message send missing --idempotency-key")
|
||||
}
|
||||
legacyUUID := send.Flags().Lookup("uuid")
|
||||
if legacyUUID == nil || !legacyUUID.Hidden {
|
||||
t.Fatalf("chat message send --uuid hidden = %#v, want hidden compatibility flag", legacyUUID)
|
||||
}
|
||||
if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "idempotency-key" {
|
||||
t.Fatalf("chat message send --uuid alias_of = %#v, want idempotency-key", got)
|
||||
}
|
||||
if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("chat message send --uuid alias_origin = %#v, want %s", got, runtimeannotate.FlagAliasOriginCorecmdV1)
|
||||
}
|
||||
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"chat", "file", "upload",
|
||||
@@ -380,7 +439,7 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
|
||||
t.Fatalf("chat file upload error = nil, want downline error\n%s", got)
|
||||
}
|
||||
got = got + "\n" + err.Error()
|
||||
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file-path"} {
|
||||
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("chat file upload output missing %q:\n%s", want, got)
|
||||
}
|
||||
@@ -403,6 +462,53 @@ func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoDryRunPreviewsOnly(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
"--id", "EVT_001",
|
||||
"--language", "zh-CN",
|
||||
"--calendar-id", "primary",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event share-info --dry-run error = %v\n%s", err, got)
|
||||
}
|
||||
for _, want := range []string{"get_event_share_info", "eventId", "EVT_001", "zh-CN", "primary"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("calendar event share-info dry-run output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoRequiresEventID(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("calendar event share-info without --id: expected error, got nil\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "\"executed\": true") {
|
||||
t.Fatalf("share-info without --id must not execute:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoOmitsOptionalArgs(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
"--id", "EVT_001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event share-info --dry-run with only --id error = %v\n%s", err, got)
|
||||
}
|
||||
if !strings.Contains(got, "\"eventId\"") {
|
||||
t.Fatalf("calendar event share-info dry-run output missing eventId:\n%s", got)
|
||||
}
|
||||
for _, unwanted := range []string{"\"calendarId\"", "\"language\""} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("calendar event share-info dry-run with only --id should not contain %q:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
|
||||
@@ -473,6 +579,80 @@ func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStaticDingTalkEndpointsFollowConfiguredMCPBaseURL(t *testing.T) {
|
||||
previous := edition.Get()
|
||||
defer edition.Override(previous)
|
||||
defer SetDynamicServers(nil)
|
||||
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "test",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{{
|
||||
ID: "contact",
|
||||
Name: "Contact",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/contact?key=abc",
|
||||
Prefixes: []string{"user"},
|
||||
}}
|
||||
},
|
||||
})
|
||||
|
||||
injectStaticServers()
|
||||
|
||||
for _, productID := range []string{"contact", "user"} {
|
||||
got, ok := directRuntimeEndpoint(productID, "")
|
||||
want := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
|
||||
if !ok || got != want {
|
||||
t.Fatalf("directRuntimeEndpoint(%q) = %q, %v; want %q, true", productID, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDingTalkEndpointsFollowSelectedTokenRegion(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
|
||||
if err := os.WriteFile(mcpURLPath, []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
endpoint := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
|
||||
if got, want := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionDefault), "https://pre-mcp-gw.dingtalk.com/server/contact?key=abc"; got != want {
|
||||
t.Fatalf("domestic profile endpoint = %q, want %q", got, want)
|
||||
}
|
||||
if got := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionInternational); got != endpoint {
|
||||
t.Fatalf("international profile endpoint = %q, want %q", got, endpoint)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(mcpURLPath, []byte("https://mcp.dingtalk.com\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if got, want := activeDingTalkGatewayEndpointForLoginRegion("https://mcp-gw.dingtalk.com/server/contact", authpkg.LoginRegionInternational), "https://mcp-gw.dingtalk.io/server/contact"; got != want {
|
||||
t.Fatalf("international profile endpoint from domestic config = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDingTalkEndpointUsesLoginScopedMCPOverride(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
restore := authpkg.PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io")
|
||||
defer restore()
|
||||
|
||||
got := activeDingTalkGatewayEndpointForLoginRegion(
|
||||
"https://mcp-gw.dingtalk.com/server/contact",
|
||||
authpkg.LoginRegionDefault,
|
||||
)
|
||||
want := "https://pre-mcp-gw.dingtalk.io/server/contact"
|
||||
if got != want {
|
||||
t.Fatalf("login-scoped endpoint = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := root
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user