Compare commits
321
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35239259fb | ||
|
|
85bf2dfc8a | ||
|
|
c4f2ab631b | ||
|
|
308e71c783 | ||
|
|
ecce09b355 | ||
|
|
1d02ff805d | ||
|
|
4d843cf7a4 | ||
|
|
8560830d3e | ||
|
|
fb9ff7de73 | ||
|
|
5ee80cdb97 | ||
|
|
bf2c0653ed | ||
|
|
e4daddf9cf | ||
|
|
e92309f7c4 | ||
|
|
7a58b0d19a | ||
|
|
78e6f11d72 | ||
|
|
9a8a41a318 | ||
|
|
af8e6a9ccc | ||
|
|
547020f47e | ||
|
|
d5eee82816 | ||
|
|
0d8763b917 | ||
|
|
600404abd0 | ||
|
|
247926d0fa | ||
|
|
9ef2a4e652 | ||
|
|
d4daf9525c | ||
|
|
63a89e68fa | ||
|
|
29b73a7d5e | ||
|
|
3488e11129 | ||
|
|
596bdce3a1 | ||
|
|
58eea98f6c | ||
|
|
b53b84616e | ||
|
|
15a2fea0dc | ||
|
|
d8da9a2e9f | ||
|
|
1a6ae856ec | ||
|
|
9c6407ae74 | ||
|
|
b9b8cc2c77 | ||
|
|
e02fdbdc8f | ||
|
|
ce529c9337 | ||
|
|
6952b22f45 | ||
|
|
3aa06e32fa | ||
|
|
97fc783cc0 | ||
|
|
a18b1e5fe4 | ||
|
|
b17e030d1f | ||
|
|
03258ca045 | ||
|
|
afd8422580 | ||
|
|
4b3e0e5046 | ||
|
|
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 | ||
|
|
2d24f74980 | ||
|
|
90278ab2fc | ||
|
|
671a41437d | ||
|
|
1b06d0105a | ||
|
|
18fad57bbe | ||
|
|
658f1e8e34 | ||
|
|
a32608f964 | ||
|
|
c3ef04988b | ||
|
|
9f1b3e8254 | ||
|
|
1f2fbca4de | ||
|
|
c718b051c2 | ||
|
|
76d54d6df6 | ||
|
|
58a8dddf31 | ||
|
|
6a93f14e0a | ||
|
|
0dc6735da2 | ||
|
|
a36189d31e | ||
|
|
913b7cf9a9 | ||
|
|
e46c4d0d71 | ||
|
|
35f399e2cf | ||
|
|
d52d16dba4 | ||
|
|
c3a3b59ad2 | ||
|
|
5b0cd561ff | ||
|
|
6b3f2e29bd | ||
|
|
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 | ||
|
|
db2caf6544 | ||
|
|
ea9e31a59f | ||
|
|
e58b85ea45 | ||
|
|
f3f1174407 | ||
|
|
e3fef0b6d4 | ||
|
|
54535bec11 | ||
|
|
d32bbe009d | ||
|
|
c7236a1844 | ||
|
|
0ea3d9810e | ||
|
|
ce6d5fb538 | ||
|
|
0a063e3ebd | ||
|
|
1e13413f79 | ||
|
|
43882bf959 | ||
|
|
e19c54f77e | ||
|
|
891dde7d03 | ||
|
|
fbc34509f8 | ||
|
|
def6ed4d2f | ||
|
|
7bf8ce79bd | ||
|
|
55c6a09bbc | ||
|
|
ad0cf639c4 | ||
|
|
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 | ||
|
|
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 | ||
|
|
b6c508acdf | ||
|
|
1d4c51a4d3 | ||
|
|
bdf3048773 | ||
|
|
57e23d661d | ||
|
|
9472f4a1d9 | ||
|
|
90e27c4b86 | ||
|
|
132dea9aaa | ||
|
|
5034c332fe | ||
|
|
e0dd800378 | ||
|
|
309c39a8e0 | ||
|
|
39d6caa24d | ||
|
|
afb25ae0e9 | ||
|
|
3af7adaad6 | ||
|
|
1f127881c9 | ||
|
|
c52f2b6e05 |
@@ -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,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,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: 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,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.
|
||||
@@ -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
|
||||
|
||||
+376
-63
@@ -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,28 @@ 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.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
- smoke
|
||||
- remaining
|
||||
- release-scripts
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -472,30 +563,85 @@ 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:
|
||||
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"
|
||||
list-shard "$TEST_SHARD" "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$package_output" ]; then
|
||||
echo "No buildable Go package is affected by this revision." \
|
||||
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
|
||||
mapfile -t packages <<< "$package_output"
|
||||
go test -v -race -count=1 -timeout=15m "${packages[@]}"
|
||||
# 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
|
||||
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
|
||||
if [ "$TEST_SHARD" = "app" ]; then
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Isolate Schema assembly and
|
||||
# bounded name ranges so each process releases that state on exit.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
|
||||
exit 0
|
||||
fi
|
||||
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
|
||||
# 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.
|
||||
# app runs several independently bounded processes; cli/smoke need headroom
|
||||
# beyond go test -timeout for setup + assembly.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -531,10 +677,19 @@ jobs:
|
||||
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.
|
||||
if [ "$TEST_SHARD" = "app" ]; then
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Isolate Schema assembly and
|
||||
# bounded name ranges so each process releases that state on exit.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
|
||||
exit 0
|
||||
fi
|
||||
# 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 +786,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 +815,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 +986,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 +1001,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 +1022,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 +1061,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 +1174,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 +1196,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 +1272,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 +1299,7 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- coverage-current
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-darwin
|
||||
@@ -1072,6 +1315,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 +1323,7 @@ jobs:
|
||||
run: |
|
||||
failed=0
|
||||
current_expected=success
|
||||
current_full_expected=skipped
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
native_expected=skipped
|
||||
@@ -1086,6 +1331,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 +1343,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 +1379,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 +1403,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 +1425,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 +1465,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 +1546,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 +1573,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
|
||||
|
||||
+122
@@ -6,6 +6,128 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
+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.4"
|
||||
version "1.0.59-beta.1"
|
||||
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.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5c2ac92e35b1f1dba80234af8b0c9505b2883f4a37c1e73892b8a1c3087b7702"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "93ef787770105fe1f0d27585adcac7b740aa6c37ff490275c4113814541ae095"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445"
|
||||
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.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "011ce16a73d8fd24275e34c3122d3d0832c60cde2480f496018eb654059b5c05"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "847b17ff8a8d80dce38f0013eb35c77c102be16c9f98b955a632b983cd5ec104"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-skills.zip"
|
||||
sha256 "f5e0c72cc92cb7e8886409319cf68bbbfc7740e969bd39a389b74af4befdbc66"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip"
|
||||
sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b"
|
||||
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"
|
||||
|
||||
@@ -210,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.
|
||||
|
||||
@@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs under `$HOME/.agents/skills/` (global; multi layout is per-product siblings, mono is the `dws/` subdirectory); `install-skills.sh` installs under `./.agents/skills/` (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).
|
||||
|
||||
@@ -433,7 +433,7 @@ 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` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
|
||||
|
||||
@@ -786,6 +786,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
|
||||
|
||||
+4
-3
@@ -207,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` 随时回滚。
|
||||
|
||||
@@ -399,7 +399,7 @@ Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / Con
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/`(全局,multi 为按产品平铺,mono 为 `dws/` 子目录);`install-skills.sh` 安装到 `./.agents/skills/`(当前项目)。
|
||||
> 安装器优先使用检测到的具体 Agent 根目录(如 `$HOME/.codex/skills/`);仅在未检测到具体 Agent 时回退到 `.agents/skills/`。multi 为按产品平铺,mono 为 `dws/` 子目录。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
@@ -427,7 +427,7 @@ 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` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
|
||||
|
||||
@@ -777,6 +777,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 层
|
||||
|
||||
@@ -17,6 +17,7 @@ const AGENT_DIRS = [
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".zcode/skills",
|
||||
".github/skills",
|
||||
".windsurf/skills",
|
||||
".augment/skills",
|
||||
@@ -233,6 +234,10 @@ function installSkillsToHomes(skillRoot) {
|
||||
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)) {
|
||||
@@ -252,6 +257,9 @@ function installSkillsToHomes(skillRoot) {
|
||||
};
|
||||
|
||||
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)) {
|
||||
@@ -265,6 +273,15 @@ function installSkillsToHomes(skillRoot) {
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -329,6 +346,40 @@ function isManagedMultiSkillDir(dir, managedNames) {
|
||||
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) => {
|
||||
@@ -577,6 +628,10 @@ function installMultiSkillsToHomes(multiRoot) {
|
||||
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")];
|
||||
@@ -604,6 +659,9 @@ function installMultiSkillsToHomes(multiRoot) {
|
||||
};
|
||||
|
||||
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)) {
|
||||
@@ -617,6 +675,15 @@ function installMultiSkillsToHomes(multiRoot) {
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
+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。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
@@ -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 必须一致,否则
|
||||
|
||||
@@ -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 表)
|
||||
@@ -673,6 +673,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`;
|
||||
|
||||
@@ -142,6 +142,9 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
|
||||
|
||||
升级器对每个 Agent 目标执行:
|
||||
|
||||
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
|
||||
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
|
||||
|
||||
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
|
||||
2. 在目标文件系统的 staging 中复制完整新集合;
|
||||
3. staging 全部成功后,才将旧集合移入备份目录;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-12T00:10:44.511794",
|
||||
"count": 399,
|
||||
"generated_at": "2026-08-14T13:50:36.324505",
|
||||
"count": 418,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3659,11 +3659,204 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+delete-space",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读取影响目标,经高风险确认后只接受 success=true 终态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+feed-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充知识库协作动态查询,严格验证 feeds 并保留游标。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-add",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-remove",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+move",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+move-to-drive",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-copy",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充库内关键词和扩展名搜索,并拒绝假空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+resolve-space",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建后要求 workspaceId 并通过空间详情读回验证真实落库。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "补充知识库详情入口,并要求 workspaceId 业务证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+wiki-new-doc",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。",
|
||||
"availability": "available"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,10 @@ 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/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=
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"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"
|
||||
@@ -326,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{
|
||||
@@ -913,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, "")
|
||||
@@ -929,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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -68,6 +68,45 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
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]
|
||||
@@ -322,9 +361,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 +375,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 +389,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 +479,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 +693,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"},
|
||||
|
||||
@@ -4,14 +4,21 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
const (
|
||||
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
)
|
||||
|
||||
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
|
||||
// fixture command path. Every argv is a complete, business-valid invocation:
|
||||
// required companion flags are present, time and enum values are valid, and
|
||||
@@ -32,12 +39,12 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
|
||||
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
|
||||
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
|
||||
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
|
||||
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2, "--mute-time", "3600000", "--yes"},
|
||||
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
|
||||
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
|
||||
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", appFixtureCurrentDOpenID, "--role-ids", "role-1", "--yes"},
|
||||
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", appFixtureCurrentDOpenID, "--yes"},
|
||||
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
|
||||
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
@@ -55,7 +62,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
|
||||
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
@@ -68,10 +75,10 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
|
||||
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
|
||||
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
|
||||
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
|
||||
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID},
|
||||
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
|
||||
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
|
||||
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID, "--yes"},
|
||||
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
|
||||
@@ -86,9 +93,9 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
|
||||
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
|
||||
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
|
||||
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
|
||||
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
|
||||
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
|
||||
@@ -124,6 +131,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
|
||||
"doc +export": {"doc", "+export", "--node", "node-1", "--export-format", "docx", "--output", "exports/fixture.docx"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
@@ -131,9 +139,43 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
|
||||
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
|
||||
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
|
||||
"doc create": {"doc", "create", "--name", "Fixture Document", "--workspace", "workspace-1"},
|
||||
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"drive +cover": {"drive", "+cover", "--node", "node-1"},
|
||||
"drive +create-folder": {"drive", "+create-folder", "--name", "Fixture Folder", "--space-id", "space-1", "--folder", "folder-1"},
|
||||
"drive +create-shortcut": {"drive", "+create-shortcut", "--node", "node-1", "--folder", "folder-1", "--workspace", "workspace-1"},
|
||||
"drive +delete": {"drive", "+delete", "--node", "node-1", "--yes"},
|
||||
"drive +download": {"drive", "+download", "--node", "node-1", "--space-id", "space-1", "--output", "downloads/fixture.bin"},
|
||||
"drive +info": {"drive", "+info", "--node", "node-1", "--space-id", "space-1"},
|
||||
"drive +inspect": {"drive", "+inspect", "--node", "node-1", "--space-id", "space-1", "--include-stats"},
|
||||
"drive +list": {"drive", "+list", "--space-id", "space-1", "--folder", "folder-1", "--limit", "7", "--cursor", "cursor-1", "--order-by", "name", "--order", "asc"},
|
||||
"drive +publish-get": {"drive", "+publish-get", "--node", "node-1"},
|
||||
"drive +publish-unset": {"drive", "+publish-unset", "--node", "node-1", "--yes"},
|
||||
"drive +recycle-list": {"drive", "+recycle-list", "--space-id", "space-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"drive +recycle-restore": {"drive", "+recycle-restore", "--id", "recycle-1", "--yes"},
|
||||
"drive +rename": {"drive", "+rename", "--node", "node-1", "--name", "Fixture Renamed", "--yes"},
|
||||
"drive +search": {"drive", "+search", "--query", "fixture"},
|
||||
"drive +star-add": {"drive", "+star-add", "--node", "node-1"},
|
||||
"drive +star-list": {"drive", "+star-list", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"drive +star-remove": {"drive", "+star-remove", "--node", "node-1"},
|
||||
"drive +stats": {"drive", "+stats", "--node", "node-1"},
|
||||
"drive +upload": {"drive", "+upload", "--file", "param_alias_payload_equivalence_test.go", "--file-name", "fixture.txt", "--mime-type", "text/plain", "--space-id", "space-1", "--node", "node-1", "--yes"},
|
||||
"drive +version-download": {"drive", "+version-download", "--node", "node-1", "--version", "3", "--output", "downloads/fixture-v3.bin"},
|
||||
"drive +version-get": {"drive", "+version-get", "--node", "node-1", "--version", "3"},
|
||||
"drive +version-history": {"drive", "+version-history", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"drive +version-revert": {"drive", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"drive commit": {"drive", "commit", "--file-name", "fixture.txt", "--file-size", "7", "--upload-id", "upload-1", "--space-id", "space-1"},
|
||||
"drive copy": {"drive", "copy", "--node", "node-1", "--folder", "folder-1"},
|
||||
"drive download": {"drive", "download", "--node", "node-1", "--output", "downloads/fixture.bin", "--version", "3", "--space-id", "space-1"},
|
||||
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
|
||||
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
|
||||
"drive mkdir": {"drive", "mkdir", "--name", "Fixture Folder", "--space-id", "space-1"},
|
||||
"drive permission add": {"drive", "permission", "add", "--node", "node-1", "--users", "user-1,user-2", "--role", "READER"},
|
||||
"drive recycle list": {"drive", "recycle", "list", "--space-id", "space-1", "--limit", "7"},
|
||||
"drive recycle restore": {"drive", "recycle", "restore", "--id", "recycle-1"},
|
||||
"drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"},
|
||||
"drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"},
|
||||
"drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
|
||||
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
|
||||
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
|
||||
@@ -156,15 +198,29 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"doc block insert": {
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
|
||||
},
|
||||
"doc +inspect": {
|
||||
"include-permissions": {"doc", "+inspect", "--node", "node-1", "--include-permissions"},
|
||||
},
|
||||
"doc +search": {
|
||||
"created-from": {"doc", "+search", "--query", "fixture", "--created-from", "1"},
|
||||
"created-to": {"doc", "+search", "--query", "fixture", "--created-to", "2"},
|
||||
"creator-uids": {"doc", "+search", "--query", "fixture", "--creator-uids", "user-1,user-2"},
|
||||
},
|
||||
"drive list": {
|
||||
"workspace": {"drive", "list", "--workspace", "workspace-1", "--limit", "7"},
|
||||
"order-by": {"drive", "list", "--folder", "folder-1", "--order-by", "name", "--limit", "7"},
|
||||
"space-id": {"drive", "list", "--space-id", "space-1", "--limit", "7"},
|
||||
"order": {"drive", "list", "--folder", "folder-1", "--order", "asc", "--limit", "7"},
|
||||
},
|
||||
"chat message list": {
|
||||
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
},
|
||||
"chat message list-by-sender": {
|
||||
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
},
|
||||
"chat message send": {
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
|
||||
},
|
||||
"chat +conversation-set-top": {
|
||||
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
|
||||
@@ -241,12 +297,105 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
|
||||
}
|
||||
|
||||
// paramAliasNewDriveCases is the exact executable-alias set introduced by the
|
||||
// reviewed Drive expansion. Guard fixtures are covered separately by the
|
||||
// exhaustive runtime-contract tests; every entry here must preserve the final
|
||||
// transport payload of its canonical spelling.
|
||||
var paramAliasNewDriveCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "drive +cover", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +create-folder", emitted: "folder-name", canonical: "name"},
|
||||
{command: "drive +create-folder", emitted: "storage-space-id", canonical: "space-id"},
|
||||
{command: "drive +create-shortcut", emitted: "source-file-id", canonical: "node"},
|
||||
{command: "drive +create-shortcut", emitted: "target-folder-id", canonical: "folder"},
|
||||
{command: "drive +create-shortcut", emitted: "target-workspace-id", canonical: "workspace"},
|
||||
{command: "drive +delete", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +download", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +download", emitted: "destination-path", canonical: "output"},
|
||||
{command: "drive +inspect", emitted: "include-statistics", canonical: "include-stats"},
|
||||
{command: "drive +list", emitted: "folder-id", canonical: "folder"},
|
||||
{command: "drive +list", emitted: "page-size", canonical: "limit"},
|
||||
{command: "drive +list", emitted: "next-token", canonical: "cursor"},
|
||||
{command: "drive +list", emitted: "sort-direction", canonical: "order"},
|
||||
{command: "drive +list", emitted: "sort-by", canonical: "order-by"},
|
||||
{command: "drive +publish-get", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +recycle-list", emitted: "storage-space-id", canonical: "space-id"},
|
||||
{command: "drive +recycle-list", emitted: "page-token", canonical: "cursor"},
|
||||
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
|
||||
{command: "drive +rename", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "new-name", canonical: "name"},
|
||||
{command: "drive +star-add", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +star-list", emitted: "max-results", canonical: "limit"},
|
||||
{command: "drive +star-remove", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +stats", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +upload", emitted: "source-file", canonical: "file"},
|
||||
{command: "drive +upload", emitted: "name", canonical: "file-name"},
|
||||
{command: "drive +upload", emitted: "overwrite-node-id", canonical: "node"},
|
||||
{command: "drive +version-download", emitted: "version-number", canonical: "version"},
|
||||
{command: "drive +version-download", emitted: "save-path", canonical: "output"},
|
||||
{command: "drive +version-get", emitted: "version-no", canonical: "version"},
|
||||
{command: "drive +version-history", emitted: "next-cursor", canonical: "cursor"},
|
||||
{command: "drive +version-history", emitted: "page-size", canonical: "limit"},
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
{command: "drive +cover", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +create-shortcut", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +delete", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +download", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +inspect", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +publish-get", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +star-add", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +star-remove", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +stats", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +upload", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-download", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-get", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-history", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-revert", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +cover", emitted: "url", canonical: "node"},
|
||||
{command: "drive +create-shortcut", emitted: "document-id", canonical: "node"},
|
||||
{command: "drive +delete", emitted: "folder-id", canonical: "node"},
|
||||
{command: "drive +inspect", emitted: "document-id", canonical: "node"},
|
||||
{command: "drive +inspect", emitted: "folder-id", canonical: "node"},
|
||||
{command: "drive +publish-get", emitted: "url", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "document-id", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "folder-id", canonical: "node"},
|
||||
{command: "drive +star-add", emitted: "url", canonical: "node"},
|
||||
{command: "drive +star-remove", emitted: "doc-id", canonical: "node"},
|
||||
{command: "drive +stats", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +upload", emitted: "file-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasNewDriveConfirmationCases selects one newly reviewed alias for
|
||||
// every Drive command in the expansion whose declared runtime safety requires
|
||||
// confirmation. The full matrix below proves all spellings preserve the
|
||||
// confirmed payload; this smaller matrix proves aliases cannot cross the
|
||||
// confirmation boundary before any transport call is made.
|
||||
var paramAliasNewDriveConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "drive +delete", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
|
||||
{command: "drive +rename", emitted: "new-name", canonical: "name"},
|
||||
{command: "drive +upload", emitted: "source-file", canonical: "file"},
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
// old concept aliases, command overrides, native compatibility flags, read and
|
||||
// write commands, and different products. Every reviewed alias is still
|
||||
// checked through the embedded PreParse delivery path and against a complete
|
||||
// business-valid command template. The separate IM gate below continues to
|
||||
// execute every alias introduced by the current IM optimization.
|
||||
// business-valid command template. The dedicated product gates below continue
|
||||
// to execute every alias introduced by the reviewed IM and Drive expansions.
|
||||
//
|
||||
// Keeping the older 100+ aliases at the contract layer avoids rebuilding and
|
||||
// executing the complete 800+ command Root twice per spelling under -race.
|
||||
@@ -261,6 +410,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
|
||||
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
|
||||
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
|
||||
paramAliasPayloadCaseKey("doc create", "space-id"): true, // published Doc workspace compatibility remains payload-equivalent
|
||||
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
|
||||
@@ -269,6 +419,8 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
|
||||
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
|
||||
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
|
||||
paramAliasPayloadCaseKey("doc +search", "create-time-start"): true, // observed lower-bound spelling preserves milliseconds
|
||||
paramAliasPayloadCaseKey("doc +search", "create-time-end"): true, // observed upper-bound spelling preserves milliseconds
|
||||
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
|
||||
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
|
||||
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
|
||||
@@ -278,6 +430,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
|
||||
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
|
||||
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
|
||||
paramAliasPayloadCaseKey("drive info", "workspace"): true, // published numeric storage-space compatibility remains payload-equivalent
|
||||
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
@@ -421,14 +574,118 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
}
|
||||
|
||||
// Resource download deliberately continues from the transport call into a
|
||||
// local HTTPS download. The generic capture caller returns an empty object, so
|
||||
// this command's stable post-transport validation error is the expected test
|
||||
// boundary; canonical and alias calls must still produce the same request and
|
||||
// the same error.
|
||||
func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
activeAliases := 0
|
||||
for _, test := range paramAliasNewDriveCases {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Drive alias has no complete-command E2E template")
|
||||
}
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active {
|
||||
return
|
||||
}
|
||||
if target != test.canonical {
|
||||
t.Fatalf("active reviewed Drive alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical)
|
||||
}
|
||||
activeAliases++
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
|
||||
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
if activeAliases != len(paramAliasNewDriveCases) {
|
||||
t.Fatalf("new Drive aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewDriveCases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewDriveConfirmationCases {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Drive confirmation alias has no complete-command E2E template")
|
||||
}
|
||||
aliasArgs, replacements := replaceLongFlag(complete, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, complete)
|
||||
}
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
|
||||
if removals != 1 {
|
||||
t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
|
||||
}
|
||||
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active || target != test.canonical {
|
||||
t.Fatalf("reviewed Drive alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed alias command crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Some artifact commands deliberately continue past the final captured
|
||||
// transport call into local download/upload handling. Deterministic invalid
|
||||
// resource responses form their expected post-transport test boundary;
|
||||
// canonical and alias calls must still produce the same request and error.
|
||||
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
|
||||
return command == "chat +messages-resource-download" && err != nil &&
|
||||
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
switch command {
|
||||
case "chat +messages-resource-download":
|
||||
return strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
|
||||
case "drive +download", "drive +version-download":
|
||||
return strings.Contains(err.Error(), "下载地址必须是受信任域名上的 HTTPS URL")
|
||||
case "drive +upload":
|
||||
return strings.Contains(err.Error(), "incomplete drive upload credentials")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// +chat-messages supplies the current wall-clock time when callers omit
|
||||
@@ -483,3 +740,16 @@ func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) {
|
||||
}
|
||||
return out, replacements
|
||||
}
|
||||
|
||||
func removeExactArg(args []string, target string) ([]string, int) {
|
||||
out := make([]string, 0, len(args))
|
||||
removals := 0
|
||||
for _, arg := range args {
|
||||
if arg == target {
|
||||
removals++
|
||||
continue
|
||||
}
|
||||
out = append(out, arg)
|
||||
}
|
||||
return out, removals
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
@@ -473,6 +532,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
|
||||
|
||||
@@ -230,13 +230,20 @@ func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
t.Chdir(dir)
|
||||
// Keep argv portable: an absolute Windows path contains a volume colon,
|
||||
// which the CLI intentionally rejects as unsafe user-supplied output.
|
||||
target := "result.json"
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalInfo, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
@@ -277,7 +284,7 @@ func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
|
||||
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
|
||||
}
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertOutputFile(t, target, "original", originalInfo.Mode().Perm())
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
+53
-23
@@ -137,7 +137,6 @@ func newCommandRunnerWithFlags(flags *GlobalFlags) executor.Runner {
|
||||
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
|
||||
}
|
||||
transportClient := transport.NewClient(httpClient)
|
||||
transportClient.ExtraHeaders = resolveIdentityHeaders()
|
||||
transportClient.FileLogger = FileLoggerInstance()
|
||||
return &runtimeRunner{
|
||||
transport: transportClient,
|
||||
@@ -156,12 +155,14 @@ type runtimeRunner struct {
|
||||
enforceContentScan bool
|
||||
includeScanReport bool
|
||||
auditSink audit.Sink
|
||||
agentMetadata *agentMetadataSnapshot
|
||||
}
|
||||
|
||||
var (
|
||||
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
|
||||
runnerResolveProfile = authpkg.ResolveProfile
|
||||
runnerGetCachedRuntimeToken = getCachedRuntimeToken
|
||||
runnerResolveAuthSnapshot = (*runtimeRunner).resolveAuthSnapshot
|
||||
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
|
||||
runnerCallTool = (*transport.Client).CallTool
|
||||
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
|
||||
@@ -237,7 +238,6 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
|
||||
if r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
r.transport.ExtraHeaders = resolveIdentityHeaders()
|
||||
|
||||
// Mock mode: skip endpoint resolution, use a placeholder endpoint.
|
||||
if r.globalFlags != nil && r.globalFlags.Mock {
|
||||
@@ -543,20 +543,25 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
emitAudit(auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
|
||||
}()
|
||||
|
||||
// Check if this product has plugin-level auth credentials registered.
|
||||
// If so, use the plugin's token instead of the default DingTalk OAuth token.
|
||||
// This allows third-party MCP servers (e.g. Bailian) to use their own API keys.
|
||||
// Check whether this product belongs to an HTTP plugin. Every accepted
|
||||
// plugin has an ownership record; credentials within that record are
|
||||
// optional. Plugin requests never fall back to the default DingTalk OAuth.
|
||||
pluginAuth, hasPluginAuth := LookupPluginAuth(invocation.CanonicalProduct)
|
||||
|
||||
authToken := ""
|
||||
if hasPluginAuth {
|
||||
authToken = pluginAuth.Token
|
||||
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
|
||||
var tokenErr error
|
||||
authToken, tokenErr = r.resolveAuthToken(ctx)
|
||||
snapshot, tokenErr := runnerResolveAuthSnapshot(r, ctx)
|
||||
if tokenErr != nil {
|
||||
return executor.Result{}, tokenResolutionError(tokenErr)
|
||||
}
|
||||
authToken = snapshot.AccessToken
|
||||
if !hasDirectRuntimeEndpointOverride(invocation.CanonicalProduct) &&
|
||||
isDingTalkMCPGatewayEndpoint(endpoint) &&
|
||||
(snapshot.LoginRegionKnown || authpkg.MCPBaseURLOverride() != "") {
|
||||
endpoint = activeDingTalkGatewayEndpointForLoginRegion(endpoint, snapshot.LoginRegion)
|
||||
}
|
||||
}
|
||||
|
||||
var timeoutSec int
|
||||
@@ -603,9 +608,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fail-fast: reject unauthenticated requests before making network calls.
|
||||
// This provides a clear error message instead of cryptic HTTP 400 from MCP.
|
||||
if strings.TrimSpace(authToken) == "" {
|
||||
// Preserve a final execution-boundary guard even though the built-in token
|
||||
// resolver normally returns either a non-empty token or an error. HTTP
|
||||
// plugins are ownership-scoped separately and may intentionally be anonymous.
|
||||
if !hasPluginAuth && strings.TrimSpace(authToken) == "" {
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"未登录,请先执行 dws auth login",
|
||||
apperrors.WithReason("not_authenticated"),
|
||||
@@ -616,12 +622,20 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
|
||||
var tc *transport.Client
|
||||
if hasPluginAuth {
|
||||
// Use plugin-level auth: inject the plugin's token and trust its domains.
|
||||
tc = r.transport.WithAuth(authToken, pluginAuth.ExtraHeaders)
|
||||
// Plugin ownership is authoritative even when the plugin is anonymous.
|
||||
// Copy and sanitize manifest headers so plugins cannot opt themselves into
|
||||
// DWS-owned Agent metadata by declaring the reserved names directly.
|
||||
tc = r.transport.WithAuth(authToken, pluginRequestHeaders(pluginAuth))
|
||||
tc.TrustedDomains = pluginAuth.TrustedDomains
|
||||
} else {
|
||||
// Default path: use DingTalk OAuth token with identity headers.
|
||||
tc = r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||
// Default path: use DingTalk OAuth token with identity headers. Agent
|
||||
// metadata is resolved exactly once per invocation and is excluded from
|
||||
// helper-only service-discovery requests.
|
||||
if r.agentMetadata != nil {
|
||||
tc = r.transport.WithAuth(authToken, resolveMCPRequestHeadersForInvocation(invocation, *r.agentMetadata))
|
||||
} else {
|
||||
tc = r.transport.WithAuth(authToken, resolveMCPRequestHeadersForInvocation(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
callCtx := ctx
|
||||
@@ -868,21 +882,33 @@ func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) {
|
||||
explicitToken := ""
|
||||
if r != nil && r.globalFlags != nil {
|
||||
explicitToken = r.globalFlags.Token
|
||||
}
|
||||
return resolveRuntimeAuthToken(ctx, explicitToken)
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
|
||||
snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
|
||||
snapshot, err := r.resolveAuthSnapshot(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return snapshot.AccessToken, nil
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) resolveAuthSnapshot(ctx context.Context) (AccessTokenSnapshot, error) {
|
||||
explicitToken := ""
|
||||
if r != nil && r.globalFlags != nil {
|
||||
explicitToken = r.globalFlags.Token
|
||||
}
|
||||
return resolveRuntimeAuthSnapshot(ctx, explicitToken)
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
|
||||
snapshot, err := resolveRuntimeAuthSnapshot(ctx, explicitToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return snapshot.AccessToken, nil
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthSnapshot(ctx context.Context, explicitToken string) (AccessTokenSnapshot, error) {
|
||||
return runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
|
||||
}
|
||||
|
||||
// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The
|
||||
// cache itself lives exclusively in TokenManager.
|
||||
func getCachedRuntimeToken(ctx context.Context) (string, error) {
|
||||
@@ -1069,6 +1095,10 @@ func resolveIdentityHeaders() map[string]string {
|
||||
} else {
|
||||
delete(headers, agentproduct.HeaderName)
|
||||
}
|
||||
// Agent version and extension are intentionally MCP-request-only. Remove
|
||||
// every case variant potentially supplied by an edition or credential hook
|
||||
// so shared consumers such as A2A cannot inherit them.
|
||||
removeAgentMetadataHeaders(headers)
|
||||
return headers
|
||||
}
|
||||
|
||||
|
||||
@@ -127,12 +127,14 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldPreflight := runnerPreflightDocDownload
|
||||
oldCall := runnerCallTool
|
||||
oldHandle := runnerHandlePatAuthCheck
|
||||
oldRetry := runnerRetryWithPatAuthRetry
|
||||
oldCapture := runnerCaptureRuntimeFailure
|
||||
oldResolveSnapshot := runnerResolveAuthSnapshot
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
runnerPreflightDocDownload = oldPreflight
|
||||
@@ -140,6 +142,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
runnerHandlePatAuthCheck = oldHandle
|
||||
runnerRetryWithPatAuthRetry = oldRetry
|
||||
runnerCaptureRuntimeFailure = oldCapture
|
||||
runnerResolveAuthSnapshot = oldResolveSnapshot
|
||||
})
|
||||
|
||||
pluginAuthMu.Lock()
|
||||
@@ -168,6 +171,27 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented {
|
||||
t.Fatalf("default auth execution = %#v, %v", got, err)
|
||||
}
|
||||
runnerResolveAuthSnapshot = func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
|
||||
return AccessTokenSnapshot{
|
||||
AccessToken: "international-token",
|
||||
LoginRegion: authpkg.LoginRegionInternational,
|
||||
LoginRegionKnown: true,
|
||||
}, nil
|
||||
}
|
||||
successfulCall := runnerCallTool
|
||||
routedEndpoint := ""
|
||||
runnerCallTool = func(_ *transport.Client, _ context.Context, endpoint, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
||||
routedEndpoint = endpoint
|
||||
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://mcp-gw.dingtalk.com/server/contact", inv); err != nil {
|
||||
t.Fatalf("international auth execution = %v", err)
|
||||
}
|
||||
if routedEndpoint != "https://mcp-gw.dingtalk.io/server/contact" {
|
||||
t.Fatalf("international routed endpoint = %q", routedEndpoint)
|
||||
}
|
||||
runnerResolveAuthSnapshot = oldResolveSnapshot
|
||||
runnerCallTool = successfulCall
|
||||
|
||||
wantErr := errors.New("preflight")
|
||||
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||
@@ -362,6 +386,12 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
||||
if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" {
|
||||
t.Fatalf("runtime explicit token = %q, %v", got, err)
|
||||
}
|
||||
edition.Override(&edition.Hooks{TokenProvider: func(context.Context, func() (string, error)) (string, error) {
|
||||
return "", errors.New("snapshot failed")
|
||||
}})
|
||||
if _, err := r.resolveAuthToken(context.Background()); err == nil || !strings.Contains(err.Error(), "snapshot failed") {
|
||||
t.Fatalf("resolveAuthToken error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(envDWSChannel, "channel")
|
||||
edition.Override(&edition.Hooks{
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 399
|
||||
publicShortcutCount = 418
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 401
|
||||
schemaPublishedShortcutCount = 420
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 399
|
||||
publiclyDeliveredShortcutCount = 418
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -140,6 +140,78 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("wiki +space-search interface_mode = %q, want composite", got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
for _, fragment := range []string{"query/limit", "search_wikiSpaces.keyword/pageSize", "versioned Schema migration"} {
|
||||
if !strings.Contains(reason, fragment) {
|
||||
t.Fatalf("wiki +space-search interface_reason = %q, want fragment %q", reason, fragment)
|
||||
}
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range map[string]string{"query": "query", "limit": "limit"} {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("wiki +space-search missing --%s parameter: %#v", name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("wiki +space-search --%s property = %q, want compatibility value %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
checked := 0
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service != "wiki" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Fatalf("delivery schema --all is missing %s", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 {
|
||||
t.Fatalf("%s has no delivered examples", canonical)
|
||||
}
|
||||
for _, example := range examples {
|
||||
argv, err := cli.ParseAgentExampleArgv(example)
|
||||
if err != nil {
|
||||
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
|
||||
}
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Required {
|
||||
continue
|
||||
}
|
||||
names := append([]string{flag.Name}, flag.Aliases...)
|
||||
if !schemaExampleHasLongFlag(argv, names...) {
|
||||
t.Errorf("%s example %q is missing required --%s", canonical, example, flag.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 20 {
|
||||
t.Fatalf("checked Wiki shortcut examples = %d, want 20", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
for _, argument := range argv {
|
||||
for _, name := range names {
|
||||
if argument == "--"+name || strings.HasPrefix(argument, "--"+name+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
@@ -207,7 +279,11 @@ func assertChatCatalogCompleteLeafContracts(t testing.TB) {
|
||||
})
|
||||
|
||||
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
|
||||
assertSchemaLeafParameterRequired(t, auditJoin, "chat group audit-join-validation", "conversation-id", true)
|
||||
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
|
||||
if parameters := schemaContractMap(auditJoin["parameters"]); parameters["group"] != nil {
|
||||
t.Fatalf("chat group audit-join-validation publishes hidden --group alias: %#v", parameters["group"])
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
|
||||
|
||||
@@ -103,7 +103,7 @@ func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Wri
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
@@ -197,7 +197,7 @@ func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
@@ -229,7 +229,7 @@ func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
|
||||
@@ -126,6 +126,7 @@ var agentSkillPaths = map[string]string{
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"zcode": ".zcode/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
// IDE / agent registries also probed by `dws skill setup --target all`.
|
||||
"gemini": ".gemini/skills",
|
||||
|
||||
@@ -452,7 +452,7 @@ func TestSupportedTargets(t *testing.T) {
|
||||
// Should contain all predefined targets — including the agents/* sentinel
|
||||
// and the IDE/agent registries we share with skillSetupAgentHomes.
|
||||
expectedTargets := []string{
|
||||
"agents", "claude", "cursor", "codex", "opencode", "qoder",
|
||||
"agents", "claude", "cursor", "codex", "zcode", "opencode", "qoder",
|
||||
"gemini", "github", "windsurf", "augment", "cline",
|
||||
"amp", "kiro", "trae", "openclaw", "hermes",
|
||||
".",
|
||||
|
||||
@@ -29,6 +29,7 @@ var skillSetupAgentHomes = []string{
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".zcode/skills",
|
||||
".github/skills",
|
||||
".windsurf/skills",
|
||||
".augment/skills",
|
||||
@@ -92,6 +93,7 @@ type skillSetupBackup struct {
|
||||
type skillSetupTargetPlan struct {
|
||||
Destination string
|
||||
Backups []skillSetupBackup
|
||||
CleanupOnly bool
|
||||
}
|
||||
|
||||
type skillSetupPlan struct {
|
||||
@@ -147,7 +149,8 @@ multi 模式支持按产品挑选:
|
||||
备份失败时保留原目录并跳过该目标,绝不静默删除。
|
||||
· 所有将被移除的目录都会在确认前逐条列出。
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录;
|
||||
未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。
|
||||
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
|
||||
Example: ` dws skill setup --mode multi --target claude --dry-run
|
||||
dws skill setup --mode multi --target claude`,
|
||||
@@ -940,18 +943,79 @@ func agentHomeForMode(base, mode string) string {
|
||||
}
|
||||
|
||||
func detectExistingAgentHomes(home, mode string) []string {
|
||||
var out []string
|
||||
var specific []string
|
||||
for i, rel := range skillSetupAgentHomes {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
base := filepath.Join(home, rel)
|
||||
parent := filepath.Dir(base)
|
||||
if i > 0 {
|
||||
if _, err := skillSetupStat(parent); errors.Is(err, os.ErrNotExist) {
|
||||
if info, err := skillSetupStat(parent); err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
specific = append(specific, agentHomeForMode(base, mode))
|
||||
}
|
||||
if len(specific) > 0 {
|
||||
return specific
|
||||
}
|
||||
return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)}
|
||||
}
|
||||
|
||||
func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) {
|
||||
// Derive HOME from a concrete Agent destination instead of resolving it a
|
||||
// second time. The destinations were already resolved from HOME by the
|
||||
// caller, and a later/transient UserHomeDir failure must not turn an
|
||||
// otherwise valid setup plan into an error. Direct/custom destinations that
|
||||
// do not match a known concrete Agent root have no generic-root migration.
|
||||
home := ""
|
||||
for _, dest := range dests {
|
||||
base := dest
|
||||
if filepath.Base(dest) == "dws" {
|
||||
base = filepath.Dir(dest)
|
||||
}
|
||||
base = filepath.Clean(base)
|
||||
for i, rel := range skillSetupAgentHomes {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
suffix := filepath.Clean(filepath.FromSlash(rel))
|
||||
needle := string(filepath.Separator) + suffix
|
||||
if strings.HasSuffix(base, needle) {
|
||||
home = strings.TrimSuffix(base, needle)
|
||||
break
|
||||
}
|
||||
}
|
||||
if home != "" {
|
||||
break
|
||||
}
|
||||
out = append(out, agentHomeForMode(base, mode))
|
||||
}
|
||||
return out
|
||||
if home == "" {
|
||||
return nil, nil
|
||||
}
|
||||
genericBase := filepath.Join(home, ".agents", "skills")
|
||||
|
||||
target := &skillSetupTargetPlan{Destination: genericBase, CleanupOnly: true}
|
||||
add := func(path, reason string) {
|
||||
if info, statErr := skillSetupStat(path); statErr == nil && info.IsDir() {
|
||||
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason})
|
||||
}
|
||||
}
|
||||
add(filepath.Join(genericBase, "dws"), skillSetupBackupMutual)
|
||||
entries, readErr := skillSetupReadDir(genericBase)
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("扫描通用 Skill 根目录失败 %s: %w", genericBase, readErr)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(genericBase, entry.Name())
|
||||
if entry.IsDir() && isManagedDWSMultiSkillDir(path, managed) {
|
||||
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupStale})
|
||||
}
|
||||
}
|
||||
if len(target.Backups) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) {
|
||||
@@ -1027,6 +1091,13 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
|
||||
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
|
||||
plan.Targets = append(plan.Targets, target)
|
||||
}
|
||||
cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames)
|
||||
if cleanupErr != nil {
|
||||
return nil, cleanupErr
|
||||
}
|
||||
if cleanupTarget != nil {
|
||||
plan.Targets = append(plan.Targets, *cleanupTarget)
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
@@ -1079,7 +1150,11 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
|
||||
}
|
||||
fmt.Fprintln(out, " destinations:")
|
||||
for _, target := range plan.Targets {
|
||||
fmt.Fprintf(out, " - %s\n", target.Destination)
|
||||
if target.CleanupOnly {
|
||||
fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination)
|
||||
} else {
|
||||
fmt.Fprintf(out, " - %s\n", target.Destination)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):")
|
||||
count := 0
|
||||
@@ -1676,6 +1751,21 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
|
||||
perTarget = len(plan.MultiSkillNames)
|
||||
}
|
||||
for _, target := range plan.Targets {
|
||||
if target.CleanupOnly {
|
||||
if skipped > 0 {
|
||||
continue
|
||||
}
|
||||
if homeErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
|
||||
skipped++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(target.Backups) > 0 && homeErr != nil {
|
||||
if plan.Mode == skillSetupModeMono {
|
||||
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr)
|
||||
|
||||
@@ -146,6 +146,86 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".codex", "skills")
|
||||
genericMono := filepath.Join(home, ".agents", "skills", "dws")
|
||||
if err := os.MkdirAll(genericMono, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
|
||||
return "", errors.New("transient HOME failure")
|
||||
})
|
||||
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
|
||||
t.Fatalf("generic cleanup target = %#v", plan.Targets)
|
||||
}
|
||||
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
|
||||
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
|
||||
}
|
||||
var preview bytes.Buffer
|
||||
renderSkillSetupPlan(&preview, plan)
|
||||
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
|
||||
t.Fatalf("generic cleanup preview missing: %s", preview.String())
|
||||
}
|
||||
|
||||
t.Run("managed multi and scan failure", func(t *testing.T) {
|
||||
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
|
||||
if err := os.MkdirAll(managedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true})
|
||||
if targetErr != nil || target == nil || len(target.Backups) != 2 {
|
||||
t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr)
|
||||
}
|
||||
failure := errors.New("generic scan failure")
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
|
||||
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
|
||||
t.Fatalf("generic scan error = %v", targetErr)
|
||||
}
|
||||
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
|
||||
t.Fatalf("generic cleanup plan error = %v", planErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) {
|
||||
failure := errors.New("cleanup failure")
|
||||
cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}}
|
||||
|
||||
t.Run("prior skip suppresses cleanup", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
|
||||
plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}}
|
||||
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("cleanup after skip = (%d, %d, %v)", installed, skipped, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("home failure keeps generic copy", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
|
||||
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup failure is reported", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") {
|
||||
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "skills")
|
||||
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
|
||||
|
||||
@@ -330,6 +330,50 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills", ".codex/skills"})
|
||||
if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".codex", "skills")
|
||||
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want [%s]", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
if err := os.MkdirAll(filepath.Join(home, ".zcode"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".zcode", "skills")
|
||||
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want [%s]", got, want)
|
||||
}
|
||||
|
||||
explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantMono := filepath.Join(want, "dws")
|
||||
if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) {
|
||||
t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageInstallSkillToHomesEndToEnd(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// detectNestedMultiSkillLayout detects the compatibility gap where an older
|
||||
// running dws process replaces itself with a newer binary, but then installs
|
||||
// the new release bundle with its legacy mono copier. That produces the
|
||||
// impossible layout <agent>/dws/multi/<skill>/SKILL.md.
|
||||
//
|
||||
// Detection is deliberately read-only. Ordinary commands must not turn a
|
||||
// compatibility warning into an unconfirmed, cross-Agent Skill refresh.
|
||||
// A legitimate mono install has no dws/multi product tree and is ignored.
|
||||
func detectNestedMultiSkillLayout() (bool, error) {
|
||||
home, err := skillSetupUserHomeDir()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, rel := range skillSetupAgentHomes {
|
||||
nested := filepath.Join(home, rel, "dws", "multi")
|
||||
if isSkillSourceRoot(nested, skillSetupModeMulti) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func shouldDetectNestedSkillLayout(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return true
|
||||
}
|
||||
if cmd.Name() == "upgrade" {
|
||||
return false
|
||||
}
|
||||
return !(cmd.Name() == "setup" && cmd.Parent() != nil && cmd.Parent().Name() == "skill")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageStartupDetectsNestedUpgradeLayoutWithoutMutation(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills", ".codex/skills"})
|
||||
nested := filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat")
|
||||
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found, err := detectNestedMultiSkillLayout()
|
||||
if err != nil || !found {
|
||||
t.Fatalf("detect nested layout = (%v, %v), want (true, nil)", found, err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
|
||||
if err != nil || string(data) != "old nested" {
|
||||
t.Fatalf("detection changed nested Skill: data=%q err=%v", data, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat")); !os.IsNotExist(err) {
|
||||
t.Fatalf("detection unexpectedly installed a canonical Skill: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStartupDetectionIgnoresMonoAndReportsReadFailure(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills"})
|
||||
mono := filepath.Join(home, ".agents", "skills", "dws")
|
||||
if err := os.MkdirAll(mono, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("valid mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := detectNestedMultiSkillLayout()
|
||||
if err != nil || found {
|
||||
t.Fatalf("valid mono detection = (%v, %v), want (false, nil)", found, err)
|
||||
}
|
||||
|
||||
failure := errors.New("HOME failure")
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
|
||||
found, err = detectNestedMultiSkillLayout()
|
||||
if found || !errors.Is(err, failure) {
|
||||
t.Fatalf("HOME failure detection = (%v, %v)", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStartupDetectionSkipsExplicitSkillManagers(t *testing.T) {
|
||||
upgradeCmd := &cobra.Command{Use: "upgrade"}
|
||||
if shouldDetectNestedSkillLayout(upgradeCmd) {
|
||||
t.Fatal("upgrade must manage its own Skill lifecycle")
|
||||
}
|
||||
skillCmd := &cobra.Command{Use: "skill"}
|
||||
setupCmd := &cobra.Command{Use: "setup"}
|
||||
skillCmd.AddCommand(setupCmd)
|
||||
if shouldDetectNestedSkillLayout(setupCmd) {
|
||||
t.Fatal("skill setup must manage its own Skill lifecycle")
|
||||
}
|
||||
if !shouldDetectNestedSkillLayout(&cobra.Command{Use: "version"}) || !shouldDetectNestedSkillLayout(nil) {
|
||||
t.Fatal("ordinary commands must trigger read-only detection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStartupWarningIsReadOnlyAndDoesNotBypassConfirmation(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupAgentHomes, []string{".codex/skills"})
|
||||
nested := filepath.Join(home, ".codex", "skills", "dws", "multi", "dingtalk-chat")
|
||||
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Any accidental return to startup mutation must fail this test before it
|
||||
// can touch the fixture.
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
|
||||
t.Fatal("ordinary command attempted to copy Skills")
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
|
||||
t.Fatal("ordinary command attempted to back up or remove Skills")
|
||||
return "", nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
|
||||
t.Fatal("ordinary command attempted to write Skill state")
|
||||
return nil
|
||||
})
|
||||
|
||||
root := newRootCommandWithEngine(context.Background(), nil, false, true)
|
||||
cmd := &cobra.Command{Use: "version"}
|
||||
cmd.SetContext(context.Background())
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
if err := root.PersistentPreRunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
warning := stderr.String()
|
||||
if !strings.Contains(warning, "dws skill setup --mode multi") {
|
||||
t.Fatalf("safe migration hint missing: %q", warning)
|
||||
}
|
||||
if strings.Contains(warning, "--yes") {
|
||||
t.Fatalf("migration hint bypasses confirmation: %q", warning)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
|
||||
if err != nil || string(data) != "old nested" {
|
||||
t.Fatalf("ordinary command changed nested Skill: data=%q err=%v", data, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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 (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
const maxTelemetryErrorRunes = 200
|
||||
|
||||
var (
|
||||
telemetryUnknownFlagPattern = regexp.MustCompile(`(?i)unknown flag:\s*(--[a-z0-9][a-z0-9-]*)`)
|
||||
telemetryAuthPattern = regexp.MustCompile(`(?i)\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+`)
|
||||
telemetrySensitiveFlag = regexp.MustCompile(`(?i)(--(?:access-token|refresh-token|token|client-secret|client-id|password|api-key|authorization|cookie|credential|secret))(?:=|\s+)\S+`)
|
||||
telemetrySensitiveValue = regexp.MustCompile(`(?i)\b(authorization|client[-_]?secret|client[-_]?id|access[-_]?token|refresh[-_]?token|api[-_]?key|password|cookie|credential|secret|token)\b\s*[:=]\s*[^\s,;]+`)
|
||||
telemetryURLPattern = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s]+`)
|
||||
telemetryJSONPattern = regexp.MustCompile(`(?s)[\[{].*[\]}]`)
|
||||
telemetryUnixPathPattern = regexp.MustCompile(`(^|[\s=:])(?:~/|/)[^\s]+`)
|
||||
telemetryWindowsPathPattern = regexp.MustCompile(`(?i)(^|[\s=])[a-z]:[\\/][^\s]+`)
|
||||
telemetryRelativePathPattern = regexp.MustCompile(`(^|[\s=:])\.\.?/[^\s]+`)
|
||||
telemetryEmailPattern = regexp.MustCompile(`(?i)\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b`)
|
||||
telemetryPhonePattern = regexp.MustCompile(`\b\+?\d[\d -]{7,}\d\b`)
|
||||
telemetryOpaqueTokenPattern = regexp.MustCompile(`\b[a-zA-Z0-9_-]{16,}\b`)
|
||||
)
|
||||
|
||||
func telemetryErrorSummary(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var patError *apperrors.PATError
|
||||
if stderrors.As(err, &patError) {
|
||||
return "permission error"
|
||||
}
|
||||
var rawError apperrors.RawStderrError
|
||||
if stderrors.As(err, &rawError) {
|
||||
return "raw stderr error"
|
||||
}
|
||||
if isUnknownCommandError(err) {
|
||||
return "unknown command"
|
||||
}
|
||||
if match := telemetryUnknownFlagPattern.FindStringSubmatch(err.Error()); len(match) == 2 {
|
||||
return "unknown flag: " + match[1]
|
||||
}
|
||||
return sanitizeTelemetryErrorText(err.Error())
|
||||
}
|
||||
|
||||
func telemetryPanicMessages(value any) (display, summary string) {
|
||||
return fmt.Sprintf("internal panic: %v", value), "internal panic"
|
||||
}
|
||||
|
||||
func sanitizeTelemetryErrorText(message string) string {
|
||||
message = output.SanitizeForTerminal(message)
|
||||
message = telemetryAuthPattern.ReplaceAllString(message, "<credential>")
|
||||
message = telemetrySensitiveFlag.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetrySensitiveValue.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetryURLPattern.ReplaceAllString(message, "<url>")
|
||||
message = telemetryJSONPattern.ReplaceAllString(message, "<payload>")
|
||||
message = redactTelemetryQuotedText(message)
|
||||
message = telemetryUnixPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryWindowsPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryRelativePathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryEmailPattern.ReplaceAllString(message, "<email>")
|
||||
message = telemetryPhonePattern.ReplaceAllString(message, "<phone>")
|
||||
message = telemetryOpaqueTokenPattern.ReplaceAllStringFunc(message, func(value string) string {
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
hasLetter = hasLetter || unicode.IsLetter(r)
|
||||
hasDigit = hasDigit || unicode.IsDigit(r)
|
||||
}
|
||||
if hasLetter && hasDigit {
|
||||
return "<id>"
|
||||
}
|
||||
return value
|
||||
})
|
||||
message = strings.Join(strings.Fields(message), " ")
|
||||
return truncateTelemetryText(message, maxTelemetryErrorRunes)
|
||||
}
|
||||
|
||||
func redactTelemetryQuotedText(message string) string {
|
||||
var result strings.Builder
|
||||
runes := []rune(message)
|
||||
for index := 0; index < len(runes); {
|
||||
quote := runes[index]
|
||||
if quote != '\'' && quote != '"' && quote != '`' {
|
||||
result.WriteRune(quote)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
result.WriteRune(quote)
|
||||
result.WriteString("<redacted>")
|
||||
index++
|
||||
escaped := false
|
||||
for index < len(runes) {
|
||||
current := runes[index]
|
||||
index++
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if current == '\\' && quote != '`' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if current == quote {
|
||||
result.WriteRune(quote)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func truncateTelemetryText(message string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(message)
|
||||
if len(runes) <= maxRunes {
|
||||
return message
|
||||
}
|
||||
if maxRunes <= 3 {
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
return string(runes[:maxRunes-3]) + "..."
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
type telemetryRawError string
|
||||
|
||||
func (e telemetryRawError) Error() string { return string(e) }
|
||||
func (e telemetryRawError) RawStderr() string { return string(e) }
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorSummaryFixedFamilies(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "nil", want: ""},
|
||||
{name: "PAT", err: &apperrors.PATError{RawJSON: `{"token":"secret"}`}, want: "permission error"},
|
||||
{name: "raw stderr", err: telemetryRawError("raw secret"), want: "raw stderr error"},
|
||||
{name: "unknown command", err: errors.New(`unknown command "secret-value" for "dws"`), want: "unknown command"},
|
||||
{name: "unknown flag", err: errors.New("unknown flag: --token=secret-value"), want: "unknown flag: --token"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := telemetryErrorSummary(tc.err); got != tc.want {
|
||||
t.Fatalf("telemetryErrorSummary() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSanitizeTelemetryErrorText(t *testing.T) {
|
||||
message := "\x1b[31mfailed\x1b[0m " +
|
||||
"--client-secret very-secret " +
|
||||
"--access-token access-secret " +
|
||||
"Authorization: Bearer abcdefghijklmnop1234 " +
|
||||
"url=https://example.test/path?token=secret " +
|
||||
`body={"access_token":"secret"} ` +
|
||||
`user="Alice" email=alice@example.test phone=13800138000 ` +
|
||||
"path=/Users/alice/private.txt relative=./private/secrets.txt id=abcDEF1234567890XYZ"
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
for _, secret := range []string{
|
||||
"very-secret", "access-secret", "abcdefghijklmnop1234", "example.test", "access_token",
|
||||
"Alice", "alice@example.test", "13800138000", "/Users/alice", "abcDEF1234567890XYZ", "\x1b",
|
||||
"./private/secrets.txt",
|
||||
} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("sanitized telemetry error leaked %q: %q", secret, got)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"failed", "<redacted>", "<url>", "<payload>", "<path>", "<id>"} {
|
||||
if !strings.Contains(got, marker) {
|
||||
t.Fatalf("sanitized telemetry error missing %q: %q", marker, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorTruncationAndPanic(t *testing.T) {
|
||||
message := strings.Repeat("错", maxTelemetryErrorRunes+1)
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
if len([]rune(got)) != maxTelemetryErrorRunes || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("truncated telemetry error rune length = %d suffix = %q", len([]rune(got)), got[len(got)-3:])
|
||||
}
|
||||
display, summary := telemetryPanicMessages("token-secret")
|
||||
if display != "internal panic: token-secret" || summary != "internal panic" || strings.Contains(summary, "token-secret") {
|
||||
t.Fatalf("panic messages = display %q summary %q", display, summary)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 0); got != "" {
|
||||
t.Fatalf("zero-limit truncation = %q", got)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 3); got != "val" {
|
||||
t.Fatalf("short-limit truncation = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorEscapesAndOpaqueWords(t *testing.T) {
|
||||
const opaqueWord = "abcdefghijklmnop"
|
||||
got := sanitizeTelemetryErrorText(`failed "quoted \"value" ` + opaqueWord)
|
||||
if strings.Contains(got, "value") || !strings.Contains(got, opaqueWord) {
|
||||
t.Fatalf("escaped quote sanitization = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// TelemetryIdentity is the privacy-reviewed subset of the local authentication
|
||||
// record that may be attached to a CLI execution event.
|
||||
type TelemetryIdentity struct {
|
||||
UserID string
|
||||
UserName string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
var telemetryResolveProfileMetadata = authpkg.ResolveProfileMetadataReadOnly
|
||||
|
||||
// ResolveTelemetryIdentity returns a pre-execution snapshot of the identity
|
||||
// selected by args. Multi-profile executions are attributed to the current
|
||||
// default profile. Resolution is deliberately best-effort: telemetry must not
|
||||
// refresh credentials or change command behavior when local auth data is
|
||||
// missing, invalid, or unreadable.
|
||||
func ResolveTelemetryIdentity(args []string) (identity TelemetryIdentity) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
identity = TelemetryIdentity{}
|
||||
}
|
||||
}()
|
||||
|
||||
selector, specified, valid := preparseProfileSelection(args)
|
||||
if specified && !valid {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
profile, err := resolveTelemetryProfileMetadata(defaultConfigDir(), selector)
|
||||
if err != nil || profile == nil {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
return TelemetryIdentity{
|
||||
UserID: strings.TrimSpace(profile.UserID),
|
||||
UserName: strings.TrimSpace(profile.UserName),
|
||||
CorpID: strings.TrimSpace(profile.CorpID),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTelemetryProfileMetadata(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" || !strings.Contains(selector, ",") {
|
||||
return telemetryResolveProfileMetadata(configDir, selector)
|
||||
}
|
||||
|
||||
// A local profile name may itself contain a comma. Match the runtime
|
||||
// resolver by trying the full selector before interpreting it as CSV.
|
||||
if profile, err := telemetryResolveProfileMetadata(configDir, selector); err == nil && profile != nil {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(selector, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil, fmt.Errorf("--profile contains an empty profile selector: %q", selector)
|
||||
}
|
||||
profile, err := telemetryResolveProfileMetadata(configDir, part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", part)
|
||||
}
|
||||
}
|
||||
return telemetryResolveProfileMetadata(configDir, "")
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityProfileSelection(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
profiles := map[string]*authpkg.ProfileMetadata{
|
||||
"": {UserID: " default-user ", UserName: " Default User ", CorpID: " default-corp "},
|
||||
"corp-a": {UserID: "user-a", UserName: "Alice", CorpID: "corp-a"},
|
||||
"corp-b": {UserID: "user-b", UserName: "Bob", CorpID: "corp-b"},
|
||||
"alpha,beta": {UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"},
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
profiles map[string]*authpkg.ProfileMetadata
|
||||
wantCalls []string
|
||||
want TelemetryIdentity
|
||||
}{
|
||||
{name: "default profile", args: []string{"version"}, profiles: profiles, wantCalls: []string{""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "single profile", args: []string{"--profile", "corp-a", "version"}, profiles: profiles, wantCalls: []string{"corp-a"}, want: TelemetryIdentity{UserID: "user-a", UserName: "Alice", CorpID: "corp-a"}},
|
||||
{name: "equals form after command", args: []string{"version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "last repeated profile", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "comma profile name", args: []string{"--profile", "alpha,beta", "version"}, profiles: profiles, wantCalls: []string{"alpha,beta"}, want: TelemetryIdentity{UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"}},
|
||||
{name: "multi profile uses default", args: []string{"--profile", "corp-a,corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "unquoted multi profile", args: []string{"--profile", "corp-a,", "corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var calls []string
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if configDir != "/telemetry-config" {
|
||||
t.Fatalf("resolver config dir = %q", configDir)
|
||||
}
|
||||
calls = append(calls, selector)
|
||||
profile := tc.profiles[selector]
|
||||
if profile == nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
clone := *profile
|
||||
return &clone, nil
|
||||
})
|
||||
|
||||
if got := ResolveTelemetryIdentity(tc.args); got != tc.want {
|
||||
t.Fatalf("ResolveTelemetryIdentity() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
if !reflect.DeepEqual(calls, tc.wantCalls) {
|
||||
t.Fatalf("metadata selectors = %#v, want %#v", calls, tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsMissingProfileValue(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"version", "--profile"},
|
||||
{"--profile=corp-a", "version", "--profile="},
|
||||
{"--profile", "--debug", "version"},
|
||||
} {
|
||||
t.Run(strings.Join(args, "_"), func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(string, string) (*authpkg.ProfileMetadata, error) {
|
||||
t.Fatal("invalid profile syntax attempted metadata resolution")
|
||||
return nil, nil
|
||||
})
|
||||
if got := ResolveTelemetryIdentity(args); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid profile identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityFailsClosed(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
fail := errors.New("metadata unavailable")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolve func(string, string) (*authpkg.ProfileMetadata, error)
|
||||
}{
|
||||
{name: "read error", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, fail }},
|
||||
{name: "missing profile", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, nil }},
|
||||
{name: "empty fields", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return &authpkg.ProfileMetadata{}, nil }},
|
||||
{name: "resolver panic", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { panic("metadata failure") }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, tc.resolve)
|
||||
if got := ResolveTelemetryIdentity(nil); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("failed-closed identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsInvalidMultiProfile(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if selector == "corp-a" {
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a", CorpID: "corp-a"}, nil
|
||||
}
|
||||
return nil, errors.New("profile not found")
|
||||
})
|
||||
if got := ResolveTelemetryIdentity([]string{"--profile", "corp-a,missing", "version"}); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid multi-profile identity = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryProfileMetadataRejectsMalformedMulti(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
switch selector {
|
||||
case "corp-a,,corp-b":
|
||||
return nil, errors.New("not a literal profile")
|
||||
case "corp-a":
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a"}, nil
|
||||
case "missing":
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, errors.New("unexpected selector")
|
||||
}
|
||||
})
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,,corp-b"); err == nil || !strings.Contains(err.Error(), "empty profile selector") {
|
||||
t.Fatalf("empty multi-profile selector error = %v", err)
|
||||
}
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,missing"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("missing multi-profile selector error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,10 @@ func TestMain(m *testing.M) {
|
||||
// remove a TempDir while the audit lock is still open.
|
||||
setupAuditSink()
|
||||
openBrowserFunc = func(string) error { return nil }
|
||||
// App tests may run in filtered CI processes. Install the same lazy Schema
|
||||
// source factory as NewRootCommand without constructing a root so ResolveMeta
|
||||
// tests never depend on an earlier test having initialized process state.
|
||||
registerSchemaRuntimeDelivery()
|
||||
code := m.Run()
|
||||
StopAllStdioClients()
|
||||
CloseAuditSink()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type agentMetadataHeaderRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f agentMetadataHeaderRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthRequestsExcludeAgentMetadataHeaders(t *testing.T) {
|
||||
t.Setenv("DWS_AGENT_VER", "1.2.3-test")
|
||||
t.Setenv("DWS_AGENT_EXT", `{"umt":"test-umt","miniwua":"test-wua","ua":"test-agent"}`)
|
||||
|
||||
assertExcluded := func(t *testing.T, header http.Header) {
|
||||
t.Helper()
|
||||
for _, name := range []string{"x-dws-agent-ver", "x-dws-agent-ext"} {
|
||||
if values := header.Values(name); len(values) != 0 {
|
||||
t.Fatalf("OAuth request header %q = %q, want absent", name, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newCaptureClient := func(responseBody string) (*http.Client, <-chan http.Header) {
|
||||
seen := make(chan http.Header, 1)
|
||||
client := &http.Client{Transport: agentMetadataHeaderRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
seen <- req.Header.Clone()
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": {"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(responseBody)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
return client, seen
|
||||
}
|
||||
|
||||
t.Run("JSON token request", func(t *testing.T) {
|
||||
client, seen := newCaptureClient(`{}`)
|
||||
provider := &OAuthProvider{httpClient: client}
|
||||
if _, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{
|
||||
"code": "test-code",
|
||||
"grantType": "authorization_code",
|
||||
}); err != nil {
|
||||
t.Fatalf("postJSON() error = %v", err)
|
||||
}
|
||||
assertExcluded(t, <-seen)
|
||||
})
|
||||
|
||||
t.Run("device code request", func(t *testing.T) {
|
||||
client, seen := newCaptureClient(`{"success":true,"result":{"deviceCode":"test-device-code","userCode":"TEST-CODE","verificationUri":"https://example.test/device","expiresIn":900,"interval":1}}`)
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.clientID = "test-client-id"
|
||||
provider.httpClient = client
|
||||
provider.SetBaseURL("https://oauth.test")
|
||||
if _, err := provider.requestDeviceCode(context.Background()); err != nil {
|
||||
t.Fatalf("requestDeviceCode() error = %v", err)
|
||||
}
|
||||
assertExcluded(t, <-seen)
|
||||
})
|
||||
}
|
||||
@@ -424,6 +424,87 @@ func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBuildAuthURLForInternationalRegion(t *testing.T) {
|
||||
authURL := buildAuthURLForRegion("client-id", "http://127.0.0.1:1234/callback", "", LoginRegionInternational)
|
||||
if !strings.HasPrefix(authURL, InternationalAuthorizeURL+"?") {
|
||||
t.Fatalf("auth URL = %s, want international authorize host", authURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) {
|
||||
if !strings.Contains(notEnabledHTML, "status.authorizeUrl") {
|
||||
t.Fatal("not-enabled page must read the authorize URL from the regional login status")
|
||||
}
|
||||
if strings.Contains(notEnabledHTML, `"https://login.dingtalk.com/oauth2/auth?client_id="`) {
|
||||
t.Fatal("not-enabled page must not hard-code the domestic authorize URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoginRegionEndpointDefaults(t *testing.T) {
|
||||
if got := AuthorizeURLForLoginRegion(LoginRegionDefault); got != AuthorizeURL {
|
||||
t.Fatalf("default authorize URL = %q, want %q", got, AuthorizeURL)
|
||||
}
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionDefault); got != DefaultDeviceBaseURL {
|
||||
t.Fatalf("default device base URL = %q, want %q", got, DefaultDeviceBaseURL)
|
||||
}
|
||||
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != InternationalUserAccessTokenURL {
|
||||
t.Fatalf("international user access token URL = %q, want %q", got, InternationalUserAccessTokenURL)
|
||||
}
|
||||
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != InternationalMCPBaseURL {
|
||||
t.Fatalf("international MCP base URL = %q, want %q", got, InternationalMCPBaseURL)
|
||||
}
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != InternationalDeviceBaseURL {
|
||||
t.Fatalf("international device base URL = %q, want %q", got, InternationalDeviceBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthProviderLoginRegionHelpers(t *testing.T) {
|
||||
var nilProvider *OAuthProvider
|
||||
if got := nilProvider.loginRegion(); got != LoginRegionDefault {
|
||||
t.Fatalf("nil provider login region = %q", got)
|
||||
}
|
||||
nilProvider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
|
||||
nilProvider.applyLoginRegionToToken(&TokenData{})
|
||||
|
||||
provider := &OAuthProvider{}
|
||||
provider.useTokenLoginRegion(nil)
|
||||
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
|
||||
if provider.LoginRegion != LoginRegionInternational {
|
||||
t.Fatalf("provider login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionDefault)})
|
||||
provider.applyLoginRegionToToken(nil)
|
||||
token := &TokenData{}
|
||||
provider.applyLoginRegionToToken(token)
|
||||
if token.LoginRegion != string(LoginRegionInternational) {
|
||||
t.Fatalf("token login region = %q, want international", token.LoginRegion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
|
||||
restore := PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io/")
|
||||
defer restore()
|
||||
|
||||
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-mcp.dingtalk.io" {
|
||||
t.Fatalf("international MCP base URL = %q, want override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
|
||||
restore := PushLoginBaseURLOverride("https://pre-login.dingtalk.io/")
|
||||
defer restore()
|
||||
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io" {
|
||||
t.Fatalf("international device base URL = %q, want override", got)
|
||||
}
|
||||
if got := AuthorizeURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/oauth2/auth" {
|
||||
t.Fatalf("international authorize URL = %q, want override", got)
|
||||
}
|
||||
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/v1.0/oauth2/userAccessToken" {
|
||||
t.Fatalf("international user access token URL = %q, want override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
|
||||
if resp.AccessToken == "" {
|
||||
return nil
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -206,6 +208,105 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
|
||||
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCheckCLIAuthEnabledTraceHeadersAndDebugLog(t *testing.T) {
|
||||
t.Setenv("DINGTALK_TRACE_ID", "trace-for-cli-auth-test")
|
||||
applyCLIAuthTraceHeaders(nil, "trace-for-cli-auth-test")
|
||||
applyCLIAuthTraceHeaders(httptest.NewRequest(http.MethodGet, "https://example.com", nil), "")
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer slog.SetDefault(previousLogger)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("EagleEye-TraceId"); got != "trace-for-cli-auth-test" {
|
||||
t.Errorf("EagleEye-TraceId = %q, want trace-for-cli-auth-test", got)
|
||||
}
|
||||
if got := r.Header.Get("X-Dingtalk-Trace-Id"); got != "trace-for-cli-auth-test" {
|
||||
t.Errorf("X-Dingtalk-Trace-Id = %q, want trace-for-cli-auth-test", got)
|
||||
}
|
||||
w.Header().Set("EagleEye-TraceId", "server-trace-001")
|
||||
w.Header().Set("EagleEye-RpcId", "rpc-001")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "sensitive-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Result == nil || !status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=true")
|
||||
}
|
||||
|
||||
logs := logBuf.String()
|
||||
for _, want := range []string{
|
||||
"auth.cli_auth_enabled.request",
|
||||
"auth.cli_auth_enabled.response",
|
||||
"trace-for-cli-auth-test",
|
||||
"server-trace-001",
|
||||
"rpc-001",
|
||||
} {
|
||||
if !strings.Contains(logs, want) {
|
||||
t.Fatalf("debug logs missing %q, got: %s", want, logs)
|
||||
}
|
||||
}
|
||||
if strings.Contains(logs, "sensitive-token") {
|
||||
t.Fatalf("debug logs leaked access token: %s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageInternationalLoginRegionRequestWrappers(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case ClientIDPath:
|
||||
_ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "international-client"})
|
||||
case SuperAdminPath:
|
||||
_ = json.NewEncoder(w).Encode(SuperAdminResponse{Success: true, Result: []SuperAdmin{{StaffID: "admin"}}})
|
||||
case SendCliAuthApplyPath:
|
||||
_ = json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldClient := oauthHTTPClient
|
||||
oauthHTTPClient = server.Client()
|
||||
restoreBaseURL := PushMCPBaseURLOverride(server.URL)
|
||||
t.Cleanup(func() {
|
||||
restoreBaseURL()
|
||||
oauthHTTPClient = oldClient
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
for name, fetch := range map[string]func() (string, error){
|
||||
"device": func() (string, error) { return deviceFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
|
||||
"oauth": func() (string, error) { return oauthFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
|
||||
} {
|
||||
if got, err := fetch(); err != nil || got != "international-client" {
|
||||
t.Fatalf("%s client ID = %q, %v", name, got, err)
|
||||
}
|
||||
}
|
||||
if got, err := deviceGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("device admins = %#v, %v", got, err)
|
||||
}
|
||||
if got, err := oauthGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("OAuth admins = %#v, %v", got, err)
|
||||
}
|
||||
if got, err := oauthSendApplyForLoginRegion(ctx, "token", "admin", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("OAuth apply = %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -1211,7 +1212,13 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
|
||||
if err := os.Symlink(filepath.Join(keyDir, "dek"), filepath.Join(keyDir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, value := range map[string]string{"app.json": "{}", profilesJSONFile: "{}", "mcp_url": "https://mcp.test", "terminal_url": "https://terminal.test"} {
|
||||
for name, value := range map[string]string{
|
||||
"app.json": "{}",
|
||||
profilesJSONFile: "{}",
|
||||
"mcp_url": "https://mcp.test",
|
||||
config.ManagedMCPURLRegionFileName: "https://mcp.test",
|
||||
"terminal_url": "https://terminal.test",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(configDir, name), []byte(value), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1234,6 +1241,9 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(importDir, "app.json")); err != nil {
|
||||
t.Fatal("config file was not imported")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(importDir, config.ManagedMCPURLRegionFileName)); err != nil {
|
||||
t.Fatal("managed MCP region marker was not imported")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")); err != nil {
|
||||
t.Fatal("encrypted token was not imported")
|
||||
}
|
||||
|
||||
@@ -74,6 +74,20 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
func deviceFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
if region.IsInternational() {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
}
|
||||
return deviceFetchClientID(ctx)
|
||||
}
|
||||
|
||||
func deviceGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
}
|
||||
return deviceGetAdmins(ctx, accessToken)
|
||||
}
|
||||
|
||||
type DeviceFlowProvider struct {
|
||||
configDir string
|
||||
clientID string
|
||||
@@ -85,6 +99,7 @@ type DeviceFlowProvider struct {
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
IdentityEnricher func(context.Context, *TokenData) error
|
||||
LoginRegion LoginRegion
|
||||
}
|
||||
|
||||
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
|
||||
@@ -104,6 +119,12 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) {
|
||||
p.baseURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) SetLoginRegion(region LoginRegion) {
|
||||
p.LoginRegion = region
|
||||
p.baseURL = DeviceBaseURLForLoginRegion(region)
|
||||
p.terminalBaseURL = MCPBaseURLForLoginRegion(region)
|
||||
}
|
||||
|
||||
// SetTerminalBaseURL sets the terminal API base URL for device flow polling.
|
||||
func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) {
|
||||
p.terminalBaseURL = strings.TrimRight(baseURL, "/")
|
||||
@@ -213,7 +234,7 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := deviceFetchClientID(ctx)
|
||||
mcpClientID, mcpErr := deviceFetchClientIDForLoginRegion(ctx, p.LoginRegion)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
@@ -272,6 +293,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
clientID: p.clientID,
|
||||
logger: p.logger,
|
||||
IdentityEnricher: p.IdentityEnricher,
|
||||
LoginRegion: p.LoginRegion,
|
||||
}
|
||||
tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode)
|
||||
if err != nil {
|
||||
@@ -331,7 +353,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
|
||||
admins, adminErr := deviceGetAdmins(ctx, tokenData.AccessToken)
|
||||
admins, adminErr := deviceGetAdminsForLoginRegion(ctx, tokenData.AccessToken, p.LoginRegion)
|
||||
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
|
||||
maxAdmins := 3
|
||||
if len(admins.Result) < maxAdmins {
|
||||
|
||||
@@ -90,6 +90,17 @@ func TestRequestDeviceCodeSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDeviceFlowSetLoginRegionUsesInternationalBaseURL(t *testing.T) {
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.SetLoginRegion(LoginRegionInternational)
|
||||
if provider.baseURL != InternationalDeviceBaseURL {
|
||||
t.Fatalf("baseURL = %q, want %q", provider.baseURL, InternationalDeviceBaseURL)
|
||||
}
|
||||
if provider.terminalBaseURL != InternationalMCPBaseURL {
|
||||
t.Fatalf("terminalBaseURL = %q, want %q", provider.terminalBaseURL, InternationalMCPBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+112
-5
@@ -50,9 +50,15 @@ const (
|
||||
// AuthorizeURL is the DingTalk OAuth authorization page.
|
||||
AuthorizeURL = "https://login.dingtalk.com/oauth2/auth"
|
||||
|
||||
// InternationalAuthorizeURL is the DingTalk international OAuth authorization page.
|
||||
InternationalAuthorizeURL = "https://login.dingtalk.io/oauth2/auth"
|
||||
|
||||
// UserAccessTokenURL exchanges an authorization code for user tokens.
|
||||
UserAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"
|
||||
|
||||
// InternationalUserAccessTokenURL exchanges authorization codes for international user tokens.
|
||||
InternationalUserAccessTokenURL = "https://api.dingtalk.io/v1.0/oauth2/userAccessToken"
|
||||
|
||||
// UserInfoURL fetches the authenticated user's profile.
|
||||
UserInfoURL = "https://api.dingtalk.com/v1.0/contact/users/me"
|
||||
|
||||
@@ -75,6 +81,9 @@ const (
|
||||
// DefaultDeviceBaseURL is the login server base URL for device flow.
|
||||
DefaultDeviceBaseURL = "https://login.dingtalk.com"
|
||||
|
||||
// InternationalDeviceBaseURL is the international login server base URL for device flow.
|
||||
InternationalDeviceBaseURL = "https://login.dingtalk.io"
|
||||
|
||||
// DeviceCodePath requests a device_code and user_code.
|
||||
DeviceCodePath = "/oauth2/device/code.json"
|
||||
|
||||
@@ -96,11 +105,12 @@ const (
|
||||
LogoutContinueURL = "https://login.dingtalk.com"
|
||||
|
||||
// MCP API endpoints for CLI authorization management.
|
||||
DefaultMCPBaseURL = config.DefaultMCPBaseURL
|
||||
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
|
||||
SuperAdminPath = "/cli/superAdmin"
|
||||
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
|
||||
ClientIDPath = "/cli/clientId"
|
||||
DefaultMCPBaseURL = config.DefaultMCPBaseURL
|
||||
InternationalMCPBaseURL = "https://mcp.dingtalk.io"
|
||||
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
|
||||
SuperAdminPath = "/cli/superAdmin"
|
||||
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
|
||||
ClientIDPath = "/cli/clientId"
|
||||
|
||||
// MCP OAuth endpoints (used when clientId is fetched from MCP).
|
||||
MCPOAuthTokenPath = "/oauth2/getToken"
|
||||
@@ -114,6 +124,57 @@ const (
|
||||
AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
||||
)
|
||||
|
||||
type LoginRegion string
|
||||
|
||||
const (
|
||||
LoginRegionDefault LoginRegion = ""
|
||||
LoginRegionInternational LoginRegion = "international"
|
||||
)
|
||||
|
||||
func (r LoginRegion) IsInternational() bool {
|
||||
return r == LoginRegionInternational
|
||||
}
|
||||
|
||||
func AuthorizeURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override + "/oauth2/auth"
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalAuthorizeURL
|
||||
}
|
||||
return AuthorizeURL
|
||||
}
|
||||
|
||||
func DeviceBaseURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalDeviceBaseURL
|
||||
}
|
||||
return DefaultDeviceBaseURL
|
||||
}
|
||||
|
||||
func MCPBaseURLForLoginRegion(region LoginRegion) string {
|
||||
if override := MCPBaseURLOverride(); override != "" {
|
||||
return override
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalMCPBaseURL
|
||||
}
|
||||
return GetMCPBaseURL()
|
||||
}
|
||||
|
||||
func UserAccessTokenURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override + "/v1.0/oauth2/userAccessToken"
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalUserAccessTokenURL
|
||||
}
|
||||
return UserAccessTokenURL
|
||||
}
|
||||
|
||||
// GetTerminalBaseURL returns the terminal base URL with priority:
|
||||
// 1. ~/.dws/terminal_url file content (for pre-release environment)
|
||||
// 2. Default value (https://open-dev.dingtalk.com)
|
||||
@@ -146,8 +207,54 @@ var (
|
||||
// clientIDFromMCP indicates whether the clientID was fetched from MCP server.
|
||||
// When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
|
||||
clientIDFromMCP bool
|
||||
|
||||
loginBaseURLMu sync.RWMutex
|
||||
loginBaseURLOverride string
|
||||
mcpBaseURLMu sync.RWMutex
|
||||
mcpBaseURLOverride string
|
||||
)
|
||||
|
||||
// PushLoginBaseURLOverride sets a process-local DingTalk login base URL
|
||||
// override and returns a restore function.
|
||||
func PushLoginBaseURLOverride(baseURL string) func() {
|
||||
loginBaseURLMu.Lock()
|
||||
previous := loginBaseURLOverride
|
||||
loginBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
loginBaseURLMu.Unlock()
|
||||
return func() {
|
||||
loginBaseURLMu.Lock()
|
||||
loginBaseURLOverride = previous
|
||||
loginBaseURLMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func LoginBaseURLOverride() string {
|
||||
loginBaseURLMu.RLock()
|
||||
defer loginBaseURLMu.RUnlock()
|
||||
return loginBaseURLOverride
|
||||
}
|
||||
|
||||
// PushMCPBaseURLOverride sets a process-local MCP base URL override and returns
|
||||
// a restore function. It is intended for one command invocation, such as
|
||||
// pre-release smoke testing.
|
||||
func PushMCPBaseURLOverride(baseURL string) func() {
|
||||
mcpBaseURLMu.Lock()
|
||||
previous := mcpBaseURLOverride
|
||||
mcpBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
mcpBaseURLMu.Unlock()
|
||||
return func() {
|
||||
mcpBaseURLMu.Lock()
|
||||
mcpBaseURLOverride = previous
|
||||
mcpBaseURLMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func MCPBaseURLOverride() string {
|
||||
mcpBaseURLMu.RLock()
|
||||
defer mcpBaseURLMu.RUnlock()
|
||||
return mcpBaseURLOverride
|
||||
}
|
||||
|
||||
// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
|
||||
func SetClientIDFromMCP(id string) {
|
||||
clientMu.Lock()
|
||||
|
||||
+132
-15
@@ -20,6 +20,7 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -51,7 +53,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
|
||||
"code": code,
|
||||
"grantType": "authorization_code",
|
||||
}
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURL, body)
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -62,6 +64,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
|
||||
// Snapshot credentials used for this token (for refresh)
|
||||
data.ClientID = clientID
|
||||
data.Source = resolveCredentialSource()
|
||||
p.applyLoginRegionToToken(data)
|
||||
// Save clientSecret for future refresh (even if env changes)
|
||||
if err := oauthSaveClientSecret(clientID, clientSecret); err != nil {
|
||||
// Log warning but don't fail login
|
||||
@@ -91,11 +94,36 @@ func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenDa
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) loginRegion() LoginRegion {
|
||||
if p == nil {
|
||||
return LoginRegionDefault
|
||||
}
|
||||
return p.LoginRegion
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) useTokenLoginRegion(data *TokenData) {
|
||||
if p == nil || p.LoginRegion != LoginRegionDefault || data == nil {
|
||||
return
|
||||
}
|
||||
if region := LoginRegion(strings.TrimSpace(data.LoginRegion)); region != LoginRegionDefault {
|
||||
p.LoginRegion = region
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) applyLoginRegionToToken(data *TokenData) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
if region := p.loginRegion(); region != LoginRegionDefault {
|
||||
data.LoginRegion = string(region)
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
|
||||
// This is used when client secret is not available (server-side secret management).
|
||||
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
|
||||
clientID := ClientID()
|
||||
url := GetMCPBaseURL() + MCPOAuthTokenPath
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPOAuthTokenPath
|
||||
body := map[string]string{
|
||||
"clientId": clientID,
|
||||
"authCode": code,
|
||||
@@ -112,6 +140,7 @@ func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*T
|
||||
// Snapshot credentials used for this token (for refresh)
|
||||
data.ClientID = clientID
|
||||
data.Source = "mcp"
|
||||
p.applyLoginRegionToToken(data)
|
||||
// MCP mode doesn't need to save clientSecret (server-side managed)
|
||||
return data, nil
|
||||
}
|
||||
@@ -120,8 +149,10 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
// Use stored Source to determine refresh path (not current runtime state)
|
||||
// This ensures refresh works even if environment variables changed since login
|
||||
if data.Source == "mcp" {
|
||||
p.useTokenLoginRegion(data)
|
||||
return p.refreshViaMCP(ctx, data)
|
||||
}
|
||||
p.useTokenLoginRegion(data)
|
||||
|
||||
// Direct mode: use stored clientId and load saved clientSecret
|
||||
clientID := data.ClientID
|
||||
@@ -145,7 +176,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
"refreshToken": data.RefreshToken,
|
||||
"grantType": "refresh_token",
|
||||
}
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURL, body)
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -156,6 +187,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
// Preserve original credentials info
|
||||
updated.ClientID = data.ClientID
|
||||
updated.Source = data.Source
|
||||
updated.LoginRegion = data.LoginRegion
|
||||
updated.PersistentCode = data.PersistentCode
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
@@ -185,7 +217,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录")
|
||||
}
|
||||
|
||||
url := GetMCPBaseURL() + MCPRefreshTokenPath
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPRefreshTokenPath
|
||||
body := map[string]string{
|
||||
"clientId": clientID,
|
||||
"refreshToken": data.RefreshToken,
|
||||
@@ -202,6 +234,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
// Preserve original credentials info
|
||||
updated.ClientID = data.ClientID
|
||||
updated.Source = data.Source
|
||||
updated.LoginRegion = data.LoginRegion
|
||||
updated.PersistentCode = data.PersistentCode
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
@@ -371,6 +404,10 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
|
||||
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
||||
return buildAuthURLForRegion(clientID, redirectURI, targetCorpID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func buildAuthURLForRegion(clientID, redirectURI, targetCorpID string, region LoginRegion) string {
|
||||
params := url.Values{
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
@@ -381,7 +418,7 @@ func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
||||
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
|
||||
params.Set("corpId", targetCorpID)
|
||||
}
|
||||
return AuthorizeURL + "?" + params.Encode()
|
||||
return AuthorizeURLForLoginRegion(region) + "?" + params.Encode()
|
||||
}
|
||||
|
||||
const successHTML = `<!doctype html>
|
||||
@@ -937,14 +974,15 @@ const notEnabledHTML = `<!doctype html>
|
||||
clientId = status.clientId || "";
|
||||
applySent = status.applySent || false;
|
||||
selectedAdminId = status.selectedAdminId || "";
|
||||
const authorizeUrl = status.authorizeUrl || "";
|
||||
|
||||
if (clientId) {
|
||||
if (clientId && authorizeUrl) {
|
||||
const port = location.port;
|
||||
const redirectUri = encodeURIComponent(
|
||||
"http://127.0.0.1:" + port + "/callback"
|
||||
);
|
||||
backLink.href =
|
||||
"https://login.dingtalk.com/oauth2/auth?client_id=" +
|
||||
authorizeUrl + "?client_id=" +
|
||||
clientId +
|
||||
"&prompt=consent&redirect_uri=" +
|
||||
redirectUri +
|
||||
@@ -1398,6 +1436,7 @@ const mcpRequestMaxRetries = 3
|
||||
// false negatives caused by momentary network issues.
|
||||
func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
||||
var lastErr error
|
||||
traceID := cliAuthTraceID()
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
@@ -1406,7 +1445,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
status, err := p.doCheckCLIAuthEnabled(ctx, accessToken)
|
||||
status, err := p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, attempt+1, traceID)
|
||||
if err == nil {
|
||||
return status, nil
|
||||
}
|
||||
@@ -1416,16 +1455,27 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
||||
url := GetMCPBaseURL() + CLIAuthEnabledPath
|
||||
return p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, 1, cliAuthTraceID())
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) doCheckCLIAuthEnabledAttempt(ctx context.Context, accessToken string, attempt int, traceID string) (*CLIAuthStatus, error) {
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + CLIAuthEnabledPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
applyCLIAuthTraceHeaders(req, traceID)
|
||||
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
|
||||
req.Header.Set("x-dws-channel", ch)
|
||||
}
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
slog.Debug("auth.cli_auth_enabled.request",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"trace_id", traceID,
|
||||
"channel", os.Getenv("DWS_CHANNEL"),
|
||||
)
|
||||
|
||||
client := p.httpClient
|
||||
if client == nil {
|
||||
@@ -1433,9 +1483,23 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Debug("auth.cli_auth_enabled.error",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"trace_id", traceID,
|
||||
"error", err,
|
||||
)
|
||||
return nil, fmt.Errorf("sending request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
slog.Debug("auth.cli_auth_enabled.response",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"status", resp.StatusCode,
|
||||
"trace_id", traceID,
|
||||
"response_trace_id", cliAuthResponseTraceID(resp.Header),
|
||||
"eagleeye_rpc_id", resp.Header.Get("EagleEye-RpcId"),
|
||||
)
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
@@ -1449,9 +1513,42 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func cliAuthTraceID() string {
|
||||
if traceID := strings.TrimSpace(os.Getenv("DINGTALK_TRACE_ID")); traceID != "" {
|
||||
return traceID
|
||||
}
|
||||
return strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
|
||||
func applyCLIAuthTraceHeaders(req *http.Request, traceID string) {
|
||||
if req == nil || traceID == "" {
|
||||
return
|
||||
}
|
||||
req.Header.Set("EagleEye-TraceId", traceID)
|
||||
req.Header.Set("X-Dingtalk-Trace-Id", traceID)
|
||||
}
|
||||
|
||||
func cliAuthResponseTraceID(headers http.Header) string {
|
||||
for _, key := range []string{
|
||||
"EagleEye-TraceId",
|
||||
"X-Trace-Id",
|
||||
"X-Request-Id",
|
||||
"X-Dingtalk-Trace-Id",
|
||||
} {
|
||||
if value := strings.TrimSpace(headers.Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetSuperAdmins fetches the list of corp super admins.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func GetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1461,7 +1558,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
result, err := doGetSuperAdmins(ctx, accessToken)
|
||||
result, err := doGetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -1471,7 +1568,11 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
|
||||
}
|
||||
|
||||
func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
||||
url := GetMCPBaseURL() + SuperAdminPath
|
||||
return doGetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doGetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + SuperAdminPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
@@ -1500,6 +1601,10 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo
|
||||
// SendCliAuthApply sends a CLI auth apply request to the specified admin.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
||||
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func SendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1509,7 +1614,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID)
|
||||
result, err := doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -1519,7 +1624,11 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
|
||||
}
|
||||
|
||||
func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
||||
url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
|
||||
return doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doSendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
@@ -1557,6 +1666,10 @@ type ClientIDResponse struct {
|
||||
// This is used when no client ID is provided via flags, config, or env vars.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func FetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1566,7 +1679,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
id, err := doFetchClientIDFromMCP(ctx)
|
||||
id, err := doFetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
@@ -1576,7 +1689,11 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
func doFetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
url := GetMCPBaseURL() + ClientIDPath
|
||||
return doFetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doFetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + ClientIDPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
|
||||
@@ -68,6 +68,27 @@ var (
|
||||
oauthSleep = time.Sleep
|
||||
)
|
||||
|
||||
func oauthFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
if region.IsInternational() {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
}
|
||||
return oauthFetchClientID(ctx)
|
||||
}
|
||||
|
||||
func oauthGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
}
|
||||
return oauthGetAdmins(ctx, accessToken)
|
||||
}
|
||||
|
||||
func oauthSendApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
|
||||
}
|
||||
return oauthSendApply(ctx, accessToken, adminStaffID)
|
||||
}
|
||||
|
||||
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
|
||||
type OAuthProvider struct {
|
||||
configDir string
|
||||
@@ -80,6 +101,7 @@ type OAuthProvider struct {
|
||||
// IdentityEnricher resolves userId/userName/corpName while the freshly
|
||||
// exchanged access token is still only in memory.
|
||||
IdentityEnricher func(context.Context, *TokenData) error
|
||||
LoginRegion LoginRegion
|
||||
}
|
||||
|
||||
// NewOAuthProvider creates a new OAuth provider.
|
||||
@@ -173,7 +195,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := oauthFetchClientID(ctx)
|
||||
mcpClientID, mcpErr := oauthFetchClientIDForLoginRegion(ctx, p.LoginRegion)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
@@ -401,7 +423,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
|
||||
return
|
||||
}
|
||||
result, err := oauthGetAdmins(ctx, token.AccessToken)
|
||||
result, err := oauthGetAdminsForLoginRegion(ctx, token.AccessToken, p.LoginRegion)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
|
||||
return
|
||||
@@ -425,7 +447,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
|
||||
return
|
||||
}
|
||||
result, err := oauthSendApply(ctx, token.AccessToken, adminStaffID)
|
||||
result, err := oauthSendApplyForLoginRegion(ctx, token.AccessToken, adminStaffID, p.LoginRegion)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
|
||||
return
|
||||
@@ -448,7 +470,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
applySent := callbackApplySent
|
||||
selectedAdminId := callbackSelectedAdminId
|
||||
callbackTokenMu.Unlock()
|
||||
_, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId)
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"clientId": p.clientID,
|
||||
"authorizeUrl": AuthorizeURLForLoginRegion(p.LoginRegion),
|
||||
"applySent": applySent,
|
||||
"selectedAdminId": selectedAdminId,
|
||||
})
|
||||
_, _ = w.Write(data)
|
||||
})
|
||||
|
||||
// API endpoint: check CLI auth enabled status
|
||||
@@ -491,7 +519,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_ = server.Shutdown(shutCtx)
|
||||
}()
|
||||
|
||||
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID)
|
||||
authURL := buildAuthURLForRegion(p.clientID, redirectURI, p.TargetCorpID, p.LoginRegion)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("authorization URL", "url", authURL)
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
|
||||
|
||||
func portableConfigFiles(configDir string) ([]string, error) {
|
||||
var files []string
|
||||
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"}
|
||||
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", config.ManagedMCPURLRegionFileName, "terminal_url"}
|
||||
for _, pattern := range patterns {
|
||||
matches, err := portableGlob(filepath.Join(configDir, pattern))
|
||||
if err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user