Compare commits
244
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a55bd9bff8 | ||
|
|
cf8dd167a4 | ||
|
|
1dabfa1dc6 | ||
|
|
d82e12d09e | ||
|
|
30f3273a17 | ||
|
|
3e362fb3d1 | ||
|
|
d40a22aeb0 | ||
|
|
516bd5d99c | ||
|
|
9818f7779a | ||
|
|
65a00b497b | ||
|
|
0e856f5a6e | ||
|
|
b29a12abbf | ||
|
|
e08fb484a8 | ||
|
|
65bedd5f8c | ||
|
|
2df3b99e26 | ||
|
|
21c6581975 | ||
|
|
3e4a3fb9d9 | ||
|
|
1f1c27d68f | ||
|
|
388ae0d37b | ||
|
|
f2a3025f41 | ||
|
|
5282a55a54 | ||
|
|
07c5d25d55 | ||
|
|
1b8ca149cb | ||
|
|
e9bbfdd20c | ||
|
|
59978d9c06 | ||
|
|
1f7d8c16bd | ||
|
|
9c14d9a6e1 | ||
|
|
8c25736f39 | ||
|
|
dacf166935 | ||
|
|
fd26152141 | ||
|
|
a53971b146 | ||
|
|
56bb50913b | ||
|
|
54aefaaf60 | ||
|
|
0a90c0350d | ||
|
|
654b740532 | ||
|
|
6910bda9c7 | ||
|
|
bfd836064d | ||
|
|
f256d7a43c | ||
|
|
488411615f | ||
|
|
01c1428b66 | ||
|
|
f6a699227e | ||
|
|
185fbb1544 | ||
|
|
b6c508acdf | ||
|
|
1d4c51a4d3 | ||
|
|
9472f4a1d9 | ||
|
|
90e27c4b86 | ||
|
|
132dea9aaa | ||
|
|
5034c332fe |
@@ -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,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: 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,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.
|
||||
@@ -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.
|
||||
|
||||
+286
-54
@@ -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';
|
||||
@@ -151,6 +153,12 @@ jobs:
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
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 +170,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 +293,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 +347,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 +360,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 +414,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 +437,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 +449,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 +465,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'
|
||||
@@ -495,7 +565,8 @@ jobs:
|
||||
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 +602,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[@]}"
|
||||
@@ -831,7 +911,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 +926,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 +947,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 +986,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 +1099,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 +1121,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 +1197,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 +1224,7 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- coverage-current
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-darwin
|
||||
@@ -1072,6 +1240,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 +1248,7 @@ jobs:
|
||||
run: |
|
||||
failed=0
|
||||
current_expected=success
|
||||
current_full_expected=skipped
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
native_expected=skipped
|
||||
@@ -1086,6 +1256,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 +1268,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 +1304,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 +1328,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 +1350,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 +1390,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 +1471,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,7 +1498,7 @@ 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 \
|
||||
@@ -1294,25 +1510,41 @@ jobs:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
|
||||
|
||||
- name: Validate trusted main CHANGELOG-only push
|
||||
- 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:
|
||||
|
||||
+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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 层
|
||||
|
||||
+15
-2
@@ -201,8 +201,21 @@ base_ref=$(git merge-base HEAD origin/main)
|
||||
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
|
||||
@@ -50,7 +50,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||
|---|---|---|
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||
|
||||
@@ -122,7 +122,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||
|
||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
|
||||
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
@@ -145,7 +145,7 @@ legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interfa
|
||||
`required` / `cli_required` 或重写 constraint;
|
||||
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
|
||||
`required_when` 必须完全一致;
|
||||
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
|
||||
- `required` / `cli_required` 必须在 rename 前后完全一致,升高或降低都失败;
|
||||
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
|
||||
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
|
||||
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
|
||||
|
||||
@@ -0,0 +1,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+128
-24
@@ -79,6 +79,7 @@ var (
|
||||
rootPluginSyncSkills = plugin.SyncSkills
|
||||
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
|
||||
rootEmitResult = output.EmitResult
|
||||
)
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
@@ -136,6 +137,16 @@ 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:])
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
|
||||
timing := NewTimingCollector()
|
||||
defer func() {
|
||||
rootStopAllStdioClients() // Ensure child processes are terminated on exit
|
||||
@@ -147,6 +158,7 @@ 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()
|
||||
@@ -260,6 +272,70 @@ func Execute() (exitCode int) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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
|
||||
// failure body. Exit code and category are derived from the same error value,
|
||||
// preventing the wire and process status from drifting apart.
|
||||
@@ -633,12 +709,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 +761,29 @@ 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 查看迁移计划并确认")
|
||||
@@ -777,10 +880,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
|
||||
@@ -791,7 +896,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)
|
||||
@@ -1755,17 +1862,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 {
|
||||
@@ -1776,20 +1882,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
|
||||
|
||||
@@ -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"
|
||||
@@ -92,8 +94,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 +371,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 +489,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
|
||||
|
||||
+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{
|
||||
|
||||
@@ -207,7 +207,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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -97,6 +97,7 @@ type TokenData struct {
|
||||
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
LoginRegion string `json:"login_region,omitempty"`
|
||||
// LegacyOrgScopedProfile is an in-memory destination for an explicitly
|
||||
// matched historical profile whose userId was never resolved. It is never
|
||||
// persisted as token material.
|
||||
|
||||
@@ -177,6 +177,7 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
aliasMap := make(map[string]string)
|
||||
blockedSet := make(map[string]bool)
|
||||
excludedSet := make(map[string]bool)
|
||||
claimedRealSet := make(map[string]bool)
|
||||
pendingReview := ov.Confirm || ov.Investigate
|
||||
|
||||
for boundFlag, conceptID := range ov.Bind {
|
||||
@@ -218,6 +219,14 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
for m := range eff {
|
||||
if _, isReal := realByMorph[m]; isReal {
|
||||
claimedRealSet[m] = true
|
||||
}
|
||||
}
|
||||
for _, exclude := range concept.Excludes {
|
||||
excludedSet[cmdutil.Morph(exclude)] = true
|
||||
}
|
||||
visible := distinctRealNames(candidates, true)
|
||||
var canon string
|
||||
switch len(visible) {
|
||||
@@ -267,21 +276,18 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
}
|
||||
aliasMap[m] = canon
|
||||
}
|
||||
// Excludes are not passive prose: once this concept is active on a
|
||||
// reviewed command, a non-real excluded spelling is protected from
|
||||
// downstream fuzzy correction. A real flag is left alone because it
|
||||
// already has an independently valid command-local meaning.
|
||||
for _, exclude := range concept.Excludes {
|
||||
morphed := cmdutil.Morph(exclude)
|
||||
if _, isReal := realByMorph[morphed]; !isReal {
|
||||
excludedSet[morphed] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for excluded := range excludedSet {
|
||||
if _, isAlias := aliasMap[excluded]; !isAlias {
|
||||
blockedSet[excluded] = true
|
||||
if _, isAlias := aliasMap[excluded]; isAlias {
|
||||
continue
|
||||
}
|
||||
if claimedRealSet[excluded] {
|
||||
continue
|
||||
}
|
||||
if _, isReal := realByMorph[excluded]; isReal {
|
||||
continue
|
||||
}
|
||||
blockedSet[excluded] = true
|
||||
}
|
||||
|
||||
// (b) Command scoped aliases override concept reductions.
|
||||
|
||||
+1468
-485
File diff suppressed because it is too large
Load Diff
@@ -237,6 +237,42 @@ func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceLeafParamAliasesBindExcludesRealFlags(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases(
|
||||
"demo cmd",
|
||||
realMap(realFlag{name: "id"}, realFlag{name: "name"}, realFlag{name: "query"}),
|
||||
[]Concept{
|
||||
{ID: "base_id", Members: []string{"base-id", "base-token"}, Excludes: []string{"keyword", "name", "query", "unsafe"}},
|
||||
{ID: "query", Members: []string{"query", "keyword"}},
|
||||
},
|
||||
CommandOverride{Bind: map[string]string{"id": "base_id"}},
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("reduceLeafParamAliases() problems = %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("reduceLeafParamAliases() entry = nil")
|
||||
}
|
||||
if entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" {
|
||||
t.Fatalf("bound aliases = %#v, want base-id/base-token -> id", entry.Aliases)
|
||||
}
|
||||
if entry.Aliases["keyword"] != "query" {
|
||||
t.Fatalf("query alias = %#v, want keyword -> query", entry.Aliases)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "keyword") {
|
||||
t.Fatalf("excluded alias entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "name") {
|
||||
t.Fatalf("real excluded flag entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "query") {
|
||||
t.Fatalf("claimed real excluded flag entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if !containsParamAlias(entry.Blocked, "unsafe") {
|
||||
t.Fatalf("non-real excluded flag was not blocked: %#v", entry.Blocked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasEntryLookupMethods(t *testing.T) {
|
||||
entry := ParamAliasEntry{
|
||||
Aliases: map[string]string{"uid": "user"},
|
||||
@@ -457,6 +493,22 @@ func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcep
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesExcludesDoNotBlockRealFlag(t *testing.T) {
|
||||
concepts := []Concept{
|
||||
{ID: "single_id", Members: []string{"id", "item-id"}, Excludes: []string{"item-ids"}},
|
||||
}
|
||||
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}, realFlag{name: "item-ids"}), concepts, CommandOverride{})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("expected a reduced entry")
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "item-ids") {
|
||||
t.Fatalf("real exclude was blocked: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) {
|
||||
real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"})
|
||||
for name, override := range map[string]CommandOverride{
|
||||
@@ -472,6 +524,58 @@ func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedParamAliasesBlockPluralListSpellingsOnSingleIDCommands(t *testing.T) {
|
||||
entries := make(map[string]ParamAliasEntry, len(generatedParamAliases))
|
||||
for _, entry := range generatedParamAliases {
|
||||
entries[entry.CLIPath] = entry
|
||||
}
|
||||
assertBlocked := func(path string, names ...string) {
|
||||
t.Helper()
|
||||
entry, ok := entries[path]
|
||||
if !ok {
|
||||
t.Fatalf("missing generated alias entry for %q", path)
|
||||
}
|
||||
for _, name := range names {
|
||||
if !entry.IsBlocked(cmdutil.Morph(name)) {
|
||||
t.Fatalf("%s: %q not blocked; entry = %#v", path, name, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"chat message add-emoji",
|
||||
"chat message remove-emoji",
|
||||
"chat message add-text-emotion",
|
||||
"chat message remove-text-emotion",
|
||||
} {
|
||||
assertBlocked(path, "msg-ids", "message-ids")
|
||||
}
|
||||
for _, path := range []string{
|
||||
"chat message send",
|
||||
"chat conversation-info",
|
||||
"chat category add-conv",
|
||||
"chat category remove-conv",
|
||||
"chat message list",
|
||||
"chat message list-mentions",
|
||||
"chat message recall-by-bot",
|
||||
"chat message search",
|
||||
} {
|
||||
assertBlocked(path, "conversation-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedParamAliasesKeepAuditJoinUserRoleAmbiguous(t *testing.T) {
|
||||
entry, ok := LookupParamAlias("chat group audit-join-validation")
|
||||
if !ok {
|
||||
t.Fatal("missing generated alias entry for chat group audit-join-validation")
|
||||
}
|
||||
for _, name := range []string{"user", "user-id", "userid", "uid", "staff-id"} {
|
||||
if !entry.IsAmbiguous(cmdutil.Morph(name)) {
|
||||
t.Fatalf("%q not ambiguous; entry = %#v", name, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratedParamAliasesAreWellFormed guards the committed generated table
|
||||
// at the Go level, complementing the byte-identity drift gate.
|
||||
func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
|
||||
@@ -515,3 +619,68 @@ func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePublishedSpaceWorkspaceAliasesRemainExecutable(t *testing.T) {
|
||||
docCommands := []string{
|
||||
"doc +access-change",
|
||||
"doc +access-grant",
|
||||
"doc +access-revoke",
|
||||
"doc +copy",
|
||||
"doc +create",
|
||||
"doc +create-from-template",
|
||||
"doc +grant-and-share",
|
||||
"doc +import",
|
||||
"doc +list",
|
||||
"doc +move",
|
||||
"doc create",
|
||||
"doc file create",
|
||||
"doc import",
|
||||
"doc template apply",
|
||||
}
|
||||
for _, command := range docCommands {
|
||||
entry, ok := LookupParamAlias(command)
|
||||
if !ok {
|
||||
t.Errorf("published Doc command %q has no generated parameter-alias entry", command)
|
||||
continue
|
||||
}
|
||||
for _, emitted := range []string{"space", "space-id"} {
|
||||
target, active := entry.ResolveAlias(emitted)
|
||||
if !active || target != "workspace" {
|
||||
t.Errorf("%s --%s resolution = active:%v target:%q, want --workspace", command, emitted, active, target)
|
||||
}
|
||||
if entry.IsBlocked(emitted) || entry.IsAmbiguous(emitted) {
|
||||
t.Errorf("%s --%s remains protected after restoring its published alias", command, emitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
driveInfo, ok := LookupParamAlias("drive info")
|
||||
if !ok {
|
||||
t.Fatal("published drive info command has no generated parameter-alias entry")
|
||||
}
|
||||
for _, emitted := range []string{"space", "workspace", "workspace-id"} {
|
||||
target, active := driveInfo.ResolveAlias(emitted)
|
||||
if !active || target != "space-id" {
|
||||
t.Errorf("drive info --%s resolution = active:%v target:%q, want --space-id", emitted, active, target)
|
||||
}
|
||||
if driveInfo.IsBlocked(emitted) || driveInfo.IsAmbiguous(emitted) {
|
||||
t.Errorf("drive info --%s remains protected after restoring its published alias", emitted)
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility remains exact-path scoped. Strong type spellings introduced
|
||||
// after the split continue to guard the two value domains elsewhere.
|
||||
for _, test := range []struct {
|
||||
command string
|
||||
emitted string
|
||||
}{
|
||||
{command: "doc create", emitted: "storage-space-id"},
|
||||
{command: "drive +upload", emitted: "workspace-id"},
|
||||
{command: "drive info", emitted: "knowledge-base-id"},
|
||||
} {
|
||||
entry, ok := LookupParamAlias(test.command)
|
||||
if !ok || !entry.IsBlocked(test.emitted) {
|
||||
t.Errorf("%s --%s must remain blocked outside the published compatibility set", test.command, test.emitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
},
|
||||
|
||||
"concepts": {
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "drive +find-file", "drive +search", "drive +search-docs", "drive search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +search-docs", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive permission list", "drive recent", "drive recycle list", "drive search", "drive star list", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive recent", "drive recycle list", "drive search", "drive star list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
|
||||
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
|
||||
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
|
||||
@@ -31,31 +31,48 @@
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
|
||||
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"},
|
||||
"drive_storage_space_id": {"denotes": "single numeric DingDrive storage space ID with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id"], "excludes": ["space", "workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "commands": ["drive +create-folder", "drive +download", "drive +info", "drive +inspect", "drive +list", "drive +recycle-list", "drive +upload", "drive commit", "drive download", "drive info", "drive list", "drive mkdir", "drive recycle list", "drive upload", "drive upload-info"], "risk": "yellow"},
|
||||
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
|
||||
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
|
||||
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url", "dentry-uuid"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision", "dentry-id", "space-id", "name", "role"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
|
||||
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
|
||||
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
|
||||
"doc_version_number": {"denotes": "single document or Drive ordinary-file historical version number with unchanged positive integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert", "drive +version-download", "drive +version-get", "drive +version-revert", "drive download", "drive download-version", "drive revert"], "risk": "yellow"},
|
||||
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
|
||||
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
|
||||
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"},
|
||||
"drive_recycle_item_id": {"denotes": "single Drive recycle-bin item ID returned by recycle list", "canonical_hint": "id", "members": ["id", "recycle-item-id", "trash-item-id", "deleted-item-id"], "excludes": ["node", "node-id", "file-id", "folder-id", "space-id", "workspace-id"], "commands": ["drive +recycle-restore", "drive recycle restore"], "risk": "yellow"},
|
||||
"drive_modified_time_start": {"denotes": "Drive search modified-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-from", "members": ["modified-from", "modified-after", "modify-time-from", "modified-time-start"], "excludes": ["modified-to", "created-from", "created-to", "start", "from"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
|
||||
"drive_modified_time_end": {"denotes": "Drive search modified-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-to", "members": ["modified-to", "modified-before", "modify-time-to", "modified-time-end"], "excludes": ["modified-from", "created-from", "created-to", "end", "to"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
|
||||
"drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"},
|
||||
"drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"},
|
||||
"workspace_id": {"denotes": "single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID", "canonical_hint": "workspace", "members": ["workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "excludes": ["space", "space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id", "folder", "folder-id", "node", "node-id", "dentry-id"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive +copy", "drive +create-shortcut", "drive +move", "drive copy", "drive list", "drive move", "drive permission add", "drive permission list", "drive permission remove", "drive permission transfer-owner", "drive permission update", "drive shortcut", "drive upload"], "risk": "yellow"},
|
||||
"created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start", "create-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"},
|
||||
"creator_user_ids": {"denotes": "creator userId list used to filter Doc/Drive search results, passed unchanged", "canonical_hint": "creator-uids", "members": ["creator-uids", "creator-user-ids", "creator-ids", "created-by-user-ids"], "excludes": ["user", "user-id", "user-ids", "users", "owner-id", "modifier-uids"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"}
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
|
||||
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
|
||||
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
|
||||
"chat group members": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
|
||||
"chat group members remove": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."},
|
||||
"drive list": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
|
||||
"drive upload": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
|
||||
"chat conversation-info": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat group-mute": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat group-mute-member": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message list-mentions": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message recall-by-bot": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message search": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat mute": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id and hidden compatibility --group/--id/--chat/--open-conversation-id remain unchanged; reviewed chat-id reduces to --conversation-id."},
|
||||
"drive list": {"ambiguous": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]},
|
||||
"drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true},
|
||||
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
|
||||
"ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}},
|
||||
"contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"},
|
||||
@@ -73,12 +90,12 @@
|
||||
"chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
|
||||
"chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
|
||||
"chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
|
||||
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
|
||||
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "ambiguous": ["chat-id", "open-conversation-id"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message list-by-sender": {"scoped_aliases": {"user-id": "sender-user-id", "open-dingtalk-id": "sender-open-dingtalk-id"}, "block": ["time"], "scope_strict": true, "note": "Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."},
|
||||
"contact +resolve-dept": {"bind": {"name": "search_query"}, "note": "The real --name is a department-name search keyword and carries the search_query concept on this shortcut."},
|
||||
"contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"},
|
||||
"contact +dept-members": {"bind": {"dept": "search_query"}, "scoped_aliases": {"name": "dept"}, "note": "The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."},
|
||||
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."},
|
||||
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "ambiguous": ["chat-id", "open-conversation-id"], "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."},
|
||||
"chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."},
|
||||
"chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."},
|
||||
@@ -87,8 +104,8 @@
|
||||
"chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category add-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
|
||||
"chat category remove-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
|
||||
"chat category add-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat category remove-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
@@ -98,12 +115,12 @@
|
||||
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
|
||||
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids", "chat-id", "open-conversation-id"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
|
||||
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
@@ -114,7 +131,7 @@
|
||||
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
|
||||
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
|
||||
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
|
||||
"doc update": {"block": ["stdin", "version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision. --stdin is not a real switch: stdin input is expressed as --content -, which requires a value-form transformation outside central name aliases."},
|
||||
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
|
||||
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
|
||||
@@ -131,7 +148,7 @@
|
||||
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
|
||||
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
|
||||
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
|
||||
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
@@ -158,21 +175,86 @@
|
||||
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
|
||||
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
|
||||
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
|
||||
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
|
||||
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
|
||||
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
|
||||
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
|
||||
"doc +access-change": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc +access-grant": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "block": ["target-user-id", "target-user-ids", "user-id", "user-ids"], "ambiguous": ["target-user", "user", "users"], "scope_strict": true, "note": "The real --to accepts collaborator names and resolves them before granting access. Explicit ID spellings cannot be passed through unchanged, while role-free user spellings do not prove whether their values are names or IDs. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +access-revoke": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc file create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc import": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc template apply": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "ambiguous": ["element"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value. --element cannot choose between document content, a block target, or an insertion reference."},
|
||||
"doc +inspect": {"scoped_aliases": {"include-access": "include-permissions", "include-member": "include-permissions", "include-members": "include-permissions", "include-versions": "include-history"}, "block": ["include", "include-blocks", "include-content", "include-info", "include-meta", "include-metadata"], "scope_strict": true, "note": "Access/member spellings denote the same optional permission list, and versions/history denote the same history section. Generic --include needs value-dependent expansion; base metadata is already returned; block/content reads belong to +fetch, so those spellings stop before fuzzy correction or dispatch."},
|
||||
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "block": ["content-format", "doc-format", "range"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary. --range is an invented composite argument observed alongside --scope range and cannot be split into block IDs by name-only normalization; content-format spellings do not map to the independent --detail contract."},
|
||||
"doc +export": {"block": ["wait"], "scope_strict": true, "note": "The shortcut already submits, polls, and downloads in one workflow. A generic --wait value cannot be converted into the distinct integer --max-polls contract by parameter-name normalization."},
|
||||
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
|
||||
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "block": ["after-block-id", "before-block-id"], "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role. Before/after block spellings each require expansion into both --ref-block and --where, which name-only normalization cannot perform."},
|
||||
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
|
||||
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
|
||||
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
|
||||
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +version-save": {"block": ["message", "title"], "scope_strict": true, "note": "The current snapshot API accepts only the document target. Version title/message metadata are unsupported and cannot be represented by another existing flag."},
|
||||
"drive copy": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive cover": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive download-version": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked.", "block": ["dentry-id", "file", "file-path", "folder", "folder-id"]},
|
||||
"drive move": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive permission add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
|
||||
"drive permission apply": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "approver-user-ids": "users", "approver-ids": "users", "target-node-id": "node", "apply-reason": "reason", "notification-mode": "notify-mode"}, "scope_strict": true, "note": "The user list denotes approvers, not target collaborators; values and notification enum are passed unchanged.", "block": ["dentry-id"]},
|
||||
"drive permission apply-info": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive permission list": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "role-filter": "filter-role", "permission-role-filter": "filter-role", "target-node-id": "node"}, "scope_strict": true, "note": "Filtering by a role is not the same as granting/updating a role.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "permission", "role", "space-id", "storage-space-id"]},
|
||||
"drive permission remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
|
||||
"drive permission transfer-owner": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "target-node-id": "node", "target-workspace-id": "workspace", "old-owner-role": "reserve-role", "keep-role": "reserve-role", "new-owner-id": "new-owner", "new-owner-user-id": "new-owner"}, "scope_strict": true, "note": "Node/workspace target and new/old owner roles are distinct on this irreversible command; role-free names stop before dispatch.", "block": ["current-owner", "dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id", "user-ids", "users"], "ambiguous": ["owner", "owner-user-id", "target-id", "user-id"]},
|
||||
"drive permission update": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
|
||||
"drive publish get": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive publish set": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "public-permission": "permission", "public-role": "permission"}, "scope_strict": true, "note": "Internet-public permission is not the same as a direct collaborator role.", "block": ["access-role", "dentry-id", "permission-role", "role"]},
|
||||
"drive publish unset": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive rename": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["dentry-id"]},
|
||||
"drive revert": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive shortcut": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive star add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive star remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive stats": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive +cover": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "This shortcut calls the same get_cover nodeId interface as drive cover, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId, folder-role spellings and --name remain protected."},
|
||||
"drive +copy": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive +create-folder": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id", "parent-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The new shortcut creates a numeric DingDrive-space folder. Folder display name, parent dentryUuid and numeric storage space are separate roles; knowledge-base workspace spellings are not accepted."},
|
||||
"drive +create-shortcut": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "doc", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles. The source ID/URL aliases match drive shortcut and pass through unchanged; generic target/id spellings remain ambiguous, and storage-space IDs are not knowledge-base workspace IDs.", "ambiguous": ["destination-id", "id", "target-id"]},
|
||||
"drive +delete": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "folder": "node", "folder-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "id", "name", "url"], "scope_strict": true, "note": "Delete targets one already confirmed Drive file or folder dentryUuid. Folder spellings are the same single target role; numeric dentryId, unreviewed Doc URL spellings and --name stop before confirmation or write dispatch."},
|
||||
"drive +download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "knowledge-base-id", "name", "url", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The remote ordinary-file node and local output path are different roles. Node-ID spelling is accepted, while Doc URLs, local input paths, folders, knowledge-base workspaces and numeric dentryId values are not interchangeable."},
|
||||
"drive +info": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."},
|
||||
"drive +inspect": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "folder": "node", "folder-id": "node", "include-statistics": "include-stats", "include-publish-status": "include-publish", "include-public-status": "include-publish", "include-thumbnail": "include-cover"}, "block": ["dentry-id", "doc", "document-url", "id", "include", "include-content", "include-history", "include-permissions", "name", "url"], "scope_strict": true, "note": "Drive inspect accepts one file, folder or document node ID and can aggregate only stats, public-publish status and cover. URL spellings, Doc inspect section names and a generic --include remain protected because this shortcut does not declare URL input or value splitting."},
|
||||
"drive +list": {"ambiguous": ["root-id", "space"], "scoped_aliases": {"directory-id": "folder", "parent-folder-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "block": ["dentry-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This shortcut accepts one numeric DingDrive space and an optional parent dentryUuid; it does not accept a knowledge-base workspace. Sort field and direction remain separate roles."},
|
||||
"drive +move": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive +publish-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same publication-status fileId interface as drive publish get, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +publish-unset": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same set_file_publish fileId interface as drive publish unset, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected before confirmation."},
|
||||
"drive +recycle-restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycleItemId returned by drive +recycle-list, not a normal Drive node ID."},
|
||||
"drive +rename": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node", "folder": "node", "folder-id": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id"], "scope_strict": true, "note": "The existing document, file or folder node and the requested new display name are separate required roles. Node ID/URL aliases match drive rename and pass through unchanged; numeric dentryId remains protected."},
|
||||
"drive delete": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url"], "scope_strict": true, "note": "This command publicly accepts an ID-only Drive node; dentry spellings preserve the value and URL-specific spellings remain protected."},
|
||||
"drive download": {"scoped_aliases": {"dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "block": ["dentry-id", "document-url", "file", "file-path", "folder", "folder-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked."},
|
||||
"drive info": {"scoped_aliases": {"dentry-uuid": "node", "space": "space-id", "workspace": "space-id", "workspace-id": "space-id"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target. Its command-scoped --space/--workspace/--workspace-id aliases preserve compatibility published before the workspace/storage-space concept split and pass the value unchanged to --space-id; new commands must not infer that knowledge-base workspace IDs and numeric storage-space IDs are globally interchangeable."},
|
||||
"drive commit": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "upload-session-id": "upload-id", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "Upload session, file name, file size in bytes, parent folder, and storage space remain separate roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
|
||||
"drive mkdir": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
|
||||
"drive upload-info": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "content-type": "mime-type", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "File metadata aliases preserve MIME and byte units; file path and upload session are different stages.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
|
||||
"drive recycle list": {"block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."},
|
||||
"drive search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."},
|
||||
"drive +search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."},
|
||||
"drive +star-add": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same mark_star nodeId interface as drive star add, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +star-remove": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same unmark_star nodeId interface as drive star remove, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +stats": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same get_node_stats nodeId interface as drive stats, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +version-download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "Ordinary-file node, positive historical version number and local output path are three distinct roles; revision and Doc URL spellings must not be guessed."},
|
||||
"drive +version-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command reads one ordinary-file historical version by node ID and positive version number; document revision and URL roles are different."},
|
||||
"drive +version-history": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command pages versions for one ordinary-file Drive node; document URLs and numeric dentryId are not accepted."},
|
||||
"drive +version-revert": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "The high-risk revert targets one ordinary-file Drive node and a positive historical version number. Doc revisions and URLs must stop before confirmation or write dispatch."},
|
||||
"drive recycle restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycle-item ID from recycle list, not a normal Drive node ID."},
|
||||
"drive star list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Content types and resource types are separate arrays; a generic type list cannot choose one.", "scoped_aliases": {"order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}},
|
||||
"drive recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."},
|
||||
"drive +recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."},
|
||||
"drive +star-list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "The shortcut exposes only the API contentTypes filter. Generic type spellings may denote file extensions or node/resource types, so the current name-only layer must not guess the value domain."},
|
||||
"drive +upload": {"ambiguous": ["destination-id", "space", "target-id"], "scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "overwrite-node-id": "node", "directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "target-folder-id": "folder", "source-file": "file", "local-file": "file", "file-path": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id", "output-path"], "scope_strict": true, "note": "Local source path, remote display name, MIME type, parent folder, numeric storage space and optional overwrite node are distinct roles. ID-suffixed file/node spellings denote the overwrite target; the shortcut does not expose a knowledge-base workspace route."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
@@ -243,7 +325,7 @@
|
||||
{"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2},
|
||||
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "concept:space_id", "occ": 2},
|
||||
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "override:published-storage-space-compatibility", "occ": 2},
|
||||
{"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2},
|
||||
{"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
|
||||
@@ -252,7 +334,7 @@
|
||||
{"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
|
||||
{"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped"},
|
||||
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
|
||||
{"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
|
||||
@@ -325,7 +407,7 @@
|
||||
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
|
||||
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
@@ -390,6 +472,7 @@
|
||||
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
|
||||
{"command": "chat group audit-join-validation", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
@@ -415,9 +498,9 @@
|
||||
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
|
||||
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
|
||||
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
|
||||
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
|
||||
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
|
||||
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
|
||||
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned", "occ": 1},
|
||||
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation", "occ": 5},
|
||||
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
|
||||
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
|
||||
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
|
||||
@@ -425,11 +508,154 @@
|
||||
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
|
||||
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
|
||||
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
|
||||
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
|
||||
{"command": "doc +copy", "emitted": "dentry-uuid", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"},
|
||||
{"command": "doc create", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "doc create", "emitted": "space-id", "expect": "workspace", "via": "override:published-workspace-compatibility"},
|
||||
{"command": "drive list", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "drive list", "emitted": "order-field", "expect": "order-by", "via": "override:scoped-sort-field"},
|
||||
{"command": "drive info", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-node-id"},
|
||||
{"command": "drive info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"},
|
||||
{"command": "drive copy", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-destination-folder"},
|
||||
{"command": "drive copy", "emitted": "target-id", "expect": "did-you-mean:ambiguous", "via": "guard:destination-role-required"},
|
||||
{"command": "drive search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"},
|
||||
{"command": "drive search", "emitted": "created-before", "expect": "created-to", "via": "concept:created_time_end"},
|
||||
{"command": "drive search", "emitted": "modified-after", "expect": "modified-from", "via": "concept:drive_modified_time_start"},
|
||||
{"command": "drive search", "emitted": "modified-before", "expect": "modified-to", "via": "concept:drive_modified_time_end"},
|
||||
{"command": "drive search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"},
|
||||
{"command": "drive permission add", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"},
|
||||
{"command": "drive permission list", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-filter-role"},
|
||||
{"command": "drive upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"},
|
||||
{"command": "doc +search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"},
|
||||
{"command": "doc +search", "emitted": "create-time-start", "expect": "created-from", "via": "concept:created_time_start", "occ": 1},
|
||||
{"command": "doc +search", "emitted": "create-time-end", "expect": "created-to", "via": "concept:created_time_end", "occ": 1},
|
||||
{"command": "doc +search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"},
|
||||
{"command": "doc +access-grant", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"},
|
||||
{"command": "doc +export", "emitted": "output-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive recycle restore", "emitted": "recycle-item-id", "expect": "id", "via": "concept:drive_recycle_item_id"},
|
||||
{"command": "drive upload-info", "emitted": "size-bytes", "expect": "file-size", "via": "concept:drive_file_size_bytes"},
|
||||
{"command": "drive +info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive commit", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive download", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"},
|
||||
{"command": "drive mkdir", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive recycle list", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive upload", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive upload-info", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "doc +access-grant", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 6},
|
||||
{"command": "doc +access-grant", "emitted": "target-user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 1},
|
||||
{"command": "doc +access-grant", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1},
|
||||
{"command": "doc +access-grant", "emitted": "user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1},
|
||||
{"command": "doc +access-grant", "emitted": "target-user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"},
|
||||
{"command": "doc +access-grant", "emitted": "target-user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"},
|
||||
{"command": "doc +fetch", "emitted": "content-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 3},
|
||||
{"command": "doc +fetch", "emitted": "doc-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 1},
|
||||
{"command": "doc +fetch", "emitted": "range", "expect": "did-you-mean:blocked", "via": "guard:composite-range-needs-block-ids", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-access", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 3},
|
||||
{"command": "doc +inspect", "emitted": "include-member", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-members", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 2},
|
||||
{"command": "doc +inspect", "emitted": "include-meta", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 2},
|
||||
{"command": "doc +inspect", "emitted": "include-metadata", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-blocks", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-content", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-document-node", "occ": 1},
|
||||
{"command": "doc +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role", "occ": 2},
|
||||
{"command": "doc +export", "emitted": "wait", "expect": "did-you-mean:blocked", "via": "guard:workflow-wait-vs-max-polls", "occ": 1},
|
||||
{"command": "doc +media-insert", "emitted": "after-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion", "occ": 1},
|
||||
{"command": "doc +media-insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion"},
|
||||
{"command": "doc +update", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform", "occ": 1},
|
||||
{"command": "doc +update", "emitted": "element", "expect": "did-you-mean:ambiguous", "via": "guard:content-vs-block-vs-reference-role", "occ": 1},
|
||||
{"command": "doc update", "emitted": "stdin", "expect": "did-you-mean:blocked", "via": "guard:stdin-requires-content-dash-transform", "occ": 1},
|
||||
{"command": "doc +version-save", "emitted": "title", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1},
|
||||
{"command": "doc +version-save", "emitted": "message", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1},
|
||||
{"command": "drive +search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
|
||||
{"command": "contact +search-user", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:person-name-vs-search-query", "occ": 1},
|
||||
{"command": "drive copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive +move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive +cover", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +cover", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"},
|
||||
{"command": "drive +create-folder", "emitted": "folder-name", "expect": "name", "via": "override:scoped-folder-name"},
|
||||
{"command": "drive +create-folder", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive +create-shortcut", "emitted": "source-file-id", "expect": "node", "via": "override:scoped-source-node"},
|
||||
{"command": "drive +create-shortcut", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-target-folder"},
|
||||
{"command": "drive +create-shortcut", "emitted": "target-workspace-id", "expect": "workspace", "via": "override:scoped-target-workspace"},
|
||||
{"command": "drive +create-shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"},
|
||||
{"command": "drive +delete", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +delete", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-node-id"},
|
||||
{"command": "drive +download", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive +inspect", "emitted": "include-statistics", "expect": "include-stats", "via": "override:scoped-inspect-section"},
|
||||
{"command": "drive +inspect", "emitted": "include-history", "expect": "did-you-mean:blocked", "via": "guard:doc-inspect-section"},
|
||||
{"command": "drive +list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id"},
|
||||
{"command": "drive +list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "drive +list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "drive +list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"},
|
||||
{"command": "drive +list", "emitted": "sort-by", "expect": "order-by", "via": "override:scoped-sort-field"},
|
||||
{"command": "drive +publish-get", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +publish-unset", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +recycle-list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive +recycle-list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "drive +recycle-restore", "emitted": "recycle-item-id", "expect": "id", "via": "override:bind(drive_recycle_item_id)"},
|
||||
{"command": "drive +recycle-restore", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:recycle-item-vs-node-id"},
|
||||
{"command": "drive +rename", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +rename", "emitted": "new-name", "expect": "name", "via": "override:scoped-display-name"},
|
||||
{"command": "drive +star-add", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +star-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "drive +star-list", "emitted": "types", "expect": "did-you-mean:ambiguous", "via": "guard:content-type-value-domain"},
|
||||
{"command": "drive +star-remove", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +stats", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"},
|
||||
{"command": "drive +upload", "emitted": "name", "expect": "file-name", "via": "override:scoped-remote-name"},
|
||||
{"command": "drive +upload", "emitted": "overwrite-node-id", "expect": "node", "via": "override:scoped-overwrite-node"},
|
||||
{"command": "drive +upload", "emitted": "workspace-id", "expect": "did-you-mean:blocked", "via": "guard:unsupported-workspace-route"},
|
||||
{"command": "drive +version-download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive +version-download", "emitted": "save-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive +version-get", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive +version-history", "emitted": "next-cursor", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "drive +version-history", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "drive +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "guard:document-revision-vs-file-version"},
|
||||
{"command": "drive +cover", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +create-shortcut", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +delete", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +inspect", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +publish-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +publish-unset", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +rename", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +star-add", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +star-remove", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +stats", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +upload", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-history", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-revert", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +cover", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +create-shortcut", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +delete", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
|
||||
{"command": "drive +inspect", "emitted": "document-id", "expect": "node", "via": "override:document-node-id"},
|
||||
{"command": "drive +inspect", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
|
||||
{"command": "drive +publish-get", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +publish-unset", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +rename", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +rename", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
|
||||
{"command": "drive +star-add", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +star-remove", "emitted": "doc-id", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +stats", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +upload", "emitted": "file-id", "expect": "node", "via": "override:overwrite-node-id-role"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1354,15 +1354,16 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
interfaceType string
|
||||
}{
|
||||
{"chat message edit", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat message edit", "msg-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "message-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "message-id", "openMsgId", true, ""},
|
||||
{"chat message send", "idempotency-key", "uuid", false, ""},
|
||||
{"chat message send-card", "at-all", "atAll", false, ""},
|
||||
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
|
||||
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
|
||||
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
|
||||
{"chat category list-by-conv", "group", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "group", "openConversationId", true, ""},
|
||||
{"chat category list-by-conv", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "conversation-id", "", true, ""},
|
||||
{"chat group upgrade-to-external", "extension", "extension", false, "object"},
|
||||
{"chat +messages-send-card", "receiver-open-dingtalk-id", "receiverOpenDingTalkId", false, ""},
|
||||
{"chat message list-favorites", "size", "", false, "string"},
|
||||
@@ -1396,7 +1397,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden conversation aliases must stay unpublished (merge-base parity).
|
||||
// Manifest-covered migrations hide legacy aliases; manifest-external
|
||||
// commands keep their existing visible flags for compatibility.
|
||||
editLeaf, err := queryDeliverySchemaPayload([]string{"chat message edit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1407,14 +1409,65 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
t.Fatalf("chat message edit unexpectedly publishes hidden alias --%s", hidden)
|
||||
}
|
||||
}
|
||||
sendLeaf, err = queryDeliverySchemaPayload([]string{"chat message send"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sendParams = schemaMap(sendLeaf["parameters"])
|
||||
if _, ok := sendParams["uuid"]; ok {
|
||||
t.Fatal("chat message send unexpectedly publishes hidden alias --uuid")
|
||||
}
|
||||
listByConv, err := queryDeliverySchemaPayload([]string{"chat category list-by-conv"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listParams := schemaMap(listByConv["parameters"])
|
||||
for _, hidden := range []string{"conversation-id", "id"} {
|
||||
if _, ok := listParams[hidden]; ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --%s", hidden)
|
||||
if _, ok := listParams["conversation-id"]; !ok {
|
||||
t.Fatalf("chat category list-by-conv missing public canonical --conversation-id")
|
||||
}
|
||||
if _, ok := listParams["group"]; !ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly hides manifest-external --group")
|
||||
}
|
||||
if _, ok := listParams["id"]; ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --id")
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"chat message add-emoji",
|
||||
"chat message remove-emoji",
|
||||
"chat message add-text-emotion",
|
||||
"chat message remove-text-emotion",
|
||||
} {
|
||||
leaf, err := queryDeliverySchemaPayload([]string{path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := schemaMap(leaf["parameters"])
|
||||
if _, ok := params["conversation-id"]; !ok {
|
||||
t.Fatalf("%s missing public canonical --conversation-id", path)
|
||||
}
|
||||
for _, visible := range []string{"group", "id", "chat"} {
|
||||
if _, ok := params[visible]; !ok {
|
||||
t.Fatalf("%s unexpectedly hides manifest-external --%s", path, visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groupBots, err := queryDeliverySchemaPayload([]string{"chat group bots"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groupBotsParams := schemaMap(groupBots["parameters"])
|
||||
group := groupBotsParams["group"]
|
||||
if group == nil {
|
||||
t.Fatal("chat group bots missing public legacy --group")
|
||||
}
|
||||
if group["property"] != "openConversationId" {
|
||||
t.Fatalf("chat group bots --group property = %#v, want openConversationId", group["property"])
|
||||
}
|
||||
for _, migrated := range []string{"conversation-id", "group-name"} {
|
||||
if _, ok := groupBotsParams[migrated]; ok {
|
||||
t.Fatalf("chat group bots unexpectedly publishes migrated --%s", migrated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,19 +43,14 @@ func init() {
|
||||
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
|
||||
})
|
||||
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
|
||||
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
|
||||
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
|
||||
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
|
||||
|
||||
@@ -252,7 +252,8 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.batch_query_group_chat_settings --groups": "Reviewed unpinned adapter: chat.batch_query_group_chat_settings has no singular pinned interface_ref; --groups is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.batch_update_group_chat_settings --items": "Reviewed unpinned adapter: chat.batch_update_group_chat_settings has no singular pinned interface_ref; --items is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.create_text_emotion --background-id": "Runtime extension: the executable helper forwards backgroundId to im/create_text_emotion, but the pinned source-revision metadata does not declare that optional property; preserve the compatibility flag without advertising it as a pinned RPC field.",
|
||||
"chat.get_group_mute_config --group": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --group is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.get_group_mute_config --conversation-id": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.get_group_mute_config --group": "Reviewed legacy Schema compatibility: the historical visible --group wrapper input was required but did not publish a direct interface property.",
|
||||
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -265,6 +266,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.remove_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.remove_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-message-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
|
||||
|
||||
+25
-12
@@ -578,18 +578,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
for _, alias := range flag.Aliases {
|
||||
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
if registered := cmd.Flags().Lookup(alias); registered != nil {
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
flag.Name,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
AnnotateFlagAlias(cmd, alias, flag.Name)
|
||||
}
|
||||
if flag.MarkRequired {
|
||||
_ = cmd.MarkFlagRequired(flag.Name)
|
||||
@@ -600,6 +589,30 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
}
|
||||
}
|
||||
|
||||
// AnnotateFlagAlias records framework-owned evidence that aliasName is a hidden
|
||||
// compatibility alias for canonicalName. It is for commands that already own
|
||||
// their Cobra flag registration outside FlagSpec but still need the same
|
||||
// interface-snapshot alias contract as FlagSpec.Aliases.
|
||||
func AnnotateFlagAlias(cmd *cobra.Command, aliasName, canonicalName string) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
registered := cmd.Flags().Lookup(aliasName)
|
||||
if registered == nil {
|
||||
return
|
||||
}
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
canonicalName,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
|
||||
// RegisterFlag registers one flag by Kind. Default is applied at registration
|
||||
// for every kind so --help DefValue matches the declared fallback.
|
||||
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
|
||||
|
||||
@@ -116,6 +116,16 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAnnotateFlagAliasIgnoresMissingInputs(t *testing.T) {
|
||||
AnnotateFlagAlias(nil, "alias", "canonical")
|
||||
|
||||
cmd := newTestCommand()
|
||||
AnnotateFlagAlias(cmd, "missing", "canonical")
|
||||
if flag := cmd.Flags().Lookup("missing"); flag != nil {
|
||||
t.Fatalf("unexpected missing flag registered: %#v", flag)
|
||||
}
|
||||
}
|
||||
|
||||
// ── effective value fallback chain ─────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageEffectiveValueFallbackChain(t *testing.T) {
|
||||
|
||||
@@ -15,9 +15,13 @@ package errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
|
||||
@@ -382,6 +386,27 @@ func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePrintJSONCLIOrgNotAuthorizedUsesInternationalActionURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), config.FilePerm); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI(
|
||||
"business error",
|
||||
WithServerDiag(ServerDiagnostics{ServerErrorCode: "CLI_ORG_NOT_AUTHORIZED"}),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
|
||||
want := `"action_url": "https://open-dev.dingtalk.io/fe/old#/developerSettings"`
|
||||
if got := b.String(); !strings.Contains(got, want) {
|
||||
t.Fatalf("expected international action_url %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+947
-502
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
@@ -32,14 +34,26 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
|
||||
|
||||
func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string) error {
|
||||
t.Helper()
|
||||
command, _, err := newChatCommand().Find(path)
|
||||
InitDeps(&scriptedToolCaller{})
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.PersistentFlags().Bool("debug", false, "")
|
||||
root.PersistentFlags().Bool("verbose", false, "")
|
||||
command, _, err := root.Find(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for name, value := range flags {
|
||||
if err := command.Flags().Set(name, value); err != nil {
|
||||
flag := command.Flag(name)
|
||||
if flag == nil {
|
||||
return fmt.Errorf("no such flag -%s", name)
|
||||
}
|
||||
if err := flag.Value.Set(value); err != nil {
|
||||
return err
|
||||
}
|
||||
flag.Changed = true
|
||||
}
|
||||
return command.RunE(command, nil)
|
||||
}
|
||||
@@ -93,7 +107,7 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
|
||||
hint string
|
||||
}{
|
||||
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil {
|
||||
@@ -113,6 +127,80 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatAliasInstallerRemainingEdges(t *testing.T) {
|
||||
restoreChatManifestExternalVisibleFlags(nil)
|
||||
|
||||
mismatchedRoot := &cobra.Command{Use: "chat"}
|
||||
mismatchedRoot.AddCommand(&cobra.Command{Use: "other"})
|
||||
restoreChatManifestExternalVisibleFlags(mismatchedRoot)
|
||||
|
||||
missingPrimary := &cobra.Command{Use: "leaf"}
|
||||
installChatFlagAliases(missingPrimary, "conversation-id", []string{"group"}, requireChatConversationID)
|
||||
if flag := missingPrimary.Flags().Lookup("group"); flag != nil {
|
||||
t.Fatalf("alias registered without canonical flag: %#v", flag)
|
||||
}
|
||||
|
||||
skipGroup := &cobra.Command{Use: "leaf", RunE: func(cmd *cobra.Command, args []string) error { return nil }}
|
||||
skipGroup.Flags().String("conversation-id", "", "")
|
||||
skipGroup.Flags().String("group-name", "", "")
|
||||
installChatFlagAliases(skipGroup, "conversation-id", []string{"group", "chat"}, requireChatConversationID)
|
||||
if flag := skipGroup.Flags().Lookup("group"); flag != nil {
|
||||
t.Fatalf("group alias registered beside group-name: %#v", flag)
|
||||
}
|
||||
if flag := skipGroup.Flags().Lookup("chat"); flag == nil {
|
||||
t.Fatal("non-group alias was not registered")
|
||||
}
|
||||
|
||||
preRunCalled := false
|
||||
withPreRun := &cobra.Command{
|
||||
Use: "leaf",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
preRunCalled = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return nil },
|
||||
}
|
||||
withPreRun.Flags().String("conversation-id", "", "")
|
||||
installChatFlagAliases(withPreRun, "conversation-id", []string{"group"}, requireChatConversationID)
|
||||
withPreRun.SetArgs([]string{"--group", "cid"})
|
||||
if err := withPreRun.ExecuteContext(context.Background()); err != nil {
|
||||
t.Fatalf("execute with alias and previous PreRunE: %v", err)
|
||||
}
|
||||
if !preRunCalled {
|
||||
t.Fatal("previous PreRunE was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageForwardRequiresMessageID(t *testing.T) {
|
||||
caller := &productExampleCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
command, _, err := root.Find([]string{"message", "forward"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"message-id", "msg-id"} {
|
||||
if flag := command.Flags().Lookup(name); flag != nil && flag.Annotations != nil {
|
||||
delete(flag.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
}
|
||||
if err := command.Flags().Set("src-conversation-id", "src"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := command.Flags().Set("dest-conversation-id", "dest"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = command.RunE(command, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required flag: --message-id") {
|
||||
t.Fatalf("forward missing message id error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("tool calls = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
@@ -160,6 +248,9 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
caller := &productExampleCaller{}
|
||||
if got := formatChatMessageListAllTime(0); got == "" {
|
||||
t.Fatal("formatChatMessageListAllTime returned empty string")
|
||||
}
|
||||
|
||||
commands := [][]string{
|
||||
{"chmod", "chat.read", "--ttl="},
|
||||
@@ -168,9 +259,17 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
|
||||
{"message", "list", "--user=D-user", "--time=2026-01-01", "--limit=1"},
|
||||
{"message", "list", "--open-dingtalk-id=D-user", "--time=2026-01-01", "--direction=sideways"},
|
||||
{"message", "list-direct", "--user=D-user", "--limit=1"},
|
||||
{"message", "list-all"},
|
||||
{"message", "list-all", "--end=2026-01-02T00:00:00Z"},
|
||||
{"message", "list-all", "--end=bad"},
|
||||
{"message", "list-all", "--start=not-a-time", "--end=2026-01-02T00:00:00Z"},
|
||||
{"message", "list-all", "--start=2026-01-01 00:00:00", "--end=bad"},
|
||||
{"message", "list-all", "--start=2026-01-02 00:00:00", "--end=2026-01-01 00:00:00"},
|
||||
{"message", "list-all", "--start=2027-01-01 00:00:00"},
|
||||
{"message", "list-by-sender", "--sender-user-id=u1", "--start=bad"},
|
||||
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=bad"},
|
||||
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
|
||||
{"message", "list-by-sender", "--sender-user-id=u1", "--end=2026-01-02T00:00:00Z"},
|
||||
{"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z"},
|
||||
{"message", "list-by-sender", "--sender-open-dingtalk-id=D1", "--start=2026-01-01T00:00:00Z"},
|
||||
{"message", "list-mentions", "--start=bad", "--end=2026-01-02T00:00:00Z"},
|
||||
@@ -188,9 +287,10 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
|
||||
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
|
||||
{"message", "list-by-ids", "--msg-ids=" + strings.Repeat("id,", 51) + "last"},
|
||||
{"group", "transfer-owner", "--group=cid", "--new-owner=D-owner"},
|
||||
{"group", "transfer-owner", "--group=cid", "--new-owner=DAAAAAAAAAAAiE"},
|
||||
{"group", "update-icon", "--group=cid", "--icon-media-id=@valid"},
|
||||
{"group", "set-history", "--group=cid", "--option=ALL"},
|
||||
{"group", "audit-join-validation", "--group=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
|
||||
{"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
|
||||
{"mark-read", "--conversation-id=cid", "--message-id=mid"},
|
||||
{"text", "translate", "--query=hello", "--to=zh_CN"},
|
||||
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"},
|
||||
@@ -282,7 +382,7 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-card",
|
||||
"--group=cid",
|
||||
"--conversation-id=cid",
|
||||
"--at-open-dingtalk-ids=D1,D2,D1",
|
||||
"--at-all",
|
||||
)
|
||||
@@ -303,13 +403,13 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
|
||||
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
|
||||
{name: "member mention rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-open-dingtalk-ids=D2"}},
|
||||
{name: "at all rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-all"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") {
|
||||
t.Fatalf("error = %v, want group-only mention validation", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
@@ -319,6 +419,168 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendCardHiddenAliasesMapToCanonicalPayload(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "group alias",
|
||||
args: []string{"--group=cid"},
|
||||
want: map[string]any{"openConversationId": "cid"},
|
||||
},
|
||||
{
|
||||
name: "receiver alias",
|
||||
args: []string{"--receiver=DAAAAAAAAAAAiE"},
|
||||
want: map[string]any{"receiverOpenDingTalkId": "DAAAAAAAAAAAiE"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, tc.want) {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationUsesCanonicalAndAliasPayload(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flag string
|
||||
}{
|
||||
{name: "canonical conversation-id", flag: "--conversation-id=cid"},
|
||||
{name: "hidden group alias", flag: "--group=cid"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"group", "audit-join-validation",
|
||||
tc.flag,
|
||||
"--record-id=123",
|
||||
"--applicant=D-applicant",
|
||||
"--inviter=D-inviter",
|
||||
"--status=AuditDelete",
|
||||
"--description=deny",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"applyRecordId": int64(123),
|
||||
"applicantUid": "D-applicant",
|
||||
"inviterUid": "D-inviter",
|
||||
"status": "AuditDelete",
|
||||
"auditDescription": "deny",
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "audit_join_group" || !reflect.DeepEqual(caller.args, want) {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path []string
|
||||
flag map[string]string
|
||||
want string
|
||||
}{
|
||||
{name: "message list mutually exclusive targets", path: []string{"message", "list"}, flag: map[string]string{"conversation-id": "cid", "user": "u1", "time": "2026-01-01"}, want: "mutually exclusive"},
|
||||
{name: "message list missing target", path: []string{"message", "list"}, flag: map[string]string{"time": "2026-01-01"}, want: "--conversation-id, --user or --open-dingtalk-id is required"},
|
||||
{name: "topic replies missing conversation", path: []string{"message", "list-topic-replies"}, flag: map[string]string{"topic-id": "t1"}, want: "conversation-id"},
|
||||
{name: "read status missing conversation", path: []string{"message", "read-status"}, flag: map[string]string{"message-id": "m1"}, want: "conversation-id"},
|
||||
{name: "read status conflicting aliases", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid1", "group": "cid2", "message-id": "m1"}, want: "conflicts"},
|
||||
{name: "read status missing message", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "update text emotion missing message", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t", "background-id": "b"}, want: "message-id"},
|
||||
{name: "update text emotion missing detail flag", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "message-id": "m1", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t"}, want: "background-id"},
|
||||
{name: "transfer owner missing conversation", path: []string{"group", "transfer-owner"}, flag: map[string]string{"new-owner": "D1"}, want: "conversation-id"},
|
||||
{name: "invite url missing conversation", path: []string{"group", "invite-url"}, want: "conversation-id"},
|
||||
{name: "quit missing conversation", path: []string{"group", "quit"}, want: "conversation-id"},
|
||||
{name: "update icon missing conversation", path: []string{"group", "update-icon"}, flag: map[string]string{"icon-media-id": "@media"}, want: "conversation-id"},
|
||||
{name: "update settings missing conversation", path: []string{"group", "update-settings"}, flag: map[string]string{"setting-key": "searchable"}, want: "conversation-id"},
|
||||
{name: "set admin missing conversation", path: []string{"group", "set-admin"}, flag: map[string]string{"users": "D1"}, want: "conversation-id"},
|
||||
{name: "role list missing conversation", path: []string{"group-role", "list"}, want: "group"},
|
||||
{name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"},
|
||||
{name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"},
|
||||
{name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"},
|
||||
{name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
|
||||
{name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
|
||||
{name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"},
|
||||
{name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"},
|
||||
{name: "bots rejects migrated conversation id", path: []string{"group", "bots"}, flag: map[string]string{"conversation-id": "cid"}, want: "no such flag"},
|
||||
{name: "dismiss missing conversation", path: []string{"group", "dismiss"}, flag: map[string]string{"yes": "true"}, want: "conversation-id"},
|
||||
{name: "set history missing conversation", path: []string{"group", "set-history"}, flag: map[string]string{"option": "ALL"}, want: "conversation-id"},
|
||||
{name: "set pin missing message", path: []string{"message", "set-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "unset pin missing message", path: []string{"message", "unset-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "audit join missing conversation", path: []string{"group", "audit-join-validation"}, flag: map[string]string{"record-id": "1", "applicant": "D1", "inviter": "D2", "status": "AuditApprove"}, want: "conversation-id"},
|
||||
{name: "set top missing message", path: []string{"message", "set-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "unset top missing message", path: []string{"message", "unset-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "update alias missing conversation", path: []string{"group", "update-alias"}, flag: map[string]string{"alias-title": "alias"}, want: "conversation-id"},
|
||||
{name: "notice create missing conversation", path: []string{"group", "notice", "create"}, flag: map[string]string{"content": "hello"}, want: "conversation-id"},
|
||||
{name: "notice edit missing conversation", path: []string{"group", "notice", "edit"}, flag: map[string]string{"notice-id": "n1", "content": "hello"}, want: "conversation-id"},
|
||||
{name: "notice get missing conversation", path: []string{"group", "notice", "get"}, flag: map[string]string{"notice-id": "n1"}, want: "conversation-id"},
|
||||
{name: "notice list missing conversation", path: []string{"group", "notice", "list"}, want: "conversation-id"},
|
||||
}
|
||||
|
||||
probe := newChatCommand()
|
||||
messageList, _, err := probe.Find([]string{"message", "list"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := chatConversationID(messageList); err != nil || got != "" {
|
||||
t.Fatalf("empty chatConversationID = %q, %v", got, err)
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := runChatCoverageDirect(t, tc.path, tc.flag)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageReadStatusConversationAliasesExecute(t *testing.T) {
|
||||
for _, alias := range []string{"group", "id", "chat", "open-conversation-id"} {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runChatCoverageCommand(t, caller, "message", "read-status", "--"+alias, "cid-1", "--message-id", "msg-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.server != "im" || caller.tool != "query_msg_read_status" {
|
||||
t.Fatalf("call = %s/%s, want im/query_msg_read_status", caller.server, caller.tool)
|
||||
}
|
||||
if got := caller.args["openConversationId"]; got != "cid-1" {
|
||||
t.Fatalf("openConversationId = %#v, want cid-1", got)
|
||||
}
|
||||
if got := caller.args["openMessageId"]; got != "msg-1" {
|
||||
t.Fatalf("openMessageId = %#v, want msg-1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
@@ -332,6 +594,7 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--open-dingtalk-id=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--user=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}}, "conversation-info", "--user=u1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--open-dingtalk-id=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--receiver=D1")
|
||||
|
||||
oldGet := httpGetFile
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
helperCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
helperCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
)
|
||||
|
||||
func newChatFlagTestCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "chat"}
|
||||
cmd.Flags().String("forward", "", "")
|
||||
@@ -37,6 +42,22 @@ func newChatFlagTestCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestNativeChatIDSplitUsesCurrentDFormat(t *testing.T) {
|
||||
userIDs, openIDs := splitChatIDValues([]string{
|
||||
helperCurrentDOpenID,
|
||||
"D-prefix-fixture-user",
|
||||
"d-prefix-fixture-user",
|
||||
"D-invalid",
|
||||
"fixture-user-id",
|
||||
})
|
||||
if len(openIDs) != 1 || openIDs[0] != helperCurrentDOpenID {
|
||||
t.Fatalf("open IDs=%#v", openIDs)
|
||||
}
|
||||
if got := strings.Join(userIDs, ","); got != "D-prefix-fixture-user,d-prefix-fixture-user,D-invalid,fixture-user-id" {
|
||||
t.Fatalf("user IDs=%#v", userIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
|
||||
search := &cobra.Command{Use: "search"}
|
||||
search.Flags().String("nicks", "", "")
|
||||
@@ -151,18 +172,18 @@ func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveOpenDingTalkIDsCoverage(t *testing.T) {
|
||||
if id, err := resolveOpenDingTalkID(context.Background(), "D-direct"); err != nil || id != "D-direct" {
|
||||
if id, err := resolveOpenDingTalkID(context.Background(), helperCurrentDOpenID); err != nil || id != helperCurrentDOpenID {
|
||||
t.Fatalf("direct ID = %q, %v", id, err)
|
||||
}
|
||||
if _, err := resolveOpenDingTalkID(context.Background(), ""); err == nil {
|
||||
t.Fatal("empty ID unexpectedly resolved")
|
||||
}
|
||||
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" D1 ", ""}); err != nil || ids[0] != "D1" {
|
||||
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" " + helperCurrentDOpenID + " ", ""}); err != nil || ids[0] != helperCurrentDOpenID {
|
||||
t.Fatalf("direct IDs = %#v, %v", ids, err)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}}}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != "D1" {
|
||||
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != helperCurrentDOpenID2 {
|
||||
t.Fatalf("resolved IDs = %#v, %v", ids, err)
|
||||
}
|
||||
caller.steps = []scriptedToolStep{{text: `{}`}, {text: `{}`}}
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
|
||||
caller := &chatFilePathCaller{}
|
||||
commandArgs := []string{
|
||||
"message", "send",
|
||||
"--open-dingtalk-id=D-target",
|
||||
"--open-dingtalk-id=" + helperCurrentDOpenID,
|
||||
"--msg-type=file",
|
||||
"--file-path=" + filePath,
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
|
||||
server: "im",
|
||||
tool: "init_conversation_file_upload",
|
||||
args: map[string]any{
|
||||
"openDingTalkId": "D-target",
|
||||
"openDingTalkId": helperCurrentDOpenID,
|
||||
"fileName": "report.pdf",
|
||||
"fileSize": int64(len(payload)),
|
||||
"md5": fileMD5,
|
||||
@@ -216,7 +216,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
|
||||
server: "im",
|
||||
tool: "commit_conversation_file_upload",
|
||||
args: map[string]any{
|
||||
"openDingTalkId": "D-target",
|
||||
"openDingTalkId": helperCurrentDOpenID,
|
||||
"uploadKey": "upload-key",
|
||||
"fileName": "report.pdf",
|
||||
"fileSize": int64(len(payload)),
|
||||
@@ -231,7 +231,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
|
||||
if send.server != "chat" || send.tool != "send_personal_message" || send.args["msgType"] != "file" {
|
||||
t.Fatalf("send direct target call = %#v", send)
|
||||
}
|
||||
if send.args["receiverOpenDingTalkId"] != "D-target" {
|
||||
if send.args["receiverOpenDingTalkId"] != helperCurrentDOpenID {
|
||||
t.Fatalf("send direct target = %#v", send.args)
|
||||
}
|
||||
if _, ok := send.args["openDingTalkId"]; ok {
|
||||
|
||||
@@ -17,8 +17,88 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationAliasContract(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "audit-join-validation"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := leaf.Flags().Lookup("conversation-id")
|
||||
if canonical == nil || canonical.Hidden {
|
||||
t.Fatalf("conversation-id flag = %#v, want visible canonical", canonical)
|
||||
}
|
||||
legacy := leaf.Flags().Lookup("group")
|
||||
if legacy == nil || !legacy.Hidden {
|
||||
t.Fatalf("group flag = %#v, want hidden compatibility alias", legacy)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
|
||||
t.Fatalf("group alias_of annotation = %#v", got)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("group alias_origin annotation = %#v", got)
|
||||
}
|
||||
if got := legacy.Annotations[cobra.BashCompOneRequiredFlag]; len(got) != 0 {
|
||||
t.Fatalf("hidden group alias kept required annotation: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationRestoreRequiredNoop(t *testing.T) {
|
||||
restoreChatGroupBotsLegacyRequired(nil)
|
||||
restoreChatPendingMigrationCanonicalRequired(nil)
|
||||
root := &cobra.Command{Use: "chat"}
|
||||
root.AddCommand(&cobra.Command{Use: "other"})
|
||||
restoreChatGroupBotsLegacyRequired(root)
|
||||
restoreChatPendingMigrationCanonicalRequired(root)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupBotsKeepsLegacyGroupFlag(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "bots"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group := leaf.Flags().Lookup("group")
|
||||
if group == nil || group.Hidden {
|
||||
t.Fatalf("group flag = %#v, want visible legacy flag", group)
|
||||
}
|
||||
if got := group.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
|
||||
t.Fatalf("group required annotation = %#v, want true", got)
|
||||
}
|
||||
if leaf.Flags().Lookup("conversation-id") != nil {
|
||||
t.Fatalf("chat group bots still exposes migrated --conversation-id")
|
||||
}
|
||||
if leaf.Flags().Lookup("group-name") != nil {
|
||||
t.Fatalf("chat group bots still exposes migrated --group-name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatPendingMigrationAliasesMatchManifest(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "dismiss"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := leaf.Flags().Lookup("conversation-id")
|
||||
if canonical == nil {
|
||||
t.Fatal("missing conversation-id flag")
|
||||
}
|
||||
if got := canonical.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
|
||||
t.Fatalf("conversation-id required annotation = %#v, want true", got)
|
||||
}
|
||||
legacy := leaf.Flags().Lookup("group")
|
||||
if legacy == nil || !legacy.Hidden {
|
||||
t.Fatalf("group flag = %#v, want hidden legacy alias", legacy)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
|
||||
t.Fatalf("group alias_of annotation = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -51,7 +131,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
|
||||
contains: []string{
|
||||
"send -> query-send-status -> edit",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
@@ -61,7 +141,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
|
||||
contains: []string{
|
||||
"send -> query-send-status -> recall",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
@@ -90,3 +170,184 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
contains []string
|
||||
absent []string
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
args: []string{"message", "list", "--help"},
|
||||
contains: []string{
|
||||
"--time 可选,不传时默认上海时间当前时间并向旧消息拉取",
|
||||
"默认上海时间当前时间",
|
||||
"未传 --time 时默认 older",
|
||||
},
|
||||
absent: []string{"开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)"},
|
||||
},
|
||||
{
|
||||
name: "search",
|
||||
args: []string{"message", "search", "--help"},
|
||||
contains: []string{
|
||||
"可选,不传时默认最近 7 天到当前时间",
|
||||
"默认当前时间前 7 天",
|
||||
"默认当前时间",
|
||||
},
|
||||
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
|
||||
},
|
||||
{
|
||||
name: "list-by-sender",
|
||||
args: []string{"message", "list-by-sender", "--help"},
|
||||
contains: []string{
|
||||
"--start 和 --end 可选,不传时默认最近 7 天到当前时间",
|
||||
"默认当前时间前 7 天",
|
||||
"默认当前时间",
|
||||
},
|
||||
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
|
||||
},
|
||||
{
|
||||
name: "list-mentions",
|
||||
args: []string{"message", "list-mentions", "--help"},
|
||||
contains: []string{
|
||||
"--start 和 --end 可选,不传时默认最近 7 天到当前时间",
|
||||
"默认当前时间前 7 天",
|
||||
"默认当前时间",
|
||||
},
|
||||
absent: []string{"开始时间,ISO-8601 格式 (必填)", "结束时间,ISO-8601 格式 (必填)"},
|
||||
},
|
||||
{
|
||||
name: "list-all",
|
||||
args: []string{"message", "list-all", "--help"},
|
||||
contains: []string{
|
||||
"--start 和 --end 可选,不传时默认最近 1 天到当前时间",
|
||||
"默认当前时间前 1 天",
|
||||
"默认当前时间",
|
||||
},
|
||||
absent: []string{"起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)", "结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)"},
|
||||
},
|
||||
{
|
||||
name: "download-media",
|
||||
args: []string{"message", "download-media", "--help"},
|
||||
contains: []string{
|
||||
"只支持聊天消息 mediaId 下载,不支持钉盘 fileId",
|
||||
"fileId 下载请使用钉盘/drive 下载命令",
|
||||
"仅支持聊天消息 mediaId,不支持钉盘 fileId",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs(test.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("dws chat %s: %v\n%s", strings.Join(test.args, " "), err, output.String())
|
||||
}
|
||||
help := output.String()
|
||||
for _, want := range test.contains {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Errorf("chat message %s help missing %q:\n%s", test.name, want, help)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range test.absent {
|
||||
if strings.Contains(help, unwanted) {
|
||||
t.Errorf("chat message %s help still contains %q:\n%s", test.name, unwanted, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatReactionHelpKeepsManifestExternalAliasesVisible(t *testing.T) {
|
||||
for _, command := range []string{"add-emoji", "remove-emoji", "add-text-emotion", "remove-text-emotion"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", command, "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message %s --help: %v\n%s", command, err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--conversation-id") {
|
||||
t.Fatalf("chat message %s help missing --conversation-id:\n%s", command, help)
|
||||
}
|
||||
for _, visible := range []string{"--group", "--id", "--chat"} {
|
||||
if !strings.Contains(help, visible+" string") {
|
||||
t.Fatalf("chat message %s help hides manifest-external alias %s:\n%s", command, visible, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupBotsHelpKeepsLegacyGroup(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"group", "bots", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat group bots --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--group string") {
|
||||
t.Fatalf("chat group bots help missing visible --group:\n%s", help)
|
||||
}
|
||||
for _, hidden := range []string{"--conversation-id", "--group-name"} {
|
||||
if strings.Contains(help, hidden) {
|
||||
t.Fatalf("chat group bots help exposes migrated flag %s:\n%s", hidden, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendCardHelpUsesCanonicalIDFlags(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", "send-card", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message send-card --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
for _, visible := range []string{"--conversation-id", "--open-dingtalk-id"} {
|
||||
if !strings.Contains(help, visible) {
|
||||
t.Fatalf("send-card help missing %s:\n%s", visible, help)
|
||||
}
|
||||
}
|
||||
for _, visible := range []string{"--group", "--receiver"} {
|
||||
if !strings.Contains(help, visible+" string") {
|
||||
t.Fatalf("send-card help hides manifest-external alias %s:\n%s", visible, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationHelpUsesCanonicalConversationID(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"group", "audit-join-validation", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat group audit-join-validation --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--conversation-id") {
|
||||
t.Fatalf("audit-join-validation help missing --conversation-id:\n%s", help)
|
||||
}
|
||||
if strings.Contains(help, "--group string") {
|
||||
t.Fatalf("audit-join-validation help exposes hidden --group alias:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -148,6 +149,356 @@ func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationUsesDefaultTimeWindows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantLookback time.Duration
|
||||
}{
|
||||
{
|
||||
name: "list-all defaults to one day",
|
||||
args: []string{"message", "list-all"},
|
||||
wantTool: "search_messages_by_time_range",
|
||||
wantLookback: 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "list-by-sender defaults to seven days",
|
||||
args: []string{"message", "list-by-sender", "--sender-user-id", "u1"},
|
||||
wantTool: "search_messages_by_sender",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "list-mentions defaults to seven days",
|
||||
args: []string{"message", "list-mentions"},
|
||||
wantTool: "search_at_me_message",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "search defaults to seven days",
|
||||
args: []string{"message", "search", "--query", "发布"},
|
||||
wantTool: "search_messages_by_keyword",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
before := time.Now()
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one call", caller.calls)
|
||||
}
|
||||
got := caller.calls[0]
|
||||
if got.tool != tt.wantTool {
|
||||
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
|
||||
}
|
||||
startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
|
||||
endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
|
||||
wantEndMin := before.Truncate(time.Second).UnixMilli()
|
||||
wantEndMax := after.Add(time.Second).UnixMilli()
|
||||
if endMs < wantEndMin || endMs > wantEndMax {
|
||||
t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax)
|
||||
}
|
||||
wantStartMin := before.Add(-tt.wantLookback).Truncate(time.Second).UnixMilli()
|
||||
wantStartMax := after.Add(-tt.wantLookback).Add(time.Second).UnixMilli()
|
||||
if startMs < wantStartMin || startMs > wantStartMax {
|
||||
t.Fatalf("startTime = %d, want between %d and %d", startMs, wantStartMin, wantStartMax)
|
||||
}
|
||||
if tt.wantTool == "search_messages_by_time_range" {
|
||||
assertChatMessageListAllTimeFormat(t, got.args["startTime"])
|
||||
assertChatMessageListAllTimeFormat(t, got.args["endTime"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationDefaultsStartFromExplicitEnd(t *testing.T) {
|
||||
endRaw := "2026-01-01T00:00:00+08:00"
|
||||
endMs, err := parseISOTimeToMillis("end", endRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantLookback time.Duration
|
||||
}{
|
||||
{
|
||||
name: "list-all defaults start one day before explicit end",
|
||||
args: []string{"message", "list-all", "--end", endRaw},
|
||||
wantTool: "search_messages_by_time_range",
|
||||
wantLookback: 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "list-by-sender defaults start seven days before explicit end",
|
||||
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--end", endRaw},
|
||||
wantTool: "search_messages_by_sender",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "list-mentions defaults start seven days before explicit end",
|
||||
args: []string{"message", "list-mentions", "--end", endRaw},
|
||||
wantTool: "search_at_me_message",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "search defaults start seven days before explicit end",
|
||||
args: []string{"message", "search", "--query", "发布", "--end", endRaw},
|
||||
wantTool: "search_messages_by_keyword",
|
||||
wantLookback: 7 * 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one call", caller.calls)
|
||||
}
|
||||
got := caller.calls[0]
|
||||
if got.tool != tt.wantTool {
|
||||
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
|
||||
}
|
||||
startMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
|
||||
gotEndMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
|
||||
if gotEndMs != endMs {
|
||||
t.Fatalf("endTime = %d, want %d", gotEndMs, endMs)
|
||||
}
|
||||
wantStartMs := time.UnixMilli(endMs).Add(-tt.wantLookback).UnixMilli()
|
||||
if startMs != wantStartMs {
|
||||
t.Fatalf("startTime = %d, want %d", startMs, wantStartMs)
|
||||
}
|
||||
if tt.wantTool == "search_messages_by_time_range" {
|
||||
assertStringArg(t, got.args["startTime"], formatChatMessageListAllTime(wantStartMs))
|
||||
assertStringArg(t, got.args["endTime"], endRaw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageListAllDefaultStartFromTimezoneLessEndUsesShanghai(t *testing.T) {
|
||||
previousLocal := time.Local
|
||||
t.Cleanup(func() { time.Local = previousLocal })
|
||||
|
||||
for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} {
|
||||
t.Run(loc.String(), func(t *testing.T) {
|
||||
time.Local = loc
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, "message", "list-all", "--end", "2026-03-01 00:00:00")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one call", caller.calls)
|
||||
}
|
||||
got := caller.calls[0]
|
||||
assertStringArg(t, got.args["endTime"], "2026-03-01 00:00:00")
|
||||
assertStringArg(t, got.args["startTime"], "2026-02-28 00:00:00")
|
||||
|
||||
startMs, err := parseISOTimeToMillis("start", got.args["startTime"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endMs, err := parseISOTimeToMillis("end", got.args["endTime"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotWindow := time.Duration(endMs-startMs) * time.Millisecond; gotWindow != 24*time.Hour {
|
||||
t.Fatalf("window = %v, want 24h", gotWindow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationDefaultsEndFromNowWhenOnlyStartProvided(t *testing.T) {
|
||||
startRaw := "2026-01-01T00:00:00+08:00"
|
||||
startMs, err := parseISOTimeToMillis("start", startRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
}{
|
||||
{
|
||||
name: "list-all defaults end to now",
|
||||
args: []string{"message", "list-all", "--start", startRaw},
|
||||
wantTool: "search_messages_by_time_range",
|
||||
},
|
||||
{
|
||||
name: "list-by-sender defaults end to now",
|
||||
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", startRaw},
|
||||
wantTool: "search_messages_by_sender",
|
||||
},
|
||||
{
|
||||
name: "list-mentions defaults end to now",
|
||||
args: []string{"message", "list-mentions", "--start", startRaw},
|
||||
wantTool: "search_at_me_message",
|
||||
},
|
||||
{
|
||||
name: "search defaults end to now",
|
||||
args: []string{"message", "search", "--query", "发布", "--start", startRaw},
|
||||
wantTool: "search_messages_by_keyword",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
before := time.Now()
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one call", caller.calls)
|
||||
}
|
||||
got := caller.calls[0]
|
||||
if got.tool != tt.wantTool {
|
||||
t.Fatalf("tool = %q, want %q", got.tool, tt.wantTool)
|
||||
}
|
||||
gotStartMs := chatMessageTimeArgAsMillis(t, got.args["startTime"], tt.wantTool)
|
||||
if gotStartMs != startMs {
|
||||
t.Fatalf("startTime = %d, want %d", gotStartMs, startMs)
|
||||
}
|
||||
endMs := chatMessageTimeArgAsMillis(t, got.args["endTime"], tt.wantTool)
|
||||
wantEndMin := before.Truncate(time.Second).UnixMilli()
|
||||
wantEndMax := after.Add(time.Second).UnixMilli()
|
||||
if endMs < wantEndMin || endMs > wantEndMax {
|
||||
t.Fatalf("endTime = %d, want between %d and %d", endMs, wantEndMin, wantEndMax)
|
||||
}
|
||||
if tt.wantTool == "search_messages_by_time_range" {
|
||||
assertStringArg(t, got.args["startTime"], startRaw)
|
||||
assertChatMessageListAllTimeFormat(t, got.args["endTime"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageListAllRejectsInvalidTimeBounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid start",
|
||||
args: []string{"message", "list-all", "--start", "not-a-time", "--end", "2020-01-01T00:00:00+08:00"},
|
||||
wantErr: "cannot parse time for --start",
|
||||
},
|
||||
{
|
||||
name: "end before start",
|
||||
args: []string{"message", "list-all", "--start", "2021-01-01T00:00:00+08:00", "--end", "2020-01-01T00:00:00+08:00"},
|
||||
wantErr: "--end must be after --start",
|
||||
},
|
||||
{
|
||||
name: "default end before future start",
|
||||
args: []string{"message", "list-all", "--start", "2027-01-01 00:00:00"},
|
||||
wantErr: "--end must be after --start",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, tt.args...)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want contains %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("calls = %#v, want no MCP call", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func numericArgAsInt64(t *testing.T, value any) int64 {
|
||||
t.Helper()
|
||||
switch v := value.(type) {
|
||||
case int64:
|
||||
return v
|
||||
case int:
|
||||
return int64(v)
|
||||
case float64:
|
||||
return int64(v)
|
||||
case string:
|
||||
parsed, err := parseISOTimeToMillis("time", v)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time arg %q: %v", v, err)
|
||||
}
|
||||
return parsed
|
||||
default:
|
||||
t.Fatalf("unsupported numeric arg type %T (%#v)", value, value)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func chatMessageTimeArgAsMillis(t *testing.T, value any, tool string) int64 {
|
||||
t.Helper()
|
||||
if tool == "search_messages_by_time_range" {
|
||||
return parseChatMessageListAllTimeArg(t, value).UnixMilli()
|
||||
}
|
||||
return numericArgAsInt64(t, value)
|
||||
}
|
||||
|
||||
func parseChatMessageListAllTimeArg(t *testing.T, value any) time.Time {
|
||||
t.Helper()
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("time arg = %#v, want time string", value)
|
||||
}
|
||||
if strings.Contains(raw, "T") {
|
||||
parsedMs, err := parseISOTimeToMillis("time", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("time arg = %q, parse err = %v", raw, err)
|
||||
}
|
||||
return time.UnixMilli(parsedMs)
|
||||
}
|
||||
parsedMs, err := parseISOTimeToMillis("time", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("time arg = %q, parse err = %v", raw, err)
|
||||
}
|
||||
return time.UnixMilli(parsedMs)
|
||||
}
|
||||
|
||||
func assertChatMessageListAllTimeFormat(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("time arg = %#v, want time string", value)
|
||||
}
|
||||
if strings.Contains(raw, "T") {
|
||||
t.Fatalf("time arg = %q, want yyyy-MM-dd HH:mm:ss without RFC3339 separator", raw)
|
||||
}
|
||||
if _, err := parseISOTimeToMillis("time", raw); err != nil {
|
||||
t.Fatalf("time arg = %q, parse err = %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringArg(t *testing.T, value any, want string) {
|
||||
t.Helper()
|
||||
got, ok := value.(string)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("arg = %#v, want %q", value, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -724,6 +724,99 @@ func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageListDefaultTimeUsesShanghaiLocation(t *testing.T) {
|
||||
previousLocal := time.Local
|
||||
t.Cleanup(func() { time.Local = previousLocal })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantTarget map[string]any
|
||||
}{
|
||||
{
|
||||
name: "group",
|
||||
args: []string{"message", "list", "--group", "cid-1", "--limit", "50"},
|
||||
wantTool: "list_conversation_message_v2",
|
||||
wantTarget: map[string]any{"openconversation_id": "cid-1"},
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
args: []string{"message", "list", "--user", "user-1", "--limit", "50"},
|
||||
wantTool: "list_individual_chat_message",
|
||||
wantTarget: map[string]any{"userId": "user-1"},
|
||||
},
|
||||
{
|
||||
name: "user open DingTalk ID fallback",
|
||||
args: []string{"message", "list", "--user", helperCurrentDOpenID, "--limit", "50"},
|
||||
wantTool: "list_individual_chat_message",
|
||||
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "open DingTalk ID",
|
||||
args: []string{"message", "list", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"},
|
||||
wantTool: "list_individual_chat_message",
|
||||
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "direct user",
|
||||
args: []string{"message", "list-direct", "--user", "user-1", "--limit", "50"},
|
||||
wantTool: "list_individual_chat_message",
|
||||
wantTarget: map[string]any{"userId": "user-1"},
|
||||
},
|
||||
{
|
||||
name: "direct open DingTalk ID",
|
||||
args: []string{"message", "list-direct", "--open-dingtalk-id", helperCurrentDOpenID, "--limit", "50"},
|
||||
wantTool: "list_individual_chat_message",
|
||||
wantTarget: map[string]any{"openDingTalkId": helperCurrentDOpenID},
|
||||
},
|
||||
}
|
||||
|
||||
for _, loc := range []*time.Location{time.UTC, time.FixedZone("EST", -5*3600)} {
|
||||
t.Run(loc.String(), func(t *testing.T) {
|
||||
time.Local = loc
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
before := time.Now()
|
||||
err := executeChatChangedContract(t, caller, tt.args...)
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one MCP call", caller.calls)
|
||||
}
|
||||
if caller.calls[0].toolName != tt.wantTool {
|
||||
t.Fatalf("tool = %q, want %q", caller.calls[0].toolName, tt.wantTool)
|
||||
}
|
||||
for key, want := range tt.wantTarget {
|
||||
if got := caller.calls[0].args[key]; got != want {
|
||||
t.Fatalf("arg %s = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
raw, ok := caller.calls[0].args["time"].(string)
|
||||
if !ok || raw == "" {
|
||||
t.Fatalf("time arg = %#v, want non-empty string", caller.calls[0].args["time"])
|
||||
}
|
||||
gotMs, err := parseISOTimeToMillis("time", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("time arg = %q, parse err = %v", raw, err)
|
||||
}
|
||||
wantMin := before.Add(-time.Second).UnixMilli()
|
||||
wantMax := after.Add(time.Second).UnixMilli()
|
||||
if gotMs < wantMin || gotMs > wantMax {
|
||||
t.Fatalf("time arg = %q (%d), want between %d and %d", raw, gotMs, wantMin, wantMax)
|
||||
}
|
||||
if caller.calls[0].args["forward"] != false {
|
||||
t.Fatalf("forward = %#v, want false when --time is omitted", caller.calls[0].args["forward"])
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller,
|
||||
@@ -786,11 +879,11 @@ func TestChatSendAndReplyDefaultToAgentProductForIMClawType(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "send",
|
||||
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello"},
|
||||
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello"},
|
||||
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -819,11 +912,11 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "send",
|
||||
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello", "--ai-tag=false"},
|
||||
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello", "--ai-tag=false"},
|
||||
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -857,25 +950,25 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
name: "send",
|
||||
args: []string{
|
||||
"message", "send", "--group", "cid",
|
||||
"--text", "收到 @D-target 和 <@D-second>",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second",
|
||||
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
|
||||
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "text",
|
||||
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
|
||||
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target", "D-second"},
|
||||
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
|
||||
},
|
||||
{
|
||||
name: "send keeps missing member placeholders unchanged",
|
||||
args: []string{
|
||||
"message", "send", "--group", "cid",
|
||||
"--text", "DWS 发消息自测",
|
||||
"--at-open-dingtalk-ids", "D-target",
|
||||
"--at-open-dingtalk-ids", helperCurrentDOpenID,
|
||||
},
|
||||
contentField: "text",
|
||||
wantContent: "DWS 发消息自测",
|
||||
wantOpenIDs: []string{"D-target"},
|
||||
wantOpenIDs: []string{helperCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
@@ -883,15 +976,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "收到 @D-target 和 <@D-second>",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second",
|
||||
"--ref-sender", helperCurrentDOpenID,
|
||||
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
|
||||
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
|
||||
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target", "D-second"},
|
||||
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
|
||||
},
|
||||
{
|
||||
name: "reply adds missing member placeholders",
|
||||
@@ -899,13 +992,13 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "DWS 回复艾特前津(非主用)自测",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
|
||||
"--ref-sender", helperCurrentDOpenID,
|
||||
"--text", "DWS synthetic reply mention test",
|
||||
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2 + "," + helperCurrentDOpenID,
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
|
||||
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
|
||||
wantContent: "<@" + helperCurrentDOpenID + "> <@" + helperCurrentDOpenID2 + "> DWS synthetic reply mention test",
|
||||
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2, helperCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "reply adds missing member placeholders after at-all",
|
||||
@@ -913,15 +1006,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--ref-sender", helperCurrentDOpenID,
|
||||
"--text", "请大家确认",
|
||||
"--at-open-dingtalk-ids", "D-target",
|
||||
"--at-open-dingtalk-ids", helperCurrentDOpenID,
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> <@D-target> 请大家确认",
|
||||
wantContent: "<@all> <@" + helperCurrentDOpenID + "> 请大家确认",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target"},
|
||||
wantOpenIDs: []string{helperCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "reply at-all preserves alliance word",
|
||||
@@ -929,7 +1022,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--ref-sender", helperCurrentDOpenID,
|
||||
"--text", "联系 @alliance",
|
||||
"--at-all",
|
||||
},
|
||||
@@ -943,7 +1036,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--ref-sender", helperCurrentDOpenID,
|
||||
"--text", "联系 @alliance",
|
||||
},
|
||||
contentField: "content",
|
||||
|
||||
@@ -81,6 +81,32 @@ func TestCrossPlatformCoverageChatUpdateTextEmotion(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "open-conversation-id alias",
|
||||
args: []string{
|
||||
"message", "update-text-emotion",
|
||||
"--open-conversation-id", "conv-3",
|
||||
"--message-id", "msg-3",
|
||||
"--old-emotion-id", "old-3",
|
||||
"--emotion-id", "new-3",
|
||||
"--emotion-name", "smile",
|
||||
"--text", "done",
|
||||
"--background-id", "im_bg_2",
|
||||
},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "update_text_emotion",
|
||||
args: map[string]any{
|
||||
"openConversationId": "conv-3",
|
||||
"openMsgId": "msg-3",
|
||||
"oldEmotionId": "old-3",
|
||||
"emotionId": "new-3",
|
||||
"emotionName": "smile",
|
||||
"text": "done",
|
||||
"backgroundId": "im_bg_2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
@@ -127,7 +153,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
{
|
||||
name: "missing conversation-id and aliases",
|
||||
args: dropFlag("--conversation-id"),
|
||||
wantErr: "at least one of the flags in the group [conversation-id group id chat] is required",
|
||||
wantErr: "missing required flag: --conversation-id (or --group / --id / --chat / --open-conversation-id)",
|
||||
},
|
||||
{
|
||||
name: "missing old-emotion-id",
|
||||
@@ -135,7 +161,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
wantErr: `required flag(s) "old-emotion-id" not set`,
|
||||
},
|
||||
{
|
||||
name: "missing msg-id and background-id",
|
||||
name: "missing message-id and background-id",
|
||||
args: []string{
|
||||
"message", "update-text-emotion",
|
||||
"--conversation-id", "conv-1",
|
||||
@@ -144,7 +170,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
"--emotion-name", "like",
|
||||
"--text", "nice",
|
||||
},
|
||||
wantErr: `required flag(s) "background-id", "msg-id" not set`,
|
||||
wantErr: "missing required flag: --message-id (or --msg-id / --open-message-id)",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -163,19 +189,41 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfig(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "get-mute-config", "--group", "conv-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get-mute-config returned error: %v", err)
|
||||
}
|
||||
want := guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-1"},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want guardedMutationCall
|
||||
}{
|
||||
{
|
||||
name: "legacy group alias",
|
||||
args: []string{"group", "get-mute-config", "--group", "conv-1"},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "canonical conversation id",
|
||||
args: []string{"group", "get-mute-config", "--conversation-id", "conv-2"},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-2"},
|
||||
},
|
||||
},
|
||||
} {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("get-mute-config returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
|
||||
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,12 +244,12 @@ func TestCrossPlatformCoverageChatGroupGetMuteConfigRecordsRawArgs(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresGroup(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresConversationID(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "get-mute-config")
|
||||
if err == nil || !strings.Contains(err.Error(), "--group") {
|
||||
t.Fatalf("err = %v, want message containing --group", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "--conversation-id") {
|
||||
t.Fatalf("err = %v, want message containing --conversation-id", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %#v, want none", caller.calls)
|
||||
|
||||
+298
-5
@@ -413,6 +413,13 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// --type/--start/--end 客户端过滤:激活即切 BFS 全量拉取后筛(两路由统一);
|
||||
// 未启用返回零值,存量分支字节级不变。互斥/非法值/start>end 在此拒绝。
|
||||
filter, err := parseDriveListFilter(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// --versions 模式:列出文件历史版本(仅普通文件)
|
||||
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
|
||||
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
|
||||
@@ -454,7 +461,8 @@ func newDriveCommand() *cobra.Command {
|
||||
if workspaceID != "" {
|
||||
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
|
||||
// 知识库无对应参数,静默忽略。
|
||||
if depth > 1 || latest > 0 {
|
||||
// filter 激活时 depth==1 也走 BFS 退化态(全量拉取→CLI 侧筛)。
|
||||
if depth > 1 || latest > 0 || filter.active() {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{"workspaceId": workspaceID}
|
||||
rootFolder := docFolderFlag(cmd, "node", "file-id")
|
||||
@@ -463,7 +471,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter)
|
||||
}
|
||||
if pattern != "" {
|
||||
return &CLIError{
|
||||
@@ -487,7 +495,9 @@ func newDriveCommand() *cobra.Command {
|
||||
return callMCPToolOnServer("doc", "list_nodes", toolArgs)
|
||||
}
|
||||
|
||||
if depth > 1 {
|
||||
// 钉盘:filter 激活即 depth=1 单层退化态(全量翻完当前目录后 CLI 侧筛);
|
||||
// filter+latest 单层同走 BFS(先筛后排,不走 runDriveListLatest 凑够即停)。
|
||||
if depth > 1 || filter.active() {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
|
||||
@@ -508,7 +518,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest, filter)
|
||||
}
|
||||
|
||||
// 默认路由:钉盘文件列表
|
||||
@@ -563,6 +573,11 @@ func newDriveCommand() *cobra.Command {
|
||||
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
|
||||
argsMap["withThumbnail"] = true
|
||||
}
|
||||
// --pattern 页内过滤(现状缺口附带修复:透传即打印无法夹过滤,取回解析后筛);
|
||||
// 不带 pattern 时保持 callMCPTool 纯透传,存量行为不变。
|
||||
if pattern != "" {
|
||||
return callDriveListPageWithPattern(argsMap, pattern)
|
||||
}
|
||||
return callMCPTool("list_files", argsMap)
|
||||
},
|
||||
}
|
||||
@@ -591,11 +606,13 @@ func newDriveCommand() *cobra.Command {
|
||||
"用户要浏览「我的文件」/钉盘/网盘某目录下有哪些文件或文件夹时",
|
||||
"已知父文件夹 dentryUuid,要列出其子项以便继续 download/copy/move 时",
|
||||
"传 --workspace 时要列出文档空间/知识库根或子目录(与 wiki node list 场景重叠时,用户说钉盘/我的文件优先本命令)",
|
||||
"要在已知目录内按类型/修改时间做无关键词筛选时:--type file --start 7d(钉盘与知识库路由均可,CLI 侧过滤)",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"只记得关键词、不知道所在目录时改用 dws drive search",
|
||||
"明确要在某个知识库内按目录浏览且已有 workspaceId 时可用 dws wiki node list",
|
||||
"要找最近打开/编辑过的文档改用 dws drive recent",
|
||||
"带关键词的过滤改用 dws drive search(--extensions/--modified-from 等已可用)",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws drive list --limit 20 --format json",
|
||||
@@ -1209,8 +1226,11 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
|
||||
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
|
||||
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥;扫描触发 2000 条上限或途中目录读取失败时报错,不产出不完整的 Top-N (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
|
||||
driveListCmd.Flags().String("type", "", "按节点类型过滤: file|folder(客户端过滤:全量扫描后筛,钉盘/知识库均可用;与 --versions/--cursor/--order-by/--order/--limit 互斥)(可选)")
|
||||
driveListCmd.Flags().String("start", "", "按修改时间过滤·起始: 相对时间如 24h/7d/2w、RFC3339、YYYY-MM-DD(客户端过滤,互斥同 --type)(可选)")
|
||||
driveListCmd.Flags().String("end", "", "按修改时间过滤·截止: 语法同 --start(客户端过滤,互斥同 --type)(可选)")
|
||||
|
||||
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
|
||||
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
|
||||
@@ -3279,6 +3299,274 @@ func newDriveCommand() *cobra.Command {
|
||||
RegisterCrossProductAliases(child)
|
||||
}
|
||||
|
||||
driveStatusCmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "比较本地文件夹与钉盘文件夹的差异",
|
||||
Long: `比较本地文件夹与钉盘文件夹的差异:本地取 --local-folder(绝对路径),钉盘取
|
||||
--remote-folder(文件夹 dentryUuid)指向的文件夹,按精确 MD5(默认)或快速
|
||||
modified_time(--quick)逐文件比对。两侧各自递归遍历,rel_path 相对各自根目录。
|
||||
|
||||
输出五类差异:
|
||||
new_local 仅本地存在
|
||||
new_remote 仅钉盘存在
|
||||
modified 两侧都存在且本次检测判定为已变更
|
||||
unchanged 两侧都存在且本次检测判定为未变更
|
||||
unknown 两侧都存在,但 exact 模式下远端无可靠 MD5、无法核对内容(不判 unchanged/modified)
|
||||
|
||||
只比对钉盘 type=file 的二进制文件(跳过在线文档与快捷方式);本地只比对常规文件。`,
|
||||
Example: ` dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid>
|
||||
dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --space-id xxxx
|
||||
dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --quick`,
|
||||
RunE: runDriveStatus,
|
||||
}
|
||||
driveStatusCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
|
||||
driveStatusCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)")
|
||||
driveStatusCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
|
||||
driveStatusCmd.Flags().Bool("quick", false, "快速模式:只比较 modified_time,不计算 MD5 (可选)")
|
||||
|
||||
drivePullCmd := &cobra.Command{
|
||||
Use: "pull",
|
||||
Short: "把钉盘文件夹单向镜像到本地(Drive → 本地)",
|
||||
Long: `递归下载钉盘 --remote-folder 文件夹下所有 type=file 的文件到本地
|
||||
--local-folder 对应路径(子目录自动创建),单向、文件级镜像。
|
||||
|
||||
已存在的本地文件按 --if-exists 处理:
|
||||
skip 默认,安全:本地已存在则保持不动,只新增
|
||||
smart 推荐增量同步:本地 modified_time 已 ≥ 远端时则跳过下载
|
||||
overwrite 总是下载覆盖(Drive 作为权威源)
|
||||
|
||||
该命令会写入本地文件系统,执行前需要用户确认;非交互环境先用 --dry-run
|
||||
预览,确认后以相同参数追加 --yes 执行。
|
||||
|
||||
输出 summary(downloaded/skipped/failed)与逐文件 items。
|
||||
若有文件下载失败,命令以非零退出码退出,结构化结果仍在 stdout。`,
|
||||
Example: ` dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid>
|
||||
dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart
|
||||
dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --space-id xxxx`,
|
||||
RunE: runDrivePull,
|
||||
}
|
||||
drivePullCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
|
||||
drivePullCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)")
|
||||
drivePullCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
|
||||
drivePullCmd.Flags().String("if-exists", "skip", "本地文件已存在时的策略: skip|smart|overwrite;命令会写本地,执行需确认 (可选)")
|
||||
|
||||
drivePushCmd := &cobra.Command{
|
||||
Use: "push",
|
||||
Short: "把本地文件夹单向镜像到钉盘(本地 → Drive)",
|
||||
Long: `递归把本地 --local-folder 下的文件与子目录(含空目录)镜像到钉盘
|
||||
--remote-folder 文件夹:缺失的目录按需创建(已存在则复用,不重建),文件按
|
||||
--if-exists 处理。文件级镜像——只新增/覆盖,不删除远端多余文件。
|
||||
|
||||
已存在的远端文件按 --if-exists 处理:
|
||||
skip 默认,安全:已存在则保持不动,只新增
|
||||
smart 增量同步:远端 modified_time 已 ≥ 本地时跳过,否则走覆盖路径
|
||||
overwrite 覆盖远端同名文件
|
||||
|
||||
该命令会写入钉盘,执行前需要用户确认;非交互环境先用 --dry-run 预览,
|
||||
确认后以相同参数追加 --yes 执行。
|
||||
|
||||
输出 summary(uploaded/skipped/failed,uploaded 含新建与覆盖)与逐条 items
|
||||
(含 folder_created)。若有文件失败,命令以非零退出码退出,结构化结果仍在 stdout。`,
|
||||
Example: ` dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid>
|
||||
dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart
|
||||
dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists overwrite`,
|
||||
RunE: runDrivePush,
|
||||
}
|
||||
drivePushCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
|
||||
drivePushCmd.Flags().String("remote-folder", "", "钉盘目标文件夹 ID (dentryUuid) (必填)")
|
||||
drivePushCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
|
||||
drivePushCmd.Flags().String("if-exists", "skip", "远端文件已存在时的策略: skip|smart|overwrite;命令会写钉盘,执行需确认 (可选)")
|
||||
|
||||
driveSyncCmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "本地文件夹与钉盘文件夹双向同步(本地 ⇄ Drive)",
|
||||
Long: `把本地 --local-folder 与钉盘 --remote-folder 做文件级双向同步:先按精确 MD5
|
||||
(默认)或快速 modified_time(--quick)算出差异,再按方向执行:
|
||||
new_local 仅本地存在 → 上传到钉盘(缺失的远端目录按需创建)
|
||||
new_remote 仅钉盘存在 → 下载到本地
|
||||
modified 两侧都变更 → 按 --on-conflict 解决
|
||||
unchanged 两侧一致 → 不动
|
||||
|
||||
两侧都变更时的 --on-conflict 策略:
|
||||
skip 默认,两侧都不动并保留两边内容
|
||||
remote-wins 拉取远端覆盖本地
|
||||
local-wins 上传本地覆盖远端
|
||||
keep-both 本地文件改名保留,再拉取远端到原路径
|
||||
ask 交互式逐个询问
|
||||
|
||||
exact 模式下远端无可靠 MD5、内容无法核对的文件归入 unknown 并跳过(可改用 --quick)。
|
||||
文件级同步——只新增/覆盖,不删除任何一侧的多余文件。输出 summary(pulled/pushed/
|
||||
skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出,结构化结果仍在 stdout。
|
||||
|
||||
该命令会同时写入本地与钉盘,执行前需要用户确认;非交互环境先用 --dry-run
|
||||
预览,确认后以相同参数追加 --yes 执行。`,
|
||||
Example: ` dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid>
|
||||
dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --on-conflict local-wins
|
||||
dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --quick --on-conflict keep-both`,
|
||||
RunE: runDriveSync,
|
||||
}
|
||||
driveSyncCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
|
||||
driveSyncCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)")
|
||||
driveSyncCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
|
||||
driveSyncCmd.Flags().String("on-conflict", "skip", "两侧都变更时的策略: skip|remote-wins|local-wins|keep-both|ask;命令会写双端,执行需确认 (可选)")
|
||||
driveSyncCmd.Flags().Bool("quick", false, "快速模式:只比较 modified_time,不计算 MD5 (可选)")
|
||||
|
||||
DeclareLeafMetadata(driveStatusCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "folder_status",
|
||||
CanonicalPath: "drive.folder_status",
|
||||
CLIPath: "drive status",
|
||||
PrimaryCLIPath: "drive status",
|
||||
},
|
||||
Description: "比较本地文件夹与钉盘文件夹的差异,只读不落盘。",
|
||||
Result: driveFolderStatusResultSpec(),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files, walks the local tree, and compares both sides by MD5 or modification time; no single pinned RPC represents the diff.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "比较本地文件夹与钉盘文件夹的差异,只读不落盘。",
|
||||
UseWhen: []string{"需要先看清本地与钉盘之间哪些文件新增、变更或一致,再决定拉取还是推送时"},
|
||||
AvoidWhen: []string{"只要单个文件的元数据用 drive info;要真正传输文件用 drive pull / push / sync"},
|
||||
Examples: []string{
|
||||
"dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid>",
|
||||
"dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --quick",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "local-folder", Required: boolPtr(true)},
|
||||
{Name: "remote-folder", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
DeclareLeafMetadata(drivePullCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "folder_pull",
|
||||
CanonicalPath: "drive.folder_pull",
|
||||
CLIPath: "drive pull",
|
||||
PrimaryCLIPath: "drive pull",
|
||||
},
|
||||
Description: "把钉盘文件夹单向镜像到本地;写操作需确认,默认跳过本地既有文件。",
|
||||
DryRun: &contract.DryRunSpec{
|
||||
PreviewKind: contract.DryRunPreviewPlan,
|
||||
RemoteReads: true,
|
||||
},
|
||||
Result: driveFolderPullResultSpec(),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files and then downloads each file through drive/download_file plus an HTTP GET into a temporary file committed by an atomic rename; no single pinned RPC represents the mirror.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把钉盘文件夹单向镜像到本地;写操作需确认,默认跳过本地既有文件。",
|
||||
UseWhen: []string{"需要把整个钉盘文件夹拉到本地目录时"},
|
||||
AvoidWhen: []string{"只下载单个文件用 drive download;要把本地推到钉盘用 drive push;要双向对齐用 drive sync"},
|
||||
Examples: []string{
|
||||
"dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid>",
|
||||
"dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart --dry-run",
|
||||
},
|
||||
ExampleDispositions: driveFolderStatefulExampleDispositions(),
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "local-folder", Required: boolPtr(true)},
|
||||
{Name: "remote-folder", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
DeclareLeafMetadata(drivePushCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "folder_push",
|
||||
CanonicalPath: "drive.folder_push",
|
||||
CLIPath: "drive push",
|
||||
PrimaryCLIPath: "drive push",
|
||||
},
|
||||
Description: "把本地文件夹单向镜像到钉盘;写操作需确认,默认跳过远端既有文件。",
|
||||
DryRun: &contract.DryRunSpec{
|
||||
PreviewKind: contract.DryRunPreviewPlan,
|
||||
RemoteReads: true,
|
||||
},
|
||||
Result: driveFolderPushResultSpec(),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files, creates missing folders through drive/create_folder, and uploads each file through drive/get_upload_info plus an HTTP PUT and drive/commit_upload; no single pinned RPC represents the mirror.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把本地文件夹单向镜像到钉盘;写操作需确认,默认跳过远端既有文件。",
|
||||
UseWhen: []string{"需要把整个本地目录推送到钉盘文件夹时"},
|
||||
AvoidWhen: []string{"只上传单个文件用 drive upload;要把钉盘拉到本地用 drive pull;要双向对齐用 drive sync"},
|
||||
Examples: []string{
|
||||
"dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid>",
|
||||
"dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart --dry-run",
|
||||
},
|
||||
ExampleDispositions: driveFolderStatefulExampleDispositions(),
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "local-folder", Required: boolPtr(true)},
|
||||
{Name: "remote-folder", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
DeclareLeafMetadata(driveSyncCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "folder_sync",
|
||||
CanonicalPath: "drive.folder_sync",
|
||||
CLIPath: "drive sync",
|
||||
PrimaryCLIPath: "drive sync",
|
||||
},
|
||||
Description: "本地与钉盘文件夹双向同步;写操作需确认,默认跳过双端冲突。",
|
||||
DryRun: &contract.DryRunSpec{
|
||||
PreviewKind: contract.DryRunPreviewPlan,
|
||||
RemoteReads: true,
|
||||
},
|
||||
Result: driveFolderSyncResultSpec(),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command computes the same diff as drive status and then resolves it in both directions through drive/download_file, drive/create_folder, drive/get_upload_info and drive/commit_upload according to --on-conflict; no single pinned RPC represents the bidirectional sync.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "本地与钉盘文件夹双向同步;写操作需确认,默认跳过双端冲突。",
|
||||
UseWhen: []string{"需要让本地目录与钉盘文件夹互相补齐时"},
|
||||
AvoidWhen: []string{"只需单方向镜像用 drive pull / push;只想看差异用 drive status"},
|
||||
Examples: []string{
|
||||
"dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid>",
|
||||
"dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --on-conflict remote-wins --dry-run",
|
||||
},
|
||||
ExampleDispositions: driveFolderStatefulExampleDispositions(),
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "local-folder", Required: boolPtr(true)},
|
||||
{Name: "remote-folder", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
driveCmd.AddCommand(
|
||||
driveListCmd,
|
||||
driveListSpacesCmd,
|
||||
@@ -3302,6 +3590,11 @@ func newDriveCommand() *cobra.Command {
|
||||
drivePermissionCmd,
|
||||
drivePublishCmd,
|
||||
recycleCmd,
|
||||
// 同步命令:status / pull / push / sync
|
||||
driveStatusCmd,
|
||||
drivePullCmd,
|
||||
drivePushCmd,
|
||||
driveSyncCmd,
|
||||
driveStarCmd,
|
||||
driveCoverCmd,
|
||||
driveRevertCmd,
|
||||
|
||||
+228
-17
@@ -10,11 +10,14 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -178,7 +181,8 @@ func newDocDepthRoute() driveDepthRoute {
|
||||
}
|
||||
|
||||
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
|
||||
// filter 零值 = 未启用;启用时由 emitDriveDepthResult 管线在 pattern 之后、latest 之前筛。
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int, filter driveListFilter) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return printDriveDepthDryRun(route, baseArgs, maxDepth)
|
||||
}
|
||||
@@ -209,7 +213,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
|
||||
bfs:
|
||||
for len(queue) > 0 {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
|
||||
}
|
||||
folder := queue[0]
|
||||
queue = queue[1:]
|
||||
@@ -220,7 +224,7 @@ bfs:
|
||||
pages := 0
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
|
||||
}
|
||||
pages++
|
||||
if pages > maxPagesPerFolder {
|
||||
@@ -233,7 +237,7 @@ bfs:
|
||||
args := route.buildArgs(baseArgs, folder.id, pageToken)
|
||||
text, err := route.fetchPage(ctx, args)
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth, route, filter)
|
||||
}
|
||||
if err != nil {
|
||||
folderErr = err
|
||||
@@ -295,9 +299,14 @@ bfs:
|
||||
|
||||
if folderErr != nil {
|
||||
if driveDepthUnrecoverable(folderErr) {
|
||||
if latest > 0 {
|
||||
// 不完整集合上的 Top-N 会被误读为全局最新:latest 下不吐 partial,
|
||||
// 直接回根因错误(auth 过期 / 网络不可达比通用 token 更可操作)。
|
||||
return folderErr
|
||||
}
|
||||
// partial 照吐 stdout,错误详情走 stderr,非零退出
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
return folderErr
|
||||
@@ -330,26 +339,215 @@ bfs:
|
||||
}
|
||||
}
|
||||
|
||||
if truncated && latest > 0 {
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
|
||||
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
|
||||
}
|
||||
// BFS 序与修改时间无关:截断与递归途中目录失败都让未扫区域可能含更新文件,
|
||||
// 此时的 Top-N 不是全局最新,两者同属一条防线——拒绝以成功状态产出。
|
||||
if latest > 0 && (truncated || len(errs) > 0) {
|
||||
return driveLatestIncompleteError(latest, truncated, errs, driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID))
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter)
|
||||
}
|
||||
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
|
||||
// driveLatestScope 是原调用的完整候选集快照,用于生成不改变候选集的恢复命令。
|
||||
//
|
||||
// 恢复命令若丢掉任何一项,用户照抄后都会在**另一个集合**上拿到一份「看起来对」的 Top-N ——
|
||||
// 比直接报错更难发现:丢 --workspace/--space-id 会从知识库切到普通钉盘(或反之);丢 --folder
|
||||
// 会从子树跳到空间根;丢 --pattern/--type/--start/--end 会把全部条目纳入排序基。
|
||||
type driveLatestScope struct {
|
||||
// domain 是查询域 flag 串(如 "--workspace ws-1" / "--space-id sp-1"),无则空串。
|
||||
domain string
|
||||
// filters 是决定候选集的过滤 flag 串(--pattern/--type/--start/--end),无则空串。
|
||||
filters string
|
||||
// folder 是原调用实际使用的扫描根(已解析的 ID,非用户原始 URL),空则为空间根。
|
||||
folder string
|
||||
// depth 是原调用的 --depth 层数,让「去掉 --latest 重跑」的恢复命令给出确切层数。
|
||||
depth int
|
||||
// notes 收集无法安全内联进可执行命令的原值展示行。POSIX 构建下恒为空(单引号足够);
|
||||
// Windows 构建下含元字符的值走这里,命令里只留占位符。
|
||||
notes []string
|
||||
// 以上 domain/filters/folder 里的值全部经 driveLatestScopeValue 渲染:恢复命令是给用户
|
||||
// 直接复制到 shell 执行的,而 workspace 的常见形态就是带 & 查询串的 URL,pattern 又天然
|
||||
// 含 * 与中文,裸拼接会改变命令解析。
|
||||
}
|
||||
|
||||
// driveLatestValueRenderer 把用户值渲染成可内联的命令片段;ok 为 false 表示该值在目标 shell
|
||||
// 下无法安全内联。取成参数而非直接调用平台绑定函数,是为了让任一平台的测试都能驱动另一平台
|
||||
// 的降级分支 —— 否则「Windows 上降级为占位符」这条路在 POSIX 机器上永不可达、无法验证。
|
||||
type driveLatestValueRenderer func(string) (string, bool)
|
||||
|
||||
// value 渲染单个用户值供内联。值在目标 shell 下无法安全内联时,登记一条展示行并返回占位符
|
||||
// —— 宁可让用户手动粘一次,也不能给出一条粘贴即执行额外命令的「恢复命令」。
|
||||
// 展示行用 strconv.Quote 包裹并显式声明非可执行,与 internal/auth 侧展示 profile 标识一致。
|
||||
func (s *driveLatestScope) value(render driveLatestValueRenderer, label, v string) string {
|
||||
if inline, ok := render(v); ok {
|
||||
return inline
|
||||
}
|
||||
s.notes = append(s.notes, fmt.Sprintf("%s 原值(仅作数据展示,不是可执行命令)%s", label, strconv.Quote(v)))
|
||||
return driveLatestUnsafeValuePlaceholder
|
||||
}
|
||||
|
||||
// driveLatestUnsafeValuePlaceholder 是不可内联值在命令中的占位符。
|
||||
const driveLatestUnsafeValuePlaceholder = "<见下方原值>"
|
||||
|
||||
// driveLatestScopeFromCmd 从原命令抽完整候选集。--workspace 决定路由(知识库 vs 钉盘),判定与
|
||||
// drive list 里的路由分支同源(同一个 flagOrFallback(cmd, "workspace", "workspace-id"));
|
||||
// 钉盘侧的 --space-id 同样必须保留。目录 flag 名无需按路由切换:--folder 两条路由都接受。
|
||||
//
|
||||
// rootFolder 取 runDriveListDepth 实际使用的扫描根而非重新读 flag:用户可能传的是 URL,
|
||||
// 解析后的 ID 才是真正被扫描的目标,也是照抄时更精确的形态。
|
||||
func driveLatestScopeFromCmd(cmd *cobra.Command, depth int, rootFolder string) driveLatestScope {
|
||||
return driveLatestScopeFrom(cmd, depth, rootFolder, driveLatestScopeValue)
|
||||
}
|
||||
|
||||
// driveLatestScopeFrom 是 driveLatestScopeFromCmd 的可注入本体:render 决定用户值以何种形态
|
||||
// 进入恢复命令。生产路径固定传平台绑定的 driveLatestScopeValue;测试可传另一平台的策略,
|
||||
// 从而在单一平台上覆盖两种形态。
|
||||
func driveLatestScopeFrom(cmd *cobra.Command, depth int, rootFolder string, render driveLatestValueRenderer) driveLatestScope {
|
||||
scope := driveLatestScope{depth: depth}
|
||||
if workspaceID := flagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
|
||||
scope.domain = "--workspace " + scope.value(render, "--workspace", workspaceID)
|
||||
} else if spaceID, _ := cmd.Flags().GetString("space-id"); spaceID != "" {
|
||||
scope.domain = "--space-id " + scope.value(render, "--space-id", spaceID)
|
||||
}
|
||||
if rootFolder != "" {
|
||||
scope.folder = scope.value(render, "--folder", rootFolder)
|
||||
}
|
||||
// 顺序固定为注册顺序,保证同一组入参每次给出同一条恢复命令(便于用户比对与测试断言)。
|
||||
filters := make([]string, 0, 4)
|
||||
for _, name := range []string{"pattern", "type", "start", "end"} {
|
||||
if v, _ := cmd.Flags().GetString(name); v != "" {
|
||||
filters = append(filters, "--"+name+" "+scope.value(render, "--"+name, v))
|
||||
}
|
||||
}
|
||||
scope.filters = strings.Join(filters, " ")
|
||||
return scope
|
||||
}
|
||||
|
||||
// command 拼一条保留原候选集的恢复命令:查询域 + 指定的 --folder + 原过滤条件。
|
||||
// folderArg 传 "" 表示该条命令不带 --folder(原调用就在空间根时不应凭空塞一个)。
|
||||
func (s driveLatestScope) command(folderArg string) string {
|
||||
parts := make([]string, 0, 4)
|
||||
parts = append(parts, "dws drive list")
|
||||
if s.domain != "" {
|
||||
parts = append(parts, s.domain)
|
||||
}
|
||||
if folderArg != "" {
|
||||
parts = append(parts, "--folder "+folderArg)
|
||||
}
|
||||
if s.filters != "" {
|
||||
parts = append(parts, s.filters)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// driveLatestIncompleteError 是排序基不完整时的拒绝产出错误。截断与目录失败共用
|
||||
// CodeContentTruncated(→ ExitAPI),但 token 分开,便于消费方区分「范围太大」与「读不到」;
|
||||
// 二者同真时两个 token 都带。拒绝产出后 errors[] 不再进 stdout,失败详情必须落在错误消息里,
|
||||
// 否则用户完全瞎。调用点已保证 truncated 与 len(errs)>0 至少一真。
|
||||
func driveLatestIncompleteError(latest int, truncated bool, errs []driveDepthError, scope driveLatestScope) error {
|
||||
// 目录失败详情排在截断之前:BFS 可以先记下可恢复目录错误、再在别的目录撞上 2000 上限,
|
||||
// 此时 permission_denied 这类 reason 是用户唯一能动手修的线索,不能被截断提示吞掉。
|
||||
causes := make([]string, 0, 2)
|
||||
if len(errs) > 0 {
|
||||
causes = append(causes, driveLatestFolderFailureCause(errs))
|
||||
}
|
||||
if truncated {
|
||||
causes = append(causes, fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断", driveDepthMaxItems))
|
||||
}
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("%s,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d",
|
||||
strings.Join(causes, ";同时 "), latest),
|
||||
Suggestion: driveLatestIncompleteSuggestion(latest, truncated, len(errs) > 0, scope),
|
||||
}
|
||||
}
|
||||
|
||||
// driveLatestFolderFailureCause 组装目录失败详情,含首个失败的 folder/depth/reason。
|
||||
// folderName 空回落 folderID,两者都空回落 <root>。
|
||||
//
|
||||
// folderName / folderID / message 三项都是远端可控内容(目录名由共享目录的创建者决定,
|
||||
// message 是服务端错误文本),必须过 driveLatestSafeRemoteText。Reason 不用过:它是
|
||||
// classifyDriveDepthReason 的固定三值映射,与服务端字符串无关。
|
||||
func driveLatestFolderFailureCause(errs []driveDepthError) string {
|
||||
first := errs[0]
|
||||
folder := first.FolderName
|
||||
if folder == "" {
|
||||
folder = first.FolderID
|
||||
}
|
||||
if folder == "" {
|
||||
folder = "<root>"
|
||||
}
|
||||
return fmt.Sprintf("LATEST_SCAN_INCOMPLETE: %d 个目录未读全(首个失败 folder=%s depth=%d reason=%s: %s)",
|
||||
len(errs), driveLatestSafeRemoteText(folder), first.Depth, first.Reason,
|
||||
driveLatestSafeRemoteText(first.Message))
|
||||
}
|
||||
|
||||
// driveLatestSafeRemoteText 把远端可控文本压成可安全嵌进单行 stderr 错误消息的形式。
|
||||
//
|
||||
// 拒绝产出后 errors[] 不再进 stdout,失败详情改走纯文本错误消息 —— 而 JSON 编码会转义的
|
||||
// 控制字符在纯文本里会被终端直接执行:ANSI/OSC 序列可以伪造提示、清屏、隐藏后续内容、改窗口
|
||||
// 标题,在 AI Agent 场景还会污染上下文窗口。latest=0 的既有路径仍把原值放进 errors[] JSON,
|
||||
// 不受影响,也不该受影响(消费方需要原始数据)。
|
||||
//
|
||||
// output.SanitizeForTerminal 负责剥 ANSI/OSC、C0 控制字符与危险 Unicode,但按设计保留 \n 与
|
||||
// \t;本错误消息是单行叙述,故再把这两者折成空格,避免远端换行把一条错误拆成多行伪造输出。
|
||||
func driveLatestSafeRemoteText(s string) string {
|
||||
s = output.SanitizeForTerminal(s)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.ReplaceAll(s, "\t", " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// driveLatestIncompleteSuggestion 按实际触发的成因给恢复指引。约束两条:
|
||||
// 1. 每条示例命令都带原查询域(scope.base()),照抄不会切换查询域;
|
||||
// 2. 每个子句的示例命令与该子句正文一致——「去掉 --latest」的子句示例不带 --latest,
|
||||
// 否则照抄复现同一错误。
|
||||
func driveLatestIncompleteSuggestion(latest int, truncated, folderFailed bool, scope driveLatestScope) string {
|
||||
// 「缩小范围」类命令要求用户换一个目录,故 --folder 给占位符;查询域与原过滤条件由
|
||||
// command 一并带上,否则照抄后候选集就变了(例如丢掉 --pattern 会对全部条目取 Top-N)。
|
||||
narrowed := scope.command("<可读子目录ID>")
|
||||
clauses := make([]string, 0, 3)
|
||||
if folderFailed {
|
||||
clauses = append(clauses, "确认目录权限后重试")
|
||||
}
|
||||
switch {
|
||||
case folderFailed && truncated:
|
||||
// 两个成因都要解:既要换到可读目录,也要把范围缩到 2000 条以内。
|
||||
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录、并降低 --depth 层数后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
|
||||
case folderFailed:
|
||||
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
|
||||
default:
|
||||
clauses = append(clauses, fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 %s --latest %d", narrowed, latest))
|
||||
}
|
||||
// partial+errors[] 承诺限定 --depth>1:单层去掉 --latest 会路由回普通单层 list,本就无
|
||||
// errors[] 契约,故该子句只在多层时给出,并直接带上原层数。这是唯一一条「按原范围」命令,
|
||||
// 必须原样带回原 --folder(原调用在空间根时则不带),照抄即复现同一候选集、只是不取 Top-N。
|
||||
if folderFailed && scope.depth > 1 {
|
||||
clauses = append(clauses, fmt.Sprintf("需要看失败明细请去掉 --latest 按原范围重跑(同时输出已扫到的 partial 与 errors[] 明细):%s --depth %d", scope.command(scope.folder), scope.depth))
|
||||
}
|
||||
// Windows 构建下无法安全引用的值不会进入命令(cmd.exe 不把单引号当引号),原值改在此处以
|
||||
// 数据行给出,由用户手动替换占位符 —— 少一次复制粘贴的便利,换掉一条粘贴即执行的命令。
|
||||
if len(scope.notes) > 0 {
|
||||
clauses = append(clauses, fmt.Sprintf("命令中的 %s 请手动替换为 —— %s",
|
||||
driveLatestUnsafeValuePlaceholder, strings.Join(scope.notes, "、")))
|
||||
}
|
||||
return strings.Join(clauses, ";")
|
||||
}
|
||||
|
||||
// emitDriveDepthCancelled 处理 SIGINT 取消:吐已扫到的 partial(truncated=true)后回退出码 130。
|
||||
//
|
||||
// 这里刻意**不**套用 latest 的拒绝产出防线(只有 BFS 尾部 guard 与 unrecoverable 分支走):
|
||||
// 取消是用户主动发起的,退出码 130 本身已明确告知结果不完整,此时 partial 是用户的预期产物而
|
||||
// 非冒充全局最新的误导。sortTime 仍由 emitDriveDepthResult 统一剥除,取消路径不例外。
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth, route, filter); err != nil {
|
||||
return err
|
||||
}
|
||||
return &driveDepthCancelledError{}
|
||||
}
|
||||
|
||||
// depth>1 不输出 nextToken。
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
|
||||
if pattern != "" {
|
||||
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
@@ -364,8 +562,13 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
if filter.active() {
|
||||
// 类型(route.isFolder 反判)+ 时间(直读收集段注入的 sortTime 毫秒)区间筛
|
||||
items = applyDriveListFilter(items, route, filter)
|
||||
}
|
||||
if latest > 0 {
|
||||
items = applyDriveListLatest(items, latest)
|
||||
// --type folder 时 latest 对过滤后的目录取 Top-N,避免「先留目录再剔目录」的空结果。
|
||||
items = applyDriveListLatest(items, latest, filter.nodeType == "folder")
|
||||
} else {
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
@@ -383,7 +586,15 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
if latest > 0 && reqDepth == 1 {
|
||||
// sortTime 是内部排序字段(applyDriveListLatest 排 Top-N、applyDriveListFilter 筛时间区间
|
||||
// 时都已用完),任何输出路径都不得泄露进契约。放在这里一处覆盖三条 emit 路径:正常 emit、
|
||||
// SIGINT 取消、unrecoverable partial。
|
||||
// depth/parentId/rel_path 不在此处删——它们是 depth>1 的既有输出契约,
|
||||
// stripDriveDepthDecorations 仅在单层(reqDepth==1)把这套装饰整体剥掉。
|
||||
for _, item := range items {
|
||||
delete(item, "sortTime")
|
||||
}
|
||||
if (latest > 0 || filter.active()) && reqDepth == 1 {
|
||||
stripDriveDepthDecorations(items)
|
||||
}
|
||||
if items == nil {
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
|
||||
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
|
||||
}
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = decodeDepthResult(t, out)
|
||||
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
|
||||
}
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
|
||||
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = decodeDepthResult(t, out)["items"].([]any)
|
||||
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
deps.Out.w = failingWriter{}
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0, newDrivePanDepthRoute(), driveListFilter{}); err == nil {
|
||||
t.Fatal("failing writer returned nil")
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute
|
||||
t.Helper()
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0, driveListFilter{})
|
||||
return decodeDepthResult(t, out), err
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
@@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0, driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
@@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 3 {
|
||||
@@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil {
|
||||
t.Fatal("root failure returned nil")
|
||||
}
|
||||
@@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil {
|
||||
t.Fatal("unrecoverable returned nil")
|
||||
}
|
||||
@@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
|
||||
t.Fatalf("err = %v, want pagination anomaly", err)
|
||||
}
|
||||
@@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0, driveListFilter{})
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// 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 helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// driveFolderStatefulExampleDispositions 明确记录文件夹镜像示例为什么只做契约校验:
|
||||
// 预览必须读取用户指定的本地目录与真实钉盘目录,无法在隔离的 Agent 示例环境中伪造。
|
||||
// DryRun 能力仍由 ContractFinal 正式发布,命令级测试负责执行并验证零写入预览。
|
||||
func driveFolderStatefulExampleDispositions() []contract.ExampleDisposition {
|
||||
first, second := 0, 1
|
||||
return []contract.ExampleDisposition{
|
||||
{
|
||||
Index: &first,
|
||||
Mode: contract.ExampleDispositionModeContractOnly,
|
||||
ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
|
||||
Reason: "预览需要读取用户指定的真实钉盘目录和本地文件系统状态",
|
||||
Reviewed: true,
|
||||
},
|
||||
{
|
||||
Index: &second,
|
||||
Mode: contract.ExampleDispositionModeContractOnly,
|
||||
ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
|
||||
Reason: "预览需要读取用户指定的真实钉盘目录和本地文件系统状态",
|
||||
Reviewed: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func driveFolderStatusResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"本地文件夹与钉盘文件夹的差异",
|
||||
"properties":{
|
||||
"detection":{"type":"string","description":"差异检测模式","enum":["exact","quick"]},
|
||||
"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},
|
||||
"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},
|
||||
"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},
|
||||
"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},
|
||||
"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}
|
||||
},
|
||||
"required":["detection","new_local","new_remote","modified","unchanged","unknown"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func driveFolderPullResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomePartialFailure,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"钉盘文件夹拉取的执行结果或预览计划",
|
||||
"properties":{
|
||||
"summary":{"type":"object","description":"真实执行的动作计数","properties":{"downloaded":{"type":"integer","description":"成功下载的文件数"},"skipped":{"type":"integer","description":"按策略跳过的文件数"},"failed":{"type":"integer","description":"失败的文件数"}},"required":["downloaded","skipped","failed"],"additionalProperties":false},
|
||||
"items":{"type":"array","description":"真实执行的逐文件结果","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"},"action":{"type":"string","description":"执行动作"},"error":{"type":"string","description":"失败原因"}},"required":["rel_path","action"],"additionalProperties":false}},
|
||||
"dry_run":{"type":"boolean","description":"是否为只读预览"},
|
||||
"executed":{"type":"boolean","description":"是否执行了写操作"},
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令"},
|
||||
"if_exists":{"type":"string","description":"本地同名文件处理策略"},
|
||||
"plan":{"type":"object","description":"预览的拉取计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"downloaded":{"type":"integer","description":"计划下载的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的文件数"}},"required":["downloaded","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐文件动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"},"action":{"type":"string","description":"计划动作"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func driveFolderPushResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomePartialFailure,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"本地文件夹推送的执行结果或预览计划",
|
||||
"properties":{
|
||||
"summary":{"type":"object","description":"真实执行的动作计数","properties":{"uploaded":{"type":"integer","description":"成功上传或覆盖的文件数"},"skipped":{"type":"integer","description":"按策略跳过的文件数"},"failed":{"type":"integer","description":"失败的条目数"},"aborted":{"type":"boolean","description":"是否因失败中止后续上传"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},
|
||||
"items":{"type":"array","description":"真实执行的逐条目结果","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"执行动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"失败原因"}},"required":["rel_path","action"],"additionalProperties":false}},
|
||||
"dry_run":{"type":"boolean","description":"是否为只读预览"},
|
||||
"executed":{"type":"boolean","description":"是否执行了写操作"},
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令"},
|
||||
"if_exists":{"type":"string","description":"远端同名文件处理策略"},
|
||||
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"uploaded":{"type":"integer","description":"计划上传或覆盖的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func driveFolderSyncResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomePartialFailure,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"本地与钉盘双向同步的执行结果或预览计划",
|
||||
"properties":{
|
||||
"detection":{"type":"string","description":"差异检测模式","enum":["exact","quick"]},
|
||||
"diff":{"type":"object","description":"同步前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},
|
||||
"summary":{"type":"object","description":"真实执行的动作计数","properties":{"pulled":{"type":"integer","description":"成功拉取的条目数"},"pushed":{"type":"integer","description":"成功推送的条目数"},"skipped":{"type":"integer","description":"跳过的条目数"},"failed":{"type":"integer","description":"失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},
|
||||
"items":{"type":"array","description":"真实执行的逐条目结果","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"执行动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}},
|
||||
"dry_run":{"type":"boolean","description":"是否为只读预览","const":true},
|
||||
"executed":{"type":"boolean","description":"是否执行了写操作","const":false},
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令","const":"drive sync"},
|
||||
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数","properties":{"pulled":{"type":"integer","description":"计划拉取的条目数"},"pushed":{"type":"integer","description":"计划推送的条目数"},"skipped":{"type":"integer","description":"计划跳过的条目数"},"failed":{"type":"integer","description":"预检失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"oneOf":[
|
||||
{"required":["detection","diff","summary","items"]},
|
||||
{"required":["dry_run","executed","preview_kind","operation","plan"]}
|
||||
],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// 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 helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDriveFolderContractsPublishResultAndDryRun(t *testing.T) {
|
||||
drive := newDriveCommand()
|
||||
want := map[string]struct {
|
||||
canonical string
|
||||
dryRun bool
|
||||
outcomes []contract.ResultOutcome
|
||||
properties []string
|
||||
}{
|
||||
"status": {
|
||||
canonical: "drive.folder_status",
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
properties: []string{"detection", "modified", "new_local", "new_remote", "unchanged", "unknown"},
|
||||
},
|
||||
"pull": {
|
||||
canonical: "drive.folder_pull",
|
||||
dryRun: true,
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePartialFailure, contract.ResultOutcomeFailure},
|
||||
properties: []string{"dry_run", "executed", "if_exists", "items", "operation", "plan", "preview_kind", "summary"},
|
||||
},
|
||||
"push": {
|
||||
canonical: "drive.folder_push",
|
||||
dryRun: true,
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePartialFailure, contract.ResultOutcomeFailure},
|
||||
properties: []string{"dry_run", "executed", "if_exists", "items", "operation", "plan", "preview_kind", "summary"},
|
||||
},
|
||||
"sync": {
|
||||
canonical: "drive.folder_sync",
|
||||
dryRun: true,
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePartialFailure, contract.ResultOutcomeFailure},
|
||||
properties: []string{"detection", "diff", "dry_run", "executed", "items", "operation", "plan", "preview_kind", "summary"},
|
||||
},
|
||||
}
|
||||
|
||||
for name, expectation := range want {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
leaf, _, err := drive.Find([]string{name})
|
||||
if err != nil || leaf == nil {
|
||||
t.Fatalf("find drive %s: command=%v err=%v", name, leaf, err)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
||||
if !ok || final.Identity == nil || final.Identity.CanonicalPath != expectation.canonical {
|
||||
t.Fatalf("drive %s ContractFinal identity = %#v", name, final.Identity)
|
||||
}
|
||||
if final.Result == nil || !reflect.DeepEqual(final.Result.Outcomes, expectation.outcomes) {
|
||||
t.Fatalf("drive %s result = %#v, want outcomes %#v", name, final.Result, expectation.outcomes)
|
||||
}
|
||||
properties := resultSchemaProperties(t, final.Result.DataSchema)
|
||||
if got := sortedContractSchemaKeys(properties); !reflect.DeepEqual(got, expectation.properties) {
|
||||
t.Fatalf("drive %s result properties = %#v, want %#v", name, got, expectation.properties)
|
||||
}
|
||||
if expectation.dryRun {
|
||||
if final.DryRun == nil || final.DryRun.PreviewKind != contract.DryRunPreviewPlan || !final.DryRun.RemoteReads {
|
||||
t.Fatalf("drive %s dry_run = %#v, want remote-reading plan", name, final.DryRun)
|
||||
}
|
||||
if final.Selection == nil || len(final.Selection.ExampleDispositions) != len(final.Selection.Examples) {
|
||||
t.Fatalf("drive %s stateful example dispositions = %#v", name, final.Selection)
|
||||
}
|
||||
for _, disposition := range final.Selection.ExampleDispositions {
|
||||
if disposition.Mode != contract.ExampleDispositionModeContractOnly ||
|
||||
disposition.ReasonCode != contract.ExampleDispositionReasonStatefulPreflight || !disposition.Reviewed {
|
||||
t.Fatalf("drive %s invalid example disposition: %#v", name, disposition)
|
||||
}
|
||||
}
|
||||
} else if final.DryRun != nil {
|
||||
t.Fatalf("drive %s unexpectedly publishes dry_run: %#v", name, final.DryRun)
|
||||
}
|
||||
|
||||
if name == "sync" {
|
||||
assertDriveSyncResultSchema(t, final.Result.DataSchema)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func resultSchemaProperties(t *testing.T, raw json.RawMessage) map[string]any {
|
||||
t.Helper()
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("result data_schema is not JSON: %v\n%s", err, raw)
|
||||
}
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result data_schema.properties = %#v, want object", schema["properties"])
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
func sortedContractSchemaKeys(values map[string]any) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func assertDriveSyncResultSchema(t *testing.T, raw json.RawMessage) {
|
||||
t.Helper()
|
||||
properties := resultSchemaProperties(t, raw)
|
||||
diff := schemaObjectProperty(t, properties, "diff")
|
||||
assertSchemaRequired(t, diff, "new_local", "new_remote", "modified", "unchanged", "unknown")
|
||||
if got := sortedContractSchemaKeys(schemaProperties(t, diff)); !reflect.DeepEqual(got, []string{"modified", "new_local", "new_remote", "unchanged", "unknown"}) {
|
||||
t.Fatalf("sync diff properties = %#v", got)
|
||||
}
|
||||
|
||||
plan := schemaObjectProperty(t, properties, "plan")
|
||||
assertSchemaRequired(t, plan, "detection", "diff", "summary", "items")
|
||||
planProperties := schemaProperties(t, plan)
|
||||
planDiff := schemaObjectProperty(t, planProperties, "diff")
|
||||
assertSchemaRequired(t, planDiff, "new_local", "new_remote", "modified", "unchanged", "unknown")
|
||||
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oneOf, ok := schema["oneOf"].([]any)
|
||||
if !ok || len(oneOf) != 2 {
|
||||
t.Fatalf("sync result oneOf = %#v, want execution/dry-run alternatives", schema["oneOf"])
|
||||
}
|
||||
}
|
||||
|
||||
func schemaObjectProperty(t *testing.T, properties map[string]any, name string) map[string]any {
|
||||
t.Helper()
|
||||
property, ok := properties[name].(map[string]any)
|
||||
if !ok || property["type"] != "object" {
|
||||
t.Fatalf("schema property %s = %#v, want object", name, properties[name])
|
||||
}
|
||||
return property
|
||||
}
|
||||
|
||||
func schemaProperties(t *testing.T, schema map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema properties = %#v, want object", schema["properties"])
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
func assertSchemaRequired(t *testing.T, schema map[string]any, want ...string) {
|
||||
t.Helper()
|
||||
raw, ok := schema["required"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema required = %#v", schema["required"])
|
||||
}
|
||||
got := make([]string, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("schema required value = %#v, want string", value)
|
||||
}
|
||||
got = append(got, text)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("schema required = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,11 @@ func driveLatestExclusiveError(flag string, latest int) error {
|
||||
}
|
||||
}
|
||||
|
||||
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
|
||||
// foldersOnly=true 时对目录取 Top-N(--type folder --latest 组合),否则对文件取 Top-N。
|
||||
func applyDriveListLatest(items []map[string]any, latest int, foldersOnly bool) []map[string]any {
|
||||
files := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
|
||||
if (isDriveDepthFolder(item) || isDocDepthFolder(item)) != foldersOnly {
|
||||
continue
|
||||
}
|
||||
files = append(files, item)
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// 本文件锁定 drive list --latest 的两个 P1 行为,独立于 pr868_*_test.go / drive_depth_test.go:
|
||||
//
|
||||
// P1-a:sortTime 是内部排序字段,任何输出路径都不得泄露进契约;
|
||||
// P1-b:递归途中目录读取失败时,Top-N 建立在不完整集合上,必须拒绝产出而非吐 partial。
|
||||
//
|
||||
// 改代码前这些断言对 origin/main 应为红:main 采集端无条件写 sortTime、emit 仅在单层
|
||||
// latest/filter 才剥;main 尾部拒绝 guard 只拦截断、不拦目录失败。
|
||||
//
|
||||
// 另锁定评审反馈的三个边界:
|
||||
//
|
||||
// 截断与目录失败同真时,失败详情(permission_denied 等)不得被截断提示吞掉;
|
||||
// 拒绝产出时给的恢复命令必须保留原查询域(--workspace / --space-id),照抄不会切换查询域;
|
||||
// 恢复命令里的用户值必须过 argv 引用,URL 查询串与 shell 元字符都不能改变命令解析。
|
||||
//
|
||||
// 关于 TestCrossPlatformCoverage 前缀:这是门禁约定,不是命名风格。平台覆盖率门禁
|
||||
// scripts/policy/run-platform-coverage-gate.sh 只跑
|
||||
// -run '^(TestAllShortcuts|TestCrossPlatformCoverage)',本包新增的生产代码若没有带该前缀的
|
||||
// 测试覆盖,Coverage (macOS) / (Windows) 会以「changed code coverage 低于 100%」失败
|
||||
// (本 PR 改名前实测 69.0476%,84 条可执行语句)。改名务必保留前缀。
|
||||
|
||||
// assertNoSortTime 断言 stdout 每个 item 都不含内部排序字段 sortTime。
|
||||
func assertNoSortTime(t *testing.T, result map[string]any) {
|
||||
t.Helper()
|
||||
items, ok := result["items"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("items missing or wrong type: %#v", result["items"])
|
||||
}
|
||||
for i, raw := range items {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("item[%d] not an object: %#v", i, raw)
|
||||
}
|
||||
if _, leaked := item["sortTime"]; leaked {
|
||||
t.Fatalf("item[%d] leaked internal sortTime into output contract: %#v", i, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestNoSortTimeLeak 覆盖 main 的覆盖漏洞:main 的
|
||||
// TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime 名字带 SortTime,
|
||||
// 却只断言 TRUNCATED、从不检查输出无 sortTime。这里把三条泄露路径都钉住。
|
||||
func TestCrossPlatformCoverageDriveLatestNoSortTimeLeak(t *testing.T) {
|
||||
twoFiles := `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifiedTime":1000},{"fileId":"f2","name":"b.txt","type":"FILE","modifiedTime":2000}]}`
|
||||
|
||||
// 场景 A:depth>1 --latest —— 走 applyDriveListLatest(只读 sortTime、不剥),reqDepth>1 不触发 strip。
|
||||
t.Run("depth_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
assertNoSortTime(t, decodeDepthResult(t, out))
|
||||
})
|
||||
|
||||
// 场景 B:--depth 2 无 latest 无 filter —— 走 else 分支树序排序,同样不触发 strip。
|
||||
t.Run("depth_no_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
assertNoSortTime(t, decodeDepthResult(t, out))
|
||||
})
|
||||
|
||||
// 场景 C:--depth 2 --type file —— #971 引入的 filter 也读 sortTime(applyDriveListFilter),
|
||||
// strip 条件 (latest>0||filter.active()) && reqDepth==1 在多层下依旧不成立,泄露面因此变大。
|
||||
t.Run("depth_filter_no_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{nodeType: "file"}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
if len(result["items"].([]any)) != 2 {
|
||||
t.Fatalf("filter 应保留两个 FILE: %#v", result["items"])
|
||||
}
|
||||
assertNoSortTime(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime 同时承担两件事:
|
||||
// 1. P1-a 的第四条路径 —— SIGINT 取消也走 emitDriveDepthResult,同样不得泄露 sortTime;
|
||||
// 2. SIGINT 契约锁 —— 本次 P1-b 只在 BFS 尾部 guard 与 unrecoverable 分支生效,**不改**
|
||||
// 取消路径:SIGINT 是用户主动中断、退出码 130 已明确告知不完整,partial 是明确预期。
|
||||
// 这条测试防止后续误把 fail-closed 扩到取消路径,也为外部评测用例
|
||||
// test_sigint_exits_130_with_partial 提供本地对照。
|
||||
func TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
out := installDepthCaller(t, caller)
|
||||
items := []map[string]any{
|
||||
{"fileId": "f1", "name": "a.txt", "type": "FILE", "sortTime": int64(2000), "rel_path": "a.txt", "depth": 2},
|
||||
}
|
||||
errs := []driveDepthError{
|
||||
{Depth: 1, FolderID: "fid-1", FolderName: "半路目录", Reason: "permission_denied", Message: "denied"},
|
||||
}
|
||||
// latest>0 且 reqDepth>1:main 在此不 strip,sortTime 泄露。
|
||||
err := emitDriveDepthCancelled(items, errs, "", 5, 3, newDrivePanDepthRoute(), driveListFilter{})
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
}
|
||||
if cancelErr.ExitCode() != 130 {
|
||||
t.Fatalf("exit code = %d, want 130", cancelErr.ExitCode())
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
// partial 契约不变:items 与 errors 都照吐,truncated 标记为真。
|
||||
if len(result["items"].([]any)) != 1 {
|
||||
t.Fatalf("取消路径应保留 partial items: %#v", result["items"])
|
||||
}
|
||||
if len(result["errors"].([]any)) != 1 {
|
||||
t.Fatalf("取消路径应保留 errors[]: %#v", result["errors"])
|
||||
}
|
||||
if result["truncated"] != true {
|
||||
t.Fatalf("取消结果 truncated = %#v, want true", result["truncated"])
|
||||
}
|
||||
assertNoSortTime(t, result)
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure 覆盖 P1-b:递归途中一个可恢复目录失败(403/business,
|
||||
// 非 auth 非限流 → 记 errs[] 跳过),Top-N 落在不完整集合上,必须拒绝产出。
|
||||
// 构造:根目录成功产出 FOLDER+FILE(collected>0 且 dirA 入队),子目录返回 forbidden.* →
|
||||
// recoverable → errs=[1]。旧代码尾部 `if truncated && latest>0` 不触发 → emit 吐 partial(err=nil);
|
||||
// 新代码 `len(errs)>0` → LATEST_SCAN_INCOMPLETE 且 stdout 无 items。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("err = %v, want LATEST_SCAN_INCOMPLETE", err)
|
||||
}
|
||||
// 拒绝产出:stdout 必须没有 items(不是 partial)。旧代码此处会吐 partial,断言随之失败。
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure 端到端证明「截断 + 目录失败」组合确实可达:
|
||||
// 根目录出 dirA/dirB 两个子目录 → dirA 权限失败记 errs[] → dirB 返回 2000 条触发全局截断,
|
||||
// 尾部 guard 拿到 truncated=true 且 len(errs)=1。旧实现在此让 truncated 短路,permission_denied
|
||||
// 详情整块丢失(评审反馈的阻塞点);现在两个 token 与失败详情都必须在。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
var bulk strings.Builder
|
||||
bulk.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
bulk.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&bulk, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
bulk.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"报表","type":"FOLDER"},{"fileId":"dirB","name":"dirB","type":"FOLDER"}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`}, // dirA:可恢复 → 记 errs[]
|
||||
{text: bulk.String()}, // dirB:撞 2000 上限 → truncated
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
if !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("组合场景缺 TRUNCATED token: %q", msg)
|
||||
}
|
||||
// 旧实现在这里丢掉整段目录失败详情。
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
|
||||
!strings.Contains(msg, "folder=报表") ||
|
||||
!strings.Contains(msg, "permission_denied") {
|
||||
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand 端到端验证查询域从原命令一路带到恢复命令:单测构造器
|
||||
// 拿不到的是 runDriveListDepth 里的接线(driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID)),这里补上。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"})
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{"spaceId": "sp-7"}, "", 4, "", true, 5, driveListFilter{})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
t.Fatalf("err = %T %v", err, err)
|
||||
}
|
||||
// 恢复命令必须带原 --space-id,且给出原 --depth 层数。
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "--space-id sp-7")
|
||||
if !strings.Contains(cliErr.Suggestion, "--depth 4") {
|
||||
t.Fatalf("恢复命令应保留原层数: %q", cliErr.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure 覆盖 P1-b 的另一半:递归途中遇不可恢复错误
|
||||
// (auth 过期 / 网络不可达)且 latest>0 时,不吐 partial,直接回根因错误。
|
||||
// 与 latest=0 的既有行为(TestCrossPlatformCoverageRunDriveListDepthUnrecoverable:partial
|
||||
// + errors[] 进 stdout 后非零退出)对照——latest 下 partial 的 Top-N 会被误读为全局最新,
|
||||
// 故必须拒绝产出;回根因错误而非 INCOMPLETE token,因为 auth/网络比通用截断提示更可操作。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
// 回根因错误:Code 仍是 auth 过期,不被包装成 LATEST_SCAN_INCOMPLETE。
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthTokenExpired {
|
||||
t.Fatalf("err = %T %v, want CodeAuthTokenExpired", err, err)
|
||||
}
|
||||
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("unrecoverable 应回根因错误而非 INCOMPLETE 包装: %q", cliErr.Message)
|
||||
}
|
||||
// 拒绝产出:不吐 partial(对照 latest=0 时会输出 2 条 items + 1 条 errors)。
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches 直接单测构造器的各条分支。
|
||||
func TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches(t *testing.T) {
|
||||
// 纯截断分支:errs 为空 → 只有 TRUNCATED。
|
||||
t.Run("truncated_only", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, true, nil, driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
if !strings.Contains(cliErr.Message, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("message = %q", cliErr.Message)
|
||||
}
|
||||
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("无目录失败时不应出现 INCOMPLETE: %q", cliErr.Message)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
})
|
||||
|
||||
// 纯目录失败分支:未截断 → 只有 INCOMPLETE,Message 含首个失败的 folder/depth/reason。
|
||||
t.Run("folder_failure_only", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(3, false, twoDriveDepthErrors(), driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
|
||||
!strings.Contains(msg, "报表") ||
|
||||
!strings.Contains(msg, "depth=2") ||
|
||||
!strings.Contains(msg, "permission_denied") ||
|
||||
!strings.Contains(msg, "2 个目录未读全") {
|
||||
t.Fatalf("message = %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("未截断时不应出现 TRUNCATED: %q", msg)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
})
|
||||
|
||||
// 组合分支(评审反馈的阻塞点):截断与目录失败同真时,旧实现让 truncated 短路、
|
||||
// permission_denied 这类目录失败详情整块丢失。现在两个 token 都必须在,且失败详情不得被吞。
|
||||
t.Run("truncated_with_folder_failures", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(4, true, twoDriveDepthErrors(), driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
// 两个成因都要可被消费方 token 匹配到。
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") || !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("组合场景需同时带两个 token: %q", msg)
|
||||
}
|
||||
// 目录失败详情必须完整保留——这是用户唯一能动手修的线索。
|
||||
if !strings.Contains(msg, "报表") ||
|
||||
!strings.Contains(msg, "depth=2") ||
|
||||
!strings.Contains(msg, "permission_denied") ||
|
||||
!strings.Contains(msg, "2 个目录未读全") {
|
||||
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "拒绝输出不完整的 Top-4") {
|
||||
t.Fatalf("message 缺少拒绝产出结论: %q", msg)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
// 组合场景的指引要同时覆盖两条补救:换可读目录 + 降层数。
|
||||
if !strings.Contains(cliErr.Suggestion, "确认目录权限") || !strings.Contains(cliErr.Suggestion, "--depth") {
|
||||
t.Fatalf("组合场景 suggestion 需同时给出权限与降层数补救: %q", cliErr.Suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// folderName 为空回落 folderID;folderID 也空回落 <root>。
|
||||
t.Run("folder_fallback", func(t *testing.T) {
|
||||
byID := driveLatestIncompleteError(1, false, []driveDepthError{{FolderID: "fid-x"}}, driveLatestScope{depth: 2})
|
||||
if !strings.Contains(byID.Error(), "folder=fid-x") {
|
||||
t.Fatalf("fallback to folderID: %v", byID)
|
||||
}
|
||||
byRoot := driveLatestIncompleteError(1, false, []driveDepthError{{}}, driveLatestScope{depth: 2})
|
||||
if !strings.Contains(byRoot.Error(), "folder=<root>") {
|
||||
t.Fatalf("fallback to <root>: %v", byRoot)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// twoDriveDepthErrors 是两条目录失败样本,首条用于断言「首个失败」详情。
|
||||
func twoDriveDepthErrors() []driveDepthError {
|
||||
return []driveDepthError{
|
||||
{Depth: 2, FolderID: "fid-9", FolderName: "报表", Reason: "permission_denied", Message: "denied"},
|
||||
{Depth: 1, FolderID: "fid-3", FolderName: "归档", Reason: "api_error", Message: "boom"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion 覆盖评审反馈的第二个阻塞点:恢复命令此前固定
|
||||
// 生成 `dws drive list --folder ...`,把原调用的 --workspace / --space-id 丢掉。用户照抄后
|
||||
// 会从知识库切到普通钉盘(或从指定钉盘空间切到「我的文件」),在另一个查询域里拿到一份
|
||||
// 「看起来对」的 Top-N —— 比直接报错更难发现。
|
||||
func TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
want string
|
||||
}{
|
||||
// 知识库路由:--workspace 决定路由,丢了就切到普通钉盘。
|
||||
{name: "workspace", flags: map[string]string{"workspace": "ws-1"}, want: "--workspace ws-1"},
|
||||
// 别名路径:--workspace-id 与 --workspace 同源(flagOrFallback)。
|
||||
{name: "workspace_id_alias", flags: map[string]string{"workspace-id": "ws-alias"}, want: "--workspace ws-alias"},
|
||||
// 钉盘路由:--space-id 丢了就退回「我的文件」。
|
||||
{name: "space_id", flags: map[string]string{"space-id": "sp-7"}, want: "--space-id sp-7"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, tc.flags), 3, "")
|
||||
// 三条成因组合下的恢复命令都必须带原查询域。
|
||||
for _, variant := range []struct {
|
||||
label string
|
||||
truncated bool
|
||||
errs []driveDepthError
|
||||
}{
|
||||
{"truncated_only", true, nil},
|
||||
{"folder_failure_only", false, twoDriveDepthErrors()},
|
||||
{"both", true, twoDriveDepthErrors()},
|
||||
} {
|
||||
err := driveLatestIncompleteError(5, variant.truncated, variant.errs, scope)
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
t.Fatalf("%s: err = %T %v", variant.label, err, err)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --workspace 优先于 --space-id:与 drive list 的路由判定同序(先看 workspace 再看 space-id),
|
||||
// 否则恢复命令会把知识库查询写成钉盘查询。
|
||||
t.Run("workspace_wins_over_space_id", func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{
|
||||
"workspace": "ws-1", "space-id": "sp-7",
|
||||
}), 3, "")
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, "--workspace ws-1") || strings.Contains(suggestion, "--space-id") {
|
||||
t.Fatalf("workspace 应优先且不混入 space-id: %q", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// 无查询域时不得凭空造 flag(原调用就是「我的文件」根,硬塞 scope 同样是改查询域)。
|
||||
t.Run("no_scope_adds_nothing", func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, nil), 3, "")
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if strings.Contains(suggestion, "--workspace") || strings.Contains(suggestion, "--space-id") {
|
||||
t.Fatalf("无 scope 时不应凭空造查询域 flag: %q", suggestion)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, suggestion, "")
|
||||
})
|
||||
|
||||
// depth==1(知识库 --latest 单层)时不给 --depth 1:partial+errors[] 契约只在多层成立,
|
||||
// 硬塞 --depth 1 会让「去掉 --latest 看明细」的子句自相矛盾。
|
||||
t.Run("single_depth_omits_depth_flag", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--workspace ws-1", depth: 1})
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if strings.Contains(suggestion, "--depth") {
|
||||
t.Fatalf("单层不应出现 --depth: %q", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// 多层时给出确切层数,用户无需把 <原层数> 换成数字。
|
||||
t.Run("multi_depth_emits_actual_depth", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--space-id sp-7", depth: 4})
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, "--depth 4") {
|
||||
t.Fatalf("应给出原层数 --depth 4: %q", suggestion)
|
||||
}
|
||||
if strings.Contains(suggestion, "<原层数>") {
|
||||
t.Fatalf("不应残留占位符: %q", suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues 覆盖评审的 P1 阻断项:恢复命令是给用户直接复制到
|
||||
// shell 里执行的,查询域的值来自用户输入(workspace 常见形态就是带查询串的 URL)。未引用时
|
||||
// 一个 `&` 就把命令拆成后台任务,`;` / `$()` 更能执行额外内容。
|
||||
// 断言落在「值被完整包在单引号里」而不只是「出现过」——后者对裸拼接也成立,抓不住缺陷。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flag string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
// 合法 workspace URL:& 在裸拼接下直接改变 shell 解析(前半段被丢进后台)。
|
||||
{name: "workspace_url", flag: "workspace", value: "https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc",
|
||||
want: `--workspace 'https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc'`},
|
||||
// 命令替换:裸拼接会在用户复制执行时真的跑起来。
|
||||
{name: "command_substitution", flag: "workspace", value: "$(id)", want: `--workspace '$(id)'`},
|
||||
// 分号拆语句。
|
||||
{name: "semicolon", flag: "space-id", value: "sp-7;id", want: `--space-id 'sp-7;id'`},
|
||||
// 空格拆参:裸拼接会让 --folder 收到错误的值。
|
||||
{name: "space", flag: "space-id", value: "sp 7", want: `--space-id 'sp 7'`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 显式注入 POSIX 策略:本测试断言的是「危险值被单引号正确包住」这一 POSIX 形态。
|
||||
// Windows 策略下这些值根本不内联(见 ...SuggestionNeverInlinesHostileValue),
|
||||
// 若走平台绑定,这里在 Windows runner 上会因形态不同而误报。
|
||||
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{tc.flag: tc.value}), 3, "", driveLatestPosixScopeValue)
|
||||
err := driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, tc.want) {
|
||||
t.Fatalf("查询域未安全引用\n want substring: %s\n got suggestion: %s", tc.want, suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder 覆盖自动 CR 的 P2:
|
||||
// --pattern/--type/--start/--end 与 --folder 同样决定 --latest 的候选集合。恢复命令丢掉任一项,
|
||||
// 用户照抄后就是在**另一个集合**上取 Top-N —— 原调用带 --pattern 时,缺了它的重试命令会对全部
|
||||
// 条目排序,结果「看起来成功」却答非所问,比直接报错更难发现。
|
||||
func TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder(t *testing.T) {
|
||||
cmd := newDriveListScopeCmd(t, map[string]string{
|
||||
"workspace": "ws-1",
|
||||
"pattern": "*日报*",
|
||||
"type": "file",
|
||||
"start": "7d",
|
||||
"end": "2026-08-01",
|
||||
})
|
||||
// 注入 POSIX 策略:`--pattern '*日报*'` 这种引用形态是 POSIX 专属,Windows 下该值会降级为
|
||||
// 占位符 + 展示行(见 ...SuggestionNeverInlinesHostileValue)。走平台绑定会在 Windows 误报。
|
||||
scope := driveLatestScopeFrom(cmd, 4, "folder-root", driveLatestPosixScopeValue)
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
|
||||
// 每条示例命令都要带齐查询域与全部过滤条件;pattern 含 * 与中文,必须是引用后的形态。
|
||||
for _, want := range []string{
|
||||
"--workspace ws-1",
|
||||
"--pattern '*日报*'",
|
||||
"--type file",
|
||||
"--start 7d",
|
||||
"--end 2026-08-01",
|
||||
} {
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cmdText, want) {
|
||||
t.Fatalf("恢复命令丢失候选集条件 %q:\n clause: %s", want, cmdText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 「去掉 --latest 按原范围重跑」是唯一的原范围命令,必须原样带回原 --folder 而非占位符,
|
||||
// 否则它就不是「原范围」。
|
||||
var origin string
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
if strings.Contains(clause, "去掉 --latest") {
|
||||
origin = extractTrailingDwsCommand(clause)
|
||||
}
|
||||
}
|
||||
if origin == "" {
|
||||
t.Fatalf("多层场景应给出「去掉 --latest 按原范围重跑」子句: %q", suggestion)
|
||||
}
|
||||
if !strings.Contains(origin, "--folder folder-root") {
|
||||
t.Fatalf("原范围命令应保留原 --folder: %q", origin)
|
||||
}
|
||||
if strings.Contains(origin, "<可读子目录ID>") {
|
||||
t.Fatalf("原范围命令不应把原 --folder 换成占位符: %q", origin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot 原调用就在空间根时不得凭空
|
||||
// 造 --folder:硬塞一个目录同样是改候选集。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"}), 3, "")
|
||||
err := driveLatestIncompleteError(2, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
if !strings.Contains(clause, "去掉 --latest") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(extractTrailingDwsCommand(clause), "--folder") {
|
||||
t.Fatalf("空间根扫描的原范围命令不应带 --folder: %q", clause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars 覆盖自动 CR 的 P2 安全项:
|
||||
// 拒绝产出后,目录名与服务端错误文本从 JSON(编码时会被转义)挪进了纯文本 stderr。若原样透传,
|
||||
// 其中的 ANSI/OSC 序列会被终端直接执行 —— 可清屏、伪造彩色「成功」、隐藏后续输出、改窗口标题,
|
||||
// 在 AI Agent 场景还会污染上下文窗口。目录名对共享目录而言是他人可控输入。
|
||||
func TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars(t *testing.T) {
|
||||
assertNoControlChars := func(t *testing.T, msg string) {
|
||||
t.Helper()
|
||||
for name, r := range map[string]rune{"ESC": 0x1b, "BEL": 0x07, "CR": '\r', "LF": '\n', "TAB": '\t'} {
|
||||
if strings.ContainsRune(msg, r) {
|
||||
t.Fatalf("错误消息残留 %s 控制字符: %q", name, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// folderName 路径:CSI 清屏 + 变色,外加 OSC 改标题。
|
||||
t.Run("folder_name", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(3, false, []driveDepthError{{
|
||||
Depth: 2,
|
||||
FolderName: "报表\x1b[2J\x1b[31m看起来成功\x1b[0m",
|
||||
Reason: "permission_denied",
|
||||
Message: "denied\r\n\x1b]0;pwned\x07次行",
|
||||
}}, driveLatestScope{depth: 2})
|
||||
msg := err.(*CLIError).Message
|
||||
assertNoControlChars(t, msg)
|
||||
// 清理不能把诊断信息一起抹掉:可读部分必须留下。
|
||||
if !strings.Contains(msg, "报表") || !strings.Contains(msg, "denied") || !strings.Contains(msg, "次行") {
|
||||
t.Fatalf("清理后应保留可读文本: %q", msg)
|
||||
}
|
||||
})
|
||||
|
||||
// folderName 为空时回落到 folderID,该字段同样来自服务端。
|
||||
t.Run("folder_id_fallback", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(1, true, []driveDepthError{{
|
||||
FolderID: "fid\x1b[1m-x",
|
||||
Reason: "api_error",
|
||||
Message: "boom",
|
||||
}}, driveLatestScope{depth: 1})
|
||||
msg := err.(*CLIError).Message
|
||||
assertNoControlChars(t, msg)
|
||||
if !strings.Contains(msg, "fid-x") {
|
||||
t.Fatalf("剥离控制序列后 folderID 应连成 fid-x: %q", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSafeRemoteText 直接钉住清理函数的各条分支:换行/制表符
|
||||
// 折成空格(SanitizeForTerminal 按设计保留这两者,但单行错误消息里它们会拆出伪造行),
|
||||
// 首尾空白收掉,可打印内容与中文原样保留。
|
||||
func TestCrossPlatformCoverageDriveLatestSafeRemoteText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "plain", in: "denied", want: "denied"},
|
||||
{name: "cjk", in: "报表目录", want: "报表目录"},
|
||||
{name: "csi", in: "a\x1b[31mb", want: "ab"},
|
||||
{name: "osc", in: "a\x1b]0;t\x07b", want: "ab"},
|
||||
{name: "newline_to_space", in: "a\nb", want: "a b"},
|
||||
{name: "tab_to_space", in: "a\tb", want: "a b"},
|
||||
{name: "carriage_return_dropped", in: "a\rb", want: "ab"},
|
||||
{name: "trim_outer", in: "\n denied \t", want: "denied"},
|
||||
{name: "empty", in: "", want: ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := driveLatestSafeRemoteText(tc.in); got != tc.want {
|
||||
t.Fatalf("driveLatestSafeRemoteText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue 端到端锁定评审提出的
|
||||
// Windows 注入阻断项:`--space-id sp-7&whoami` 这种值,在 POSIX 下靠单引号关停,但 cmd.exe
|
||||
// 不把单引号当引号,粘贴后 `&whoami` 仍会执行。因此不变量必须是「恢复命令里不存在能被目标
|
||||
// shell 解释的裸元字符」,两个平台分别用各自的手段满足:POSIX 引用内联,Windows 不内联。
|
||||
//
|
||||
// 两种形态都通过注入渲染策略在本机验证,不依赖当前 GOOS —— 否则「Windows 上降级为占位符」
|
||||
// 这条路在 POSIX 机器上永不可达,就成了只有 Windows runner 才跑到的盲区(平台覆盖率门禁也会
|
||||
// 因此报未覆盖)。平台绑定本身由 TestCrossPlatformCoverageDriveLatestScopeValueBinding 覆盖。
|
||||
func TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue(t *testing.T) {
|
||||
const hostile = "sp-7&whoami"
|
||||
newSuggestion := func(t *testing.T, render driveLatestValueRenderer) string {
|
||||
t.Helper()
|
||||
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{"space-id": hostile}), 3, "", render)
|
||||
return driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope).(*CLIError).Suggestion
|
||||
}
|
||||
|
||||
t.Run("windows_never_inlines", func(t *testing.T) {
|
||||
suggestion := newSuggestion(t, driveLatestWindowsScopeValue)
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
// cmd.exe 里 & 分隔命令,且单引号不是引号,故它压根不能进命令。
|
||||
if strings.Contains(cmdText, "&") {
|
||||
t.Fatalf("windows 形态的命令不得含 &: %s", cmdText)
|
||||
}
|
||||
if strings.Contains(cmdText, hostile) {
|
||||
t.Fatalf("windows 形态的命令不得内联原值: %s", cmdText)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
|
||||
t.Fatalf("应降级为占位符: %s", suggestion)
|
||||
}
|
||||
if !strings.Contains(suggestion, strconv.Quote(hostile)) {
|
||||
t.Fatalf("应在展示行给出原值: %s", suggestion)
|
||||
}
|
||||
if !strings.Contains(suggestion, "不是可执行命令") {
|
||||
t.Fatalf("展示行须显式声明非可执行: %s", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("posix_quotes_inline", func(t *testing.T) {
|
||||
suggestion := newSuggestion(t, driveLatestPosixScopeValue)
|
||||
if !strings.Contains(suggestion, "'"+hostile+"'") {
|
||||
t.Fatalf("posix 形态应单引号内联原值: %s", suggestion)
|
||||
}
|
||||
// POSIX 下不该无谓降级 —— 那会白白损失可复制体验。
|
||||
if strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
|
||||
t.Fatalf("posix 形态不应降级为占位符: %s", suggestion)
|
||||
}
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(cmdText, "&") && !strings.Contains(cmdText, "'"+hostile+"'") {
|
||||
t.Fatalf("posix 形态出现未引用的元字符: %s", cmdText)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newDriveListScopeCmd 造一个带 drive list 查询域与过滤 flag 的命令。flag 名与 newDriveCommand
|
||||
// 里 driveListCmd 的注册保持一致;workspace-id 是 cross-product 别名,此处显式注册以覆盖别名路径。
|
||||
func newDriveListScopeCmd(t *testing.T, flags map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
for _, name := range []string{
|
||||
"workspace", "workspace-id", "space-id", // 查询域
|
||||
"folder", // 扫描根(scope 从 rootFolder 参数取,注册仅为对齐真实命令)
|
||||
"pattern", "type", "start", "end", // 决定候选集的过滤条件
|
||||
} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name, value := range flags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s=%s: %v", name, value, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// assertDriveLatestSuggestion 钉住 Suggestion 的三条约束:
|
||||
// 1. 每条示例命令都带原查询域 wantScope(空串表示原调用无查询域,此时只跳过该项检查);
|
||||
// 2. 含 --latest 的引导子句存在;
|
||||
// 3. 「去掉 --latest」子句给出的示例命令本身不带 --latest(否则照抄复现同一错误)。
|
||||
func assertDriveLatestSuggestion(t *testing.T, suggestion, wantScope string) {
|
||||
t.Helper()
|
||||
clauses := strings.Split(suggestion, ";")
|
||||
sawLatestGuide := false
|
||||
for _, clause := range clauses {
|
||||
cmd := extractTrailingDwsCommand(clause)
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
if wantScope != "" && !strings.Contains(cmd, wantScope) {
|
||||
t.Fatalf("示例命令丢失原查询域 %q(照抄会切换查询域): %q", wantScope, cmd)
|
||||
}
|
||||
if strings.Contains(clause, "去掉 --latest") {
|
||||
if strings.Contains(cmd, "--latest") {
|
||||
t.Fatalf("「去掉 --latest」子句的示例命令仍含 --latest: %q", cmd)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(cmd, "--latest") {
|
||||
sawLatestGuide = true
|
||||
}
|
||||
}
|
||||
if !sawLatestGuide {
|
||||
t.Fatalf("no --latest-bearing guidance clause in suggestion: %q", suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// extractTrailingDwsCommand 抽子句里以 "dws " 开头的尾部命令片段(到子句末),无则空串。
|
||||
func extractTrailingDwsCommand(clause string) string {
|
||||
idx := strings.LastIndex(clause, "dws ")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return clause[idx:]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
|
||||
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
|
||||
//
|
||||
// 非 Windows 构建面向 POSIX shell,单引号可靠地关闭所有展开,故含元字符的值引用后内联即安全。
|
||||
// Windows 构建见 drive_latest_scope_windows.go —— 两个平台的策略本体都是
|
||||
// shell_quote.go 里的纯函数,可在任意平台被测试直接调用;本文件只做编译期绑定。
|
||||
func driveLatestScopeValue(value string) (string, bool) {
|
||||
return driveLatestPosixScopeValue(value)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package helpers
|
||||
|
||||
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
|
||||
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
|
||||
//
|
||||
// Windows 构建下没有对 cmd.exe 与 PowerShell 同时成立的引用形式(cmd.exe 不认单引号,双引号
|
||||
// 又挡不住 %VAR% 展开),故只内联本身就安全的值;理由与取舍详见
|
||||
// driveLatestWindowsScopeValue 的注释。POSIX 构建见 drive_latest_scope_posix.go。
|
||||
func driveLatestScopeValue(value string) (string, bool) {
|
||||
return driveLatestWindowsScopeValue(value)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// drive list --type/--start/--end 客户端过滤(CLI 侧筛)
|
||||
//
|
||||
// 两路由统一(拍板⑤):钉盘与知识库均为全量拉取后在进程内筛——
|
||||
// 复用 depth/latest 的 BFS 基建(钉盘单层 = depth=1 退化态,全量翻完当前目录不下钻),
|
||||
// 过滤挂在 emitDriveDepthResult 管线内(pattern 名称筛之后、latest Top-N 之前)。
|
||||
// 类型判定复用 route.isFolder 反判;时间比较直读 BFS 收集段注入的 sortTime 毫秒。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
type driveListFilter struct {
|
||||
nodeType string // ""=不过滤 / "file" / "folder"
|
||||
startMs int64
|
||||
endMs int64
|
||||
hasStart bool
|
||||
hasEnd bool
|
||||
}
|
||||
|
||||
func (f driveListFilter) active() bool {
|
||||
return f.nodeType != "" || f.hasStart || f.hasEnd
|
||||
}
|
||||
|
||||
// parseDriveListFilter 读取并校验 --type/--start/--end。
|
||||
// 三 flag 均未给值时返回零值(active()=false,调用方走存量分支)。
|
||||
// 非法值 / start>end / 互斥组合在此拒绝(退出码 3,风格对齐 drive_latest.go 互斥条款)。
|
||||
func parseDriveListFilter(cmd *cobra.Command) (driveListFilter, error) {
|
||||
var filter driveListFilter
|
||||
nodeType, _ := cmd.Flags().GetString("type")
|
||||
startRaw, _ := cmd.Flags().GetString("start")
|
||||
endRaw, _ := cmd.Flags().GetString("end")
|
||||
nodeType = strings.TrimSpace(nodeType)
|
||||
startRaw = strings.TrimSpace(startRaw)
|
||||
endRaw = strings.TrimSpace(endRaw)
|
||||
if nodeType == "" && startRaw == "" && endRaw == "" {
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
if nodeType != "" {
|
||||
switch strings.ToLower(nodeType) {
|
||||
case "file", "folder":
|
||||
filter.nodeType = strings.ToLower(nodeType)
|
||||
default:
|
||||
return filter, &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--type 取值非法: %q(合法值: file|folder)", nodeType),
|
||||
}
|
||||
}
|
||||
}
|
||||
if startRaw != "" {
|
||||
ms, err := parseDriveListTime(startRaw)
|
||||
if err != nil {
|
||||
return filter, &CLIError{Code: CodeInvalidParam, Message: fmt.Sprintf("--start %s", err)}
|
||||
}
|
||||
filter.startMs = ms
|
||||
filter.hasStart = true
|
||||
}
|
||||
if endRaw != "" {
|
||||
ms, err := parseDriveListTime(endRaw)
|
||||
if err != nil {
|
||||
return filter, &CLIError{Code: CodeInvalidParam, Message: fmt.Sprintf("--end %s", err)}
|
||||
}
|
||||
filter.endMs = ms
|
||||
filter.hasEnd = true
|
||||
}
|
||||
if filter.hasStart && filter.hasEnd && filter.startMs > filter.endMs {
|
||||
return filter, &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--start %q 晚于 --end %q,请修正时间范围", startRaw, endRaw),
|
||||
}
|
||||
}
|
||||
|
||||
// 互斥:filter 为 CLI 侧全量扫描模式,服务端分页/排序语义无法保持。
|
||||
if cmd.Flags().Changed("versions") {
|
||||
return filter, driveListFilterExclusiveError("versions", "--versions 为独立的版本列表模式,请去掉 --versions 或过滤条件")
|
||||
}
|
||||
// 用 Changed 判定而非值非空:显式 --cursor= 空值同样视为启用游标分页。
|
||||
for _, f := range []string{"cursor", "next-token", "page-token"} {
|
||||
if fl := cmd.Flags().Lookup(f); fl != nil && fl.Changed {
|
||||
return filter, driveListFilterExclusiveError("cursor", "过滤模式为从头全量扫描,无连续游标;如需逐页翻页请去掉过滤条件")
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"order-by", "order"} {
|
||||
if cmd.Flags().Changed(f) {
|
||||
return filter, driveListFilterExclusiveError(f, "过滤模式输出由客户端筛选后重排,服务端排序语义无法保持;如需服务端排序请去掉过滤条件")
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
|
||||
return filter, driveListFilterExclusiveError("limit", "过滤模式为全量扫描,数据量由全局上限 2000 与目录范围控制;如需控制单页条数请去掉过滤条件")
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func driveListFilterExclusiveError(flag, guidance string) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--type/--start/--end 不能与 --%s 同时使用:%s", flag, guidance),
|
||||
}
|
||||
}
|
||||
|
||||
// parseDriveListTime 单一时间解析入口:先试相对时间语法,失败回落 ISO8601
|
||||
// (agoal.go parseISO8601ToMillis,RFC3339/无时区默认 Asia/Shanghai/仅日期)。
|
||||
// 两 flag 共用同一入口;不接受毫秒时间戳(规约红线,与 search 历史遗留切割)。
|
||||
func parseDriveListTime(value string) (int64, error) {
|
||||
if ms, ok := parseDriveRelativeTimeAgo(value); ok {
|
||||
return ms, nil
|
||||
}
|
||||
if ms, err := parseISO8601ToMillis(value); err == nil {
|
||||
return ms, nil
|
||||
}
|
||||
return 0, fmt.Errorf("时间格式不支持: %q(支持: 相对时间如 24h/7d/2w、RFC3339 如 2026-08-01T00:00:00+08:00、无时区 ISO8601 如 2026-08-01 08:00:00(默认 Asia/Shanghai)、仅日期 2026-08-01;不支持毫秒时间戳与 m 单位)", value)
|
||||
}
|
||||
|
||||
// parseDriveRelativeTimeAgo 相对时间 Nh/Nd/Nw(小时/天/周),按本机时钟换算为绝对毫秒。
|
||||
// 不支持 m 单位(lark 双解析器 m=分钟/月语义打架的教训,直接规避);
|
||||
// ok=false 表示非相对时间语法,由调用方回落 ISO8601 解析。
|
||||
func parseDriveRelativeTimeAgo(value string) (int64, bool) {
|
||||
s := strings.TrimSpace(value)
|
||||
if len(s) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
var unit time.Duration
|
||||
switch s[len(s)-1] {
|
||||
case 'h':
|
||||
unit = time.Hour
|
||||
case 'd':
|
||||
unit = 24 * time.Hour
|
||||
case 'w':
|
||||
unit = 7 * 24 * time.Hour
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(s[:len(s)-1])
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return time.Now().Add(-time.Duration(n) * unit).UnixMilli(), true
|
||||
}
|
||||
|
||||
// applyDriveListFilter 在 emit 管线内做类型/时间筛(pattern 之后、latest Top-N 之前)。
|
||||
func applyDriveListFilter(items []map[string]any, route driveDepthRoute, filter driveListFilter) []map[string]any {
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if filter.nodeType != "" {
|
||||
folder := route.isFolder(item)
|
||||
if filter.nodeType == "folder" && !folder {
|
||||
continue
|
||||
}
|
||||
if filter.nodeType == "file" && folder {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if filter.hasStart || filter.hasEnd {
|
||||
// sortTime 由 BFS 收集段无条件注入(drive_depth.go 时间戳归一);
|
||||
// 无时间信息的条目(sortTime<=0)在时间条件下不可判定,保守滤除。
|
||||
ts, _ := item["sortTime"].(int64)
|
||||
if ts <= 0 {
|
||||
continue
|
||||
}
|
||||
if filter.hasStart && ts < filter.startMs {
|
||||
continue
|
||||
}
|
||||
if filter.hasEnd && ts > filter.endMs {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// callDriveListPageWithPattern 单层钉盘 --pattern 页内过滤(现状缺口附带修复:
|
||||
// 纯透传分支此前未消费 pattern,flag 文案承诺的客户端过滤静默失效)。
|
||||
// callMCPTool 透传即打印、无夹过滤的钩子,故取回解析后页内筛再输出;
|
||||
// 输出保留原 body 形态(nextToken 等分页字段不动),仅条目数组被筛。
|
||||
func callDriveListPageWithPattern(argsMap map[string]any, pattern string) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"tool": "list_files",
|
||||
"arguments": argsMap,
|
||||
"pattern": pattern,
|
||||
"note": "pattern 为客户端过滤,仅作用于本页返回条目",
|
||||
})
|
||||
}
|
||||
text, err := callMCPToolReturnText(context.Background(), "list_files", argsMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(text), &body) != nil {
|
||||
// 非 JSON 返回无法筛,原样输出(与透传分支的兜底形态一致)。
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
target := body
|
||||
if inner, ok := body["result"].(map[string]any); ok && driveDepthListItemsKey(inner) != "" {
|
||||
target = inner
|
||||
}
|
||||
if key := driveDepthListItemsKey(target); key != "" {
|
||||
arr, _ := target[key].([]any)
|
||||
filtered := make([]any, 0, len(arr))
|
||||
for _, entry := range arr {
|
||||
item, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
filtered = append(filtered, entry)
|
||||
continue
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = item["fileName"].(string)
|
||||
}
|
||||
if matchDriveNamePattern(name, pattern) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
target[key] = filtered
|
||||
}
|
||||
return deps.Out.PrintJSON(body)
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// drive list --type/--start/--end 客户端过滤测试矩阵(设计稿 §9.3)。
|
||||
// 命令级用例统一经 executeDriveListCapture 捕获 stdout JSON;
|
||||
// 零值 filter 的存量分支行为由 drive_depth_test.go / drive_list_modes_test.go 既有用例兜底。
|
||||
|
||||
// 钉盘路由(serverID 空)经 resolveProductID 扫 os.Args 推断产品,命令级测试需伪装命令行。
|
||||
func useDriveListArgs(t *testing.T) {
|
||||
t.Helper()
|
||||
old := os.Args
|
||||
os.Args = []string{"dws", "drive", "list"}
|
||||
t.Cleanup(func() { os.Args = old })
|
||||
}
|
||||
|
||||
func executeDriveListCapture(t *testing.T, caller edition.ToolCaller, args ...string) (*bytes.Buffer, error) {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
buf := &bytes.Buffer{}
|
||||
deps.Out.w = buf
|
||||
deps.Out.errW = io.Discard
|
||||
root := newDriveCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return buf, err
|
||||
}
|
||||
|
||||
// 1. 正向:--workspace --type file → list_nodes 全量拉取后仅 file,单层剥装饰字段。
|
||||
func TestCrossPlatformCoverageDriveListFilterWorkspaceTypeFile(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"nodes":[
|
||||
{"nodeId":"n1","name":"doc1","nodeType":"doc","updateTime":1754000000000},
|
||||
{"nodeId":"n2","name":"sub","nodeType":"folder","updateTime":1754000000000}
|
||||
],"hasMore":false}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--workspace", "ws-1", "--type", "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1(depth=1 退化态不下钻 folder)", caller.calls)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
items := result["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("filtered items = %#v", items)
|
||||
}
|
||||
item := items[0].(map[string]any)
|
||||
if item["nodeId"] != "n1" {
|
||||
t.Fatalf("item = %#v", item)
|
||||
}
|
||||
for _, key := range []string{"depth", "parentId", "rel_path", "sortTime"} {
|
||||
if _, ok := item[key]; ok {
|
||||
t.Fatalf("decoration %q not stripped: %#v", key, item)
|
||||
}
|
||||
}
|
||||
if result["truncated"] != false || result["maxDepth"] != float64(1) {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 正向:--workspace --start 7d --end <RFC3339> → sortTime 区间断言。
|
||||
func TestCrossPlatformCoverageDriveListFilterWorkspaceTimeRange(t *testing.T) {
|
||||
now := time.Now()
|
||||
within := now.Add(-24 * time.Hour).UnixMilli()
|
||||
before := now.Add(-30 * 24 * time.Hour).UnixMilli()
|
||||
after := now.Add(24 * time.Hour).UnixMilli()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"nodes":[
|
||||
{"nodeId":"in","name":"within","nodeType":"doc","updateTime":%d},
|
||||
{"nodeId":"old","name":"before","nodeType":"doc","updateTime":%d},
|
||||
{"nodeId":"new","name":"after","nodeType":"doc","updateTime":%d}
|
||||
],"hasMore":false}`, within, before, after)},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller,
|
||||
"list", "--workspace", "ws-1", "--start", "7d", "--end", now.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["nodeId"] != "in" {
|
||||
t.Fatalf("time-range items = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 组合:--workspace --depth 2 --type file → BFS 路径 filter 生效,装饰字段保留。
|
||||
func TestCrossPlatformCoverageDriveListFilterWithDepthKeepsDecorations(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"nodes":[
|
||||
{"nodeId":"fA","name":"dirA","nodeType":"folder"},
|
||||
{"nodeId":"n1","name":"doc1","nodeType":"doc"}
|
||||
],"hasMore":false}`},
|
||||
{text: `{"nodes":[{"nodeId":"n2","name":"doc2","nodeType":"doc"}],"hasMore":false}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller,
|
||||
"list", "--workspace", "ws-1", "--depth", "2", "--type", "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2(depth=2 下钻 dirA)", caller.calls)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("filtered items = %#v(folder 应被滤除)", items)
|
||||
}
|
||||
for _, raw := range items {
|
||||
item := raw.(map[string]any)
|
||||
if _, ok := item["depth"]; !ok {
|
||||
t.Fatalf("depth>1 装饰字段应保留: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 组合:--workspace --type file --latest 3 → filter 先筛后 Top-N;
|
||||
// 触顶时 LATEST_SCAN_TRUNCATED 拒绝优先于 filter 的 partial 语义。
|
||||
func TestCrossPlatformCoverageDriveListFilterWithLatestTopN(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"nodes":[
|
||||
{"nodeId":"d1","name":"dir","nodeType":"folder","updateTime":400},
|
||||
{"nodeId":"f1","name":"a","nodeType":"doc","updateTime":100},
|
||||
{"nodeId":"f2","name":"b","nodeType":"doc","updateTime":300},
|
||||
{"nodeId":"f3","name":"c","nodeType":"doc","updateTime":200},
|
||||
{"nodeId":"f4","name":"d","nodeType":"doc","updateTime":50}
|
||||
],"hasMore":false}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller,
|
||||
"list", "--workspace", "ws-1", "--type", "file", "--latest", "2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("top-n items = %#v", items)
|
||||
}
|
||||
// folder(updateTime 最大)不得进入 Top-N 排序基;筛选后按 sortTime 倒序。
|
||||
if items[0].(map[string]any)["nodeId"] != "f2" || items[1].(map[string]any)["nodeId"] != "f3" {
|
||||
t.Fatalf("top-n order = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListFilterLatestTruncatedRejected(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifyTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
|
||||
_, err := executeDriveListCapture(t, caller, "list", "--type", "file", "--latest", "2")
|
||||
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("err = %v, want LATEST_SCAN_TRUNCATED", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 钉盘路由:--folder + --type file 单层退化态全量翻页后仅 file;
|
||||
// --start 7d --latest 3 走 BFS(depth=1);filter 与 cursor/order-by/order/limit/versions 互斥退出码 3。
|
||||
func TestCrossPlatformCoverageDriveListFilterPanFolderTypeFile(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[
|
||||
{"fileId":"d1","name":"sub","type":"FOLDER"},
|
||||
{"fileId":"f1","name":"a.txt","type":"FILE"}
|
||||
],"nextToken":"p2"}`},
|
||||
{text: `{"items":[{"fileId":"f2","name":"b.txt","type":"FILE"}]}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--folder", "root-1", "--type", "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want 2(翻完当前目录两页,sub 不下钻)", len(caller.calls))
|
||||
}
|
||||
if caller.calls[0]["parentId"] != "root-1" || caller.calls[0]["maxResults"] != float64(driveDepthPageSize) {
|
||||
t.Fatalf("page args = %#v", caller.calls[0])
|
||||
}
|
||||
if caller.calls[1]["nextToken"] != "p2" {
|
||||
t.Fatalf("page2 args = %#v", caller.calls[1])
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("filtered items = %#v", items)
|
||||
}
|
||||
for _, raw := range items {
|
||||
item := raw.(map[string]any)
|
||||
if item["type"] != "FILE" {
|
||||
t.Fatalf("folder leaked: %#v", item)
|
||||
}
|
||||
if _, ok := item["depth"]; ok {
|
||||
t.Fatalf("single-layer decorations not stripped: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListFilterPanStartWithLatest(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
now := time.Now()
|
||||
within := now.Add(-24 * time.Hour).UnixMilli()
|
||||
stale := now.Add(-30 * 24 * time.Hour).UnixMilli()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"items":[
|
||||
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d},
|
||||
{"fileId":"f2","name":"b.txt","type":"FILE","modifyTime":%d},
|
||||
{"fileId":"f3","name":"c.txt","type":"FILE","modifyTime":%d}
|
||||
]}`, within-1000, within, stale)},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--start", "7d", "--latest", "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
if _, ok := result["truncated"]; !ok {
|
||||
t.Fatalf("filter+latest 应走 BFS 聚合形态: %#v", result)
|
||||
}
|
||||
items := result["items"].([]any)
|
||||
// --start 7d 先筛掉 stale,Top-1 取 sortTime 最大的 f2。
|
||||
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f2" {
|
||||
t.Fatalf("items = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListFilterExclusiveFlags(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"list", "--type", "file", "--cursor", "c1"},
|
||||
{"list", "--type", "file", "--cursor", ""},
|
||||
{"list", "--type", "file", "--order-by", "name"},
|
||||
{"list", "--start", "7d", "--order", "asc"},
|
||||
{"list", "--type", "file", "--limit", "5"},
|
||||
{"list", "--end", "2026-08-01", "--max", "5"},
|
||||
{"list", "--versions", "--node", "n1", "--type", "file"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
caller := &scriptedToolCaller{}
|
||||
_, err := executeDriveListCapture(t, caller, args...)
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
|
||||
t.Fatalf("args %v: err = %v, want CLIError exit 3", args, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "不能与") || !strings.Contains(err.Error(), "同时使用") {
|
||||
t.Fatalf("args %v: err = %v, want exclusivity message", args, err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("args %v: calls = %d, want 0", args, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 边界:--type 非法值列合法值;start>end 退出码 3;相对时间解析与 m/毫秒拒绝。
|
||||
func TestCrossPlatformCoverageDriveListFilterInvalidValues(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
_, err := executeDriveListCapture(t, caller, "list", "--type", "dir")
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
|
||||
t.Fatalf("--type dir err = %v, want exit 3", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file|folder") {
|
||||
t.Fatalf("--type dir err = %v, want valid values listed", err)
|
||||
}
|
||||
|
||||
_, err = executeDriveListCapture(t, caller, "list", "--start", "2026-08-10", "--end", "2026-08-01")
|
||||
if !errors.As(err, &cliErr) || cliErr.ExitCode() != ExitValidation {
|
||||
t.Fatalf("start>end err = %v, want exit 3", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "晚于") {
|
||||
t.Fatalf("start>end err = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListFilterTimeParsing(t *testing.T) {
|
||||
// 相对时间按本机时钟换算为绝对毫秒。
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
unit time.Duration
|
||||
}{
|
||||
{"24h", time.Hour},
|
||||
{"7d", 24 * time.Hour},
|
||||
{"2w", 7 * 24 * time.Hour},
|
||||
} {
|
||||
before := time.Now()
|
||||
ms, err := parseDriveListTime(tc.raw)
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.raw, err)
|
||||
}
|
||||
expected := time.Duration(mustAtoi(t, tc.raw[:len(tc.raw)-1])) * tc.unit
|
||||
lo := before.Add(-expected).UnixMilli()
|
||||
hi := after.Add(-expected).UnixMilli()
|
||||
if ms < lo || ms > hi {
|
||||
t.Fatalf("%s: ms = %d, want in [%d, %d]", tc.raw, ms, lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
// RFC3339 / 无时区 ISO8601(默认 Asia/Shanghai)/ 仅日期。
|
||||
shanghai := time.FixedZone("CST", 8*3600)
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want int64
|
||||
}{
|
||||
{"2026-08-01T00:00:00+08:00", time.Date(2026, 8, 1, 0, 0, 0, 0, shanghai).UnixMilli()},
|
||||
{"2026-08-01 08:00:00", time.Date(2026, 8, 1, 8, 0, 0, 0, shanghai).UnixMilli()},
|
||||
{"2026-08-01", time.Date(2026, 8, 1, 0, 0, 0, 0, shanghai).UnixMilli()},
|
||||
} {
|
||||
ms, err := parseDriveListTime(tc.raw)
|
||||
if err != nil || ms != tc.want {
|
||||
t.Fatalf("%s: ms = %d err = %v, want %d", tc.raw, ms, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// m 单位 / 毫秒时间戳 / 零与负值一律拒绝。
|
||||
for _, raw := range []string{"7m", "30m", "1754000000000", "0d", "-3d", "abc"} {
|
||||
if _, err := parseDriveListTime(raw); err == nil {
|
||||
t.Fatalf("%q accepted, want rejection", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustAtoi(t *testing.T, s string) int {
|
||||
t.Helper()
|
||||
n := 0
|
||||
if _, err := fmt.Sscanf(s, "%d", &n); err != nil {
|
||||
t.Fatalf("atoi %q: %v", s, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 7. dry-run:--workspace --type file --dry-run → printDriveDepthDryRun 路径,不发起调用。
|
||||
func TestCrossPlatformCoverageDriveListFilterDryRun(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--workspace", "ws-1", "--type", "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry-run calls = %d", caller.calls)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
if result["dry_run"] != true || result["tool"] != "list_nodes" || result["maxDepth"] != float64(1) {
|
||||
t.Fatalf("dry-run payload = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 触顶:2000 条上限 → partial + truncated=true + 退出码 0(filter 触顶结果不全但不会错)。
|
||||
func TestCrossPlatformCoverageDriveListFilterTruncatedPartial(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifyTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--type", "file")
|
||||
if err != nil {
|
||||
t.Fatalf("filter 触顶应放行(退出码 0): %v", err)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
if result["truncated"] != true {
|
||||
t.Fatalf("truncated = %#v", result["truncated"])
|
||||
}
|
||||
if got := len(result["items"].([]any)); got != driveDepthMaxItems {
|
||||
t.Fatalf("items len = %d, want %d(全 FILE 类型筛全保留)", got, driveDepthMaxItems)
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 回归:不带 filter 的存量分支行为不变;--pattern 单层钉盘页内过滤为本次有意修复。
|
||||
func TestCrossPlatformCoverageDriveListFilterRegressionUnaffectedPaths(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
// 单层纯透传(无 filter/pattern):body 原样输出,args 语义不变。
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"f1","name":"a.txt"}],"nextToken":"nt"}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
if result["nextToken"] != "nt" || len(result["items"].([]any)) != 1 {
|
||||
t.Fatalf("透传 body 被改变: %#v", result)
|
||||
}
|
||||
if _, ok := result["truncated"]; ok {
|
||||
t.Fatalf("透传形态不应含 BFS 聚合字段: %#v", result)
|
||||
}
|
||||
if caller.args["maxResults"] != float64(20) {
|
||||
t.Fatalf("args = %#v", caller.args)
|
||||
}
|
||||
|
||||
// workspace 单层透传:无 filter/depth/latest 时仍走单页 list_nodes。
|
||||
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"nodes":[{"nodeId":"n1","name":"doc1","nodeType":"doc"}],"hasMore":false}`},
|
||||
}}
|
||||
buf2, err := executeDriveListCapture(t, caller2, "list", "--workspace", "ws-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result2 := decodeDepthResult(t, buf2)
|
||||
if _, ok := result2["nodes"]; !ok {
|
||||
t.Fatalf("workspace 单层透传形态被改变: %#v", result2)
|
||||
}
|
||||
if caller2.args["pageSize"] != 20 || caller2.args["workspaceId"] != "ws-1" {
|
||||
t.Fatalf("args = %#v", caller2.args)
|
||||
}
|
||||
|
||||
// 钉盘单层 --latest(无 filter):仍走 runDriveListLatest(非 BFS 聚合形态)。
|
||||
now := time.Now().UnixMilli()
|
||||
caller3 := &depthArgsRecordingCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d}],"nextToken":""}`, now)},
|
||||
}}
|
||||
buf3, err := executeDriveListCapture(t, caller3, "list", "--latest", "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result3 := decodeDepthResult(t, buf3)
|
||||
if _, ok := result3["truncated"]; ok {
|
||||
t.Fatalf("单层 latest 不应切到 BFS 聚合形态: %#v", result3)
|
||||
}
|
||||
if len(result3["items"].([]any)) != 1 {
|
||||
t.Fatalf("items = %#v", result3["items"])
|
||||
}
|
||||
if caller3.calls[0]["orderBy"] != "modifyTime" || caller3.calls[0]["order"] != "desc" {
|
||||
t.Fatalf("latest 扫描参数被改变: %#v", caller3.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListPatternSingleLayerFixed(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[
|
||||
{"fileId":"f1","name":"a.txt"},
|
||||
{"fileId":"f2","name":"b.md"}
|
||||
],"nextToken":"nt"}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--pattern", "*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, buf)
|
||||
items := result["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" {
|
||||
t.Fatalf("pattern 页内过滤未生效: %#v", items)
|
||||
}
|
||||
if result["nextToken"] != "nt" {
|
||||
t.Fatalf("分页字段应保留: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// 10. 钉盘路由:--type folder 反向筛(FILE 被滤除,仅 FOLDER 保留)。
|
||||
func TestCrossPlatformCoverageDriveListFilterPanFolderTypeFolder(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[
|
||||
{"fileId":"d1","name":"sub","type":"FOLDER"},
|
||||
{"fileId":"f1","name":"a.txt","type":"FILE"}
|
||||
]}`},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--folder", "root-1", "--type", "folder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1(depth=1 退化态不下钻 folder)", caller.calls)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "d1" {
|
||||
t.Fatalf("folder filter items = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 钉盘路由:--start 下无时间信息的条目(sortTime<=0)不可判定,保守滤除。
|
||||
func TestCrossPlatformCoverageDriveListFilterPanMissingTimeDropped(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
now := time.Now().UnixMilli()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"items":[
|
||||
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d},
|
||||
{"fileId":"f2","name":"no-time.txt","type":"FILE"}
|
||||
]}`, now)},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--start", "7d")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" {
|
||||
t.Fatalf("missing-time 条目应被滤除: %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 单层钉盘 --pattern 页内过滤边界:dry-run 预览 / MCP 错误透传 / 非 JSON 原样输出 /
|
||||
// 非 map 条目保留 + name 缺失时 fileName 兜底匹配。
|
||||
func TestCrossPlatformCoverageDriveListPatternPassthroughEdges(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
|
||||
// dry-run:打印预览,不发起调用。
|
||||
dryCaller := &scriptedToolCaller{dry: true}
|
||||
buf, err := executeDriveListCapture(t, dryCaller, "list", "--pattern", "*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dryCaller.calls != 0 {
|
||||
t.Fatalf("dry-run calls = %d", dryCaller.calls)
|
||||
}
|
||||
preview := decodeDepthResult(t, buf)
|
||||
if preview["dry_run"] != true || preview["tool"] != "list_files" || preview["pattern"] != "*.txt" {
|
||||
t.Fatalf("dry-run payload = %#v", preview)
|
||||
}
|
||||
|
||||
// MCP 错误:原样返回。
|
||||
errCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("boom")}}}
|
||||
if _, err := executeDriveListCapture(t, errCaller, "list", "--pattern", "*.txt"); err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("err = %v, want boom", err)
|
||||
}
|
||||
|
||||
// 非 JSON 返回:无法筛,原样输出(与透传分支兜底形态一致)。
|
||||
rawCaller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "plain-text-result"}}}
|
||||
rawBuf, err := executeDriveListCapture(t, rawCaller, "list", "--pattern", "*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rawBuf.String(), "plain-text-result") {
|
||||
t.Fatalf("raw output = %q", rawBuf.String())
|
||||
}
|
||||
|
||||
// 非 map 条目原样保留;name 缺失时 fileName 兜底参与匹配。
|
||||
mixedCaller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[
|
||||
"raw-entry",
|
||||
{"fileId":"f1","fileName":"日报模板.doc"},
|
||||
{"fileId":"f2","name":"其他.txt"}
|
||||
],"nextToken":"nt"}`},
|
||||
}}
|
||||
mixedBuf, err := executeDriveListCapture(t, mixedCaller, "list", "--pattern", "*模板*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mixed := decodeDepthResult(t, mixedBuf)["items"].([]any)
|
||||
if len(mixed) != 2 || mixed[0] != "raw-entry" || mixed[1].(map[string]any)["fileId"] != "f1" {
|
||||
t.Fatalf("items = %#v", mixed)
|
||||
}
|
||||
}
|
||||
|
||||
// 13. --type folder --latest 回归(P1 CR):latest 对过滤后的目录按时间取 Top-N,
|
||||
// 不再「先留目录再剔目录」返回空列表。
|
||||
func TestCrossPlatformCoverageDriveListFilterFolderLatest(t *testing.T) {
|
||||
useDriveListArgs(t)
|
||||
now := time.Now().UnixMilli()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"items":[
|
||||
{"fileId":"d1","name":"old-dir","type":"FOLDER","modifyTime":%d},
|
||||
{"fileId":"d2","name":"new-dir","type":"FOLDER","modifyTime":%d},
|
||||
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":%d}
|
||||
]}`, now-5000, now, now)},
|
||||
}}
|
||||
buf, err := executeDriveListCapture(t, caller, "list", "--type", "folder", "--latest", "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := decodeDepthResult(t, buf)["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "d2" {
|
||||
t.Fatalf("folder latest items = %#v", items)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageDriveMirrorCoverageGaps 覆盖镜像整批 preflight 引入的
|
||||
// 输出失败和防御式解析分支。测试只调用命名 helper,不依赖真实 Drive 或网络。
|
||||
func TestCrossPlatformCoverageDriveMirrorCoverageGaps(t *testing.T) {
|
||||
t.Run("preflight output failures", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "local")
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`,
|
||||
})
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: failingWriter{}}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
pull := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, pull, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
if err := runDrivePull(pull, nil); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("pull preflight output error = %v", err)
|
||||
}
|
||||
|
||||
push := findDriveSubcommand(t, "push")
|
||||
mustSetFlags(t, push, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
if err := runDrivePush(push, nil); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("push preflight output error = %v", err)
|
||||
}
|
||||
|
||||
sync := findDriveSubcommand(t, "sync")
|
||||
mustSetFlags(t, sync, map[string]string{"local-folder": root, "remote-folder": "ROOT", "quick": "true"})
|
||||
if err := runDriveSync(sync, nil); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("sync preflight output error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pull remote-only preflight output failure", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"UP"},{"name":"a.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`,
|
||||
})
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: failingWriter{}}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
pull := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, pull, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
if err := runDrivePull(pull, nil); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("pull remote-only preflight output error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run preflight output failures", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
remote := map[string]*remoteFile{"A.txt": {RelPath: "A.txt"}, "a.txt": {RelPath: "a.txt"}}
|
||||
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: failingWriter{}}})
|
||||
if err := printDrivePullDryRun(root, ifExistsSkip, remote, []string{"A.txt", "a.txt"}, true); err == nil {
|
||||
t.Fatal("pull dry-run preflight must propagate output failure")
|
||||
}
|
||||
local := []localPushFile{{RelPath: "a.txt"}}
|
||||
if err := printDrivePushDryRun(ifExistsSkip,
|
||||
map[string]*remoteFile{"A.txt": {RelPath: "A.txt"}}, map[string]string{"": "ROOT"}, nil, local); err == nil {
|
||||
t.Fatal("push dry-run preflight must propagate output failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parser errors", func(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"result":`,
|
||||
`{"result":{"items":[],"hasMore":"bad"}}`,
|
||||
`{"result":{"items":null}}`,
|
||||
`{"result":{"items":"bad"}}`,
|
||||
`{"result":[`,
|
||||
`{"result":[true]}`,
|
||||
`{"result":[{}]}`,
|
||||
`{"result":true}`,
|
||||
} {
|
||||
if _, _, err := parseDriveList(body); err == nil {
|
||||
t.Errorf("parseDriveList(%q) unexpectedly succeeded", body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pull dry-run existing-file policies", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
skipPath := filepath.Join(root, "skip.txt")
|
||||
smartPath := filepath.Join(root, "smart.txt")
|
||||
mustWrite(t, skipPath, "skip")
|
||||
mustWrite(t, smartPath, "smart")
|
||||
smartMTime := readFileMTimeMillis(t, smartPath)
|
||||
remote := map[string]*remoteFile{
|
||||
"skip.txt": {RelPath: "skip.txt"},
|
||||
"smart.txt": {RelPath: "smart.txt", ModifiedTime: smartMTime - 1, ModifiedTimeValid: true},
|
||||
}
|
||||
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: io.Discard}})
|
||||
if err := printDrivePullDryRunWithPreflight(root, ifExistsSkip, remote, []string{"skip.txt"}, false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := printDrivePullDryRunWithPreflight(root, ifExistsSmart, remote, []string{"smart.txt"}, false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remote["smart.txt"] = &remoteFile{RelPath: "smart.txt"}
|
||||
if err := printDrivePullDryRunWithPreflight(root, ifExistsSmart, remote, []string{"smart.txt"}, false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pull mkdir and replace errors", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := pullListingCaller("")
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
testseam.Swap(t, &pullMkdirAll, func(string, os.FileMode) error { return errTestDownload })
|
||||
if action, err := pullOneFile(context.Background(), "", &remoteFile{FileID: "F"},
|
||||
filepath.Join(root, "blocked", "x.txt"), ifExistsOverwrite); action != pullActionFailed || err == nil {
|
||||
t.Fatalf("mkdir failure = (%q, %v)", action, err)
|
||||
}
|
||||
|
||||
targetDir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(targetDir, "target.txt"), "local")
|
||||
testseam.Swap(t, &pullMkdirAll, os.MkdirAll)
|
||||
testseam.Swap(t, &pullRename, func(*os.Root, string, string) error { return errTestDownload })
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("remote"), 0o644)
|
||||
})
|
||||
if action, err := pullOneFile(context.Background(), "", &remoteFile{FileID: "F"},
|
||||
filepath.Join(targetDir, "target.txt"), ifExistsOverwrite); action != pullActionFailed || err == nil {
|
||||
t.Fatalf("replace failure = (%q, %v)", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sync pull defensive failures", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
res := &driveSyncResult{}
|
||||
syncPullFile(res, context.Background(), "", &remoteFile{FileID: "F"}, root, "sub/a.txt", syncDirectionPull)
|
||||
if res.Summary.Failed != 1 {
|
||||
t.Fatalf("escape failed = %d", res.Summary.Failed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pull remote file rechecks symlink escape", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
action, err := pullRemoteFile(context.Background(), "", &remoteFile{FileID: "F"}, root,
|
||||
"sub/a.txt", ifExistsOverwrite)
|
||||
if action != pullActionFailed || err == nil {
|
||||
t.Fatalf("pull remote TOCTOU guard = (%q, %v)", action, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func runDriveMirrorCommand(t *testing.T, caller *driveScriptCaller, dryRun bool, args ...string) ([]byte, error) {
|
||||
t.Helper()
|
||||
caller.dryRun = dryRun
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.AddCommand(newDriveCommand())
|
||||
full := append(append([]string{}, args...), "--yes")
|
||||
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...))
|
||||
root.SetArgs(append([]string{"drive"}, full...))
|
||||
err := root.Execute()
|
||||
return append([]byte(nil), out.Bytes()...), err
|
||||
}
|
||||
|
||||
func captureMirrorHTTP(t *testing.T) (getCalls, putCalls *int) {
|
||||
t.Helper()
|
||||
gets, puts := 0, 0
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
gets++
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
||||
puts++
|
||||
return nil
|
||||
})
|
||||
return &gets, &puts
|
||||
}
|
||||
|
||||
func assertMirrorPreflightNoWrites(t *testing.T, caller *driveScriptCaller, getCalls, putCalls int) {
|
||||
t.Helper()
|
||||
for _, tool := range []string{"create_folder", "download_file", "get_upload_info", "commit_upload"} {
|
||||
if calls := caller.callsFor(tool); len(calls) != 0 {
|
||||
t.Errorf("mirror preflight must not call %s: %v", tool, calls)
|
||||
}
|
||||
}
|
||||
if getCalls != 0 || putCalls != 0 {
|
||||
t.Errorf("mirror preflight must not transfer content: GET=%d PUT=%d", getCalls, putCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_nonRegularTargetFailsBeforeDownload(t *testing.T) {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
name := "execute"
|
||||
if dryRun {
|
||||
name = "dry-run"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "blocked.txt"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"blocked.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`,
|
||||
})
|
||||
gets, puts := captureMirrorHTTP(t)
|
||||
out, err := runDriveMirrorCommand(t, caller, dryRun, "pull", "--local-folder", root, "--remote-folder", "ROOT")
|
||||
if dryRun {
|
||||
if err != nil {
|
||||
t.Fatalf("pull dry-run: %v", err)
|
||||
}
|
||||
var got drivePullDryRunResult
|
||||
if jsonErr := json.Unmarshal(out, &got); jsonErr != nil {
|
||||
t.Fatalf("pull dry-run output is not JSON: %v\n%s", jsonErr, out)
|
||||
}
|
||||
if got.Plan.Summary.Failed != 1 || len(got.Plan.Items) != 1 || !strings.Contains(got.Plan.Items[0].Error, "不是常规文件") {
|
||||
t.Fatalf("pull dry-run plan = %+v, want one non-regular target failure", got.Plan)
|
||||
}
|
||||
} else {
|
||||
var failure *drivePartialFailure
|
||||
if !errors.As(err, &failure) || failure.failed != 1 {
|
||||
t.Fatalf("pull error = %v, want one failed item", err)
|
||||
}
|
||||
}
|
||||
assertMirrorPreflightNoWrites(t, caller, *gets, *puts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_nonRegularTargetAbortsWholeBatchBeforeDownload(t *testing.T) {
|
||||
for _, command := range []string{"pull", "sync"} {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
name := command + "/execute"
|
||||
if dryRun {
|
||||
name = command + "/dry-run"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "z-blocked.txt"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"a-first.txt","type":"file","fileId":"FIRST"},{"name":"z-blocked.txt","type":"file","fileId":"BLOCKED"}],"nextToken":""}}`,
|
||||
})
|
||||
gets, puts := captureMirrorHTTP(t)
|
||||
args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"}
|
||||
if command == "sync" {
|
||||
args = append(args, "--quick")
|
||||
}
|
||||
out, err := runDriveMirrorCommand(t, caller, dryRun, args...)
|
||||
if dryRun {
|
||||
if err != nil {
|
||||
t.Fatalf("%s dry-run: %v", command, err)
|
||||
}
|
||||
if command == "pull" {
|
||||
var got drivePullDryRunResult
|
||||
if jsonErr := json.Unmarshal(out, &got); jsonErr != nil || got.Plan.Summary.Failed != 2 {
|
||||
t.Fatalf("pull dry-run preflight = %+v, json error %v", got, jsonErr)
|
||||
}
|
||||
} else {
|
||||
assertMirrorDryRunFailureJSON(t, command, out)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatalf("%s must abort the whole batch", command)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(root, "a-first.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("%s wrote an earlier file before discovering the conflict: %v", command, statErr)
|
||||
}
|
||||
assertMirrorPreflightNoWrites(t, caller, *gets, *puts)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_remoteEquivalentTreeFailsBeforeDownload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listings map[string]string
|
||||
wantFail int
|
||||
}{
|
||||
{
|
||||
name: "equivalent files",
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"UP"},{"name":"a.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`,
|
||||
},
|
||||
wantFail: 2,
|
||||
},
|
||||
{
|
||||
name: "equivalent folder prefixes",
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"Folder","type":"folder","fileId":"UP_DIR"},{"name":"folder","type":"folder","fileId":"LOW_DIR"}],"nextToken":""}}`,
|
||||
"UP_DIR": `{"result":{"items":[{"name":"up.txt","type":"file","fileId":"UP"}],"nextToken":""}}`,
|
||||
"LOW_DIR": `{"result":{"items":[{"name":"low.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`,
|
||||
},
|
||||
wantFail: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
name := tt.name + "/execute"
|
||||
if dryRun {
|
||||
name = tt.name + "/dry-run"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := syncCaller(tt.listings)
|
||||
gets, puts := captureMirrorHTTP(t)
|
||||
out, err := runDriveMirrorCommand(t, caller, dryRun, "pull", "--local-folder", root, "--remote-folder", "ROOT")
|
||||
if dryRun {
|
||||
if err != nil {
|
||||
t.Fatalf("pull dry-run: %v", err)
|
||||
}
|
||||
var got drivePullDryRunResult
|
||||
if jsonErr := json.Unmarshal(out, &got); jsonErr != nil {
|
||||
t.Fatalf("pull dry-run output is not JSON: %v\n%s", jsonErr, out)
|
||||
}
|
||||
if got.Plan.Summary.Failed != tt.wantFail || got.Plan.Summary.Downloaded != 0 {
|
||||
t.Fatalf("pull dry-run plan = %+v", got.Plan)
|
||||
}
|
||||
} else {
|
||||
var failure *drivePartialFailure
|
||||
if !errors.As(err, &failure) || failure.failed != tt.wantFail {
|
||||
t.Fatalf("pull error = %v, want %d failed items", err, tt.wantFail)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(out), "等价路径") {
|
||||
t.Fatalf("pull output must explain equivalent path ambiguity: %s", out)
|
||||
}
|
||||
assertMirrorPreflightNoWrites(t, caller, *gets, *puts)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveMirror_unsafeLocalNamesFailClosed(t *testing.T) {
|
||||
if os.PathSeparator == '\\' {
|
||||
t.Skip("current platform cannot create a local name containing a backslash")
|
||||
}
|
||||
for _, command := range []string{"push", "sync"} {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
name := command + "/execute"
|
||||
if dryRun {
|
||||
name = command + "/dry-run"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, `unsafe\name.txt`), "local")
|
||||
caller := syncCaller(nil)
|
||||
gets, puts := captureMirrorHTTP(t)
|
||||
args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"}
|
||||
if command == "sync" {
|
||||
args = append(args, "--quick")
|
||||
}
|
||||
out, err := runDriveMirrorCommand(t, caller, dryRun, args...)
|
||||
if dryRun {
|
||||
if err != nil {
|
||||
t.Fatalf("%s dry-run: %v", command, err)
|
||||
}
|
||||
assertMirrorDryRunFailureJSON(t, command, out)
|
||||
} else if err == nil {
|
||||
t.Fatalf("%s must fail an unsafe local name", command)
|
||||
}
|
||||
if !strings.Contains(string(out), "无法安全映射到远端") {
|
||||
t.Fatalf("%s output must explain unsafe local name: %s", command, out)
|
||||
}
|
||||
assertMirrorPreflightNoWrites(t, caller, *gets, *puts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NUL 不能由真实文件系统创建,直接覆盖契约 helper,避免该分支成为死角。
|
||||
preflight := buildMirrorPreflight(nil, []localPushFile{{RelPath: "bad\x00name.txt"}}, nil, map[string]string{"": "ROOT"})
|
||||
if !strings.Contains(preflight["bad\x00name.txt"], "无法安全映射到远端") {
|
||||
t.Fatalf("NUL name preflight = %v", preflight)
|
||||
}
|
||||
if err := rejectNonRegularLocalTarget("bad\x00target"); err == nil || !strings.Contains(err.Error(), "检查本地目标失败") {
|
||||
t.Fatalf("invalid local target error = %v", err)
|
||||
}
|
||||
|
||||
localCollision := buildMirrorPreflight(nil, []localPushFile{{RelPath: "A.txt"}, {RelPath: "a.txt"}}, nil, map[string]string{"": "ROOT"})
|
||||
for _, rel := range []string{"A.txt", "a.txt"} {
|
||||
if !strings.Contains(localCollision[rel], "等价路径") {
|
||||
t.Fatalf("local equivalent collision %q = %q", rel, localCollision[rel])
|
||||
}
|
||||
}
|
||||
prefixCollision := buildMirrorPreflight(nil, []localPushFile{{RelPath: "A/x.txt"}},
|
||||
map[string]*remoteFile{"a/y.txt": {RelPath: "a/y.txt"}}, map[string]string{"": "ROOT"})
|
||||
for _, rel := range []string{"A", "a"} {
|
||||
if !strings.Contains(prefixCollision[rel], "等价路径") {
|
||||
t.Fatalf("equivalent ancestor collision %q = %q", rel, prefixCollision[rel])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveMirror_crossSideEquivalentPathsFailBeforeWrites(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(*testing.T, string)
|
||||
listings map[string]string
|
||||
}{
|
||||
{
|
||||
name: "case-equivalent files",
|
||||
prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "a.txt"), "local") },
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "case-equivalent folder prefixes",
|
||||
prepare: func(t *testing.T, root string) {
|
||||
mustWrite(t, filepath.Join(root, "Folder", "local.txt"), "local")
|
||||
},
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"folder","type":"folder","fileId":"REMOTE_DIR"}],"nextToken":""}}`,
|
||||
"REMOTE_DIR": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"REMOTE_FILE"}],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unicode-equivalent files",
|
||||
prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "caf\u00e9.txt"), "local") },
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"cafe\u0301.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "equivalent path type ambiguity",
|
||||
prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "item"), "local") },
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"ITEM","type":"folder","fileId":"REMOTE_DIR"}],"nextToken":""}}`,
|
||||
"REMOTE_DIR": `{"result":{"items":[],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote-only equivalent files",
|
||||
prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "safe-local.txt"), "local") },
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"UP"},{"name":"a.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote-only equivalent folders",
|
||||
prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "safe-local.txt"), "local") },
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"Folder","type":"folder","fileId":"UP_DIR"},{"name":"folder","type":"folder","fileId":"LOW_DIR"}],"nextToken":""}}`,
|
||||
"UP_DIR": `{"result":{"items":[],"nextToken":""}}`,
|
||||
"LOW_DIR": `{"result":{"items":[],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, command := range []string{"push", "sync"} {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
name := tt.name + "/" + command + "/execute"
|
||||
if dryRun {
|
||||
name = tt.name + "/" + command + "/dry-run"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tt.prepare(t, root)
|
||||
caller := syncCaller(tt.listings)
|
||||
gets, puts := captureMirrorHTTP(t)
|
||||
args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"}
|
||||
if command == "sync" {
|
||||
args = append(args, "--quick")
|
||||
}
|
||||
out, err := runDriveMirrorCommand(t, caller, dryRun, args...)
|
||||
if dryRun {
|
||||
if err != nil {
|
||||
t.Fatalf("%s dry-run: %v", command, err)
|
||||
}
|
||||
assertMirrorDryRunFailureJSON(t, command, out)
|
||||
} else if err == nil {
|
||||
t.Fatalf("%s must reject an equivalent cross-side path", command)
|
||||
}
|
||||
if !strings.Contains(string(out), "等价路径") {
|
||||
t.Fatalf("%s output must explain equivalent path ambiguity: %s", command, out)
|
||||
}
|
||||
assertMirrorPreflightNoWrites(t, caller, *gets, *puts)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMirrorDryRunFailureJSON(t *testing.T, command string, out []byte) {
|
||||
t.Helper()
|
||||
switch command {
|
||||
case "push":
|
||||
var got drivePushDryRunResult
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("push dry-run output is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if !got.DryRun || got.Executed || got.Operation != "drive push" || got.Plan.Summary.Failed == 0 || !got.Plan.Summary.Aborted {
|
||||
t.Fatalf("push dry-run result = %+v", got)
|
||||
}
|
||||
case "sync":
|
||||
var got driveSyncDryRunResult
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("sync dry-run output is not standalone JSON: %v\n%s", err, out)
|
||||
}
|
||||
if !got.DryRun || got.Executed || got.PreviewKind != "plan" || got.Operation != "drive sync" || got.Plan.Summary.Failed == 0 {
|
||||
t.Fatalf("sync dry-run result = %+v", got)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unsupported command %q", command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// Windows 会用已打开的目录句柄锁住目录,把「固定后目录被移走/换成软链」这类
|
||||
// TOCTOU 在该平台变成物理不可达:os.Rename 直接 ERROR_SHARING_VIOLATION。
|
||||
// 但被测的 fail-closed 分支必须在所有平台都覆盖,所以这里提供与真实替换等价的
|
||||
// 身份注入:pinnedPullRoot.verify() 与 verifyParent() 都只通过 pullPathStat /
|
||||
// pullRootLstat 读取当前身份,让这两个 seam 指向另一个目录即可命中同一分支。
|
||||
|
||||
// forcePinnedFallbackForTest 打开后,「移走固定目录」的复现手法一律走身份注入降级。
|
||||
// 默认 false:Unix 上用真实移动,验证更强。只有 Windows 才会真的走降级分支,所以
|
||||
// 下面的注入回归测试会打开它,让那条路径在任何平台都能被验证。
|
||||
var forcePinnedFallbackForTest = false
|
||||
|
||||
// swapPinnedRootIdentity 让 absDir 的根身份读数指向另一个目录,使随后的
|
||||
// pinnedPullRoot.verify() 判定「本地根目录在同步期间被替换」。
|
||||
func swapPinnedRootIdentity(t *testing.T, absDir string) {
|
||||
t.Helper()
|
||||
decoy, err := os.Stat(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Clean(absDir)
|
||||
testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) {
|
||||
if filepath.Clean(path) == want {
|
||||
return decoy, nil
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
}
|
||||
|
||||
// 降级注入只有 Windows 会真的走到(Unix 的 rename 成功)。这里强制打开开关,
|
||||
// 确保注入手段本身在任何平台都可回归:不移动目录,但固定根的校验必须随之失败,
|
||||
// 且原有目录树不得被动过。
|
||||
func TestCrossPlatformCoveragePinnedRootFallbackInjectionFailsVerification(t *testing.T) {
|
||||
testseam.Swap(t, &forcePinnedFallbackForTest, true)
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "pinned-original")
|
||||
root := newPinnedPullRootForCoverage(t, dir)
|
||||
if err := root.verify(); err != nil {
|
||||
t.Fatalf("baseline verify: %v", err)
|
||||
}
|
||||
|
||||
if moved := replacePinnedMirrorRoot(t, dir, filepath.Join(t.TempDir(), "moved")); moved {
|
||||
t.Fatal("降级路径不应真的移动目录")
|
||||
}
|
||||
if err := root.verify(); err == nil || !strings.Contains(err.Error(), "本地根目录在同步期间被替换") {
|
||||
t.Fatalf("身份注入后校验必须失败,got %v", err)
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(dir, "a.txt")); err != nil || string(b) != "pinned-original" {
|
||||
t.Fatalf("降级必须保持原目录树不变: %q err=%v", string(b), err)
|
||||
}
|
||||
}
|
||||
|
||||
// swapPinnedParentIdentity 让固定根下名为 rel 的父目录身份读数指向另一个目录,
|
||||
// 使随后的 verifyParent() 判定「本地目标目录在下载期间被替换」。
|
||||
func swapPinnedParentIdentity(t *testing.T, rel string) {
|
||||
t.Helper()
|
||||
decoy, err := os.Lstat(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &pullRootLstat, func(parent *os.Root, name string) (os.FileInfo, error) {
|
||||
if filepath.ToSlash(filepath.Clean(name)) == rel {
|
||||
return decoy, nil
|
||||
}
|
||||
return parent.Lstat(name)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,974 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// ==========================================================
|
||||
// drive pull — 把钉盘文件夹镜像到本地(Drive → 本地)
|
||||
// ==========================================================
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws drive pull — 把钉盘文件夹单向、文件级镜像到本地(Drive → 本地)
|
||||
//
|
||||
// 递归列出 --remote-folder 指向的钉盘文件夹下所有 type=FILE 的文件,逐一下载到
|
||||
// --local-folder 对应的相对路径。已存在的本地文件按 --if-exists 决定
|
||||
// overwrite / smart / skip。结构化 summary + items 始终打印到 stdout;
|
||||
// summary.failed > 0 时额外以非零退出码退出。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// --if-exists 的三种策略。
|
||||
const (
|
||||
ifExistsOverwrite = "overwrite" // 总是下载覆盖(Drive 为权威源)
|
||||
ifExistsSmart = "smart" // 推荐增量:本地 mtime 已 ≥ 远端 modified_time 则跳过
|
||||
ifExistsSkip = "skip" // 默认:本地已存在则保持不动
|
||||
)
|
||||
|
||||
// pull 下载路径使用 os.Root 固定根目录和目标父目录。以下 seam 只用于
|
||||
// 确定性覆盖文件系统与传输失败分支;测试必须通过 testseam.Swap 替换。
|
||||
var (
|
||||
pullMkdirAll = os.MkdirAll
|
||||
pullOpenRoot = os.OpenRoot
|
||||
pullRelPath = filepath.Rel
|
||||
pullPathStat = os.Stat
|
||||
pullPathLstat = os.Lstat
|
||||
pullTargetLstat = os.Lstat
|
||||
pullRootMkdir = func(root *os.Root, name string, mode os.FileMode) error {
|
||||
return root.Mkdir(name, mode)
|
||||
}
|
||||
pullOpenParentRoot = func(root *os.Root, name string) (*os.Root, error) {
|
||||
return root.OpenRoot(name)
|
||||
}
|
||||
pullRootStat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) }
|
||||
pullRootLstat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) }
|
||||
pullCreateTemp = createPinnedPullTemp
|
||||
pullTempName = uuid.NewString
|
||||
pullSyncTemp = func(file *os.File) error { return file.Sync() }
|
||||
pullCloseTemp = func(file *os.File) error { return file.Close() }
|
||||
pullRename = func(root *os.Root, oldName, newName string) error {
|
||||
return root.Rename(oldName, newName)
|
||||
}
|
||||
pullLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) }
|
||||
pullRemove = func(root *os.Root, name string) error { return root.Remove(name) }
|
||||
pullDownloadFile = defaultPullDownloadFile
|
||||
pullHTTPDo = func(request *http.Request) (*http.Response, error) {
|
||||
return (&http.Client{Timeout: 10 * time.Minute}).Do(request)
|
||||
}
|
||||
)
|
||||
|
||||
// pull 动作分类。
|
||||
const (
|
||||
pullActionDownloaded = "downloaded"
|
||||
pullActionSkipped = "skipped"
|
||||
pullActionFailed = "failed"
|
||||
)
|
||||
|
||||
// drivePullItem 是输出 items[] 中每个文件的明细。
|
||||
type drivePullItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Action string `json:"action"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// drivePullSummary 是各动作的计数汇总。
|
||||
type drivePullSummary struct {
|
||||
Downloaded int `json:"downloaded"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// drivePullResult 是 pull 命令的输出 schema。
|
||||
type drivePullResult struct {
|
||||
Summary drivePullSummary `json:"summary"`
|
||||
Items []drivePullItem `json:"items"`
|
||||
}
|
||||
|
||||
type drivePullDryRunResult struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Executed bool `json:"executed"`
|
||||
PreviewKind string `json:"preview_kind"`
|
||||
Operation string `json:"operation"`
|
||||
IfExists string `json:"if_exists"`
|
||||
Plan drivePullResult `json:"plan"`
|
||||
}
|
||||
|
||||
// drivePartialFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout,
|
||||
// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明(与 push/sync 一致)。
|
||||
type drivePartialFailure struct{ failed int }
|
||||
|
||||
func (e *drivePartialFailure) Error() string {
|
||||
return fmt.Sprintf("drive pull: %d file(s) failed", e.failed)
|
||||
}
|
||||
func (e *drivePartialFailure) RawStderr() string { return e.Error() }
|
||||
func (e *drivePartialFailure) ExitCode() int { return 1 }
|
||||
|
||||
// pathCollisionKey 把本地目标路径归一化成「目标文件系统下的等价键」,用于探测多个
|
||||
// 远端条目是否会落到同一个本地文件。caseInsensitive 为真时(Windows / 默认 macOS)
|
||||
// 折叠大小写并做 Unicode NFC 规范化,从而把 A.txt/a.txt、NFC/NFD 记法视为同一目标;
|
||||
// 大小写敏感文件系统(如 Linux ext4)则按精确路径区分,避免误判合法的异名文件。
|
||||
func pathCollisionKey(target string, caseInsensitive bool) string {
|
||||
p := filepath.Clean(target)
|
||||
if caseInsensitive {
|
||||
p = norm.NFC.String(strings.ToLower(p))
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// isCaseInsensitiveFS 是大小写探测的注入点(与 httpGetFile / httpPutFile 同样的 seam):
|
||||
// 生产路径始终是 detectCaseInsensitiveFS,测试可替换它,以便在大小写敏感的 CI 文件系统上
|
||||
// 也能走到「等价路径冲突」分支。
|
||||
var isCaseInsensitiveFS = detectCaseInsensitiveFS
|
||||
|
||||
// caseProbePattern 是探针文件名模板。抽成变量只为可测:纯数字模板的大写与原名相同,
|
||||
// 可覆盖「名称无大小写差异、无法据此判定」的回退分支。
|
||||
var caseProbePattern = "dws-caseprobe-*"
|
||||
|
||||
// detectCaseInsensitiveFS 探测 dir 所在文件系统是否大小写不敏感:在 dir 下创建一个随机
|
||||
// 小写名探针文件,再用其大写名 stat;命中同一文件即为不敏感。dir 必须已存在。
|
||||
// 探测失败时回退到平台默认(Windows / macOS 视为不敏感,其它敏感)。
|
||||
func detectCaseInsensitiveFS(dir string) bool {
|
||||
platformDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
||||
f, err := os.CreateTemp(dir, caseProbePattern)
|
||||
if err != nil {
|
||||
return platformDefault
|
||||
}
|
||||
name := f.Name()
|
||||
_ = f.Close()
|
||||
defer os.Remove(name)
|
||||
base := filepath.Base(name)
|
||||
upper := strings.ToUpper(base)
|
||||
if upper == base {
|
||||
return platformDefault // 名称无大小写差异,无法据此判定
|
||||
}
|
||||
_, statErr := os.Stat(filepath.Join(dir, upper))
|
||||
return statErr == nil
|
||||
}
|
||||
|
||||
// detectTargetCollisions 按 caseInsensitive 指定的等价规则,找出会映射到同一本地目标
|
||||
// 的多个远端 rel_path,返回被判定冲突的 rel_path 集合(其中每个都不应落盘)。
|
||||
func detectTargetCollisions(absDir string, rels []string, caseInsensitive bool) map[string]bool {
|
||||
groups := make(map[string][]string, len(rels))
|
||||
for _, rel := range rels {
|
||||
target := filepath.Join(absDir, filepath.FromSlash(rel))
|
||||
key := pathCollisionKey(target, caseInsensitive)
|
||||
groups[key] = append(groups[key], rel)
|
||||
}
|
||||
collided := make(map[string]bool)
|
||||
for _, g := range groups {
|
||||
if len(g) > 1 {
|
||||
for _, r := range g {
|
||||
collided[r] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return collided
|
||||
}
|
||||
|
||||
func runDrivePull(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil {
|
||||
return err
|
||||
}
|
||||
localDir := mustGetFlag(cmd, "local-folder")
|
||||
remoteDirID := mustGetFlag(cmd, "remote-folder")
|
||||
// space-id 可选:不传则由 fetchRemoteDriveTree 使用「我的文件」对应的空间。
|
||||
spaceID := mustGetFlag(cmd, "space-id")
|
||||
|
||||
ifExists, _ := cmd.Flags().GetString("if-exists")
|
||||
if ifExists == "" {
|
||||
// 安全默认:不自动覆盖本地既有文件。
|
||||
ifExists = ifExistsSkip
|
||||
}
|
||||
switch ifExists {
|
||||
case ifExistsOverwrite, ifExistsSmart, ifExistsSkip:
|
||||
default:
|
||||
return fmt.Errorf("--if-exists 取值非法: %s(可选 overwrite|smart|skip)", ifExists)
|
||||
}
|
||||
|
||||
absDir, err := validateLocalDirAbs(localDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 在任何远端读取之前固定本地根计划:既有根立即持有 os.Root;不存在的根只固定
|
||||
// 最近既存祖先并记录尚缺的逐层后缀。远端清单不完整或预检失败时不得创建本地根。
|
||||
rootPlan, err := planPinnedPullRoot(absDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rootPlan.close()
|
||||
|
||||
ctx := cmd.Context()
|
||||
// 复用 status 的远端遍历:按 parentId 递归拿到所有 type=FILE 的文件(key 为 rel_path)。
|
||||
remote, err := fetchRemoteDriveTree(ctx, spaceID, remoteDirID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 稳定顺序输出(rel_path 升序)。
|
||||
relPaths := make([]string, 0, len(remote))
|
||||
for rel := range remote {
|
||||
relPaths = append(relPaths, rel)
|
||||
}
|
||||
sort.Strings(relPaths)
|
||||
preflight, localInfo, err := buildDrivePullPreflightFromPlan(rootPlan, remote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return printDrivePullDryRunWithLocalInfo(ifExists, remote, relPaths, preflight, localInfo)
|
||||
}
|
||||
// 镜像契约独立于当前主机文件系统:远端 A/a 或 NFC/NFD 异写(包括目录
|
||||
// 前缀)在任何落盘前整批拒绝,避免同一远端树在不同平台得到不同镜像。
|
||||
if len(preflight) > 0 {
|
||||
res := drivePullPreflightFailureResult(relPaths, preflight)
|
||||
if perr := deps.Out.PrintJSON(res); perr != nil {
|
||||
return perr
|
||||
}
|
||||
return &drivePartialFailure{failed: res.Summary.Failed}
|
||||
}
|
||||
pinnedRoot, err := rootPlan.materialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pinnedRoot.close()
|
||||
|
||||
res := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
|
||||
for _, rel := range relPaths {
|
||||
rf := remote[rel]
|
||||
// preflight 与真正下载之间仍需二次解析,挡住祖先目录被并发替换为软链的
|
||||
// TOCTOU 逃逸;pullRemoteFile 将该检查收口在写入入口前。
|
||||
action, perr := pullRemoteFilePinned(ctx, spaceID, rf, pinnedRoot, rel, ifExists)
|
||||
item := drivePullItem{RelPath: rel, Action: action}
|
||||
switch action {
|
||||
case pullActionDownloaded:
|
||||
res.Summary.Downloaded++
|
||||
case pullActionSkipped:
|
||||
res.Summary.Skipped++
|
||||
case pullActionFailed:
|
||||
res.Summary.Failed++
|
||||
if perr != nil {
|
||||
item.Error = perr.Error()
|
||||
}
|
||||
}
|
||||
res.Items = append(res.Items, item)
|
||||
}
|
||||
|
||||
// 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。
|
||||
if perr := deps.Out.PrintJSON(res); perr != nil {
|
||||
return perr
|
||||
}
|
||||
if res.Summary.Failed > 0 {
|
||||
return &drivePartialFailure{failed: res.Summary.Failed}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string, caseInsensitive bool) error {
|
||||
return printDrivePullDryRunWithPreflight(absDir, ifExists, remote, relPaths, caseInsensitive,
|
||||
buildDrivePullPreflight(absDir, remote))
|
||||
}
|
||||
|
||||
func printDrivePullDryRunWithPreflight(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string, caseInsensitive bool, preflight map[string]string) error {
|
||||
localInfo := make(map[string]os.FileInfo, len(relPaths))
|
||||
for _, rel := range relPaths {
|
||||
localPath, err := resolveLocalTarget(absDir, rel)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info, statErr := os.Stat(localPath); statErr == nil && info.Mode().IsRegular() {
|
||||
localInfo[rel] = info
|
||||
}
|
||||
}
|
||||
_ = caseInsensitive // 保留 helper 的平台预览参数兼容性。
|
||||
return printDrivePullDryRunWithLocalInfo(ifExists, remote, relPaths, preflight, localInfo)
|
||||
}
|
||||
|
||||
func printDrivePullDryRunWithLocalInfo(ifExists string, remote map[string]*remoteFile, relPaths []string, preflight map[string]string, localInfo map[string]os.FileInfo) error {
|
||||
plan := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
|
||||
if len(preflight) > 0 {
|
||||
plan = drivePullPreflightFailureResult(relPaths, preflight)
|
||||
return deps.Out.PrintJSON(drivePullDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive pull",
|
||||
IfExists: ifExists, Plan: plan,
|
||||
})
|
||||
}
|
||||
for _, rel := range relPaths {
|
||||
action := pullActionDownloaded
|
||||
if fi := localInfo[rel]; fi != nil {
|
||||
switch ifExists {
|
||||
case ifExistsSkip:
|
||||
action = pullActionSkipped
|
||||
case ifExistsSmart:
|
||||
rf := remote[rel]
|
||||
if rf.ModifiedTimeValid && fi.ModTime().UnixMilli() >= rf.ModifiedTime {
|
||||
action = pullActionSkipped
|
||||
}
|
||||
}
|
||||
}
|
||||
if action == pullActionSkipped {
|
||||
plan.Summary.Skipped++
|
||||
} else {
|
||||
plan.Summary.Downloaded++
|
||||
}
|
||||
plan.Items = append(plan.Items, drivePullItem{RelPath: rel, Action: action})
|
||||
}
|
||||
return deps.Out.PrintJSON(drivePullDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive pull",
|
||||
IfExists: ifExists, Plan: plan,
|
||||
})
|
||||
}
|
||||
|
||||
// buildDrivePullPreflight 在创建本地根目录、探测文件系统或下载任一文件之前,
|
||||
// 一次性验证完整远端树及每个本地落盘目标。任一目标是目录、符号链接、设备文件,
|
||||
// 或经既有符号链接逃逸时,整批 pull 都必须零写入中止。
|
||||
func buildDrivePullPreflight(absDir string, remote map[string]*remoteFile) map[string]string {
|
||||
failures := buildMirrorPreflight(nil, nil, remote, nil)
|
||||
addLocalTargetPreflightFailures(absDir, remote, failures)
|
||||
return failures
|
||||
}
|
||||
|
||||
// buildDrivePullPreflightFromPlan 只从 list_files 前已固定的根计划读取本地状态。
|
||||
// existing 根不再按 absDir 重开路径;missing 根在 ancestor Root 下仍必须完整缺失。
|
||||
func buildDrivePullPreflightFromPlan(plan *pinnedPullRootPlan, remote map[string]*remoteFile) (map[string]string, map[string]os.FileInfo, error) {
|
||||
failures := buildMirrorPreflight(nil, nil, remote, nil)
|
||||
localInfo := make(map[string]os.FileInfo, len(remote))
|
||||
if err := plan.verify(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if plan.existing != nil {
|
||||
addPinnedLocalTargetPreflightFailures(plan.existing, remote, failures)
|
||||
for rel := range remote {
|
||||
if mirrorPreflightFailureForRel(rel, failures) != "" {
|
||||
continue
|
||||
}
|
||||
info, err := pullRootLstat(plan.existing.root, filepath.FromSlash(rel))
|
||||
if err == nil && info.Mode().IsRegular() {
|
||||
localInfo[rel] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := plan.verify(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return failures, localInfo, nil
|
||||
}
|
||||
|
||||
func addLocalTargetPreflightFailures(absDir string, remote map[string]*remoteFile, failures map[string]string) {
|
||||
for rel := range remote {
|
||||
// 已被名称/类型冲突根屏蔽的后代不重复计错;一个冲突根只报告一次。
|
||||
if mirrorPreflightFailureForRel(rel, failures) != "" {
|
||||
continue
|
||||
}
|
||||
localPath, err := resolveLocalTarget(absDir, rel)
|
||||
if err == nil {
|
||||
err = rejectNonRegularLocalTarget(localPath)
|
||||
}
|
||||
if err != nil {
|
||||
failures[rel] = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drivePullPreflightFailureResult(relPaths []string, preflight map[string]string) drivePullResult {
|
||||
res := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
|
||||
for _, rel := range relPaths {
|
||||
msg := mirrorPreflightFailureForRel(rel, preflight)
|
||||
if msg == "" {
|
||||
msg = "另一镜像条目未通过预检,整批拉取已中止"
|
||||
}
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: msg})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func mirrorPreflightFailureForRel(rel string, preflight map[string]string) string {
|
||||
for rel != "" {
|
||||
if msg := preflight[rel]; msg != "" {
|
||||
return msg
|
||||
}
|
||||
rel, _ = splitRel(rel)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pullRemoteFile(ctx context.Context, spaceID string, rf *remoteFile, absDir, rel, ifExists string) (string, error) {
|
||||
return pullOneFileAtRoot(ctx, spaceID, rf, absDir, rel, ifExists)
|
||||
}
|
||||
|
||||
func pullRemoteFilePinned(ctx context.Context, spaceID string, rf *remoteFile, root *pinnedPullRoot, rel, ifExists string) (string, error) {
|
||||
target, err := root.openTarget(rel)
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
defer target.close()
|
||||
return pullOneFilePinned(ctx, spaceID, rf, target, ifExists)
|
||||
}
|
||||
|
||||
// pullOneFile 处理单个远端文件:按 --if-exists 决定是否跳过,否则下载到 localPath。
|
||||
// 返回动作分类(downloaded / skipped / failed)及失败时的 error。
|
||||
func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, ifExists string) (string, error) {
|
||||
return pullOneFileAtRoot(ctx, spaceID, rf, filepath.Dir(localPath), filepath.Base(localPath), ifExists)
|
||||
}
|
||||
|
||||
func pullOneFileAtRoot(ctx context.Context, spaceID string, rf *remoteFile, absDir, rel, ifExists string) (string, error) {
|
||||
root, err := openPinnedPullRoot(absDir)
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
defer root.close()
|
||||
target, err := root.openTarget(rel)
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
defer target.close()
|
||||
return pullOneFilePinned(ctx, spaceID, rf, target, ifExists)
|
||||
}
|
||||
|
||||
func pullOneFilePinned(ctx context.Context, spaceID string, rf *remoteFile, target *pinnedPullTarget, ifExists string) (string, error) {
|
||||
// 镜像文件的目标若已存在,必须是常规文件。目录、设备、FIFO 等都不能交给
|
||||
// download_file / 临时文件写入流程,否则 dry-run 与执行结论会分叉,且目录目标
|
||||
// 会在发出远端读请求后才失败。
|
||||
initialInfo, err := target.regularTargetInfo()
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
// 本地已存在常规文件时,按策略判断是否跳过下载。
|
||||
if initialInfo != nil {
|
||||
switch ifExists {
|
||||
case ifExistsSkip:
|
||||
return pullActionSkipped, nil
|
||||
case ifExistsSmart:
|
||||
// 远端时间可信且本地 mtime 已 ≥ 远端 → 视为已对齐,跳过;
|
||||
// 时间缺失/非法时不盲跳,退回继续下载。
|
||||
if rf.ModifiedTimeValid && initialInfo.ModTime().UnixMilli() >= rf.ModifiedTime {
|
||||
return pullActionSkipped, nil
|
||||
}
|
||||
case ifExistsOverwrite:
|
||||
// 总是下载覆盖。
|
||||
}
|
||||
}
|
||||
|
||||
// download_file → 拿到带签名的下载 URL 与请求头,再 HTTP GET 落盘。
|
||||
args := map[string]any{"fileId": rf.FileID}
|
||||
if spaceID != "" {
|
||||
args["spaceId"] = spaceID
|
||||
}
|
||||
text, err := callMCPToolReturnText(ctx, "download_file", args)
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
resourceURL, headers, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
|
||||
// download_file 可执行任意时长的远端读。在任何 HTTP GET 或临时文件写入前,
|
||||
// 必须确认目标父目录仍是最初固定在本地根下的同一目录。
|
||||
if err := target.verifyParent(); err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
|
||||
// 临时文件通过已固定的 os.Root 直接打开,下载直接写已打开的句柄;
|
||||
// 生产路径不会再按可被换链的绝对路径重新打开临时文件。
|
||||
tmp, tmpName, err := pullCreateTemp(target.parentRoot)
|
||||
if err != nil {
|
||||
return pullActionFailed, fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
// 除非成功 rename,否则始终清理临时文件,不留半成品。
|
||||
committed := false
|
||||
tmpInfo, err := tmp.Stat()
|
||||
if err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = target.parentRoot.Remove(tmpName)
|
||||
return pullActionFailed, fmt.Errorf("读取临时文件身份失败: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
if !committed {
|
||||
_ = target.parentRoot.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := pullDownloadFile(ctx, resourceURL, headers, tmp); err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
// 通过仍打开的文件句柄设置 mtime,避免按 tmpName 重新解析路径产生 symlink TOCTOU。
|
||||
// 时间对齐延续既有 best-effort 语义,失败不影响已完整下载内容的发布。
|
||||
if rf.ModifiedTimeValid {
|
||||
_ = setPullFileTimes(tmp, time.UnixMilli(rf.ModifiedTime))
|
||||
}
|
||||
if err := pullSyncTemp(tmp); err != nil {
|
||||
return pullActionFailed, fmt.Errorf("同步临时文件失败: %w", err)
|
||||
}
|
||||
if err := pullCloseTemp(tmp); err != nil {
|
||||
return pullActionFailed, fmt.Errorf("关闭临时文件失败: %w", err)
|
||||
}
|
||||
currentTmp, err := pullRootLstat(target.parentRoot, tmpName)
|
||||
if err != nil || !os.SameFile(tmpInfo, currentTmp) {
|
||||
return pullActionFailed, fmt.Errorf("临时文件在下载期间被替换,已中止发布")
|
||||
}
|
||||
// 网络传输期间父目录仍可能被移走或替换。发布前再核对父目录身份和
|
||||
// 目标类型;不一致时只删除固定目录内的临时文件,绝不发布。
|
||||
if err := target.verifyParent(); err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
currentTarget, err := target.regularTargetInfo()
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
if ifExists != ifExistsOverwrite {
|
||||
if currentTarget != nil && (initialInfo == nil || !os.SameFile(initialInfo, currentTarget)) {
|
||||
return pullActionSkipped, nil
|
||||
}
|
||||
if ifExists == ifExistsSmart && currentTarget != nil && rf.ModifiedTimeValid && currentTarget.ModTime().UnixMilli() >= rf.ModifiedTime {
|
||||
return pullActionSkipped, nil
|
||||
}
|
||||
}
|
||||
if currentTarget == nil && ifExists != ifExistsOverwrite {
|
||||
if err := pullLink(target.parentRoot, tmpName, target.name); err != nil {
|
||||
if concurrent, statErr := target.regularTargetInfo(); statErr == nil && concurrent != nil {
|
||||
return pullActionSkipped, nil
|
||||
}
|
||||
return pullActionFailed, fmt.Errorf("发布目标文件失败: %w", err)
|
||||
}
|
||||
if err := pullRemove(target.parentRoot, tmpName); err != nil {
|
||||
return pullActionFailed, fmt.Errorf("清理临时文件失败: %w", err)
|
||||
}
|
||||
} else if err := pullRename(target.parentRoot, tmpName, target.name); err != nil {
|
||||
return pullActionFailed, fmt.Errorf("替换目标文件失败: %w", err)
|
||||
}
|
||||
finalInfo, err := pullRootLstat(target.parentRoot, target.name)
|
||||
if err != nil || !os.SameFile(tmpInfo, finalInfo) {
|
||||
// 身份不匹配意味着发布后 terminal entry 可能已被并发替换;保留未知对象并报错,
|
||||
// 不做 Lstat→Remove 的第二次 check/use,以免误删用户并发创建的文件。
|
||||
return pullActionFailed, fmt.Errorf("发布后的目标文件身份不一致")
|
||||
}
|
||||
if err := target.verifyParent(); err != nil {
|
||||
// 固定目录已从命令根路径移走时,保留句柄内已完成的结果并报失败;不能无条件
|
||||
// Remove,因为发布后同名 terminal entry 仍可能被并发替换。
|
||||
return pullActionFailed, err
|
||||
}
|
||||
committed = true
|
||||
return pullActionDownloaded, nil
|
||||
}
|
||||
|
||||
type pinnedPullRoot struct {
|
||||
absDir string
|
||||
root *os.Root
|
||||
rootInfo os.FileInfo
|
||||
pathInfo os.FileInfo
|
||||
}
|
||||
|
||||
// pinnedPullRootPlan 在远端清单读取前固定 pull 的本地根状态。existing 非空表示
|
||||
// absDir 当时已存在;否则 ancestor 固定最近既存祖先,missing 保存从祖先到 absDir
|
||||
// 的逐层目录名。missing 只有在完整远端清单和本地预检通过后才允许物化。
|
||||
type pinnedPullRootPlan struct {
|
||||
absDir string
|
||||
existing *pinnedPullRoot
|
||||
ancestor *pinnedPullRoot
|
||||
missing []string
|
||||
}
|
||||
|
||||
type pinnedPullTarget struct {
|
||||
base *pinnedPullRoot
|
||||
parentRoot *os.Root
|
||||
parentChain []pinnedPullDirIdentity
|
||||
name string
|
||||
ownsBase bool
|
||||
}
|
||||
|
||||
type pinnedPullDirIdentity struct {
|
||||
rel string
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
func planPinnedPullRoot(absDir string) (*pinnedPullRootPlan, error) {
|
||||
info, err := pullPathStat(absDir)
|
||||
if err == nil {
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("创建本地目录失败: %s 已存在且不是目录", absDir)
|
||||
}
|
||||
root, openErr := openExistingPinnedPullRoot(absDir)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
return &pinnedPullRootPlan{absDir: absDir, existing: root}, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("检查本地目录失败: %w", err)
|
||||
}
|
||||
|
||||
ancestorPath := absDir
|
||||
missing := make([]string, 0, 4)
|
||||
for {
|
||||
parent := filepath.Dir(ancestorPath)
|
||||
// absDir 已是验证过的绝对路径;文件系统根目录始终存在,
|
||||
// 因此循环必然在到达 parent == ancestorPath 之前以 Stat 成功结束。
|
||||
name := filepath.Base(ancestorPath)
|
||||
missing = append([]string{name}, missing...)
|
||||
ancestorPath = parent
|
||||
info, statErr := pullPathStat(ancestorPath)
|
||||
if statErr == nil {
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("创建本地目录失败: %s 已存在且不是目录", ancestorPath)
|
||||
}
|
||||
break
|
||||
}
|
||||
if !os.IsNotExist(statErr) {
|
||||
return nil, fmt.Errorf("检查本地目录失败: %w", statErr)
|
||||
}
|
||||
}
|
||||
ancestor, openErr := openExistingPinnedPullRoot(ancestorPath)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
plan := &pinnedPullRootPlan{absDir: absDir, ancestor: ancestor, missing: missing}
|
||||
if verifyErr := plan.verify(); verifyErr != nil {
|
||||
plan.close()
|
||||
return nil, verifyErr
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func (plan *pinnedPullRootPlan) verify() error {
|
||||
if plan.existing != nil {
|
||||
return plan.existing.verify()
|
||||
}
|
||||
if plan.ancestor == nil {
|
||||
return fmt.Errorf("本地根目录计划无效")
|
||||
}
|
||||
if err := plan.ancestor.verify(); err != nil {
|
||||
return err
|
||||
}
|
||||
prefix := ""
|
||||
for _, segment := range plan.missing {
|
||||
prefix = filepath.Join(prefix, segment)
|
||||
_, err := pullRootLstat(plan.ancestor.root, prefix)
|
||||
if err == nil {
|
||||
return fmt.Errorf("本地根目录 %q 在远端读取期间被占用,已中止写入", plan.absDir)
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("检查本地根目录计划失败: %w", err)
|
||||
}
|
||||
// 第一层缺失后更深的路径不可能存在;物化时每层 Mkdir 仍以 no-clobber
|
||||
// 语义拒绝 verify 与创建之间的并发抢占。
|
||||
break
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (plan *pinnedPullRootPlan) materialize() (*pinnedPullRoot, error) {
|
||||
if err := plan.verify(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if plan.existing != nil {
|
||||
root := plan.existing
|
||||
plan.existing = nil
|
||||
return root, nil
|
||||
}
|
||||
|
||||
current, err := pullOpenParentRoot(plan.ancestor.root, ".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("固定本地根祖先失败: %w", err)
|
||||
}
|
||||
for _, segment := range plan.missing {
|
||||
if err := pullRootMkdir(current, segment, 0o755); err != nil {
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("创建本地目录失败: %w", err)
|
||||
}
|
||||
createdInfo, err := pullRootLstat(current, segment)
|
||||
if err != nil || !createdInfo.IsDir() {
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("读取新建本地目录身份失败")
|
||||
}
|
||||
next, err := pullOpenParentRoot(current, segment)
|
||||
if err != nil {
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("固定新建本地目录失败: %w", err)
|
||||
}
|
||||
nextInfo, statErr := pullRootStat(next, ".")
|
||||
if statErr != nil || !os.SameFile(createdInfo, nextInfo) {
|
||||
_ = next.Close()
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("新建本地目录在固定期间被替换,已中止写入")
|
||||
}
|
||||
_ = current.Close()
|
||||
current = next
|
||||
}
|
||||
rootInfo, err := pullRootStat(current, ".")
|
||||
if err != nil {
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("读取本地根目录身份失败: %w", err)
|
||||
}
|
||||
pathInfo, err := pullPathLstat(plan.absDir)
|
||||
if err != nil {
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("读取本地根目录路径身份失败: %w", err)
|
||||
}
|
||||
root := &pinnedPullRoot{absDir: plan.absDir, root: current, rootInfo: rootInfo, pathInfo: pathInfo}
|
||||
if err := root.verify(); err != nil {
|
||||
root.close()
|
||||
return nil, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (plan *pinnedPullRootPlan) close() {
|
||||
if plan.existing != nil {
|
||||
plan.existing.close()
|
||||
plan.existing = nil
|
||||
}
|
||||
if plan.ancestor != nil {
|
||||
plan.ancestor.close()
|
||||
plan.ancestor = nil
|
||||
}
|
||||
}
|
||||
|
||||
func openPinnedPullRoot(absDir string) (*pinnedPullRoot, error) {
|
||||
if err := pullMkdirAll(absDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建本地目录失败: %w", err)
|
||||
}
|
||||
return openExistingPinnedPullRoot(absDir)
|
||||
}
|
||||
|
||||
// openExistingPinnedPullRoot 只固定已经存在的本地目录。push/sync 必须用这一入口:
|
||||
// 本地源根不存在时直接失败,不能为了扫描或上传隐式创建一个空源目录。
|
||||
func openExistingPinnedPullRoot(absDir string) (*pinnedPullRoot, error) {
|
||||
baseRoot, err := pullOpenRoot(absDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开本地根目录失败: %w", err)
|
||||
}
|
||||
rootInfo, err := pullRootStat(baseRoot, ".")
|
||||
if err != nil {
|
||||
_ = baseRoot.Close()
|
||||
return nil, fmt.Errorf("读取本地根目录身份失败: %w", err)
|
||||
}
|
||||
pathInfo, err := pullPathLstat(absDir)
|
||||
if err != nil {
|
||||
_ = baseRoot.Close()
|
||||
return nil, fmt.Errorf("读取本地根目录路径身份失败: %w", err)
|
||||
}
|
||||
root := &pinnedPullRoot{absDir: absDir, root: baseRoot, rootInfo: rootInfo, pathInfo: pathInfo}
|
||||
if err := root.verify(); err != nil {
|
||||
root.close()
|
||||
return nil, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func openPinnedPullTarget(absDir, rel string) (*pinnedPullTarget, error) {
|
||||
root, err := openPinnedPullRoot(absDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, err := root.openTarget(rel)
|
||||
if err != nil {
|
||||
root.close()
|
||||
return nil, err
|
||||
}
|
||||
target.ownsBase = true
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (root *pinnedPullRoot) openTarget(rel string) (*pinnedPullTarget, error) {
|
||||
if err := root.verify(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localPath := filepath.Join(root.absDir, filepath.FromSlash(rel))
|
||||
localRel, err := pullRelPath(root.absDir, localPath)
|
||||
if err != nil || localRel == ".." || strings.HasPrefix(localRel, ".."+string(filepath.Separator)) {
|
||||
return nil, fmt.Errorf("远端路径 %q 逃逸出本地根目录", rel)
|
||||
}
|
||||
parentRel := filepath.Dir(localRel)
|
||||
parentRoot, parentChain, err := root.openTargetParent(parentRel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := &pinnedPullTarget{
|
||||
base: root, parentRoot: parentRoot, parentChain: parentChain,
|
||||
name: filepath.Base(localRel),
|
||||
}
|
||||
if err := target.verifyParent(); err != nil {
|
||||
target.close()
|
||||
return nil, err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// openTargetParent 从固定根开始逐层创建并打开目标父目录。os.Root 会安全地阻止
|
||||
// 软链接逃逸根外,但允许跟随仍指向根内的相对软链接;镜像语义不能接受这种重定向,
|
||||
// 否则 remote sub/a.txt 可能覆盖同一根下 victim/a.txt。因此每一层都必须:
|
||||
//
|
||||
// 1. 以 Lstat 拒绝软链接 / junction 及非目录;
|
||||
// 2. 通过上一层已固定的 Root 打开下一层;
|
||||
// 3. 再以 Lstat + SameFile 证明打开的就是目录项本身,而不是竞态中换入的链接目标。
|
||||
//
|
||||
// parentChain 保存每一层目录项身份,后续每次 verifyParent 都从命令根重新复核。
|
||||
func (root *pinnedPullRoot) openTargetParent(parentRel string) (*os.Root, []pinnedPullDirIdentity, error) {
|
||||
current, err := pullOpenParentRoot(root.root, ".")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("固定本地目标目录失败: %w", err)
|
||||
}
|
||||
chain := make([]pinnedPullDirIdentity, 0, 4)
|
||||
if parentRel == "." {
|
||||
info, err := pullRootLstat(root.root, ".")
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("本地目标目录不是可固定的真实目录")
|
||||
}
|
||||
openedInfo, statErr := pullRootStat(current, ".")
|
||||
if statErr != nil || !os.SameFile(info, openedInfo) {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("本地目标目录在固定期间被替换,已中止写入")
|
||||
}
|
||||
chain = append(chain, pinnedPullDirIdentity{rel: ".", info: info})
|
||||
return current, chain, nil
|
||||
}
|
||||
|
||||
prefix := ""
|
||||
for _, segment := range strings.Split(parentRel, string(filepath.Separator)) {
|
||||
info, lstatErr := pullRootLstat(current, segment)
|
||||
if os.IsNotExist(lstatErr) {
|
||||
if mkdirErr := pullRootMkdir(current, segment, 0o755); mkdirErr != nil {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("创建本地目录失败: %w", mkdirErr)
|
||||
}
|
||||
info, lstatErr = pullRootLstat(current, segment)
|
||||
}
|
||||
if lstatErr != nil {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("检查本地目标目录失败: %w", lstatErr)
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("本地目标目录 %q 已存在且不是可固定的真实目录,已拒绝镜像", filepath.Join(prefix, segment))
|
||||
}
|
||||
|
||||
next, openErr := pullOpenParentRoot(current, segment)
|
||||
if openErr != nil {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("固定本地目标目录失败: %w", openErr)
|
||||
}
|
||||
openedInfo, statErr := pullRootStat(next, ".")
|
||||
currentInfo, currentErr := pullRootLstat(current, segment)
|
||||
if statErr != nil || currentErr != nil || !currentInfo.IsDir() || currentInfo.Mode()&os.ModeSymlink != 0 ||
|
||||
!os.SameFile(info, currentInfo) || !os.SameFile(currentInfo, openedInfo) {
|
||||
_ = next.Close()
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("本地目标目录在固定期间被替换,已中止写入")
|
||||
}
|
||||
prefix = filepath.Join(prefix, segment)
|
||||
chain = append(chain, pinnedPullDirIdentity{rel: prefix, info: currentInfo})
|
||||
_ = current.Close()
|
||||
current = next
|
||||
}
|
||||
return current, chain, nil
|
||||
}
|
||||
|
||||
func (root *pinnedPullRoot) verify() error {
|
||||
current, err := pullPathStat(root.absDir)
|
||||
currentPath, pathErr := pullPathLstat(root.absDir)
|
||||
if err != nil || pathErr != nil || !os.SameFile(root.rootInfo, current) || !os.SameFile(root.pathInfo, currentPath) {
|
||||
return fmt.Errorf("本地根目录在同步期间被替换,已中止写入")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (root *pinnedPullRoot) close() { _ = root.root.Close() }
|
||||
|
||||
func (target *pinnedPullTarget) close() {
|
||||
_ = target.parentRoot.Close()
|
||||
if target.ownsBase {
|
||||
target.base.close()
|
||||
}
|
||||
}
|
||||
|
||||
func (target *pinnedPullTarget) verifyParent() error {
|
||||
if err := target.base.verify(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, expected := range target.parentChain {
|
||||
current, err := pullRootLstat(target.base.root, expected.rel)
|
||||
if err != nil || !current.IsDir() || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(expected.info, current) {
|
||||
return fmt.Errorf("本地目标目录在下载期间被替换,已中止写入")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *pinnedPullTarget) regularTargetInfo() (os.FileInfo, error) {
|
||||
info, err := pullRootLstat(target.parentRoot, target.name)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("检查本地目标失败: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("本地目标 %q 已存在且不是常规文件,已拒绝覆盖", target.name)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func createPinnedPullTemp(root *os.Root) (*os.File, string, error) {
|
||||
for i := 0; i < 100; i++ {
|
||||
name := ".dws-pull-" + pullTempName()
|
||||
file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err == nil {
|
||||
return file, name, nil
|
||||
}
|
||||
if !os.IsExist(err) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("创建不重名临时文件失败")
|
||||
}
|
||||
|
||||
func defaultPullDownloadFile(ctx context.Context, url string, headers map[string]string, destination *os.File) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
resp, err := pullHTTPDo(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
_, err = io.Copy(destination, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// rejectNonRegularLocalTarget 在任何下载工具调用或临时文件创建之前,拒绝已存在但
|
||||
// 不是常规文件的镜像目标。不存在的目标可由后续流程安全创建。
|
||||
func rejectNonRegularLocalTarget(localPath string) error {
|
||||
info, err := pullTargetLstat(localPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("检查本地目标失败: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("本地目标 %q 已存在且不是常规文件,已拒绝覆盖", localPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// runDrivePull — 端到端:远端树 → 本地落盘
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// pullListingCaller 返回固定的单层远端清单,download_file 给出预签名链接。
|
||||
func pullListingCaller(items string) *driveScriptCaller {
|
||||
return &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) {
|
||||
switch tool {
|
||||
case "list_files":
|
||||
if nth > 0 {
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[` + items + `],"nextToken":""}}`, nil
|
||||
case "download_file":
|
||||
return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil
|
||||
}
|
||||
return `{"result":{},"success":true}`, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// 端到端 pull:远端两个文件都下载到本地,并自动创建缺失的本地根目录。
|
||||
func TestCrossPlatformCoverageDrivePull_endToEndDownloadsAll(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "created-by-pull")
|
||||
caller := pullListingCaller(
|
||||
`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1000},` +
|
||||
`{"name":"b.txt","type":"file","fileId":"B","modifyTime":2000}`)
|
||||
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("remote"), 0o644)
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, name := range []string{"a.txt", "b.txt"} {
|
||||
if b, err := os.ReadFile(filepath.Join(root, name)); err != nil || string(b) != "remote" {
|
||||
t.Errorf("%s not written: %v / %q", name, err, string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --if-exists skip:本地已存在则不下载。
|
||||
func TestCrossPlatformCoverageDrivePull_ifExistsSkipKeepsLocal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "local-original")
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9000}`)
|
||||
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
t.Error("skip must not download")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "skip"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(root, "a.txt")); string(b) != "local-original" {
|
||||
t.Errorf("local file changed: %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// --if-exists smart:本地 mtime 已 ≥ 远端 → 跳过;远端更新 → 下载。
|
||||
func TestCrossPlatformCoverageDrivePull_ifExistsSmart(t *testing.T) {
|
||||
t.Run("local newer skips", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "a.txt")
|
||||
mustWrite(t, p, "local")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":` + strconv.FormatInt(local-5000, 10) + `}`)
|
||||
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
t.Error("smart must not download when local is newer")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "local" {
|
||||
t.Errorf("local file changed: %q", string(b))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote newer downloads", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "a.txt")
|
||||
mustWrite(t, p, "local")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":` + strconv.FormatInt(local+60000, 10) + `}`)
|
||||
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("fresh"), 0o644)
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "fresh" {
|
||||
t.Errorf("expected download, got %q", string(b))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --if-exists 非法值直接拒绝。
|
||||
func TestCrossPlatformCoverageDrivePull_invalidIfExistsRejected(t *testing.T) {
|
||||
caller := pullListingCaller("")
|
||||
err := runDriveCmd(t, caller, "pull", "--local-folder", t.TempDir(), "--remote-folder", "ROOT", "--if-exists", "bogus")
|
||||
if err == nil || !strings.Contains(err.Error(), "--if-exists") {
|
||||
t.Fatalf("expected --if-exists rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// space-id 透传到 download_file。
|
||||
func TestCrossPlatformCoverageDrivePull_passesSpaceIDToDownload(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`)
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("x"), 0o644)
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--space-id", "SP9"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := caller.callsFor("download_file")[0].args["spaceId"]; got != "SP9" {
|
||||
t.Errorf("download_file spaceId = %v, want SP9", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 下载失败 → 该项记 failed,命令以 partial_failure 非零退出。
|
||||
func TestCrossPlatformCoverageDrivePull_downloadFailureIsPartialFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`)
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { return errTestDownload })
|
||||
|
||||
var out strings.Builder
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"})
|
||||
|
||||
cmd := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
err := runDrivePull(cmd, nil)
|
||||
var pf *drivePartialFailure
|
||||
if !errors.As(err, &pf) {
|
||||
t.Fatalf("expected drivePartialFailure, got %T %v", err, err)
|
||||
}
|
||||
if pf.failed != 1 || pf.RawStderr() != "drive pull: 1 file(s) failed" {
|
||||
t.Errorf("partial failure = %#v, stderr = %q", pf, pf.RawStderr())
|
||||
}
|
||||
stdout := out.String()
|
||||
if !strings.Contains(stdout, `"failed": 1`) || !strings.Contains(stdout, `"rel_path": "a.txt"`) {
|
||||
t.Errorf("stdout must retain the structured partial result: %s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// download_file 未返回下载链接 → failed(不是 panic,也不落盘)。
|
||||
func TestCrossPlatformCoverageDrivePull_missingDownloadURLFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) {
|
||||
if tool == "list_files" {
|
||||
if nth > 0 {
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"fileId":"A"},"success":true}`, nil // 无 downloadUrl
|
||||
}}
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected failure when download_file returns no URL")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "a.txt")); !os.IsNotExist(err) {
|
||||
t.Error("nothing must be written when the download URL is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// download_file 的 MCP 调用本身失败 → failed。
|
||||
func TestCrossPlatformCoverageDrivePull_downloadToolErrorFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) {
|
||||
if tool == "list_files" {
|
||||
if nth > 0 {
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":""}}`, nil
|
||||
}
|
||||
return "", errTestDownload
|
||||
}}
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected failure when download_file errors")
|
||||
}
|
||||
}
|
||||
|
||||
// 远端名称逃逸出本地根 → 记 failed,不落盘(resolveLocalTarget 二次确认)。
|
||||
func TestCrossPlatformCoverageDrivePull_escapingRelPathIsFailed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// 远端目录名合法,但目录内的软链把落盘点指到根目录外。
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
caller := &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) {
|
||||
if tool != "list_files" {
|
||||
return `{"result":{"downloadUrl":"https://oss.example.com/get"}}`, nil
|
||||
}
|
||||
switch args["parentId"] {
|
||||
case "ROOT":
|
||||
return `{"result":{"items":[{"name":"sub","type":"folder","fileId":"SUB"}],"nextToken":""}}`, nil
|
||||
case "SUB":
|
||||
return `{"result":{"items":[{"name":"leaked.txt","type":"file","fileId":"L"}],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}}
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("leak"), 0o644)
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected symlink escape to be reported as failure")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "leaked.txt")); !os.IsNotExist(err) {
|
||||
t.Error("file escaped the local root")
|
||||
}
|
||||
}
|
||||
|
||||
// 目标父目录不可创建(同名常规文件占位)→ failed,不 panic。
|
||||
func TestCrossPlatformCoveragePullOneFile_mkdirFailureIsFailed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// "sub" 是普通文件,MkdirAll("sub") 必然失败。
|
||||
mustWrite(t, filepath.Join(root, "sub"), "occupied")
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
rf := &remoteFile{RelPath: "sub/x.txt", FileID: "X"}
|
||||
action, err := pullOneFile(context.Background(), "", rf, filepath.Join(root, "sub", "x.txt"), ifExistsOverwrite)
|
||||
if action != pullActionFailed || err == nil {
|
||||
t.Fatalf("action=%q err=%v, want failed + error", action, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 探测目录不存在时 isCaseInsensitiveFS 回退平台默认,不 panic。
|
||||
func TestCrossPlatformCoverageIsCaseInsensitiveFS_missingDirFallsBackToPlatformDefault(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "absent")
|
||||
// 只断言不 panic 且返回布尔(具体值取决于平台默认)。
|
||||
_ = isCaseInsensitiveFS(missing)
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func newPinnedPullRootForCoverage(t *testing.T, path string) *pinnedPullRoot {
|
||||
t.Helper()
|
||||
root, err := openExistingPinnedPullRoot(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(root.close)
|
||||
return root
|
||||
}
|
||||
|
||||
func newMissingPinnedPullPlanForCoverage(t *testing.T) *pinnedPullRootPlan {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "missing", "mirror")
|
||||
plan, err := planPinnedPullRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(plan.close)
|
||||
return plan
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullRootPlanFailures(t *testing.T) {
|
||||
t.Run("existing root open", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullOpenRoot, func(string) (*os.Root, error) { return nil, errTestDownload })
|
||||
if _, err := planPinnedPullRoot(t.TempDir()); err == nil {
|
||||
t.Fatal("expected existing-root open failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ancestor is a file", func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
file := filepath.Join(parent, "file")
|
||||
mustWrite(t, file, "x")
|
||||
if _, err := planPinnedPullRoot(filepath.Join(file, "child")); err == nil ||
|
||||
(!strings.Contains(err.Error(), "不是目录") && !strings.Contains(err.Error(), "not a directory")) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ancestor discovered as file", func(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "file")
|
||||
mustWrite(t, file, "x")
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullPathStat, func(string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return os.Stat(file)
|
||||
})
|
||||
if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil || !strings.Contains(err.Error(), "不是目录") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ancestor open", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullOpenRoot, func(string) (*os.Root, error) { return nil, errTestDownload })
|
||||
if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected ancestor open failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path stat errors", func(t *testing.T) {
|
||||
for _, initial := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "initial", false: "ancestor"}[initial], func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if initial || calls > 1 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected path stat failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("initial verification", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected initial plan verification failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid and changed plan", func(t *testing.T) {
|
||||
if err := (&pinnedPullRootPlan{}).verify(); err == nil {
|
||||
t.Fatal("expected invalid plan failure")
|
||||
}
|
||||
root := newPinnedPullRootForCoverage(t, t.TempDir())
|
||||
bad := *root
|
||||
bad.absDir = filepath.Join(t.TempDir(), "gone")
|
||||
if err := (&pinnedPullRootPlan{ancestor: &bad}).verify(); err == nil {
|
||||
t.Fatal("expected changed ancestor failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unexpected missing-path lstat", func(t *testing.T) {
|
||||
plan := newMissingPinnedPullPlanForCoverage(t)
|
||||
testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
if err := plan.verify(); err == nil {
|
||||
t.Fatal("expected missing-path lstat failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullRootMaterializeFailures(t *testing.T) {
|
||||
t.Run("verification", func(t *testing.T) {
|
||||
if _, err := (&pinnedPullRootPlan{}).materialize(); err == nil {
|
||||
t.Fatal("expected materialize verification failure")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stub func(*testing.T)
|
||||
}{
|
||||
{"open ancestor", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullOpenParentRoot, func(*os.Root, string) (*os.Root, error) { return nil, errTestDownload })
|
||||
}},
|
||||
{"mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullRootMkdir, func(*os.Root, string, os.FileMode) error { return errTestDownload })
|
||||
}},
|
||||
{"created lstat", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
}},
|
||||
{"created not directory", func(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "file")
|
||||
mustWrite(t, file, "x")
|
||||
info, err := os.Lstat(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return info, nil })
|
||||
}},
|
||||
{"created changes before root identity", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
}},
|
||||
{"open created", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullOpenParentRoot, func(root *os.Root, name string) (*os.Root, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.OpenRoot(name)
|
||||
})
|
||||
}},
|
||||
{"created identity", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.Stat(name)
|
||||
})
|
||||
}},
|
||||
{"final root stat", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 3 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.Stat(name)
|
||||
})
|
||||
}},
|
||||
{"final verification", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullPathLstat, func(path string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls >= 2 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return os.Lstat(path)
|
||||
})
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plan := newMissingPinnedPullPlanForCoverage(t)
|
||||
tc.stub(t)
|
||||
if root, err := plan.materialize(); err == nil {
|
||||
root.close()
|
||||
t.Fatal("expected materialize failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("final lstat and verify", func(t *testing.T) {
|
||||
for _, failAt := range []int{1, 2} {
|
||||
t.Run(string(rune('0'+failAt)), func(t *testing.T) {
|
||||
plan := newMissingPinnedPullPlanForCoverage(t)
|
||||
calls := 0
|
||||
if failAt == 1 {
|
||||
testseam.Swap(t, &pullPathLstat, func(string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
} else {
|
||||
testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls >= 2 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
}
|
||||
if root, err := plan.materialize(); err == nil {
|
||||
root.close()
|
||||
t.Fatal("expected final materialize failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("open existing path identity and verification", func(t *testing.T) {
|
||||
for _, fail := range []string{"lstat", "verify"} {
|
||||
t.Run(fail, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if fail == "lstat" {
|
||||
testseam.Swap(t, &pullPathLstat, func(string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
} else {
|
||||
pathCalls := 0
|
||||
testseam.Swap(t, &pullPathLstat, func(path string) (os.FileInfo, error) {
|
||||
pathCalls++
|
||||
if pathCalls >= 2 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return os.Lstat(path)
|
||||
})
|
||||
}
|
||||
if _, err := openExistingPinnedPullRoot(dir); err == nil {
|
||||
t.Fatal("expected existing-root identity failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullPublishRaces(t *testing.T) {
|
||||
t.Run("smart concurrent target", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "older")
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
if _, err := destination.WriteString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(filepath.Join(root, "a.txt"), time.Now(), time.Now().Add(time.Hour))
|
||||
})
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F", ModifiedTimeValid: true}, root, "a.txt", ifExistsSmart)
|
||||
if err != nil || action != pullActionSkipped {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("link loses to regular target", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullLink, func(parent *os.Root, _, target string) error {
|
||||
file, err := parent.OpenFile(target, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
return os.ErrExist
|
||||
})
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsSkip)
|
||||
if err != nil || action != pullActionSkipped {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("link cleanup", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullRemove, func(*os.Root, string) error { return errTestDownload })
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsSkip)
|
||||
if err == nil || action != pullActionFailed || !strings.Contains(err.Error(), "清理临时文件") {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post publish parent", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
statCalls := 0
|
||||
testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) {
|
||||
statCalls++
|
||||
if statCalls >= 5 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite)
|
||||
if err == nil || action != pullActionFailed {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("overwrite rename", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullRename, func(*os.Root, string, string) error { return errTestDownload })
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite)
|
||||
if err == nil || action != pullActionFailed {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullPlanCallers(t *testing.T) {
|
||||
t.Run("second preflight verification", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
|
||||
root := newPinnedPullRootForCoverage(t, dir)
|
||||
plan := &pinnedPullRootPlan{absDir: root.absDir, existing: root}
|
||||
moved := false
|
||||
testseam.Swap(t, &pullRootLstat, func(parent *os.Root, name string) (os.FileInfo, error) {
|
||||
info, err := parent.Lstat(name)
|
||||
if name == "a.txt" && !moved {
|
||||
moved = true
|
||||
if renameErr := os.Rename(dir, dir+"-pinned"); renameErr != nil {
|
||||
// Windows 在固定目录上持有句柄时会锁住它,移走于该平台物理
|
||||
// 不可达;注入等价的根身份变化,命中同一条二次校验分支。
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Fatal(renameErr)
|
||||
}
|
||||
swapPinnedRootIdentity(t, dir)
|
||||
} else if mkdirErr := os.Mkdir(dir, 0o755); mkdirErr != nil {
|
||||
t.Fatal(mkdirErr)
|
||||
}
|
||||
}
|
||||
return info, err
|
||||
})
|
||||
if _, _, err := buildDrivePullPreflightFromPlan(plan, map[string]*remoteFile{"a.txt": {RelPath: "a.txt"}}); err == nil {
|
||||
t.Fatal("expected second verification failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("command materialize", func(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "missing")
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A"}`)
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
testseam.Swap(t, &pullOpenParentRoot, func(*os.Root, string) (*os.Root, error) { return nil, errTestDownload })
|
||||
cmd := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
if err := runDrivePull(cmd, nil); err == nil || !errors.Is(err, errTestDownload) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preflight output", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := pullListingCaller(`{"name":"A.txt","type":"file","fileId":"A"},{"name":"a.txt","type":"file","fileId":"B"}`)
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: failingWriter{}}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
cmd := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
if err := runDrivePull(cmd, nil); err == nil {
|
||||
t.Fatal("expected preflight output failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote helper", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
action, err := pullRemoteFile(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite)
|
||||
if err != nil || action != pullActionDownloaded {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("target construction final verification", func(t *testing.T) {
|
||||
root := newPinnedPullRootForCoverage(t, t.TempDir())
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullRootLstat, func(parent *os.Root, name string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls >= 2 && name == "." {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return parent.Lstat(name)
|
||||
})
|
||||
if _, err := root.openTarget("a.txt"); err == nil {
|
||||
t.Fatal("expected target verification failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullDefaultTransportWithoutListener(t *testing.T) {
|
||||
destination, err := os.Create(filepath.Join(t.TempDir(), "payload"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer destination.Close()
|
||||
|
||||
if err := defaultPullDownloadFile(context.Background(), ":", nil, destination); err == nil {
|
||||
t.Fatal("expected invalid request URL")
|
||||
}
|
||||
|
||||
t.Run("transport error", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullHTTPDo, func(*http.Request) (*http.Response, error) { return nil, errTestDownload })
|
||||
if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", nil, destination); !errors.Is(err, errTestDownload) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullHTTPDo, func(request *http.Request) (*http.Response, error) {
|
||||
if request.Header.Get("X-Test") != "yes" {
|
||||
t.Fatalf("header = %q", request.Header.Get("X-Test"))
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusTeapot, Body: io.NopCloser(strings.NewReader("denied"))}, nil
|
||||
})
|
||||
if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", map[string]string{"X-Test": "yes"}, destination); err == nil {
|
||||
t.Fatal("expected status failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success and copy", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullHTTPDo, func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload"))}, nil
|
||||
})
|
||||
if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", nil, destination); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closed, createErr := os.Create(filepath.Join(t.TempDir(), "closed"))
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
_ = closed.Close()
|
||||
if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", nil, closed); err == nil {
|
||||
t.Fatal("expected copy failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullMTimeHandle(t *testing.T) {
|
||||
file, err := os.Create(filepath.Join(t.TempDir(), "mtime"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
if err := setPullFileTimes(file, time.Unix(123, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullDryRunLocalPolicies(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "a.txt")
|
||||
mustWrite(t, path, "local")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: io.Discard}})
|
||||
remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", ModifiedTimeValid: true, ModifiedTime: info.ModTime().UnixMilli()}}
|
||||
for _, policy := range []string{ifExistsSkip, ifExistsSmart, ifExistsOverwrite} {
|
||||
if err := printDrivePullDryRunWithLocalInfo(policy, remote, []string{"a.txt"}, nil, map[string]os.FileInfo{"a.txt": info}); err != nil {
|
||||
t.Fatalf("policy %s: %v", policy, err)
|
||||
}
|
||||
}
|
||||
if err := printDrivePullDryRunWithLocalInfo(ifExistsSkip, remote, []string{"missing.txt"}, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullRejectNonRegular(t *testing.T) {
|
||||
if err := rejectNonRegularLocalTarget(filepath.Join(t.TempDir(), "missing")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := rejectNonRegularLocalTarget(dir); err == nil {
|
||||
t.Fatal("expected directory rejection")
|
||||
}
|
||||
testseam.Swap(t, &pullTargetLstat, func(string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
if err := rejectNonRegularLocalTarget("target"); err == nil {
|
||||
t.Fatal("expected lstat failure")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// smart 策略在入口按固定的 initialInfo 判定「本地落后、需要下载」,但远端读与落盘
|
||||
// 期间同一 inode 的 mtime 仍可能被并发写者推到 ≥ 远端时间。此时发布会用旧的远端版本
|
||||
// 覆盖更新的本地内容,因此必须在发布前按当前状态二次判定并跳过。
|
||||
func TestCrossPlatformCoverageDrivePullSmartSkipsWhenTargetCatchesUpDuringDownload(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "a.txt")
|
||||
const remoteMillis int64 = 2_000_000
|
||||
if err := os.WriteFile(target, []byte("local"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 入口处的 mtime 必须严格早于远端,否则在发出远端读之前就已跳过。
|
||||
stale := time.UnixMilli(remoteMillis - 60_000)
|
||||
if err := os.Chtimes(target, stale, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
if _, err := destination.WriteString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
// 就地改时间:inode 不变,所以身份比较仍然成立,只有时间判定能拦下发布。
|
||||
fresh := time.UnixMilli(remoteMillis + 60_000)
|
||||
return os.Chtimes(target, fresh, fresh)
|
||||
})
|
||||
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{
|
||||
FileID: "F",
|
||||
ModifiedTime: remoteMillis,
|
||||
ModifiedTimeValid: true,
|
||||
}, root, "a.txt", ifExistsSmart)
|
||||
if err != nil {
|
||||
t.Fatalf("pull = %v", err)
|
||||
}
|
||||
if action != pullActionSkipped {
|
||||
t.Fatalf("action = %q, want %q", action, pullActionSkipped)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "local" {
|
||||
t.Fatalf("target = %q, err=%v; 追平后的本地内容必须保留", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 发布成功后固定目录仍可能被移走。此时结果已经落盘,门禁只能报失败而不能回删——
|
||||
// 同名 terminal entry 可能已被并发替换,二次 Lstat→Remove 会误删他人的文件。
|
||||
func TestCrossPlatformCoverageDrivePullFailsWhenPinnedRootMovesAfterPublish(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
decoy := t.TempDir()
|
||||
decoyInfo, err := os.Stat(decoy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
published := false
|
||||
testseam.Swap(t, &pullRename, func(parent *os.Root, oldName, newName string) error {
|
||||
if err := parent.Rename(oldName, newName); err != nil {
|
||||
return err
|
||||
}
|
||||
published = true
|
||||
return nil
|
||||
})
|
||||
// 只在发布之后让根目录身份不匹配:下载前与发布前的两次核对都必须先通过。
|
||||
testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) {
|
||||
if published {
|
||||
return decoyInfo, nil
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite)
|
||||
if err == nil || !strings.Contains(err.Error(), "本地根目录在同步期间被替换") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if action != pullActionFailed {
|
||||
t.Fatalf("action = %q, want %q", action, pullActionFailed)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(got) != "remote" {
|
||||
t.Fatalf("published target = %q, err=%v; 已发布的结果不得被回删", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// drivePullMockCaller 按工具名分发:list_files 返回可配置的树,download_file 返回预签名 URL。
|
||||
type drivePullMockCaller struct{ listJSON string }
|
||||
|
||||
func (m *drivePullMockCaller) CallTool(_ context.Context, _ string, tool string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
text := `{"result":{"downloadUrl":"https://oss.example.com/dl"}}`
|
||||
if tool == "list_files" {
|
||||
text = m.listJSON
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
func (m *drivePullMockCaller) Format() string { return "raw" }
|
||||
func (m *drivePullMockCaller) DryRun() bool { return false }
|
||||
func (m *drivePullMockCaller) Fields() string { return "" }
|
||||
func (m *drivePullMockCaller) JQ() string { return "" }
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// isSafeRemoteSegment — 远端名称逐段安全校验(拒绝逃逸成分)
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageIsSafeRemoteSegment(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"report.pdf", true},
|
||||
{"报告 2024.xlsx", true},
|
||||
{"a.b.c", true},
|
||||
{"", false},
|
||||
{".", false},
|
||||
{"..", false},
|
||||
{"a/b", false}, // 正斜杠分隔符
|
||||
{`a\b`, false}, // 反斜杠分隔符(Windows)
|
||||
{"../etc", false}, // 相对上跳
|
||||
{"foo/../bar", false},
|
||||
{"/abs", false}, // 绝对路径成分
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isSafeRemoteSegment(c.name); got != c.ok {
|
||||
t.Errorf("isSafeRemoteSegment(%q) = %v, want %v", c.name, got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// resolveLocalTarget — 拼接后必须仍位于本地根目录内
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageResolveLocalTarget_withinRoot(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
got, err := resolveLocalTarget(root, "sub/a.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := filepath.Join(root, "sub", "a.txt")
|
||||
if got != want {
|
||||
t.Errorf("target = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 即便 isSafeRemoteSegment 被绕过,逐段拼出的逃逸路径也必须被 resolveLocalTarget 兜住。
|
||||
func TestCrossPlatformCoverageResolveLocalTarget_escapeRejected(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
for _, rel := range []string{"../evil.txt", "../../etc/passwd", "sub/../../out.txt"} {
|
||||
if _, err := resolveLocalTarget(root, rel); err == nil {
|
||||
t.Errorf("resolveLocalTarget(%q) should reject escaping path", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根目录内的目录符号链接指向外部时,词法检查会放过(root/sub 仍在 root 下),
|
||||
// 必须靠符号链接解析挡住:sub -> /outside 时 sub/a.txt 应被拒绝。
|
||||
func TestCrossPlatformCoverageResolveLocalTarget_dirSymlinkEscapeRejected(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
outside := filepath.Clean(t.TempDir())
|
||||
|
||||
link := filepath.Join(root, "sub")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("平台不支持创建符号链接: %v", err)
|
||||
}
|
||||
|
||||
if _, err := resolveLocalTarget(root, "sub/a.txt"); err == nil {
|
||||
t.Fatal("root/sub -> 外部目录时,sub/a.txt 应被拒绝(防符号链接逃逸)")
|
||||
}
|
||||
|
||||
// 对照:根目录内的真实子目录不应被误伤。
|
||||
if err := os.MkdirAll(filepath.Join(root, "real"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := resolveLocalTarget(root, "real/a.txt"); err != nil {
|
||||
t.Errorf("真实子目录不应被拒绝: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 不存在的本地根目录必须放行(不误判逃逸),由后续 MkdirAll 创建。
|
||||
func TestCrossPlatformCoverageResolveLocalTarget_nonexistentRootAllowed(t *testing.T) {
|
||||
parent := filepath.Clean(t.TempDir())
|
||||
absDir := filepath.Join(parent, "not", "created", "yet") // 尚不存在
|
||||
got, err := resolveLocalTarget(absDir, "sub/a.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("不存在的根目录不应被误判为逃逸: %v", err)
|
||||
}
|
||||
if want := filepath.Join(absDir, "sub", "a.txt"); got != want {
|
||||
t.Errorf("target = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:--local-folder 指向尚不存在的绝对路径时,pull 应自动创建目录并下载,
|
||||
// 而不是把每个远端文件都误判为符号链接逃逸而 failed。
|
||||
func TestCrossPlatformCoverageDrivePull_nonexistentLocalRoot(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
absDir := filepath.Join(parent, "new", "repo") // 尚不存在
|
||||
|
||||
caller := &drivePullMockCaller{
|
||||
listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"F1","modifyTime":1000}],"nextToken":""}}`,
|
||||
}
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("REMOTE"), 0o644)
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.AddCommand(newDriveCommand())
|
||||
|
||||
// pull/push 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。
|
||||
full := []string{"pull", "--local-folder", absDir, "--remote-folder", "ROOT", "--yes"}
|
||||
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...))
|
||||
root.SetArgs(append([]string{"drive"}, full...))
|
||||
|
||||
// 全部下载成功时 runDrivePull 返回 nil;若误判逃逸则会有 failed 并返回 partial_failure。
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("pull 到不存在的本地根目录不应失败: %v", err)
|
||||
}
|
||||
got, rerr := os.ReadFile(filepath.Join(absDir, "a.txt"))
|
||||
if rerr != nil {
|
||||
t.Fatalf("目标文件应被创建: %v", rerr)
|
||||
}
|
||||
if string(got) != "REMOTE" {
|
||||
t.Errorf("目标内容 = %q, want REMOTE", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// detectTargetCollisions — 多个远端条目映射到同一本地目标
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 大小写不敏感 FS:A.txt 与 a.txt 落到同一目标 → 两者都判冲突;无关文件不受影响。
|
||||
func TestCrossPlatformCoverageDetectTargetCollisions_caseInsensitive(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
rels := []string{"A.txt", "a.txt", "b.txt", "sub/C.md", "sub/c.md"}
|
||||
collided := detectTargetCollisions(root, rels, true)
|
||||
|
||||
for _, r := range []string{"A.txt", "a.txt", "sub/C.md", "sub/c.md"} {
|
||||
if !collided[r] {
|
||||
t.Errorf("%q 应被判为大小写冲突", r)
|
||||
}
|
||||
}
|
||||
if collided["b.txt"] {
|
||||
t.Error("b.txt 无冲突,不应被标记")
|
||||
}
|
||||
}
|
||||
|
||||
// 大小写敏感 FS:A.txt 与 a.txt 是两个合法文件 → 不应误判冲突。
|
||||
func TestCrossPlatformCoverageDetectTargetCollisions_caseSensitive(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
rels := []string{"A.txt", "a.txt", "b.txt"}
|
||||
collided := detectTargetCollisions(root, rels, false)
|
||||
if len(collided) != 0 {
|
||||
t.Errorf("大小写敏感 FS 不应有冲突, got %v", collided)
|
||||
}
|
||||
}
|
||||
|
||||
// 平台名称规范化冲突:同名的 NFC 与 NFD 记法在不敏感 FS 上落到同一目标 → 冲突。
|
||||
func TestCrossPlatformCoverageDetectTargetCollisions_unicodeNormalization(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
nfc := "café.txt" // café(预组合 é)
|
||||
nfd := "café.txt" // café(e + 组合尖音符)
|
||||
if nfc == nfd {
|
||||
t.Fatal("测试数据应为不同字节序列")
|
||||
}
|
||||
|
||||
ci := detectTargetCollisions(root, []string{nfc, nfd}, true)
|
||||
if !ci[nfc] || !ci[nfd] {
|
||||
t.Errorf("NFC/NFD 异写在大小写不敏感 FS 上应判冲突, got %v", ci)
|
||||
}
|
||||
// 大小写/规范化敏感 FS 上视为两个不同文件,不冲突。
|
||||
cs := detectTargetCollisions(root, []string{nfc, nfd}, false)
|
||||
if len(cs) != 0 {
|
||||
t.Errorf("敏感 FS 上 NFC/NFD 不应冲突, got %v", cs)
|
||||
}
|
||||
}
|
||||
|
||||
// isCaseInsensitiveFS 应能对临时目录给出与实际探针一致的判定(本机自洽)。
|
||||
func TestCrossPlatformCoverageIsCaseInsensitiveFS_selfConsistent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
got := isCaseInsensitiveFS(dir)
|
||||
// 实测:写一个小写名,再用大写名 stat。
|
||||
lower := filepath.Join(dir, "probe_case_xyz.txt")
|
||||
if err := os.WriteFile(lower, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := os.Stat(filepath.Join(dir, "PROBE_CASE_XYZ.TXT"))
|
||||
actualInsensitive := err == nil
|
||||
if got != actualInsensitive {
|
||||
t.Errorf("isCaseInsensitiveFS=%v,但实测大写名可 stat=%v", got, actualInsensitive)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// pullOneFile — 覆盖下载中断不得破坏原文件(原子替换)
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 下载中途失败时:命令报告 failed,原有本地文件内容必须原封不动,且不留临时残file。
|
||||
func TestCrossPlatformCoveragePullOneFile_downloadFailureKeepsOriginal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "a.txt")
|
||||
const original = "ORIGINAL-CONTENT-DO-NOT-LOSE"
|
||||
mustWrite(t, target, original)
|
||||
|
||||
// download_file 的 MCP 调用走 mock;httpGetFile 注入为“写入部分内容后报错”。
|
||||
testseam.Swap(t, &deps, &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
// 模拟传输中断:先截断/写半截,再返回错误。
|
||||
_ = os.WriteFile(dest, []byte("HALF"), 0o644)
|
||||
return errors.New("connection reset")
|
||||
})
|
||||
|
||||
rf := &remoteFile{RelPath: "a.txt", FileID: "F1"}
|
||||
action, err := pullOneFile(context.Background(), "", rf, target, ifExistsOverwrite)
|
||||
if action != pullActionFailed || err == nil {
|
||||
t.Fatalf("expected failed action with error, got action=%q err=%v", action, err)
|
||||
}
|
||||
|
||||
// 原文件必须保持原内容。
|
||||
got, rerr := os.ReadFile(target)
|
||||
if rerr != nil {
|
||||
t.Fatalf("原文件应仍存在: %v", rerr)
|
||||
}
|
||||
if string(got) != original {
|
||||
t.Errorf("原文件被破坏: got %q, want %q", got, original)
|
||||
}
|
||||
|
||||
// 不应残留临时文件。
|
||||
entries, _ := os.ReadDir(root)
|
||||
for _, e := range entries {
|
||||
if e.Name() != "a.txt" {
|
||||
t.Errorf("下载失败后残留了临时文件: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下载成功时:临时文件被原子重命名为目标,内容与远端一致。
|
||||
func TestCrossPlatformCoveragePullOneFile_downloadSuccessReplacesTarget(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "a.txt")
|
||||
mustWrite(t, target, "OLD")
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("NEW-CONTENT"), 0o644)
|
||||
})
|
||||
|
||||
rf := &remoteFile{RelPath: "a.txt", FileID: "F1"}
|
||||
action, err := pullOneFile(context.Background(), "", rf, target, ifExistsOverwrite)
|
||||
if err != nil || action != pullActionDownloaded {
|
||||
t.Fatalf("expected downloaded, got action=%q err=%v", action, err)
|
||||
}
|
||||
got, _ := os.ReadFile(target)
|
||||
if string(got) != "NEW-CONTENT" {
|
||||
t.Errorf("目标未被替换为新内容: got %q", got)
|
||||
}
|
||||
entries, _ := os.ReadDir(root)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("成功后目录应只有目标文件,got %d 项", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// parseDriveDownloadInfo — drive download_file 返回解析
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// drive 的正常返回:result.downloadUrl 是预签名 URL,无需额外 header。
|
||||
func TestCrossPlatformCoverageParseDriveDownloadInfo_downloadUrl(t *testing.T) {
|
||||
text := `{"result":{"downloadType":"urlPreSignature","downloadUrl":"https://oss.example.com/f.file?Expires=1&Signature=xyz","fileName":"a.txt"},"success":true}`
|
||||
url, headers, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if url != "https://oss.example.com/f.file?Expires=1&Signature=xyz" {
|
||||
t.Errorf("url = %q", url)
|
||||
}
|
||||
if len(headers) != 0 {
|
||||
t.Errorf("预签名 URL 不应带 header, got %v", headers)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容 flat resourceUrl 字段。
|
||||
func TestCrossPlatformCoverageParseDriveDownloadInfo_resourceUrlFallback(t *testing.T) {
|
||||
text := `{"resourceUrl":"https://flat.example.com/dl"}`
|
||||
url, _, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if url != "https://flat.example.com/dl" {
|
||||
t.Errorf("url = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容 resourceUrls[].url 数组格式(在 result 包裹下)。
|
||||
func TestCrossPlatformCoverageParseDriveDownloadInfo_resourceUrlsArrayFallback(t *testing.T) {
|
||||
text := `{"result":{"resourceUrls":[{"url":"https://arr.example.com/dl","headers":{}}]}}`
|
||||
url, _, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if url != "https://arr.example.com/dl" {
|
||||
t.Errorf("url = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParseDriveDownloadInfo_missingURL(t *testing.T) {
|
||||
text := `{"result":{"fileName":"a.txt"}}`
|
||||
if _, _, err := parseDriveDownloadInfo(text); err == nil {
|
||||
t.Fatal("expected error when downloadUrl is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParseDriveDownloadInfo_invalidJSON(t *testing.T) {
|
||||
if _, _, err := parseDriveDownloadInfo("not json"); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// drivePartialFailure — pull 部分失败错误:exit=1,stderr 仅保留简短说明
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDrivePartialFailure(t *testing.T) {
|
||||
e := &drivePartialFailure{failed: 2}
|
||||
if e.ExitCode() != 1 {
|
||||
t.Errorf("ExitCode() = %d, want 1", e.ExitCode())
|
||||
}
|
||||
want := "drive pull: 2 file(s) failed"
|
||||
if e.Error() != want || e.RawStderr() != want {
|
||||
t.Errorf("Error()/RawStderr() = %q / %q, want %q", e.Error(), e.RawStderr(), want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// setPullFileTimes 只通过已打开文件描述符更新访问/修改时间,不重新解析文件名。
|
||||
func setPullFileTimes(file *os.File, modified time.Time) error {
|
||||
stamp := unix.NsecToTimeval(modified.UnixNano())
|
||||
return unix.Futimes(int(file.Fd()), []unix.Timeval{stamp, stamp})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user