Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a95659133 | ||
|
|
29c00341fa | ||
|
|
14a2175434 | ||
|
|
1b06d0105a | ||
|
|
18fad57bbe | ||
|
|
c3ef04988b | ||
|
|
76d54d6df6 | ||
|
|
a36189d31e | ||
|
|
d52d16dba4 | ||
|
|
d77fa91c69 | ||
|
|
9ea527a7c4 | ||
|
|
f78f1b83e7 | ||
|
|
3a6fa9a00c | ||
|
|
dc43d0d6d4 | ||
|
|
bb69ed76df | ||
|
|
a26b16b30e | ||
|
|
e0c9b4910d | ||
|
|
a1dc997004 | ||
|
|
b525497da8 | ||
|
|
ea9e31a59f | ||
|
|
e58b85ea45 | ||
|
|
e3fef0b6d4 | ||
|
|
c7236a1844 | ||
|
|
0a063e3ebd | ||
|
|
e19c54f77e | ||
|
|
fbc34509f8 | ||
|
|
ad0cf639c4 | ||
|
|
2f8e136dc0 | ||
|
|
fdbd11e0ea | ||
|
|
d07bf39586 | ||
|
|
eee41a9b45 | ||
|
|
896801634f | ||
|
|
a203572ee3 | ||
|
|
6c0ba91414 | ||
|
|
472d3d321b | ||
|
|
0b68450709 | ||
|
|
346444ea38 | ||
|
|
54dc8fadb7 | ||
|
|
b469bb127a | ||
|
|
c6e810e4d9 | ||
|
|
98d03455b1 | ||
|
|
fad41d4d99 | ||
|
|
5fed80fc0f | ||
|
|
1727025f67 | ||
|
|
715f5346da | ||
|
|
bfd836064d |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
|
||||
+93
-12
@@ -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';
|
||||
@@ -162,6 +164,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 +287,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 +341,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 +354,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 +408,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 +431,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 +443,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 +459,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'
|
||||
@@ -1256,6 +1320,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 +1347,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 +1359,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:
|
||||
|
||||
@@ -6,6 +6,64 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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.58-beta.6"
|
||||
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.58-beta.6/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca"
|
||||
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.58-beta.6/dws-darwin-amd64.tar.gz"
|
||||
sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491"
|
||||
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.58-beta.6/dws-linux-arm64.tar.gz"
|
||||
sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d"
|
||||
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.58-beta.6/dws-linux-amd64.tar.gz"
|
||||
sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d"
|
||||
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.58-beta.6/dws-skills.zip"
|
||||
sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -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 必须一致,否则
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, &runnerResolveAuthToken, func(*runtimeRunner, context.Context) (string, error) {
|
||||
resolveCalled = true
|
||||
return "", 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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ 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",
|
||||
)
|
||||
@@ -336,7 +336,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)
|
||||
@@ -627,11 +627,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"},
|
||||
|
||||
@@ -12,6 +12,11 @@ import (
|
||||
"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 +37,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 +60,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 +73,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 +91,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", "--uuid", "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"},
|
||||
@@ -160,7 +165,7 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"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"},
|
||||
|
||||
+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
|
||||
|
||||
+26
-13
@@ -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
|
||||
runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken
|
||||
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,9 +543,9 @@ 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 := ""
|
||||
@@ -553,7 +553,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
authToken = pluginAuth.Token
|
||||
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
|
||||
var tokenErr error
|
||||
authToken, tokenErr = r.resolveAuthToken(ctx)
|
||||
authToken, tokenErr = runnerResolveAuthToken(r, ctx)
|
||||
if tokenErr != nil {
|
||||
return executor.Result{}, tokenResolutionError(tokenErr)
|
||||
}
|
||||
@@ -603,9 +603,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 +617,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
|
||||
@@ -1069,6 +1078,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -491,6 +491,11 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
|
||||
if senderUserID == "" && senderOpenDingTalkID == "" {
|
||||
return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required")
|
||||
}
|
||||
if senderOpenDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--sender-open-dingtalk-id", senderOpenDingTalkID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -993,8 +998,7 @@ func isNumericUserID(value string) bool {
|
||||
}
|
||||
|
||||
func isOpenDingTalkID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
return len(value) > 0 && (value[0] == 'D' || value[0] == 'd')
|
||||
return targetresolver.LooksLikeCurrentDOpenDingTalkID(value)
|
||||
}
|
||||
|
||||
// webhookErrcodeFailure 解析自定义机器人 webhook 的响应,判定是否发送失败。
|
||||
@@ -2636,6 +2640,11 @@ func newChatCommand() *cobra.Command {
|
||||
if specified == 0 {
|
||||
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if userID != "" && isOpenDingTalkID(userID) {
|
||||
openDingTalkID = userID
|
||||
userID = ""
|
||||
@@ -2723,6 +2732,11 @@ func newChatCommand() *cobra.Command {
|
||||
if userID == "" && openDingTalkID == "" {
|
||||
return fmt.Errorf("--user or --open-dingtalk-id is required")
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if userID != "" && isOpenDingTalkID(userID) {
|
||||
openDingTalkID = userID
|
||||
userID = ""
|
||||
@@ -2852,6 +2866,11 @@ func newChatCommand() *cobra.Command {
|
||||
if specified == 0 {
|
||||
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if userID != "" && isOpenDingTalkID(userID) {
|
||||
openDingTalkID = userID
|
||||
userID = ""
|
||||
@@ -4667,9 +4686,10 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
|
||||
|
||||
userID := rawUserID
|
||||
openDingTalkID := rawOpenDingTalkID
|
||||
if openDingTalkID != "" && !isOpenDingTalkID(openDingTalkID) {
|
||||
userID = openDingTalkID
|
||||
openDingTalkID = ""
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if userID != "" && isOpenDingTalkID(userID) {
|
||||
openDingTalkID = userID
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -786,11 +786,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 +819,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 +857,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 +883,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 +899,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 +913,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 +929,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 +943,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",
|
||||
|
||||
@@ -324,11 +324,23 @@ func (m FlagMigration) validate() error {
|
||||
if m.Canonical.After.Hidden {
|
||||
return fmt.Errorf("canonical flag must remain visible")
|
||||
}
|
||||
if !m.Canonical.After.Required {
|
||||
return fmt.Errorf("canonical flag must be required after migration")
|
||||
// Requiredness belongs to the one logical parameter. The hidden legacy
|
||||
// spelling must not remain independently required, while the canonical
|
||||
// spelling inherits the exact before-state contract. An already-visible
|
||||
// canonical flag cannot change requiredness; an existing hidden canonical
|
||||
// placeholder may inherit it when promoted to the public spelling.
|
||||
if m.Legacy.After.Required {
|
||||
return fmt.Errorf("legacy compatibility alias must not remain independently required after migration")
|
||||
}
|
||||
if m.Canonical.Before.Present && m.Canonical.Before.Required {
|
||||
return fmt.Errorf("canonical flag must be absent or optional before migration")
|
||||
if m.Legacy.Before.Required != m.Canonical.After.Required {
|
||||
return fmt.Errorf(
|
||||
"flag requiredness must be preserved from legacy before to canonical after",
|
||||
)
|
||||
}
|
||||
if m.Canonical.Before.Present &&
|
||||
!m.Canonical.Before.Hidden &&
|
||||
m.Canonical.Before.Required != m.Canonical.After.Required {
|
||||
return fmt.Errorf("canonical flag requiredness must remain unchanged when already present")
|
||||
}
|
||||
if m.Legacy.After.AliasOf != m.Canonical.Name {
|
||||
return fmt.Errorf(
|
||||
@@ -682,6 +694,7 @@ func flagMigrationAuthorizesChange(
|
||||
return true
|
||||
}
|
||||
if migration.Canonical.Before.Present &&
|
||||
migration.Canonical.Before.Hidden &&
|
||||
!migration.Canonical.Before.Required &&
|
||||
migration.Canonical.After.Required &&
|
||||
change.Kind == "flag_became_required" {
|
||||
|
||||
@@ -188,7 +188,7 @@ func TestCrossPlatformCoverageFlagMigrationManifestParserEdges(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *testing.T) {
|
||||
optionalCanonical := func() FlagMigrationManifest {
|
||||
manifest := coverageManifest(FlagMigrationPending)
|
||||
manifest := coverageOptionalManifest(FlagMigrationPending)
|
||||
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
@@ -269,6 +269,36 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After = FlagMigrationState{} },
|
||||
wantErr: "canonical flag must be present after migration",
|
||||
},
|
||||
{
|
||||
name: "required legacy cannot become optional canonical",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Required = false },
|
||||
wantErr: "requiredness must be preserved from legacy before to canonical after",
|
||||
},
|
||||
{
|
||||
name: "optional legacy cannot become required canonical",
|
||||
make: func() FlagMigrationManifest { return coverageOptionalManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Required = true },
|
||||
wantErr: "requiredness must be preserved from legacy before to canonical after",
|
||||
},
|
||||
{
|
||||
name: "existing canonical cannot change requiredness",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(manifest *FlagMigrationManifest) {
|
||||
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Scope: "local",
|
||||
}
|
||||
},
|
||||
wantErr: "canonical flag requiredness must remain unchanged when already present",
|
||||
},
|
||||
{
|
||||
name: "hidden legacy alias is not independently required",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Required = true },
|
||||
wantErr: "legacy compatibility alias must not remain independently required",
|
||||
},
|
||||
{
|
||||
name: "legacy after declares alias target",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
@@ -711,8 +741,32 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCa
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing optional canonical becomes required", func(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
t.Run("optional canonical is introduced without becoming required", func(t *testing.T) {
|
||||
pending := coverageOptionalManifest(FlagMigrationPending)
|
||||
consumed := coverageOptionalManifest(FlagMigrationConsumed)
|
||||
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
|
||||
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
|
||||
|
||||
ordinary := Compare(after, before, "merge-base")
|
||||
if !hasFlagChange(ordinary.Blocking, "flag_became_hidden", pending.Migrations[0].Command, pending.Migrations[0].Legacy.Name) {
|
||||
t.Fatalf("fixture did not create flag_became_hidden: %#v", ordinary.Blocking)
|
||||
}
|
||||
if hasFlagChange(ordinary.Blocking, "required_flag_added", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
|
||||
t.Fatalf("optional canonical was treated as required: %#v", ordinary.Blocking)
|
||||
}
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("optional rename = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing optional canonical remains optional", func(t *testing.T) {
|
||||
pending := coverageOptionalManifest(FlagMigrationPending)
|
||||
pending.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
@@ -725,8 +779,8 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCa
|
||||
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
|
||||
|
||||
ordinary := Compare(after, before, "merge-base")
|
||||
if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
|
||||
t.Fatalf("fixture did not create flag_became_required: %#v", ordinary.Blocking)
|
||||
if hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
|
||||
t.Fatalf("fixture changed canonical requiredness: %#v", ordinary.Blocking)
|
||||
}
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
@@ -735,7 +789,83 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCa
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("optional-to-required migration = (%#v, %v), want compatible", report, err)
|
||||
t.Fatalf("existing optional canonical migration = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hidden canonical inherits requiredness when promoted", func(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
pending.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Hidden: true,
|
||||
Scope: "local",
|
||||
}
|
||||
consumed := pending
|
||||
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
|
||||
consumed.Migrations[0].State = FlagMigrationConsumed
|
||||
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
|
||||
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
|
||||
|
||||
ordinary := Compare(after, before, "merge-base")
|
||||
if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
|
||||
t.Fatalf("fixture did not change canonical requiredness: %#v", ordinary.Blocking)
|
||||
}
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("hidden canonical promotion = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *testing.T) {
|
||||
pending := coverageOptionalManifest(FlagMigrationPending)
|
||||
consumed := coverageOptionalManifest(FlagMigrationConsumed)
|
||||
empty := coverageEmptyManifest()
|
||||
migration := pending.Migrations[0]
|
||||
before := coverageMigrationSnapshot(migration, false, false)
|
||||
after := coverageMigrationSnapshot(migration, true, false)
|
||||
|
||||
t.Run("candidate cannot self authorize", func(t *testing.T) {
|
||||
_, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
empty,
|
||||
pending,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
|
||||
t.Fatalf("candidate self-authorization error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial application remains rejected", func(t *testing.T) {
|
||||
partial := coverageMigrationSnapshot(migration, false, false)
|
||||
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Hidden = true
|
||||
_, err := CompareAllWithFlagMigrations(
|
||||
partial,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "partially applied flag migration") {
|
||||
t.Fatalf("partial optional migration error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("consumed receipt remains stale after every reference converges", func(t *testing.T) {
|
||||
_, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
consumed,
|
||||
consumed,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
|
||||
t.Fatalf("stale optional migration error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -886,6 +1016,13 @@ func coverageManifest(state string) FlagMigrationManifest {
|
||||
}
|
||||
}
|
||||
|
||||
func coverageOptionalManifest(state string) FlagMigrationManifest {
|
||||
manifest := coverageManifest(state)
|
||||
manifest.Migrations[0].Legacy.Before.Required = false
|
||||
manifest.Migrations[0].Canonical.After.Required = false
|
||||
return manifest
|
||||
}
|
||||
|
||||
func coverageEmptyManifest() FlagMigrationManifest {
|
||||
return FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package interfacesnapshot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -37,6 +38,42 @@ const validFlagMigrationManifestJSON = `{
|
||||
]
|
||||
}`
|
||||
|
||||
func optionalFlagMigrationManifestJSON() string {
|
||||
manifest := strings.Replace(
|
||||
validFlagMigrationManifestJSON,
|
||||
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"before": {"present": true, "type": "string", "scope": "local"}`,
|
||||
1,
|
||||
)
|
||||
return strings.Replace(
|
||||
manifest,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "scope": "local"}`,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
func hiddenCanonicalFlagMigrationManifestJSON() string {
|
||||
return strings.Replace(
|
||||
validFlagMigrationManifestJSON,
|
||||
`"before": {"present": false}`,
|
||||
`"before": {"present": true, "type": "string", "hidden": true, "scope": "local"}`,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
func TestApprovedFlagMigrationManifestRemainsValid(t *testing.T) {
|
||||
manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-flag-migrations-v1.json")
|
||||
if err != nil {
|
||||
t.Fatalf("open approved flag migration manifest: %v", err)
|
||||
}
|
||||
defer manifest.Close()
|
||||
|
||||
if _, err := ReadFlagMigrationManifest(manifest); err != nil {
|
||||
t.Fatalf("approved flag migration manifest is invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(`{
|
||||
"version": 1,
|
||||
@@ -110,6 +147,12 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
|
||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)); err != nil {
|
||||
t.Fatalf("ReadFlagMigrationManifest(valid) error = %v", err)
|
||||
}
|
||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(optionalFlagMigrationManifestJSON())); err != nil {
|
||||
t.Fatalf("ReadFlagMigrationManifest(optional rename) error = %v", err)
|
||||
}
|
||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil {
|
||||
t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -162,24 +205,34 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
|
||||
wantErr: "canonical flag must remain visible",
|
||||
},
|
||||
{
|
||||
name: "canonical remains optional",
|
||||
name: "required legacy becomes optional canonical",
|
||||
input: strings.Replace(
|
||||
validFlagMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "canonical flag must be required after migration",
|
||||
wantErr: "requiredness must be preserved from legacy before to canonical after",
|
||||
},
|
||||
{
|
||||
name: "canonical was already required",
|
||||
name: "optional legacy becomes required canonical",
|
||||
input: strings.Replace(
|
||||
optionalFlagMigrationManifestJSON(),
|
||||
`"after": {"present": true, "type": "string", "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "requiredness must be preserved from legacy before to canonical after",
|
||||
},
|
||||
{
|
||||
name: "existing canonical changes requiredness",
|
||||
input: strings.Replace(
|
||||
validFlagMigrationManifestJSON,
|
||||
`"before": {"present": false}`,
|
||||
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"before": {"present": true, "type": "string", "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "canonical flag must be absent or optional before migration",
|
||||
wantErr: "canonical flag requiredness must remain unchanged when already present",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -215,9 +268,10 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon
|
||||
canonicalDrift := manifest
|
||||
canonicalDrift.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
|
||||
canonicalDrift.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Scope: "local",
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Scope: "local",
|
||||
}
|
||||
canonicalDrift.Migrations[0].Canonical.After.Type = "stringSlice"
|
||||
if err := canonicalDrift.Validate(); err == nil || !strings.Contains(err.Error(), "canonical flag type") {
|
||||
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
var sensitiveKeys = map[string]bool{
|
||||
"authorization": true,
|
||||
"x-user-access-token": true,
|
||||
"dws_agent_ext": true,
|
||||
"x-dws-agent-ext": true,
|
||||
"client_secret": true,
|
||||
"client-secret": true,
|
||||
"token": true,
|
||||
|
||||
@@ -30,6 +30,12 @@ func TestIsSensitiveKey(t *testing.T) {
|
||||
{"authorization", true},
|
||||
{"x-user-access-token", true},
|
||||
{"X-User-Access-Token", true},
|
||||
{"DWS_AGENT_EXT", true},
|
||||
{"dws_agent_ext", true},
|
||||
{"x-dws-agent-ext", true},
|
||||
{"X-Dws-Agent-Ext", true},
|
||||
{"DWS_AGENT_VER", false},
|
||||
{"x-dws-agent-ver", false},
|
||||
{"client_secret", true},
|
||||
{"client-secret", true},
|
||||
{"token", true},
|
||||
@@ -128,17 +134,24 @@ func TestSanitizeArguments_Empty(t *testing.T) {
|
||||
|
||||
func TestRedactHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
headers := http.Header{
|
||||
"Authorization": {"Bearer token123456"},
|
||||
"Content-Type": {"application/json"},
|
||||
}
|
||||
headers := make(http.Header)
|
||||
headers.Set("Authorization", "Bearer test-credential-value")
|
||||
headers.Set("Content-Type", "application/json")
|
||||
headers.Set("DWS_AGENT_EXT", `{"umt":"test-umt-value"}`)
|
||||
headers.Set("x-dws-agent-ext", `{"ua":"test-agent-value"}`)
|
||||
attrs := RedactHeaders(headers)
|
||||
if len(attrs) != 2 {
|
||||
t.Fatalf("expected 2 attrs, got %d", len(attrs))
|
||||
if len(attrs) != 4 {
|
||||
t.Fatalf("expected 4 attrs, got %d", len(attrs))
|
||||
}
|
||||
for _, attr := range attrs {
|
||||
if attr.Key == "header.authorization" && !strings.Contains(attr.Value.String(), "***") {
|
||||
t.Fatalf("authorization should be redacted: %s", attr.Value.String())
|
||||
switch attr.Key {
|
||||
case "header.authorization", "header.dws_agent_ext", "header.x-dws-agent-ext":
|
||||
if !strings.Contains(attr.Value.String(), "***") {
|
||||
t.Fatalf("%s should be redacted: %s", attr.Key, attr.Value.String())
|
||||
}
|
||||
if strings.Contains(attr.Value.String(), "test-") {
|
||||
t.Fatalf("%s leaked its original value: %s", attr.Key, attr.Value.String())
|
||||
}
|
||||
}
|
||||
if attr.Key == "header.content-type" && attr.Value.String() != "application/json" {
|
||||
t.Fatalf("content-type should not be redacted: %s", attr.Value.String())
|
||||
|
||||
@@ -24,13 +24,14 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
// isOpenID reports whether value looks like an openDingTalkId (starts with D/d),
|
||||
// mirroring helpers.isOpenDingTalkID.
|
||||
// isOpenID classifies mixed userId/openDingTalkId inputs using the canonical
|
||||
// current-D wire format shared with helpers and smart shortcuts.
|
||||
func isOpenID(v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
return len(v) > 0 && (v[0] == 'D' || v[0] == 'd')
|
||||
return targetresolver.LooksLikeCurrentDOpenDingTalkID(v)
|
||||
}
|
||||
|
||||
// splitIDs partitions a mixed list of userId / openDingTalkId values, mirroring
|
||||
@@ -50,6 +51,18 @@ func splitIDs(vals []string) (userIDs, openIDs []string) {
|
||||
return userIDs, openIDs
|
||||
}
|
||||
|
||||
func validateExplicitOpenIDs(flagName string, values []string) error {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID(flagName, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toInt64Slice converts string values to []int64, mirroring helpers.parseCSVInt64.
|
||||
func toInt64Slice(vals []string) ([]int64, error) {
|
||||
out := make([]int64, 0, len(vals))
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
// ConversationInfo gets conversation info (get_conversation_info, chat server).
|
||||
@@ -71,6 +72,9 @@ var ConversationInfo = shortcut.Shortcut{
|
||||
params["openConversationId"] = rt.Str("group")
|
||||
}
|
||||
if rt.Str("open-dingtalk-id") != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
|
||||
}
|
||||
if len(params) == 0 {
|
||||
@@ -313,17 +317,17 @@ var ConversationList = shortcut.Shortcut{
|
||||
Examples: []string{"dws chat +conversation-list --limit 50"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量;--limit 必须在 1-100"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
}, shortcut.AutoPageControlFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
}, shortcut.AutoPageControlConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +conversation-list --limit 50`,
|
||||
`dws chat +conversation-list --page-all --limit 100`,
|
||||
@@ -338,6 +342,9 @@ var ConversationList = shortcut.Shortcut{
|
||||
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 500 {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
@@ -353,9 +360,20 @@ var ConversationList = shortcut.Shortcut{
|
||||
complete := false
|
||||
hasMore := false
|
||||
nextCursor := int64(0)
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
truncatedByResultLimit := false
|
||||
unsafeResultContinuation := false
|
||||
failures := make([]map[string]any, 0)
|
||||
for pagesFetched < pageLimit {
|
||||
params := map[string]any{"limit": rt.Int("limit")}
|
||||
if pagesFetched > 0 {
|
||||
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
|
||||
failures = append(failures, map[string]any{"stage": "conversation-page-delay", "cursor": cursor, "error": err.Error()})
|
||||
stopReason = "delay_interrupted"
|
||||
break
|
||||
}
|
||||
}
|
||||
params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, rt.Int("limit"), len(convs))}
|
||||
if cursor > 0 {
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
@@ -371,6 +389,7 @@ var ConversationList = shortcut.Shortcut{
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
overflowOnPage := false
|
||||
for _, conversation := range conversationListProject(data) {
|
||||
id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"]))
|
||||
if id != "" && id != "<nil>" {
|
||||
@@ -379,6 +398,11 @@ var ConversationList = shortcut.Shortcut{
|
||||
}
|
||||
seenConversations[id] = true
|
||||
}
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
overflowOnPage = true
|
||||
continue
|
||||
}
|
||||
convs = append(convs, conversation)
|
||||
}
|
||||
page := chatmsg.Pagination(data)
|
||||
@@ -388,6 +412,14 @@ var ConversationList = shortcut.Shortcut{
|
||||
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"})
|
||||
break
|
||||
}
|
||||
if overflowOnPage {
|
||||
hasMore = true
|
||||
nextCursor = 0
|
||||
unsafeResultContinuation = true
|
||||
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标"})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if !hasMore {
|
||||
complete = true
|
||||
break
|
||||
@@ -398,27 +430,56 @@ var ConversationList = shortcut.Shortcut{
|
||||
break
|
||||
}
|
||||
if !rt.Bool("page-all") {
|
||||
stopReason = "single_page"
|
||||
break
|
||||
}
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
stopReason = "result_limit"
|
||||
break
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
}
|
||||
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit {
|
||||
failures = append(failures, map[string]any{"stage": "conversation-page-limit", "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多会话", pageLimit)})
|
||||
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit && !truncatedByResultLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
payload := map[string]any{
|
||||
"count": len(convs),
|
||||
"conversations": convs,
|
||||
"pagesFetched": pagesFetched,
|
||||
"complete": complete,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"paginationKnown": len(failures) == 0 || hasMore,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0,
|
||||
"count": len(convs),
|
||||
"conversations": convs,
|
||||
"pagesFetched": pagesFetched,
|
||||
"complete": complete,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"paginationKnown": len(failures) == 0 || hasMore,
|
||||
"stopReason": stopReason,
|
||||
"truncatedByPageLimit": truncatedByPageLimit,
|
||||
"truncatedByResultLimit": truncatedByResultLimit,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0,
|
||||
}
|
||||
return rt.Output(payload)
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if stopReason == "delay_interrupted" && rt.Command().Context().Err() != nil {
|
||||
return rt.Command().Context().Err()
|
||||
}
|
||||
if unsafeResultContinuation {
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("会话列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
|
||||
apperrors.WithOperation("im/list_all_conversations"),
|
||||
apperrors.WithReason("conversation_list_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
@@ -148,6 +150,96 @@ func TestCrossPlatformCoverageConversationListSinglePagePreservesTypedCursor(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListMaxItemsPublishesStableTruncation(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1", "--page-delay", "0"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["truncated"] != true ||
|
||||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != float64(2) {
|
||||
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListRejectsOversizedLimitPage(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"},{"openConversationId":"cid-2"}],"hasMore":true,"nextCursor":2}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("oversized lower page unexpectedly published a safe continuation")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListPropagatesDelayCancellation(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
root.SetContext(ctx)
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--page-delay", "1"})
|
||||
if err := root.Execute(); err == nil || err != context.Canceled {
|
||||
t.Fatalf("delay cancellation error = %v, want context.Canceled", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListAutoPageValidationAndOutputFailure(t *testing.T) {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+conversation-list", "--max-items", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("max-items without page-all unexpectedly succeeded")
|
||||
}
|
||||
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":false}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root = newPlatformCoverageRoot()
|
||||
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
|
||||
root.SetArgs([]string{"chat", "+conversation-list"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("output error was swallowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`,
|
||||
|
||||
@@ -409,6 +409,9 @@ var ChatMembersGet = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws chat +chat-members-get --id <openConversationId> --users odid1,odid2`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateExplicitOpenIDs("--users", rt.StrSlice("users")); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.CallMCP("list_group_member_by_ids", map[string]any{
|
||||
"openConversationId": rt.Str("id"),
|
||||
"cid": rt.Str("id"),
|
||||
@@ -878,16 +881,16 @@ var ChatListAll = shortcut.Shortcut{
|
||||
Examples: []string{"dws chat +chat-list-all --limit 50"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量;--limit 必须在 1-200 之间"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
}, shortcut.AutoPageControlFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
}, shortcut.AutoPageControlConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +chat-list-all --limit 50`,
|
||||
`dws chat +chat-list-all --limit 200 --page-all --page-limit 50`,
|
||||
@@ -908,6 +911,9 @@ func validateChatListAll(rt *shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
}
|
||||
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -958,10 +964,21 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
hasMore := false
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
truncatedByResultLimit := false
|
||||
var nextCursor any
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
params := map[string]any{"limit": baseParams["limit"]}
|
||||
if pagesFetched > 0 {
|
||||
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "delay", "cursor": cursorKey, "error": err.Error(),
|
||||
})
|
||||
stopReason = "delay_interrupted"
|
||||
break
|
||||
}
|
||||
}
|
||||
pageSize, _ := baseParams["limit"].(int)
|
||||
params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, pageSize, len(allGroups))}
|
||||
if cursorKey != "0" {
|
||||
params["cursor"] = cursorValue
|
||||
}
|
||||
@@ -978,6 +995,7 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
}
|
||||
pagesFetched++
|
||||
pageGroups := chatListAllProject(data)
|
||||
overflowOnPage := false
|
||||
for _, group := range pageGroups {
|
||||
id := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
|
||||
if id == "<nil>" {
|
||||
@@ -989,6 +1007,11 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
if id != "" {
|
||||
seenGroups[id] = true
|
||||
}
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
overflowOnPage = true
|
||||
continue
|
||||
}
|
||||
allGroups = append(allGroups, group)
|
||||
}
|
||||
|
||||
@@ -1003,6 +1026,16 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if overflowOnPage {
|
||||
hasMore = true
|
||||
nextCursor = nil
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "已加入群列表下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if !hasMore {
|
||||
complete = true
|
||||
nextCursor = nil
|
||||
@@ -1022,8 +1055,13 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
seenCursors[nextKey] = true
|
||||
cursorKey = nextKey
|
||||
cursorValue = nextCursor
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
stopReason = "result_limit"
|
||||
break
|
||||
}
|
||||
}
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
@@ -1033,9 +1071,11 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
"pagesFetched": pagesFetched, "paginationKnown": true,
|
||||
"complete": complete && len(failures) == 0, "hasMore": hasMore,
|
||||
"stopReason": stopReason, "truncatedByPageLimit": truncatedByPageLimit,
|
||||
"failedCount": len(failures), "failures": failures,
|
||||
"truncatedByResultLimit": truncatedByResultLimit,
|
||||
"failedCount": len(failures), "failures": failures,
|
||||
"partial": len(failures) > 0 && len(allGroups) > 0,
|
||||
}
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
if hasMore && nextCursor != nil {
|
||||
payload["nextCursor"] = nextCursor
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
const directMessagesHardPageLimit = 500
|
||||
@@ -55,6 +56,9 @@ var MessagesSendByBot = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws chat +messages-send-by-bot --robot-code <robotCode> --group <openConversationId> --title "日报" --text "## 今日完成"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"robotCode": rt.Str("robot-code"),
|
||||
"openConversationId": rt.Str("group"),
|
||||
@@ -93,6 +97,9 @@ var MessagesBatchSendByBot = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code <robotCode> --users userId1,userId2 --title "提醒" --text "请提交周报"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateExplicitOpenIDs("--open-dingtalk-ids", rt.StrSlice("open-dingtalk-ids")); err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"robotCode": rt.Str("robot-code"),
|
||||
"title": rt.Str("title"),
|
||||
@@ -493,6 +500,9 @@ func executeMessagesListDirect(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
switch {
|
||||
case rt.Str("open-dingtalk-id") != "":
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
|
||||
case rt.Str("user") != "":
|
||||
params["userId"] = rt.Str("user")
|
||||
@@ -634,6 +644,7 @@ func readAllDirectMessages(rt *shortcut.RuntimeContext, params map[string]any) (
|
||||
payload["hasMore"] = hasMore
|
||||
payload["stopReason"] = stopReason
|
||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["partial"] = len(failures) > 0 && len(messages) > 0
|
||||
@@ -1514,6 +1525,14 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
`dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content "任务已完成"`,
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if receiverOpenID := rt.Str("receiver-open-dingtalk-id"); receiverOpenID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--receiver-open-dingtalk-id", receiverOpenID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
|
||||
return err
|
||||
}
|
||||
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
|
||||
return fmt.Errorf("--flow-status 必须在 1-5 之间")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
fixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
fixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
)
|
||||
|
||||
type platformCoverageCaller struct {
|
||||
product string
|
||||
tool string
|
||||
@@ -53,7 +58,7 @@ func (f *muteMemberResolutionCaller) CallTool(_ context.Context, product, tool s
|
||||
case "contact/get_user_info_by_user_ids":
|
||||
text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-2","orgUserName":"测试成员"}}]}`
|
||||
case "chat/get_group_members":
|
||||
text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"D-open-2"}]}}`
|
||||
text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"` + fixtureCurrentDOpenID2 + `"}]}}`
|
||||
case "im/set_group_member_mute_list":
|
||||
text = `{"success":true}`
|
||||
default:
|
||||
@@ -235,8 +240,10 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{value: "DingTalk-open-id", want: true},
|
||||
{value: " dingtalk-open-id ", want: true},
|
||||
{value: "DAAAAAAAAAAAiE", want: true},
|
||||
{value: " DAAAAAAAAAAAiE ", want: true},
|
||||
{value: "D-prefix-fixture-user", want: false},
|
||||
{value: "d-prefix-fixture-user", want: false},
|
||||
{value: "user-id", want: false},
|
||||
{value: " ", want: false},
|
||||
}
|
||||
@@ -248,11 +255,11 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("split mixed IDs", func(t *testing.T) {
|
||||
userIDs, openIDs := splitIDs([]string{" user-1 ", "", "D-open-1", "d-open-2", "user-2"})
|
||||
if want := []string{"user-1", "user-2"}; !reflect.DeepEqual(userIDs, want) {
|
||||
userIDs, openIDs := splitIDs([]string{" user-1 ", "", "DAAAAAAAAAAAiE", "d-open-2", "D-prefix-fixture-user", "user-2"})
|
||||
if want := []string{"user-1", "d-open-2", "D-prefix-fixture-user", "user-2"}; !reflect.DeepEqual(userIDs, want) {
|
||||
t.Fatalf("user IDs = %#v, want %#v", userIDs, want)
|
||||
}
|
||||
if want := []string{"D-open-1", "d-open-2"}; !reflect.DeepEqual(openIDs, want) {
|
||||
if want := []string{"DAAAAAAAAAAAiE"}; !reflect.DeepEqual(openIDs, want) {
|
||||
t.Fatalf("open IDs = %#v, want %#v", openIDs, want)
|
||||
}
|
||||
})
|
||||
@@ -281,7 +288,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-mute-member",
|
||||
"--group", "cid-1",
|
||||
"--users", "user-2,D-open-2",
|
||||
"--users", "user-2," + fixtureCurrentDOpenID2,
|
||||
"--mute-time", "300000",
|
||||
"--yes",
|
||||
})
|
||||
@@ -298,7 +305,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te
|
||||
if _, ok := final.args["uids"]; ok {
|
||||
t.Fatalf("known-broken uids argument leaked into final call: %#v", final.args)
|
||||
}
|
||||
if got, want := final.args["openDingTalkIds"], []string{"D-open-2"}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := final.args["openDingTalkIds"], []string{fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("openDingTalkIds = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
|
||||
{
|
||||
name: "reply write",
|
||||
caller: &larkAlignmentCaller{failProductTool: "chat/send_personal_message"},
|
||||
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "D-sender", "--text", "收到", "--yes"},
|
||||
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", fixtureCurrentDOpenID, "--text", "收到", "--yes"},
|
||||
wantError: "fixture lower call failed",
|
||||
},
|
||||
}
|
||||
@@ -259,7 +259,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
|
||||
}}
|
||||
helpers.InitDeps(external)
|
||||
root = newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", "D-owner", "--member-query", "外部联系人", "--yes"})
|
||||
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--member-query", "外部联系人", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -400,17 +400,17 @@ func TestCrossPlatformCoverageUnifiedSendValidationMatrix(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"--identity", "user", "--group", "cid", "--users", "u1", "--text", "x"},
|
||||
{"--identity", "user", "--text", "x"},
|
||||
{"--identity", "user", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
|
||||
{"--identity", "user", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
|
||||
{"--identity", "user", "--group", "cid", "--at-user-ids", "u1", "--text", "x"},
|
||||
{"--identity", "user", "--open-dingtalk-id", "D1", "--at-all", "--text", "x"},
|
||||
{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--at-all", "--text", "x"},
|
||||
{"--identity", "bot", "--group", "cid", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--users", "u1", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--at-mobiles", "13800000000", "--text", "x"},
|
||||
{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--at-user-ids", "u2", "--text", "x"},
|
||||
{"--identity", "webhook", "--text", "x"},
|
||||
{"--identity", "webhook", "--webhook-token", "token", "--group", "cid", "--text", "x"},
|
||||
{"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", "D1", "--text", "x"},
|
||||
{"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x"},
|
||||
{"--identity", "webhook", "--webhook-token", "token", "--uuid", "key", "--text", "x"},
|
||||
}
|
||||
for _, tail := range cases {
|
||||
@@ -437,23 +437,23 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
|
||||
}{
|
||||
{
|
||||
name: "user group mentions",
|
||||
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", "D1,D2", "--at-all"},
|
||||
want: map[string]any{"atOpenDingTalkIds": []string{"D1", "D2"}, "atAll": true},
|
||||
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
|
||||
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
|
||||
},
|
||||
{
|
||||
name: "user direct",
|
||||
args: []string{"--identity", "user", "--open-dingtalk-id", "D1", "--text", "x"},
|
||||
want: map[string]any{"receiverOpenDingTalkId": "D1"},
|
||||
args: []string{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
|
||||
want: map[string]any{"receiverOpenDingTalkId": fixtureCurrentDOpenID},
|
||||
},
|
||||
{
|
||||
name: "bot group mentions",
|
||||
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", "D1"},
|
||||
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{"D1"}},
|
||||
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", fixtureCurrentDOpenID},
|
||||
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{fixtureCurrentDOpenID}},
|
||||
},
|
||||
{
|
||||
name: "bot direct targets",
|
||||
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", "D1", "--text", "x", "--at-all"},
|
||||
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{"D1"}, "isAtAll": "true"},
|
||||
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x", "--at-all"},
|
||||
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{fixtureCurrentDOpenID}, "isAtAll": "true"},
|
||||
},
|
||||
{
|
||||
name: "webhook mentions",
|
||||
@@ -580,7 +580,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--identity", "bot", "--robot-code", "r",
|
||||
"--groups", "c1,c2", "--text", "x", "--at-user-ids", "u1",
|
||||
"--at-open-dingtalk-ids", "D1", "--at-all", "--yes",
|
||||
"--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -590,7 +590,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.
|
||||
}
|
||||
for _, call := range fake.calls {
|
||||
if !reflect.DeepEqual(call.args["atUserIds"], []string{"u1"}) ||
|
||||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{"D1"}) ||
|
||||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{fixtureCurrentDOpenID}) ||
|
||||
call.args["isAtAll"] != "true" {
|
||||
t.Fatalf("batch mention args = %#v", call.args)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
@@ -73,11 +74,115 @@ func TestCrossPlatformCoverageFlagListPageTokenAndPageLimit(t *testing.T) {
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
|
||||
if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncated"] != true || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListMaxItemsPublishesStableTruncation(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"}],"hasMore":true,"nextCursor":9}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--max-items", "1", "--page-delay", "0"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["truncated"] != true ||
|
||||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["size"] != "1" || payload["nextCursor"] != float64(9) {
|
||||
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListLegacyFullRemainingPageFailsClosed(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/list_message_favorites": {
|
||||
`{"result":{"items":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true,"nextCursor":7}}`,
|
||||
`{"result":{"items":[{"openMessageId":"m3"}]}}`,
|
||||
},
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--page-size", "2", "--page-all", "--max-items", "3"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("full remaining-budget legacy page unexpectedly declared a complete result")
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].args["size"] != "2" || fake.calls[1].args["size"] != "1" {
|
||||
t.Fatalf("request sizes = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(3) || payload["complete"] != false ||
|
||||
payload["paginationKnown"] != false || payload["stopReason"] != "pagination_error" ||
|
||||
payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListFailsClosedOnOversizeAndCanceledDelay(t *testing.T) {
|
||||
t.Run("oversized lower page", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true,"nextCursor":9}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--max-items", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("oversized lower page unexpectedly published a continuation")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["size"] != "1" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canceled delay", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":9}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
root.SetContext(ctx)
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--page-delay", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("canceled delay unexpectedly succeeded")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListFailureModes(t *testing.T) {
|
||||
t.Run("later read failure keeps partial result", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{
|
||||
@@ -137,7 +242,11 @@ func TestCrossPlatformCoverageFlagListPaginationValidation(t *testing.T) {
|
||||
{"--size", "31"},
|
||||
{"--page-token", "not-a-number"},
|
||||
{"--page-limit", "2"},
|
||||
{"--max-items", "1"},
|
||||
{"--page-delay", "1"},
|
||||
{"--page-all", "--page-limit", "0"},
|
||||
{"--page-all", "--max-items", "-1"},
|
||||
{"--page-all", "--page-delay", "-1"},
|
||||
{"--page-all", "--page-limit", "501"},
|
||||
{"--cursor", "1", "--page-token", "2"},
|
||||
} {
|
||||
|
||||
@@ -417,8 +417,8 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-create", "--name", "测试群", "--users", "D-owner,user-1",
|
||||
"--owner-open-dingtalk-id", "D-owner", "--yes",
|
||||
"chat", "+chat-create", "--name", "测试群", "--users", fixtureCurrentDOpenID + ",user-1",
|
||||
"--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -427,24 +427,24 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup
|
||||
t.Fatalf("explicit owner calls = %#v", fake.calls)
|
||||
}
|
||||
create := fake.calls[0].args
|
||||
if create["ownerOpenDingTalkId"] != "D-owner" {
|
||||
if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID {
|
||||
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
|
||||
}
|
||||
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "user-1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("groupMembers = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"owner-user","openDingTalkId":"D-owner"}]}`,
|
||||
"contact/search_contact_by_key_word": `{"result":[{"name":"测试用户甲","userId":"owner-user","openDingTalkId":"` + fixtureCurrentDOpenID + `"}]}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-create", "--name", "测试群", "--users", "user-1",
|
||||
"--owner-query", "张三", "--yes",
|
||||
"--owner-query", "测试用户甲", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -454,10 +454,10 @@ func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *
|
||||
t.Fatalf("owner query calls = %#v", fake.calls)
|
||||
}
|
||||
create := fake.calls[1].args
|
||||
if create["ownerOpenDingTalkId"] != "D-owner" {
|
||||
if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID {
|
||||
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
|
||||
}
|
||||
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "user-1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("groupMembers = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -601,7 +601,7 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send",
|
||||
"--open-dingtalk-id", "D-target",
|
||||
"--open-dingtalk-id", fixtureCurrentDOpenID,
|
||||
"--file", "./fixture.bin",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
@@ -773,7 +773,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
"--at-open-dingtalk-ids", "D-one,D-two,D-one",
|
||||
"--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2 + "," + fixtureCurrentDOpenID,
|
||||
"--at-all",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
@@ -786,7 +786,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
|
||||
fake.calls[1].tool != "update_streaming_card" {
|
||||
t.Fatalf("card calls = %#v", fake.calls)
|
||||
}
|
||||
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{"D-one", "D-two"}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("card create atOpenDingTalkIds = %#v, want %#v", got, want)
|
||||
}
|
||||
if fake.calls[0].args["atAll"] != true {
|
||||
@@ -842,7 +842,7 @@ func TestCrossPlatformCoverageMessagesSendCardRejectsMissingRequestedAtTag(t *te
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
"--at-open-dingtalk-ids", "D-mentioned",
|
||||
"--at-open-dingtalk-ids", fixtureCurrentDOpenID,
|
||||
"--content", "正文",
|
||||
"--yes",
|
||||
})
|
||||
@@ -891,7 +891,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--receiver-open-dingtalk-id", "D-direct",
|
||||
"--receiver-open-dingtalk-id", fixtureCurrentDOpenID2,
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
@@ -902,7 +902,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin
|
||||
fake.calls[0].tool != "create_and_send_card" {
|
||||
t.Fatalf("card open receiver calls = %#v", fake.calls)
|
||||
}
|
||||
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != "D-direct" {
|
||||
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != fixtureCurrentDOpenID2 {
|
||||
t.Fatalf("receiverOpenDingTalkId = %#v, want D-direct", got)
|
||||
}
|
||||
}
|
||||
@@ -969,7 +969,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
"--at-open-dingtalk-ids", "D-mentioned",
|
||||
"--at-open-dingtalk-ids", fixtureCurrentDOpenID,
|
||||
"--at-all",
|
||||
"--content", "处理中",
|
||||
"--dry-run",
|
||||
@@ -990,7 +990,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
createArguments, _ := create["arguments"].(map[string]any)
|
||||
update, _ := actions[1].(map[string]any)
|
||||
updateArguments, _ := update["arguments"].(map[string]any)
|
||||
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{"D-mentioned"}) || createArguments["atAll"] != true {
|
||||
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{fixtureCurrentDOpenID}) || createArguments["atAll"] != true {
|
||||
t.Fatalf("card create dry-run mentions = %#v", createArguments)
|
||||
}
|
||||
if _, exists := updateArguments["atOpenDingTalkIds"]; exists {
|
||||
@@ -1090,10 +1090,10 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
for _, args := range [][]string{
|
||||
{"--group", "cid", "--content", "x", "--flow-status", "6"},
|
||||
{"--group", "cid", "--flow-status", "2"},
|
||||
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
|
||||
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
|
||||
{"--receiver", "user-id", "--at-open-dingtalk-ids", "D-mentioned"},
|
||||
{"--receiver-open-dingtalk-id", "D-direct", "--at-all"},
|
||||
{"--group", "cid", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2},
|
||||
{"--receiver", "user-id", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2},
|
||||
{"--receiver", "user-id", "--at-open-dingtalk-ids", fixtureCurrentDOpenID},
|
||||
{"--receiver-open-dingtalk-id", fixtureCurrentDOpenID2, "--at-all"},
|
||||
} {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -1104,6 +1104,35 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExplicitOpenIDValidationEdges(t *testing.T) {
|
||||
if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{" ", fixtureCurrentDOpenID}); err != nil {
|
||||
t.Fatalf("blank and valid IDs: %v", err)
|
||||
}
|
||||
if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{fixtureCurrentDOpenID, "not-an-open-id"}); err == nil {
|
||||
t.Fatal("invalid explicit open ID unexpectedly accepted")
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"chat", "+messages-send", "--open-dingtalk-id", "not-an-open-id", "--text", "x", "--yes"},
|
||||
{"chat", "+messages-send", "--open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"},
|
||||
{"chat", "+messages-send", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"},
|
||||
{"chat", "+messages-send-card", "--receiver-open-dingtalk-id", "not-an-open-id", "--yes"},
|
||||
{"chat", "+messages-send-card", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"},
|
||||
{"chat", "+conversation-info", "--open-dingtalk-id", "not-an-open-id"},
|
||||
{"chat", "+chat-members-get", "--id", "cid", "--users", "not-an-open-id"},
|
||||
{"chat", "+messages-send-by-bot", "--robot-code", "robot", "--group", "cid", "--title", "title", "--text", "text", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"},
|
||||
{"chat", "+messages-batch-send-by-bot", "--robot-code", "robot", "--title", "title", "--text", "text", "--open-dingtalk-ids", "not-an-open-id", "--yes"},
|
||||
{"chat", "+messages-list-direct", "--open-dingtalk-id", "not-an-open-id", "--time", "2026-01-01 00:00:00"},
|
||||
{"chat", "+chat-create", "--name", "fixture", "--users", "user-1", "--owner-open-dingtalk-id", "not-an-open-id", "--yes"},
|
||||
} {
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("invalid explicit open ID unexpectedly accepted: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
|
||||
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
|
||||
@@ -52,6 +52,11 @@ var ChatCreate = shortcut.Shortcut{
|
||||
`dws chat +chat-create --name "合作群" --member-query "张三,李四" --type EXTERNAL`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if ownerOpenID := rt.Str("owner-open-dingtalk-id"); ownerOpenID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--owner-open-dingtalk-id", ownerOpenID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
resolvedMembers, err := targetresolver.ResolveUsers(
|
||||
rt,
|
||||
rt.StrSlice("member-query"),
|
||||
@@ -339,7 +344,7 @@ func findOpenDingTalkID(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range []string{"senderOpenDingTalkId", "senderOpenDingtalkId", "openDingTalkId", "openDingtalkId"} {
|
||||
if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); isOpenID(candidate) {
|
||||
if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); candidate != "" && candidate != "<nil>" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -374,7 +379,7 @@ func findMessageSenderOpenDingTalkID(message map[string]any) string {
|
||||
"senderOpenDingtalkId",
|
||||
"senderOpenId",
|
||||
} {
|
||||
if candidate := strings.TrimSpace(fmt.Sprint(message[key])); isOpenID(candidate) {
|
||||
if candidate := strings.TrimSpace(fmt.Sprint(message[key])); candidate != "" && candidate != "<nil>" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -510,21 +515,21 @@ var FlagList = shortcut.Shortcut{
|
||||
Description: "分页查询当前用户收藏的消息,支持有界自动翻页",
|
||||
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;默认读取一页,明确要求全部收藏时加 --page-all,并用 --page-limit 保持有界。底层实际使用数字 cursor,结果按 openMessageId 去重并公开 complete、hasMore、nextCursor、stopReason 和 failures;它不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;下游真实上限为 30,显式页大小必须在 1-30 之间"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "--page-size 的兼容别名;下游真实上限为 30,显式页大小必须在 1-30 之间"},
|
||||
{Name: "page-token", Type: shortcut.FlagString, Desc: "Lark 对齐的起始分页参数;起始 cursor 必须是非负整数"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "钉钉数字分页游标;起始 cursor 必须是非负整数"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
}, shortcut.AutoPageControlFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-size", "size"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-token", "cursor"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size", "size"}, Description: "显式页大小必须在 1-30 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-token", "cursor"}, Description: "起始 cursor 必须是非负整数"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
}, shortcut.AutoPageControlConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +flag-list --cursor 0 --page-size 20`,
|
||||
`dws chat +flag-list --page-size 30 --page-all --page-limit 20`,
|
||||
@@ -548,6 +553,9 @@ func validateFlagList(rt *shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
}
|
||||
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -598,9 +606,20 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
var cursorErr error
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
truncatedByResultLimit := false
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, pageSize))
|
||||
if pagesFetched > 0 {
|
||||
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "delay", "cursor": cursor, "error": err.Error(),
|
||||
})
|
||||
stopReason = "delay_interrupted"
|
||||
break
|
||||
}
|
||||
}
|
||||
requestPageSize := shortcut.AutoPageRequestSize(rt, pageSize, len(items))
|
||||
data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, requestPageSize))
|
||||
if callErr != nil {
|
||||
if pagesFetched == 0 {
|
||||
return callErr
|
||||
@@ -613,6 +632,7 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
pagesFetched++
|
||||
pageItems := flagListItems(data)
|
||||
overflowOnPage := false
|
||||
for _, item := range pageItems {
|
||||
messageID := firstNonEmptyMapString(item, "openMessageId", "messageId", "itemId", "id")
|
||||
if messageID != "" && seenMessages[messageID] {
|
||||
@@ -621,6 +641,11 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
if messageID != "" {
|
||||
seenMessages[messageID] = true
|
||||
}
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
overflowOnPage = true
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
@@ -631,7 +656,7 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
switch {
|
||||
case nextCursor > 0:
|
||||
pageHasMore = true
|
||||
case len(pageItems) < pageSize:
|
||||
case len(pageItems) < requestPageSize:
|
||||
paginationKnown = false
|
||||
complete = true
|
||||
hasMore = false
|
||||
@@ -649,6 +674,16 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if overflowOnPage {
|
||||
hasMore = true
|
||||
nextCursor = 0
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "收藏列表下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if !hasMore {
|
||||
complete = true
|
||||
nextCursor = 0
|
||||
@@ -669,28 +704,35 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
stopReason = "result_limit"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
|
||||
truncatedByPageLimit = rt.Bool("page-all")
|
||||
if truncatedByPageLimit {
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
}
|
||||
payload := map[string]any{
|
||||
"count": len(items),
|
||||
"items": items,
|
||||
"pagesFetched": pagesFetched,
|
||||
"paginationKnown": paginationKnown,
|
||||
"complete": complete && len(failures) == 0,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"stopReason": stopReason,
|
||||
"truncatedByPageLimit": truncatedByPageLimit,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0 && len(items) > 0,
|
||||
"count": len(items),
|
||||
"items": items,
|
||||
"pagesFetched": pagesFetched,
|
||||
"paginationKnown": paginationKnown,
|
||||
"complete": complete && len(failures) == 0,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"stopReason": stopReason,
|
||||
"truncatedByPageLimit": truncatedByPageLimit,
|
||||
"truncatedByResultLimit": truncatedByResultLimit,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0 && len(items) > 0,
|
||||
}
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
|
||||
@@ -289,12 +289,12 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te
|
||||
}{
|
||||
{
|
||||
name: "user",
|
||||
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @D1", "--at-open-dingtalk-ids", "D1", "--at-all", "--idempotency-key", "u1", "--yes"},
|
||||
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @" + fixtureCurrentDOpenID, "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--idempotency-key", "u1", "--yes"},
|
||||
product: "chat",
|
||||
tool: "send_personal_message",
|
||||
want: map[string]any{"openConversationId": "cid", "msgType": "markdown", "uuid": "u1"},
|
||||
bodyKey: "content",
|
||||
body: "<@all> hello <@D1>",
|
||||
body: "<@all> hello <@" + fixtureCurrentDOpenID + ">",
|
||||
},
|
||||
{
|
||||
name: "bot",
|
||||
@@ -471,7 +471,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing
|
||||
"chat", "+messages-reply",
|
||||
"--conversation-id", "cid",
|
||||
"--message-id", "msg",
|
||||
"--ref-sender", "D-sender",
|
||||
"--ref-sender", fixtureCurrentDOpenID,
|
||||
"--text", "收到",
|
||||
"--idempotency-key", "reply-uuid",
|
||||
"--yes",
|
||||
@@ -494,7 +494,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content["referenceOpenMessageId"] != "msg" ||
|
||||
content["srcMsgSendOpenDingTalkId"] != "D-sender" ||
|
||||
content["srcMsgSendOpenDingTalkId"] != fixtureCurrentDOpenID ||
|
||||
content["replyMsgType"] != "text" ||
|
||||
content["content"] != "收到" {
|
||||
t.Fatalf("reply content = %#v", content)
|
||||
@@ -525,7 +525,7 @@ func TestCrossPlatformCoverageMessagesReplyDryRunStopsBeforeWrite(t *testing.T)
|
||||
"chat", "+messages-reply",
|
||||
"--conversation-id", "cid",
|
||||
"--message-id", "msg",
|
||||
"--ref-sender", "D-sender",
|
||||
"--ref-sender", fixtureCurrentDOpenID,
|
||||
"--text", "收到",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
|
||||
@@ -5,12 +5,14 @@ package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatListAllPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
|
||||
@@ -54,7 +56,7 @@ func TestCrossPlatformCoverageDirectMessagesPageAllUsesMillisecondCursor(t *test
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-list-direct", "--open-dingtalk-id", "D-user",
|
||||
"chat", "+messages-list-direct", "--open-dingtalk-id", fixtureCurrentDOpenID,
|
||||
"--time", "2026-08-05 16:49:00", "--forward=false", "--limit", "1",
|
||||
"--page-all", "--page-limit", "5",
|
||||
})
|
||||
@@ -95,11 +97,86 @@ func TestCrossPlatformCoverageDirectMessagesPageLimitPublishesExecutableContinua
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["hasMore"] != true ||
|
||||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
|
||||
payload["truncated"] != true || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListAllMaxItemsPublishesStableTruncation(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--max-items", "1", "--page-delay", "0"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["truncated"] != true ||
|
||||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != float64(88) {
|
||||
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListAllFailsClosedOnOversizeAndCanceledDelay(t *testing.T) {
|
||||
t.Run("oversized lower page", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"},{"openConversationId":"g2"}],"hasMore":true,"nextCursor":88}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--max-items", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("oversized lower page unexpectedly published a continuation")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != nil {
|
||||
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canceled delay", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
root.SetContext(ctx)
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--page-delay", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("canceled delay unexpectedly succeeded")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDirectMessagesPageAllFailsClosedWithoutMillisecondCursor(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true}}`,
|
||||
@@ -163,6 +240,17 @@ func TestCrossPlatformCoverageChatRemainingPaginationValidation(t *testing.T) {
|
||||
t.Fatalf("invalid args succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
root := newPlatformCoverageRoot()
|
||||
cmd, _, err := root.Find([]string{"chat", "+chat-list-all"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("max-items", "1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateChatListAll(shortcut.RuntimeContextForTest(cmd, ChatListAll)); err == nil {
|
||||
t.Fatal("direct auto-page validation unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListAllAdditionalEdges(t *testing.T) {
|
||||
|
||||
@@ -142,6 +142,17 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
|
||||
atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))
|
||||
atUserIDs := uniqueShortcutStrings(rt.StrSlice("at-user-ids"))
|
||||
atMobiles := uniqueShortcutStrings(rt.StrSlice("at-mobiles"))
|
||||
if openID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateExplicitOpenIDs("--open-dingtalk-ids", openIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", atOpenIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
contentType, err := messagesSendContentType(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -86,6 +86,7 @@ var messageResultContractV1 = MessageResultContract{
|
||||
"hasMore",
|
||||
"nextPage",
|
||||
"stopReason",
|
||||
"truncated",
|
||||
"truncatedByPageLimit",
|
||||
"truncatedByResultLimit",
|
||||
"failedCount",
|
||||
@@ -122,9 +123,21 @@ func NewMessageListPayload(messages []map[string]any) map[string]any {
|
||||
"failedCount": 0,
|
||||
"failures": []map[string]any{},
|
||||
"partial": false,
|
||||
"truncated": false,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyTruncation publishes the stable aggregate bit while preserving the
|
||||
// established reason-specific fields for compatibility and diagnosis.
|
||||
func ApplyTruncation(payload map[string]any) {
|
||||
if payload == nil {
|
||||
return
|
||||
}
|
||||
byPage, _ := payload["truncatedByPageLimit"].(bool)
|
||||
byItems, _ := payload["truncatedByResultLimit"].(bool)
|
||||
payload["truncated"] = byPage || byItems
|
||||
}
|
||||
|
||||
// ListMessageItems returns message rows from the common list response envelopes.
|
||||
func ListMessageItems(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
|
||||
@@ -441,6 +441,7 @@ func TestCrossPlatformCoverageReactionsNormalizesEmotionReplyList(t *testing.T)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
|
||||
ApplyTruncation(nil)
|
||||
payload := map[string]any{"count": 98}
|
||||
ApplyPagination(payload, map[string]any{
|
||||
"result": map[string]any{
|
||||
|
||||
@@ -486,9 +486,23 @@ var Upload = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if remoteName := firstString(verified, "name", "fileName"); remoteName == "" || !strings.HasPrefix(remoteName, strings.TrimSuffix(name, filepath.Ext(name))) {
|
||||
remoteID := firstString(verified, "fileId", "dentryUuid", "nodeId", "id")
|
||||
if remoteID == "" {
|
||||
return driveResponseError("drive/commit_upload", "readback_missing_id", "上传后读回缺少文件 ID;无法证明读回的是已提交文件")
|
||||
}
|
||||
if remoteID != nodeID {
|
||||
return driveResponseError("drive/commit_upload", "readback_id_mismatch", fmt.Sprintf("上传后读回文件 ID %q 与提交 ID %q 不一致", remoteID, nodeID))
|
||||
}
|
||||
if remoteName := firstString(verified, "name", "fileName"); !driveReadbackNameMatches(verified, name) {
|
||||
return driveResponseError("drive/commit_upload", "readback_mismatch", fmt.Sprintf("上传后读回名称 %q 与请求 %q 不一致", remoteName, name))
|
||||
}
|
||||
remoteSize, ok := firstInt64(verified, "fileSize", "size", "byteSize", "length")
|
||||
if !ok {
|
||||
return driveResponseError("drive/commit_upload", "readback_missing_size", "上传后读回缺少有效文件大小;无法证明远端文件完整")
|
||||
}
|
||||
if remoteSize != info.Size() {
|
||||
return driveResponseError("drive/commit_upload", "readback_size_mismatch", fmt.Sprintf("上传后读回大小 %d 与本地文件大小 %d 不一致", remoteSize, info.Size()))
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "sizeBytes": info.Size(), "file": verified})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ package drive
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
@@ -216,6 +218,47 @@ func nestedString(data map[string]any, keys ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func driveReadbackNameMatches(data map[string]any, requested string) bool {
|
||||
remoteName := firstString(data, "name", "fileName")
|
||||
if remoteName == requested {
|
||||
return true
|
||||
}
|
||||
extension := strings.TrimLeft(firstString(data, "extension", "fileExtension", "ext"), ".")
|
||||
return extension != "" && remoteName+"."+extension == requested
|
||||
}
|
||||
|
||||
func firstInt64(data map[string]any, keys ...string) (int64, bool) {
|
||||
for _, key := range keys {
|
||||
value, present := data[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return int64(typed), true
|
||||
case int32:
|
||||
return int64(typed), true
|
||||
case int64:
|
||||
return typed, true
|
||||
case float64:
|
||||
if !math.IsNaN(typed) && !math.IsInf(typed, 0) && typed == math.Trunc(typed) && typed >= math.MinInt64 && typed < math.MaxInt64 {
|
||||
return int64(typed), true
|
||||
}
|
||||
case json.Number:
|
||||
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func driveResponseError(operation, reason, message string) error {
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation(operation),
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
@@ -546,6 +547,31 @@ var Move = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remoteID := firstString(verified, "nodeId", "fileId", "dentryUuid", "id")
|
||||
if remoteID == "" {
|
||||
return driveResponseError("doc/move_document", "readback_missing_id", "移动后读回缺少节点 ID;无法证明读回的是已移动节点")
|
||||
}
|
||||
if remoteID != rt.Str("node") {
|
||||
return driveResponseError("doc/move_document", "readback_id_mismatch", fmt.Sprintf("移动后读回节点 %q 与请求节点 %q 不一致", remoteID, rt.Str("node")))
|
||||
}
|
||||
if rt.Changed("folder") {
|
||||
remoteFolder := firstString(verified, "folderId", "targetFolderId", "parentId")
|
||||
if remoteFolder == "" {
|
||||
return driveResponseError("doc/move_document", "readback_missing_folder", "移动后读回缺少目标文件夹 ID;无法证明移动已到达请求位置")
|
||||
}
|
||||
if remoteFolder != rt.Str("folder") {
|
||||
return driveResponseError("doc/move_document", "readback_folder_mismatch", fmt.Sprintf("移动后读回文件夹 %q 与请求 %q 不一致", remoteFolder, rt.Str("folder")))
|
||||
}
|
||||
}
|
||||
if rt.Changed("workspace") {
|
||||
remoteWorkspace := firstString(verified, "workspaceId", "spaceId")
|
||||
if remoteWorkspace == "" {
|
||||
return driveResponseError("doc/move_document", "readback_missing_workspace", "移动后读回缺少目标知识库 ID;无法证明移动已到达请求位置")
|
||||
}
|
||||
if remoteWorkspace != rt.Str("workspace") {
|
||||
return driveResponseError("doc/move_document", "readback_workspace_mismatch", fmt.Sprintf("移动后读回知识库 %q 与请求 %q 不一致", remoteWorkspace, rt.Str("workspace")))
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -176,6 +177,40 @@ func TestCrossPlatformCoverageDriveDownloadAndUploadRequireArtifactsAndReadback(
|
||||
if _, _, err := resolveDriveUploadInput("../escape.bin"); err == nil {
|
||||
t.Fatal("upload path escape was accepted")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
committedID string
|
||||
readback string
|
||||
want string
|
||||
}{
|
||||
{"missing remote id", "uploaded-2", `{"success":true,"result":{"name":"input.bin","fileSize":18}}`, "缺少文件 ID"},
|
||||
{"mismatched remote id", "uploaded-3", `{"success":true,"result":{"fileId":"other","name":"input.bin","fileSize":18}}`, "与提交 ID"},
|
||||
{"prefix-only remote name", "uploaded-4", `{"success":true,"result":{"fileId":"uploaded-4","name":"input.bin-old","fileSize":18}}`, "读回名称"},
|
||||
{"missing remote size", "uploaded-5", `{"success":true,"result":{"fileId":"uploaded-5","name":"input.bin"}}`, "缺少有效文件大小"},
|
||||
{"mismatched remote size", "uploaded-6", `{"success":true,"result":{"fileId":"uploaded-6","name":"input.bin","fileSize":17}}`, "与本地文件大小 18 不一致"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &uploadDriveFile, func(context.Context, helpers.DriveUploadRequest) (map[string]any, error) {
|
||||
return map[string]any{"success": true, "result": map[string]any{"fileId": tc.committedID}}, nil
|
||||
})
|
||||
caller := &driveCoverageCaller{responses: map[string][]string{"get_file_info": {tc.readback}}}
|
||||
err := runDriveCoverage(t, Upload, caller, "--file", "input.bin", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("split remote extension", func(t *testing.T) {
|
||||
testseam.Swap(t, &uploadDriveFile, func(context.Context, helpers.DriveUploadRequest) (map[string]any, error) {
|
||||
return map[string]any{"success": true, "result": map[string]any{"fileId": "uploaded-7"}}, nil
|
||||
})
|
||||
caller := &driveCoverageCaller{responses: map[string][]string{
|
||||
"get_file_info": {`{"success":true,"result":{"fileId":"uploaded-7","name":"input","extension":"bin","fileSize":18}}`},
|
||||
}}
|
||||
if err := runDriveCoverage(t, Upload, caller, "--file", "input.bin", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
testseam.Swap(t, &driveDownload, func(_ context.Context, _ string, options localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
if options.Output != "downloads/file.bin" || options.Headers["x-token"] != "secret" {
|
||||
@@ -218,6 +253,42 @@ func TestCrossPlatformCoverageDriveCopyPreservesSchemaProperties(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveFirstInt64(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value any
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{"int", int(1), 1, true},
|
||||
{"int32", int32(2), 2, true},
|
||||
{"int64", int64(3), 3, true},
|
||||
{"float", float64(4), 4, true},
|
||||
{"json number", json.Number("5"), 5, true},
|
||||
{"string", " 6 ", 6, true},
|
||||
{"fraction", 1.5, 0, false},
|
||||
{"nan", math.NaN(), 0, false},
|
||||
{"infinity", math.Inf(1), 0, false},
|
||||
{"overflow", float64(math.MaxInt64), 0, false},
|
||||
{"bad json number", json.Number("bad"), 0, false},
|
||||
{"bad string", "bad", 0, false},
|
||||
{"unsupported", true, 0, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := firstInt64(map[string]any{"size": tc.value}, "missing", "size")
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("firstInt64(%#v) = (%d, %t), want (%d, %t)", tc.value, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got, ok := firstInt64(map[string]any{}, "size"); ok || got != 0 {
|
||||
t.Fatalf("missing firstInt64 = (%d, %t), want (0, false)", got, ok)
|
||||
}
|
||||
if got, ok := firstInt64(map[string]any{"fileSize": nil, "size": "7"}, "fileSize", "size"); !ok || got != 7 {
|
||||
t.Fatalf("fallback firstInt64 = (%d, %t), want (7, true)", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveVersionAndPublishContracts(t *testing.T) {
|
||||
versionPayload := `{"success":true,"versions":[{"version":1,"fileSize":3},{"versionNumber":"2","fileSize":4}],"hasMore":false}`
|
||||
caller := &driveCoverageCaller{responses: map[string][]string{"list_file_versions": {versionPayload}}}
|
||||
@@ -449,11 +520,34 @@ func TestCrossPlatformCoverageDriveCreateRestoreCopyMoveRename(t *testing.T) {
|
||||
|
||||
move := &driveCoverageCaller{responses: map[string][]string{
|
||||
"move_document": {`{"success":true}`},
|
||||
"get_document_info": {`{"success":true,"result":{"nodeId":"n1"}}`},
|
||||
"get_document_info": {`{"success":true,"result":{"nodeId":"n1","folderId":"target","workspaceId":"space"}}`},
|
||||
}}
|
||||
if err := runDriveCoverage(t, Move, move, "--node", "n1", "--folder", "target", "--workspace", "space", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
readback string
|
||||
want string
|
||||
}{
|
||||
{"missing node id", `{"success":true,"result":{"folderId":"target","workspaceId":"space"}}`, "缺少节点 ID"},
|
||||
{"wrong node id", `{"success":true,"result":{"nodeId":"other","folderId":"target","workspaceId":"space"}}`, "与请求节点 \"n1\" 不一致"},
|
||||
{"missing folder", `{"success":true,"result":{"nodeId":"n1","workspaceId":"space"}}`, "缺少目标文件夹 ID"},
|
||||
{"wrong folder", `{"success":true,"result":{"nodeId":"n1","folderId":"other","workspaceId":"space"}}`, "与请求 \"target\" 不一致"},
|
||||
{"missing workspace", `{"success":true,"result":{"nodeId":"n1","folderId":"target"}}`, "缺少目标知识库 ID"},
|
||||
{"wrong workspace", `{"success":true,"result":{"nodeId":"n1","folderId":"target","workspaceId":"other"}}`, "与请求 \"space\" 不一致"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &driveCoverageCaller{responses: map[string][]string{
|
||||
"move_document": {`{"success":true}`},
|
||||
"get_document_info": {tc.readback},
|
||||
}}
|
||||
err := runDriveCoverage(t, Move, caller, "--node", "n1", "--folder", "target", "--workspace", "space", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rename := &driveCoverageCaller{responses: map[string][]string{
|
||||
"get_file_info": {`{"success":true,"result":{"fileId":"n1","type":"FILE","extension":"md","name":"old.md"}}`, `{"success":true,"result":{"fileId":"n1","name":"new.md"}}`},
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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 shortcut
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxAutoPageDelayMS = int64(math.MaxInt64) / int64(time.Millisecond)
|
||||
|
||||
// AutoPageControlFlags returns the shared item and pacing controls used by
|
||||
// cursor-based shortcuts. Callers retain ownership of --page-all and their
|
||||
// product-specific --page-limit defaults.
|
||||
func AutoPageControlFlags() []Flag {
|
||||
const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"
|
||||
return []Flag{
|
||||
{Name: "max-items", Type: FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制)。" + evidence},
|
||||
{Name: "page-delay", Type: FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待)。" + evidence},
|
||||
}
|
||||
}
|
||||
|
||||
// AutoPageControlConstraints publishes the runtime-only relationship between
|
||||
// the pagination switch and its shared controls into Help and Schema.
|
||||
func AutoPageControlConstraints() []Constraint {
|
||||
return []Constraint{{
|
||||
Kind: ConstraintCustom,
|
||||
Flags: []string{"page-all", "max-items", "page-delay"},
|
||||
Description: "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0",
|
||||
}}
|
||||
}
|
||||
|
||||
// ValidateAutoPageControls enforces the shared contract while preserving the
|
||||
// historical behavior that defaulted controls do not activate pagination.
|
||||
func ValidateAutoPageControls(rt *RuntimeContext) error {
|
||||
if !rt.Bool("page-all") {
|
||||
if rt.Changed("max-items") || rt.Changed("page-delay") {
|
||||
return fmt.Errorf("--max-items/--page-delay 仅与 --page-all 一起使用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if rt.Int("max-items") < 0 {
|
||||
return fmt.Errorf("--max-items 必须大于等于 0")
|
||||
}
|
||||
delayMS := rt.Int("page-delay")
|
||||
if delayMS < 0 {
|
||||
return fmt.Errorf("--page-delay 必须大于等于 0")
|
||||
}
|
||||
if int64(delayMS) > maxAutoPageDelayMS {
|
||||
return fmt.Errorf("--page-delay 不能大于 %d 毫秒", maxAutoPageDelayMS)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoPageRequestSize caps the next lower-page request to the remaining item
|
||||
// budget. A cursor returned for that request is therefore safe to resume from:
|
||||
// the CLI did not intentionally discard a suffix of the lower page.
|
||||
func AutoPageRequestSize(rt *RuntimeContext, pageSize, itemCount int) int {
|
||||
maxItems := rt.Int("max-items")
|
||||
if maxItems <= 0 {
|
||||
return pageSize
|
||||
}
|
||||
remaining := maxItems - itemCount
|
||||
if remaining > 0 && remaining < pageSize {
|
||||
return remaining
|
||||
}
|
||||
return pageSize
|
||||
}
|
||||
|
||||
// WaitAutoPageDelay waits between successful pages and remains cancellable so
|
||||
// a throttled pagination run cannot ignore command cancellation.
|
||||
func WaitAutoPageDelay(rt *RuntimeContext) error {
|
||||
delayMS := rt.Int("page-delay")
|
||||
if delayMS <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(time.Duration(int64(delayMS)) * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-rt.Command().Context().Done():
|
||||
return rt.Command().Context().Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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 shortcut
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func autoPageRuntimeForTest(t *testing.T, pageAll bool, maxItems, pageDelay string) *RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "page"}
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().Int("max-items", 0, "")
|
||||
cmd.Flags().Int("page-delay", 0, "")
|
||||
if pageAll {
|
||||
if err := cmd.Flags().Set("page-all", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for name, value := range map[string]string{"max-items": maxItems, "page-delay": pageDelay} {
|
||||
if value != "" {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return RuntimeContextForTest(cmd, Shortcut{})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAutoPageControlsBoundDelayAndRequestSize(t *testing.T) {
|
||||
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, false, "1", "")); err == nil {
|
||||
t.Fatal("max-items without page-all unexpectedly succeeded")
|
||||
}
|
||||
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "-1", "")); err == nil {
|
||||
t.Fatal("negative max-items unexpectedly succeeded")
|
||||
}
|
||||
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", "-1")); err == nil {
|
||||
t.Fatal("negative page-delay unexpectedly succeeded")
|
||||
}
|
||||
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", strconv.FormatInt(maxAutoPageDelayMS, 10))); err != nil {
|
||||
t.Fatalf("maximum safe page-delay failed: %v", err)
|
||||
}
|
||||
if strconv.IntSize == 64 {
|
||||
tooLarge := strconv.FormatInt(maxAutoPageDelayMS+1, 10)
|
||||
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", tooLarge)); err == nil {
|
||||
t.Fatal("overflowing page-delay unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "0", ""), 100, 40); got != 100 {
|
||||
t.Fatalf("unlimited request size = %d", got)
|
||||
}
|
||||
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "50", ""), 100, 40); got != 10 {
|
||||
t.Fatalf("remaining request size = %d", got)
|
||||
}
|
||||
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "50", ""), 100, 50); got != 100 {
|
||||
t.Fatalf("exhausted request size = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWaitAutoPageDelayNoopAndCancellation(t *testing.T) {
|
||||
if err := WaitAutoPageDelay(autoPageRuntimeForTest(t, true, "", "0")); err != nil {
|
||||
t.Fatalf("zero delay = %v", err)
|
||||
}
|
||||
if err := WaitAutoPageDelay(autoPageRuntimeForTest(t, true, "", "1")); err != nil {
|
||||
t.Fatalf("elapsed delay = %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
rt := autoPageRuntimeForTest(t, true, "", "1")
|
||||
rt.Command().SetContext(ctx)
|
||||
if err := WaitAutoPageDelay(rt); err != context.Canceled {
|
||||
t.Fatalf("canceled delay = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
@@ -93,23 +93,23 @@ var AtMe = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: append([]shortcut.Flag{
|
||||
Flags: append(append([]shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "仅查看指定群;可传 openConversationId 或群名"},
|
||||
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
|
||||
{Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true},
|
||||
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(默认 7);--days 必须在 1-3650 之间", Default: "7", Required: false},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50);--limit 必须大于 0", Default: "50"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部 @我 消息;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部 @我 消息;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||||
}, chatshortcut.MessageResourceDownloadFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
}, shortcut.AutoPageControlFlags()...), chatshortcut.MessageResourceDownloadFlags()...),
|
||||
Constraints: append(append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"days"}, Description: "--days 必须在 1-3650 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
||||
}, shortcut.AutoPageControlConstraints()...), chatshortcut.MessageResourceDownloadConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +at-me`,
|
||||
`dws chat +at-me --days 3`,
|
||||
@@ -211,6 +211,9 @@ func validateAtMe(rt *shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
}
|
||||
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -240,12 +243,24 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
||||
nextCursor := ""
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
truncatedByResultLimit := false
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
if pagesFetched > 0 {
|
||||
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "delay", "cursor": cursor, "error": err.Error(),
|
||||
})
|
||||
stopReason = "delay_interrupted"
|
||||
break
|
||||
}
|
||||
}
|
||||
params := make(map[string]any, len(baseParams))
|
||||
for key, value := range baseParams {
|
||||
params[key] = value
|
||||
}
|
||||
pageSize, _ := baseParams["limit"].(int)
|
||||
params["limit"] = shortcut.AutoPageRequestSize(rt, pageSize, len(allItems))
|
||||
params["cursor"] = cursor
|
||||
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
|
||||
if err != nil {
|
||||
@@ -260,6 +275,7 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
||||
}
|
||||
pagesFetched++
|
||||
pageItems := atMeMessageItems(data)
|
||||
overflowOnPage := false
|
||||
for _, item := range pageItems {
|
||||
id := chatmsg.StableMessageID(item)
|
||||
if id != "" && seenMessages[id] {
|
||||
@@ -268,6 +284,11 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
||||
if id != "" {
|
||||
seenMessages[id] = true
|
||||
}
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allItems) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
overflowOnPage = true
|
||||
continue
|
||||
}
|
||||
allItems = append(allItems, item)
|
||||
}
|
||||
|
||||
@@ -282,6 +303,16 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if overflowOnPage {
|
||||
hasMore = true
|
||||
nextCursor = ""
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "@我消息下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if !hasMore {
|
||||
complete = true
|
||||
nextCursor = ""
|
||||
@@ -299,8 +330,13 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allItems) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
stopReason = "result_limit"
|
||||
break
|
||||
}
|
||||
}
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
@@ -312,9 +348,11 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
||||
payload["hasMore"] = hasMore
|
||||
payload["stopReason"] = stopReason
|
||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||
payload["truncatedByResultLimit"] = truncatedByResultLimit
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["partial"] = len(failures) > 0 && len(allItems) > 0
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
if hasMore && nextCursor != "" {
|
||||
payload["nextCursor"] = nextCursor
|
||||
}
|
||||
|
||||
@@ -57,10 +57,10 @@ var ChatMessages = shortcut.Shortcut{
|
||||
Command: "+chat-messages",
|
||||
Product: "chat",
|
||||
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
|
||||
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
|
||||
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId,唯一解析后按 senderId 筛选同一次读取结果;不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
|
||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
@@ -83,10 +83,10 @@ var ChatMessages = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
|
||||
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
|
||||
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId,唯一解析后按 senderId 筛选同一次读取结果;不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
|
||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。"},
|
||||
AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"},
|
||||
Examples: []string{
|
||||
@@ -104,7 +104,8 @@ var ChatMessages = shortcut.Shortcut{
|
||||
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
|
||||
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true},
|
||||
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者并筛选同一次会话读取结果(可选,可重复或逗号分隔)"},
|
||||
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;自动唯一解析并筛选同一次会话读取结果"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析发送者的兼容入口(可选,可重复或逗号分隔)"},
|
||||
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "范围开始时间(可选、包含),支持 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
|
||||
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(可选、包含)"},
|
||||
@@ -117,9 +118,11 @@ var ChatMessages = shortcut.Shortcut{
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true},
|
||||
{Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"},
|
||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-results 仅与 --page-all 一起使用且不能为负数"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items 仅与 --page-all 一起使用且不能为负数;--max-results 仅与 --page-all 一起使用且不能为负数;--page-delay 仅与 --page-all 一起使用且不能为负数"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-results 仅与 --page-all 一起使用且不能为负数;0 表示仅受页数上限约束"},
|
||||
{Name: "max-items", Type: shortcut.FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制);--max-items 仅与 --page-all 一起使用且不能为负数"},
|
||||
{Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-items 的公开兼容别名;--max-results 仅与 --page-all 一起使用且不能为负数"},
|
||||
{Name: "page-delay", Type: shortcut.FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待);--page-delay 仅与 --page-all 一起使用且不能为负数"},
|
||||
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "把完整结构化 ledger 原子写入工作目录内的相对 JSON 文件"},
|
||||
}, chatshortcut.MessageResourceDownloadFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
@@ -131,6 +134,7 @@ var ChatMessages = shortcut.Shortcut{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "start", "start-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "end", "end-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "order", "sort"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"sender", "sender-query"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "start", "start-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "end", "end-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "order", "sort"}},
|
||||
@@ -138,7 +142,10 @@ var ChatMessages = shortcut.Shortcut{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"order", "sort"}, Description: "asc 必须指定 --start/--start-time"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式页大小必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-items"}, Description: "--max-items 仅与 --page-all 一起使用且不能为负数"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-results"}, Description: "--max-results 仅与 --page-all 一起使用且不能为负数"},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"max-items", "max-results"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-delay"}, Description: "--page-delay 仅与 --page-all 一起使用且不能为负数"},
|
||||
{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
Flags: []string{"output", "overwrite"},
|
||||
@@ -179,16 +186,25 @@ func validateChatMessages(rt *shortcut.RuntimeContext) error {
|
||||
return localChatOptionError("invalid_page_size", "+chat-messages 的 --"+name+" 必须大于 0", "--"+name)
|
||||
}
|
||||
}
|
||||
if !rt.Bool("page-all") && (rt.Changed("page-limit") || rt.Changed("max-results")) {
|
||||
return apperrors.NewValidation("--page-limit/--max-results 仅与 --page-all 一起使用")
|
||||
if !rt.Bool("page-all") && rt.Changed("page-limit") {
|
||||
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
|
||||
}
|
||||
if !rt.Bool("page-all") && rt.Changed("max-results") {
|
||||
return apperrors.NewValidation("--max-results 仅与 --page-all 一起使用")
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > chatMessagesHardPageLimit {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
if rt.Int("max-results") < 0 {
|
||||
return apperrors.NewValidation("--max-results 不能小于 0")
|
||||
}
|
||||
}
|
||||
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if rt.Int("max-results") < 0 {
|
||||
return apperrors.NewValidation("--max-results 不能小于 0")
|
||||
}
|
||||
if rt.Changed("max-items") && rt.Changed("max-results") {
|
||||
return apperrors.NewValidation("--max-items 与 --max-results 不能同时使用")
|
||||
}
|
||||
if rt.Changed("output") {
|
||||
if err := chatshortcut.ValidateMessageExportOutput(rt.Str("output")); err != nil {
|
||||
@@ -209,105 +225,267 @@ type chatMessagesRequest struct {
|
||||
}
|
||||
|
||||
type chatMessagesSenderFilter struct {
|
||||
requested bool
|
||||
applied bool
|
||||
stableIDs map[string]bool
|
||||
resolutions []targetresolver.UserResolution
|
||||
failure map[string]any
|
||||
requested bool
|
||||
applied bool
|
||||
inputs []string
|
||||
inputMode string
|
||||
stableIDs map[string]bool
|
||||
hasUserIDs bool
|
||||
hasOpenIDs bool
|
||||
resolutions []targetresolver.UserResolution
|
||||
resolutionFailure map[string]any
|
||||
resolutionErr error
|
||||
scopeErr error
|
||||
}
|
||||
|
||||
// resolveOptionalChatMessagesSenderFilter is deliberately local to
|
||||
// +chat-messages. Sender resolution is an optional post-read filter here: an
|
||||
// unresolved name must not suppress the command's primary conversation-list
|
||||
// result. +search-msg keeps its stricter semantics because sender identity is
|
||||
// part of the lower search request there.
|
||||
// resolveOptionalChatMessagesSenderFilter accepts the same mixed user target
|
||||
// as +search-msg while retaining --sender-query as a compatibility entry. It
|
||||
// resolves only explicitly requested senders; it never enumerates every member
|
||||
// or tries to infer a person from message display names.
|
||||
func resolveOptionalChatMessagesSenderFilter(rt *shortcut.RuntimeContext) chatMessagesSenderFilter {
|
||||
queries := rt.StrSlice("sender-query")
|
||||
direct := uniqueChatMessageTargets(rt.StrSlice("sender"))
|
||||
queries := uniqueChatMessageTargets(rt.StrSlice("sender-query"))
|
||||
inputs := direct
|
||||
inputMode := "sender"
|
||||
if len(inputs) == 0 {
|
||||
inputs = queries
|
||||
inputMode = "sender-query"
|
||||
}
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: len(queries) > 0,
|
||||
requested: len(inputs) > 0,
|
||||
inputs: inputs,
|
||||
inputMode: inputMode,
|
||||
stableIDs: map[string]bool{},
|
||||
}
|
||||
if !filter.requested {
|
||||
return filter
|
||||
}
|
||||
|
||||
resolutions, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
|
||||
var resolutions []targetresolver.UserResolution
|
||||
var err error
|
||||
if len(direct) > 0 {
|
||||
for _, value := range direct {
|
||||
var resolved targetresolver.UserResolution
|
||||
resolved, err = targetresolver.ResolveSenderTarget(rt, value, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
resolutions = append(resolutions, resolved)
|
||||
}
|
||||
} else {
|
||||
resolutions, err = targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
|
||||
}
|
||||
if err != nil {
|
||||
failure := map[string]any{
|
||||
"stage": "sender_resolution",
|
||||
"queries": queries,
|
||||
"error": err.Error(),
|
||||
"stage": "sender_resolution",
|
||||
"inputs": inputs,
|
||||
"mode": inputMode,
|
||||
"error": err.Error(),
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) {
|
||||
if typed.Reason != "" {
|
||||
failure["reason"] = typed.Reason
|
||||
}
|
||||
if typed.Hint != "" {
|
||||
failure["hint"] = typed.Hint
|
||||
}
|
||||
if len(typed.Details) > 0 {
|
||||
failure["details"] = typed.Details
|
||||
}
|
||||
}
|
||||
filter.failure = failure
|
||||
filter.resolutionFailure = failure
|
||||
filter.resolutionErr = err
|
||||
return filter
|
||||
}
|
||||
|
||||
filter.resolutions = resolutions
|
||||
for _, resolution := range resolutions {
|
||||
for _, identity := range []string{
|
||||
resolution.Selected.UserID,
|
||||
resolution.Selected.OpenDingTalkID,
|
||||
} {
|
||||
if identity = strings.TrimSpace(identity); identity != "" {
|
||||
filter.stableIDs[identity] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// ResolveUsers(IdentityAny) only returns users extracted with at least one
|
||||
// stable userId/openDingTalkId and fails when no resolvable user remains.
|
||||
filter.hasUserIDs, filter.hasOpenIDs = addChatMessagesResolvedSenderIDs(filter.stableIDs, resolutions)
|
||||
filter.applied = true
|
||||
return filter
|
||||
}
|
||||
|
||||
// applyOptionalChatMessagesSenderFilter preserves the unfiltered message list
|
||||
// when optional resolution fails. Successful resolution filters both the
|
||||
func uniqueChatMessageTargets(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func addChatMessagesResolvedSenderIDs(ids map[string]bool, resolutions []targetresolver.UserResolution) (bool, bool) {
|
||||
hasUserIDs := false
|
||||
hasOpenIDs := false
|
||||
for _, resolution := range resolutions {
|
||||
if identity := strings.TrimSpace(resolution.Selected.UserID); identity != "" {
|
||||
ids[identity] = true
|
||||
hasUserIDs = true
|
||||
}
|
||||
if identity := strings.TrimSpace(resolution.Selected.OpenDingTalkID); identity != "" {
|
||||
ids[identity] = true
|
||||
hasOpenIDs = true
|
||||
}
|
||||
}
|
||||
return hasUserIDs, hasOpenIDs
|
||||
}
|
||||
|
||||
// applyOptionalChatMessagesSenderFilter fails closed when an explicitly
|
||||
// requested sender cannot be resolved. Successful resolution filters both the
|
||||
// public projection and raw rows so exports and resource downloads cannot
|
||||
// accidentally include messages outside the requested sender set.
|
||||
func applyOptionalChatMessagesSenderFilter(
|
||||
rt *shortcut.RuntimeContext,
|
||||
payload map[string]any,
|
||||
rawItems []map[string]any,
|
||||
filter chatMessagesSenderFilter,
|
||||
filter *chatMessagesSenderFilter,
|
||||
) []map[string]any {
|
||||
if !filter.requested || payload == nil {
|
||||
if filter == nil || !filter.requested || payload == nil {
|
||||
return rawItems
|
||||
}
|
||||
if !filter.applied {
|
||||
failures, _ := payload["failures"].([]map[string]any)
|
||||
priorFailures := len(failures)
|
||||
failures = append(failures, filter.failure)
|
||||
failure := filter.resolutionFailure
|
||||
if failure == nil {
|
||||
failure = map[string]any{
|
||||
"stage": "sender_resolution",
|
||||
"inputs": filter.inputs,
|
||||
"mode": filter.inputMode,
|
||||
"error": "无法把发送者目标唯一解析为稳定身份",
|
||||
}
|
||||
}
|
||||
failures = append(failures, failure)
|
||||
payload["failures"] = failures
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["complete"] = false
|
||||
payload["partial"] = len(rawItems) > 0
|
||||
payload["partial"] = false
|
||||
payload["messages"] = []map[string]any{}
|
||||
payload["count"] = 0
|
||||
payload["senderFilter"] = map[string]any{
|
||||
"requested": true,
|
||||
"inputs": filter.inputs,
|
||||
"mode": filter.inputMode,
|
||||
"applied": false,
|
||||
"status": "resolution_failed",
|
||||
"suppressedCount": len(rawItems),
|
||||
}
|
||||
payload["identityResult"] = map[string]any{
|
||||
"status": "resolution_failed",
|
||||
"nameComparisonAllowed": false,
|
||||
"stableIdField": "senderId",
|
||||
"hint": "当前发送者尚未解析为稳定身份,不能根据 sender 展示名判断该人员是否发过消息。请先完成人员解析。",
|
||||
}
|
||||
if priorFailures == 0 {
|
||||
payload["stopReason"] = "sender_resolution_failed"
|
||||
}
|
||||
return rawItems
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(rawItems))
|
||||
unverifiableMessageIDs := make([]string, 0)
|
||||
for _, item := range rawItems {
|
||||
identity := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(item)))
|
||||
if identity != "" && identity != "<nil>" && filter.stableIDs[identity] {
|
||||
filtered = append(filtered, item)
|
||||
if identity == "" || identity == "<nil>" {
|
||||
messageID := chatmsg.StableMessageID(item)
|
||||
if messageID == "" {
|
||||
messageID = "<unknown>"
|
||||
}
|
||||
unverifiableMessageIDs = append(unverifiableMessageIDs, messageID)
|
||||
continue
|
||||
}
|
||||
if filter.stableIDs[identity] {
|
||||
filtered = append(filtered, item)
|
||||
continue
|
||||
}
|
||||
if targetresolver.LooksLikeCurrentDOpenDingTalkID(identity) {
|
||||
if !filter.hasOpenIDs {
|
||||
unverifiableMessageIDs = append(unverifiableMessageIDs, chatMessagesMessageID(item))
|
||||
}
|
||||
} else if !filter.hasUserIDs {
|
||||
unverifiableMessageIDs = append(unverifiableMessageIDs, chatMessagesMessageID(item))
|
||||
}
|
||||
}
|
||||
if len(unverifiableMessageIDs) > 0 {
|
||||
filter.scopeErr = apperrors.NewAPI(
|
||||
"部分消息缺少 senderId,无法证明发送者过滤范围;已阻止完整成功与导出",
|
||||
apperrors.WithReason("chat_messages_sender_scope_unverified"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("请保留 trace_id 并检查消息读取服务是否返回稳定 senderId"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"requestedSenders": filter.inputs,
|
||||
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
|
||||
}),
|
||||
)
|
||||
failures, _ := payload["failures"].([]map[string]any)
|
||||
failures = append(failures, map[string]any{
|
||||
"stage": "sender_scope_verification",
|
||||
"error": filter.scopeErr.Error(),
|
||||
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
|
||||
})
|
||||
payload["failures"] = failures
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["complete"] = false
|
||||
payload["partial"] = len(filtered) > 0
|
||||
}
|
||||
payload["messages"] = projectChatMessages(filtered, !rt.Bool("no-reactions"))
|
||||
payload["count"] = len(filtered)
|
||||
payload["resolvedFilters"] = map[string]any{"senders": filter.resolutions}
|
||||
filterStatus := "applied"
|
||||
if filter.scopeErr != nil {
|
||||
filterStatus = "scope_unverified"
|
||||
}
|
||||
payload["senderFilter"] = map[string]any{
|
||||
"requested": true,
|
||||
"inputs": filter.inputs,
|
||||
"mode": filter.inputMode,
|
||||
"applied": true,
|
||||
"status": filterStatus,
|
||||
}
|
||||
identityResult := map[string]any{
|
||||
"status": "evaluated",
|
||||
"nameComparisonAllowed": false,
|
||||
"negativeConclusionAllowed": true,
|
||||
"stableIdField": "senderId",
|
||||
}
|
||||
if filter.scopeErr != nil {
|
||||
identityResult["status"] = "scope_unverified"
|
||||
identityResult["negativeConclusionAllowed"] = false
|
||||
identityResult["hint"] = "部分消息缺少稳定 senderId,当前结果不能用于断言该人员没有发过消息。"
|
||||
}
|
||||
payload["identityResult"] = identityResult
|
||||
return filtered
|
||||
}
|
||||
|
||||
func chatMessagesMessageID(item map[string]any) string {
|
||||
messageID := chatmsg.StableMessageID(item)
|
||||
if messageID == "" {
|
||||
return "<unknown>"
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
func attachUnfilteredSenderIdentitySemantics(payload map[string]any, filter chatMessagesSenderFilter) {
|
||||
if payload == nil || filter.requested {
|
||||
return
|
||||
}
|
||||
payload["senderFilter"] = map[string]any{
|
||||
"requested": false,
|
||||
"applied": false,
|
||||
"status": "not_requested",
|
||||
}
|
||||
payload["identityResult"] = map[string]any{
|
||||
"status": "requires_query_check",
|
||||
"negativeConclusionAllowed": false,
|
||||
"hint": "若原始任务包含自然人发送者条件,请先将目标人解析为唯一 userId/openDingTalkId,再与 messages[].senderId 比较;禁止直接比较 messages[].sender 展示名。",
|
||||
}
|
||||
}
|
||||
|
||||
func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) {
|
||||
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "open-conversation-id"))
|
||||
userID := rt.Str("user")
|
||||
@@ -317,6 +495,11 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques
|
||||
"--open-dingtalk-id 收到的是群 openConversationId;群聊请改用 --group(兼容别名 --chat)",
|
||||
)
|
||||
}
|
||||
if openID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
|
||||
return chatMessagesRequest{}, err
|
||||
}
|
||||
}
|
||||
if groupID == "" && (rt.Str("group") != "" || rt.Str("chat-query") != "") {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query"))
|
||||
if err != nil {
|
||||
@@ -394,7 +577,8 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
senderFilter := resolveOptionalChatMessagesSenderFilter(rt)
|
||||
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, senderFilter)
|
||||
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, &senderFilter)
|
||||
attachUnfilteredSenderIdentitySemantics(payload, senderFilter)
|
||||
if err != nil {
|
||||
// Full-page collection returns its failure ledger together with a
|
||||
// non-zero error. Publish that ledger for diagnosis, but stop before
|
||||
@@ -406,6 +590,22 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if senderFilter.requested && !senderFilter.applied {
|
||||
if payload != nil {
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
}
|
||||
return senderFilter.resolutionErr
|
||||
}
|
||||
if senderFilter.scopeErr != nil {
|
||||
if payload != nil {
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
}
|
||||
return senderFilter.scopeErr
|
||||
}
|
||||
if rt.Bool("download-resources") {
|
||||
chatshortcut.AttachMessageResourceDownloads(
|
||||
payload,
|
||||
@@ -477,7 +677,7 @@ func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessage
|
||||
|
||||
func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) {
|
||||
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), chatMessagesDefaultPageLimit)
|
||||
maxResults := rt.Int("max-results")
|
||||
maxResults := rt.IntFirst("max-items", "max-results")
|
||||
basePageSize, _ := request.params["limit"].(int)
|
||||
if basePageSize <= 0 {
|
||||
basePageSize = chatMessagesAllPageSize
|
||||
@@ -496,6 +696,15 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
||||
var nextPage map[string]any
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
if pagesFetched > 0 {
|
||||
if delayErr := shortcut.WaitAutoPageDelay(rt); delayErr != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "delay", "error": delayErr.Error(),
|
||||
})
|
||||
stopReason = "delay_interrupted"
|
||||
break
|
||||
}
|
||||
}
|
||||
request.params["limit"] = basePageSize
|
||||
if maxResults > 0 {
|
||||
remaining := maxResults - len(allItems)
|
||||
@@ -656,6 +865,7 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
||||
payload["stopReason"] = stopReason
|
||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||
payload["truncatedByResultLimit"] = truncatedByResultLimit
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["partial"] = len(failures) > 0 && len(results) > 0
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
stderrors "errors"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -503,7 +504,29 @@ func chatMessagesRuntimeForTest(t *testing.T, values map[string]string) *shortcu
|
||||
return shortcut.RuntimeContextForTest(cmd, ChatMessages)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesKeepsMaxResultsPublic(t *testing.T) {
|
||||
root := newPlatformCoverageRoot()
|
||||
cmd, _, err := root.Find([]string{"chat", "+chat-messages"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flag := cmd.Flags().Lookup("max-results")
|
||||
if flag == nil || flag.Hidden {
|
||||
t.Fatalf("--max-results must remain a visible compatibility flag: %#v", flag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *testing.T) {
|
||||
for _, values := range []map[string]string{
|
||||
{"max-results": "1"},
|
||||
{"max-items": "1"},
|
||||
{"page-all": "true", "max-results": "-1"},
|
||||
{"page-all": "true", "max-items": "1", "max-results": "1"},
|
||||
} {
|
||||
if err := validateChatMessages(chatMessagesRuntimeForTest(t, values)); err == nil {
|
||||
t.Fatalf("pagination validation unexpectedly accepted %#v", values)
|
||||
}
|
||||
}
|
||||
for _, values := range []map[string]string{
|
||||
{"time": "2026-01-01", "start": "2026-01-01"},
|
||||
{"direction": "older", "start": "2026-01-01"},
|
||||
@@ -524,7 +547,7 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test
|
||||
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
|
||||
"sender-query": "测试用户甲",
|
||||
}))
|
||||
if filter.applied || filter.failure == nil {
|
||||
if filter.applied || filter.resolutionFailure == nil {
|
||||
t.Fatalf("identity-free sender resolution = %#v", filter)
|
||||
}
|
||||
|
||||
@@ -555,6 +578,148 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesSenderScopeRejectsUnmappedIdentityFamily(t *testing.T) {
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: true,
|
||||
applied: true,
|
||||
inputs: []string{"DAAAAAAAAAAAiE"},
|
||||
inputMode: "sender",
|
||||
stableIDs: map[string]bool{"DAAAAAAAAAAAiE": true},
|
||||
hasOpenIDs: true,
|
||||
}
|
||||
payload := map[string]any{
|
||||
"complete": true,
|
||||
"failures": []map[string]any{},
|
||||
}
|
||||
filtered := applyOptionalChatMessagesSenderFilter(
|
||||
chatMessagesRuntimeForTest(t, nil),
|
||||
payload,
|
||||
[]map[string]any{{"openMessageId": "m-user-family", "senderUserId": "user-1"}},
|
||||
&filter,
|
||||
)
|
||||
if len(filtered) != 0 || filter.scopeErr == nil || payload["complete"] != false {
|
||||
t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T) {
|
||||
t.Run("direct resolution stops after first error", func(t *testing.T) {
|
||||
fake := &platformCoverageCaller{failTool: "contact/search_contact_by_key_word"}
|
||||
helpers.InitDeps(fake)
|
||||
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
|
||||
"sender": testCurrentDOpenID + ",fixture-name,ignored-name",
|
||||
}))
|
||||
if filter.applied || filter.resolutionErr == nil || len(fake.calls) != 1 {
|
||||
t.Fatalf("filter=%#v calls=%#v", filter, fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dedupes blanks and repeated values", func(t *testing.T) {
|
||||
got := uniqueChatMessageTargets([]string{"", " ", "one", "one", " two "})
|
||||
if want := []string{"one", "two"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("targets=%#v want=%#v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("synthesizes failure when none was supplied", func(t *testing.T) {
|
||||
payload := map[string]any{"failures": []map[string]any{{"stage": "existing"}}}
|
||||
filter := chatMessagesSenderFilter{requested: true, inputs: []string{"fixture"}, inputMode: "sender"}
|
||||
if got := applyOptionalChatMessagesSenderFilter(
|
||||
chatMessagesRuntimeForTest(t, nil), payload, []map[string]any{{"openMessageId": "hidden"}}, &filter,
|
||||
); got != nil {
|
||||
t.Fatalf("filtered=%#v", got)
|
||||
}
|
||||
if _, exists := payload["stopReason"]; exists {
|
||||
t.Fatalf("existing failure should retain stop reason ownership: %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects missing and unmapped identity families", func(t *testing.T) {
|
||||
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: true,
|
||||
applied: true,
|
||||
inputs: []string{"fixture"},
|
||||
inputMode: "sender",
|
||||
stableIDs: map[string]bool{"wanted-user": true},
|
||||
hasUserIDs: true,
|
||||
}
|
||||
filtered := applyOptionalChatMessagesSenderFilter(
|
||||
chatMessagesRuntimeForTest(t, nil),
|
||||
payload,
|
||||
[]map[string]any{
|
||||
{"content": "missing sender and id"},
|
||||
{"openMessageId": "wanted", "senderUserId": "wanted-user"},
|
||||
{"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID},
|
||||
},
|
||||
&filter,
|
||||
)
|
||||
if len(filtered) != 1 || filter.scopeErr == nil || chatMessagesMessageID(map[string]any{}) != "<unknown>" || chatMessagesMessageID(filtered[0]) != "wanted" {
|
||||
t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects user identity when only open ids were resolved", func(t *testing.T) {
|
||||
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: true,
|
||||
applied: true,
|
||||
inputs: []string{testCurrentDOpenID},
|
||||
inputMode: "sender",
|
||||
stableIDs: map[string]bool{testCurrentDOpenID: true},
|
||||
hasOpenIDs: true,
|
||||
}
|
||||
applyOptionalChatMessagesSenderFilter(
|
||||
chatMessagesRuntimeForTest(t, nil), payload,
|
||||
[]map[string]any{{"senderUserId": "other-user"}}, &filter,
|
||||
)
|
||||
if filter.scopeErr == nil {
|
||||
t.Fatalf("filter=%#v", filter)
|
||||
}
|
||||
})
|
||||
|
||||
if _, err := resolveChatMessagesRequest(chatMessagesRuntimeForTest(t, map[string]string{
|
||||
"open-dingtalk-id": "not-an-open-id",
|
||||
})); err == nil {
|
||||
t.Fatal("invalid explicit open ID unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesSenderFailureOutputEdges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sender string
|
||||
}{
|
||||
{name: "resolution", sender: "fixture-name"},
|
||||
{name: "scope", sender: testCurrentDOpenID},
|
||||
} {
|
||||
t.Run(tc.name+" returns domain error", func(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("sender failure unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+" prioritizes output error", func(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatMessagesFailWriter{})
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender})
|
||||
if err := root.Execute(); err == nil || err.Error() != "fixture output failure" {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T) {
|
||||
runtimeWith := func(t *testing.T, caller *chatMessagesPagingCaller, values map[string]string) *shortcut.RuntimeContext {
|
||||
t.Helper()
|
||||
@@ -613,21 +778,23 @@ func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T
|
||||
})
|
||||
|
||||
t.Run("terminal result limit and unsafe continuation", func(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-02 00:00:00"},{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
}}
|
||||
payload, _, err := collectAllChatMessages(
|
||||
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
|
||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
|
||||
)
|
||||
if err != nil || payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
for _, flag := range []string{"max-items", "max-results"} {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-02 00:00:00"},{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
}}
|
||||
payload, _, err := collectAllChatMessages(
|
||||
runtimeWith(t, caller, map[string]string{flag: "1"}),
|
||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
|
||||
)
|
||||
if err != nil || payload["truncated"] != true || payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
||||
t.Fatalf("%s payload=%#v err=%v", flag, payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
caller = &chatMessagesPagingCaller{responses: []string{
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-03 00:00:00"}]}}`,
|
||||
}}
|
||||
payload, _, err = collectAllChatMessages(
|
||||
payload, _, err := collectAllChatMessages(
|
||||
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
|
||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
|
||||
)
|
||||
@@ -650,6 +817,23 @@ func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canceled delay", func(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"m1"}]}}`,
|
||||
}}
|
||||
rt := runtimeWith(t, caller, map[string]string{"page-delay": "1"})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
rt.Command().SetContext(ctx)
|
||||
payload, _, err := collectAllChatMessages(
|
||||
rt,
|
||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
|
||||
)
|
||||
if err == nil || payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != 1 {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("first failure ledger output error", func(t *testing.T) {
|
||||
helpers.InitDeps(&chatMessagesPagingCaller{failAt: 1})
|
||||
root := newPlatformCoverageRoot()
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestCrossPlatformCoverageChatMessagesOpenIDRoute(t *testing.T) {
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1", "--yes"})
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", testCurrentDOpenID, "--limit", "1", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -84,17 +84,17 @@ var MyGroups = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "type", Type: shortcut.FlagString, Desc: "按群类型过滤(可选,如返回中的 groupType/conversationType,大小写不敏感)", Required: false},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200);--limit 必须在 1-200 之间", Default: "200"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
}, shortcut.AutoPageControlFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
}, shortcut.AutoPageControlConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +my-groups`,
|
||||
`dws chat +my-groups --type group`,
|
||||
@@ -116,6 +116,9 @@ func validateMyGroups(rt *shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
}
|
||||
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -180,10 +183,22 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
||||
hasMore := false
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
truncatedByResultLimit := false
|
||||
eligibleCount := 0
|
||||
var nextCursor any
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
params := map[string]any{"limit": baseParams["limit"]}
|
||||
if pagesFetched > 0 {
|
||||
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "delay", "cursor": cursorKey, "error": err.Error(),
|
||||
})
|
||||
stopReason = "delay_interrupted"
|
||||
break
|
||||
}
|
||||
}
|
||||
pageSize, _ := baseParams["limit"].(int)
|
||||
params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, pageSize, eligibleCount)}
|
||||
if cursorKey != "0" {
|
||||
params["cursor"] = cursorValue
|
||||
}
|
||||
@@ -200,6 +215,7 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
||||
}
|
||||
pagesFetched++
|
||||
pageGroups := myGroupsExtract(data)
|
||||
overflowOnPage := false
|
||||
for _, group := range pageGroups {
|
||||
id := myGroupsStr(group, "openConversationId", "openConversationID", "conversationId", "openCid", "cid", "id")
|
||||
if id != "" && seenGroups[id] {
|
||||
@@ -208,7 +224,15 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
||||
if id != "" {
|
||||
seenGroups[id] = true
|
||||
}
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && myGroupsMatchesFilter(rt, group) && eligibleCount >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
overflowOnPage = true
|
||||
continue
|
||||
}
|
||||
allGroups = append(allGroups, group)
|
||||
if myGroupsMatchesFilter(rt, group) {
|
||||
eligibleCount++
|
||||
}
|
||||
}
|
||||
|
||||
page := chatmsg.Pagination(data)
|
||||
@@ -222,6 +246,16 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if overflowOnPage {
|
||||
hasMore = true
|
||||
nextCursor = nil
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "我的群列表下层返回的匹配条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if !hasMore {
|
||||
complete = true
|
||||
nextCursor = nil
|
||||
@@ -241,8 +275,13 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
||||
seenCursors[nextKey] = true
|
||||
cursorKey = nextKey
|
||||
cursorValue = nextCursor
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && eligibleCount >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
stopReason = "result_limit"
|
||||
break
|
||||
}
|
||||
}
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
@@ -254,9 +293,11 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
||||
payload["hasMore"] = hasMore
|
||||
payload["stopReason"] = stopReason
|
||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||
payload["truncatedByResultLimit"] = truncatedByResultLimit
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["partial"] = len(failures) > 0 && len(allGroups) > 0
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
if hasMore && nextCursor != nil {
|
||||
payload["nextCursor"] = nextCursor
|
||||
}
|
||||
@@ -275,6 +316,15 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
||||
)
|
||||
}
|
||||
|
||||
func myGroupsMatchesFilter(rt *shortcut.RuntimeContext, group map[string]any) bool {
|
||||
typeFilter := strings.TrimSpace(rt.Str("type"))
|
||||
if typeFilter == "" {
|
||||
return true
|
||||
}
|
||||
groupType, _ := myGroupsProject(group)["type"].(string)
|
||||
return strings.EqualFold(strings.TrimSpace(groupType), typeFilter)
|
||||
}
|
||||
|
||||
func myGroupsCursorString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestCrossPlatformCoverageNaturalSearchAndSenderFlagsStayPublicOptional(t *t
|
||||
want []string
|
||||
}{
|
||||
{name: "search natural adapters", flags: SearchMsg.Flags, want: []string{"chat-query", "sender-query"}},
|
||||
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender-query"}},
|
||||
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender", "sender-query"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -160,7 +160,7 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
|
||||
"--sender-query", "测试用户甲", "--page-all",
|
||||
"--sender", "测试用户甲", "--page-all",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -201,7 +201,64 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfilteredMessages(t *testing.T) {
|
||||
func TestChatMessagesFormatValidSenderSkipsDirectoryAndFiltersStableID(t *testing.T) {
|
||||
fake := &platformCoverageCaller{
|
||||
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
|
||||
{"openMessageId":"wanted","sender":"目标展示名","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"保留"},
|
||||
{"openMessageId":"other","sender":"其他人","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"过滤"}
|
||||
]}}`,
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
|
||||
"--sender", "DAAAAAAAAAAAiE", "--page-all",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" {
|
||||
t.Fatalf("format-valid sender unexpectedly preflighted: %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["complete"] != true {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesWithoutSenderDoesNotResolveEveryMessageIdentity(t *testing.T) {
|
||||
fake := &platformCoverageCaller{chatMessagesResult: `{"result":{"hasMore":false,"messages":[
|
||||
{"openMessageId":"m1","sender":"群昵称一","senderOpenDingTalkId":"D1","content":"一"},
|
||||
{"openMessageId":"m2","sender":"群昵称二","senderOpenDingTalkId":"D2","content":"二"}
|
||||
]}}`}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid-fixture-chat-0001", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" {
|
||||
t.Fatalf("calls=%#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity := payload["identityResult"].(map[string]any)
|
||||
if payload["count"] != float64(2) || identity["status"] != "requires_query_check" ||
|
||||
identity["negativeConclusionAllowed"] != false {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureStopsWithoutUnfilteredMessages(t *testing.T) {
|
||||
fake := &platformCoverageCaller{
|
||||
contactSearchResult: `{"result":[]}`,
|
||||
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
|
||||
@@ -216,22 +273,26 @@ func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfiltered
|
||||
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
|
||||
"--sender-query", "不存在的人", "--page-all",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("optional sender failure stopped message read: %v", err)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("sender resolution failure unexpectedly succeeded")
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" ||
|
||||
fake.calls[1].tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("calls = %#v, want message read followed by non-blocking failed resolve", fake.calls)
|
||||
t.Fatalf("calls = %#v, want message read followed by failed resolve", fake.calls)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, output.String())
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["complete"] != false ||
|
||||
payload["partial"] != true || payload["failedCount"] != float64(1) ||
|
||||
if payload["count"] != float64(0) || payload["complete"] != false ||
|
||||
payload["partial"] != false || payload["failedCount"] != float64(1) ||
|
||||
payload["stopReason"] != "sender_resolution_failed" {
|
||||
t.Fatalf("unfiltered fallback payload = %#v", payload)
|
||||
t.Fatalf("fail-closed payload = %#v", payload)
|
||||
}
|
||||
messages, ok := payload["messages"].([]any)
|
||||
if !ok || len(messages) != 0 {
|
||||
t.Fatalf("failed resolution leaked unfiltered messages: %#v", payload["messages"])
|
||||
}
|
||||
if _, exists := payload["resolvedFilters"]; exists {
|
||||
t.Fatalf("failed resolution unexpectedly published resolvedFilters: %#v", payload)
|
||||
|
||||
@@ -5,6 +5,7 @@ package smart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
@@ -83,6 +84,31 @@ func TestCrossPlatformCoverageAtMePageAllContinuesAcrossEmptyIntermediatePage(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAtMeMaxItemsPublishesStableTruncation(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+at-me", "--page-all", "--max-items", "1", "--page-delay", "0"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["truncated"] != true ||
|
||||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(caller.args) != 1 || caller.args[0]["limit"] != 1 || payload["nextCursor"] != "cursor-2" {
|
||||
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", caller.args, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMerge(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"groups":[{"openConversationId":"g1","title":"群一","groupType":"group"}],"hasMore":true,"nextCursor":88}}`,
|
||||
@@ -109,12 +135,119 @@ func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMer
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMyGroupsMaxItemsAppliesAfterTypeFilter(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"groups":[{"openConversationId":"p1","groupType":"p2p"}],"hasMore":true,"nextCursor":2}}`,
|
||||
`{"result":{"groups":[{"openConversationId":"g1","groupType":"group"}],"hasMore":true,"nextCursor":3}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+my-groups", "--type", "group", "--page-all", "--max-items", "1", "--page-delay", "0"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["truncated"] != true ||
|
||||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(caller.args) != 2 || caller.args[0]["limit"] != 1 || caller.args[1]["limit"] != 1 || payload["nextCursor"] != float64(3) {
|
||||
t.Fatalf("unsafe filtered continuation: calls=%#v payload=%#v", caller.args, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRemainingListsFailClosedOnOversizeAndCanceledDelay(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
response string
|
||||
extra []string
|
||||
}{
|
||||
{
|
||||
name: "at-me",
|
||||
command: "+at-me",
|
||||
response: `{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}]}],` +
|
||||
`"hasMore":true,"nextCursor":"next"}}`,
|
||||
},
|
||||
{
|
||||
name: "my-groups",
|
||||
command: "+my-groups",
|
||||
response: `{"result":{"groups":[{"openConversationId":"g1","groupType":"group"},{"openConversationId":"g2","groupType":"group"}],"hasMore":true,"nextCursor":2}}`,
|
||||
extra: []string{"--type", "group"},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name+" oversized lower page", func(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{tc.response}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
args := append([]string{"chat", tc.command}, tc.extra...)
|
||||
args = append(args, "--page-all", "--max-items", "1")
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("oversized lower page unexpectedly published a continuation")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(caller.args) != 1 || caller.args[0]["limit"] != 1 || payload["nextCursor"] != nil {
|
||||
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", caller.args, payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+" canceled delay", func(t *testing.T) {
|
||||
continuing := tc.response
|
||||
if tc.command == "+at-me" {
|
||||
continuing = `{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"next"}}`
|
||||
} else {
|
||||
continuing = `{"result":{"groups":[{"openConversationId":"g1","groupType":"group"}],"hasMore":true,"nextCursor":2}}`
|
||||
}
|
||||
caller := &chatMessagesPagingCaller{responses: []string{continuing}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
root.SetContext(ctx)
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
args := append([]string{"chat", tc.command}, tc.extra...)
|
||||
args = append(args, "--page-all", "--page-delay", "1")
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("canceled delay unexpectedly succeeded")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRemainingReadPaginationValidation(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"chat", "+at-me", "--page-limit", "2"},
|
||||
{"chat", "+at-me", "--max-items", "1"},
|
||||
{"chat", "+at-me", "--page-delay", "1"},
|
||||
{"chat", "+at-me", "--page-all", "--page-limit", "501"},
|
||||
{"chat", "+at-me", "--page-all", "--max-items", "-1"},
|
||||
{"chat", "+my-groups", "--limit", "201"},
|
||||
{"chat", "+my-groups", "--page-limit", "2"},
|
||||
{"chat", "+my-groups", "--max-items", "1"},
|
||||
{"chat", "+my-groups", "--page-all", "--page-limit", "501"},
|
||||
} {
|
||||
helpers.InitDeps(&chatMessagesPagingCaller{})
|
||||
|
||||
@@ -35,7 +35,7 @@ import (
|
||||
// search hits through list_messages_by_ids in chunks of 50. A later-page or
|
||||
// enrichment failure never turns a partial result into a false success: the
|
||||
// output carries an explicit failure ledger and complete=false.
|
||||
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话,会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
|
||||
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话。--group/--groups 接受群名或 openConversationId,--sender/--senders 接受姓名、userId 或 openDingTalkId,并统一唯一解析为稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
|
||||
"显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" +
|
||||
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
|
||||
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"
|
||||
@@ -70,8 +70,8 @@ var SearchMsg = shortcut.Shortcut{
|
||||
UseWhen: []string{searchMsgIntent},
|
||||
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
|
||||
Examples: []string{
|
||||
"dws chat +search-msg --group \"项目群\" --sender \"测试用户甲\" --page-all",
|
||||
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
|
||||
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all --jq '.messages[] | {messageId, text}'",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -80,15 +80,15 @@ var SearchMsg = shortcut.Shortcut{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true},
|
||||
{Name: "text-query", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "单个群名或 openConversationId;自动唯一解析并校验"},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"},
|
||||
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"},
|
||||
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名唯一解析会话过滤条件(可选,可重复或逗号分隔)"},
|
||||
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"},
|
||||
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者过滤条件(可选,可重复或逗号分隔)"},
|
||||
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群名或 openConversationId;可混合输入并逐项唯一解析"},
|
||||
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名;只接受 openConversationId"},
|
||||
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "显式按群名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
|
||||
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "多个发送者姓名、userId 或 openDingTalkId;可混合输入并逐项唯一解析"},
|
||||
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;--senders 的兼容别名"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
|
||||
{Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"},
|
||||
{Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"},
|
||||
{Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"},
|
||||
@@ -140,6 +140,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
|
||||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +search-msg --group "项目群" --sender "测试用户甲" --page-all`,
|
||||
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
|
||||
`dws chat +search-msg --senders <openDingTalkId> --at-me --days 3 --page-all`,
|
||||
`dws chat +search-msg --group <openConversationId> --query "changefree" --jq '.messages[] | {messageId, text}'`,
|
||||
@@ -270,6 +271,13 @@ var SearchMsg = shortcut.Shortcut{
|
||||
}
|
||||
messages = validatedMessages
|
||||
}
|
||||
if len(resolvedFilters.Senders) > 0 {
|
||||
var unverifiableMessageIDs []string
|
||||
messages, unverifiableMessageIDs = filterSearchSenderScope(messages, resolvedFilters.Senders)
|
||||
if len(unverifiableMessageIDs) > 0 {
|
||||
return searchSenderScopeUnverifiedError(resolvedFilters.Senders, unverifiableMessageIDs)
|
||||
}
|
||||
}
|
||||
|
||||
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
|
||||
if order == "" {
|
||||
@@ -293,9 +301,19 @@ var SearchMsg = shortcut.Shortcut{
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"queryRange": searchMessageQueryRange(params, order),
|
||||
"timeCoverage": searchMessageTimeCoverage(rt),
|
||||
"conclusionGuard": searchMessageConclusionGuard(rt, complete, len(results)),
|
||||
}
|
||||
if len(resolvedFilters.Chats) > 0 || len(resolvedFilters.Senders) > 0 {
|
||||
payload["resolvedFilters"] = resolvedFilters
|
||||
}
|
||||
if len(resolvedFilters.Senders) > 0 {
|
||||
payload["resolvedFilters"] = resolvedFilters
|
||||
payload["senderScope"] = map[string]any{
|
||||
"targetsResolved": true,
|
||||
"filterApplied": true,
|
||||
"filterMode": "server_and_client",
|
||||
"resultsWithinScope": true,
|
||||
}
|
||||
}
|
||||
if scopedSearch {
|
||||
payload["scope"] = searchScopePayload(requestedConversationIDs, paginationKnown && !hasMore)
|
||||
@@ -313,6 +331,39 @@ var SearchMsg = shortcut.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func searchMessageTimeCoverage(rt *shortcut.RuntimeContext) map[string]any {
|
||||
source := "implicit_default"
|
||||
if rt.Changed("start") || rt.Changed("start-time") || rt.Changed("end") || rt.Changed("end-time") {
|
||||
source = "explicit_range"
|
||||
} else if rt.Changed("days") {
|
||||
source = "explicit_days"
|
||||
}
|
||||
|
||||
coverage := map[string]any{
|
||||
"source": source,
|
||||
"allHistory": false,
|
||||
}
|
||||
if source != "explicit_range" {
|
||||
coverage["days"] = rt.Int("days")
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
func searchMessageConclusionGuard(rt *shortcut.RuntimeContext, complete bool, resultCount int) map[string]any {
|
||||
coverage := searchMessageTimeCoverage(rt)
|
||||
guard := map[string]any{
|
||||
"absenceWithinQueryRangeAllowed": complete,
|
||||
"absenceAcrossAllHistoryAllowed": false,
|
||||
"allHistoryCountAllowed": false,
|
||||
"requiredActionForAllHistory": "widen_time_range_or_reuse_complete_message_ledger",
|
||||
}
|
||||
if coverage["source"] == "implicit_default" && resultCount == 0 {
|
||||
guard["requiredAction"] = "widen_time_range_or_reuse_complete_message_ledger"
|
||||
guard["hint"] = "当前只证明默认 7 天时间窗内没有命中,不能据此判断全部历史没有。若已有完整会话消息,请解析稳定身份后复用现有消息;否则扩大时间范围。"
|
||||
}
|
||||
return guard
|
||||
}
|
||||
|
||||
func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateSearchMsg(rt); err != nil {
|
||||
return err
|
||||
@@ -350,6 +401,39 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
|
||||
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 40 {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-40 之间")
|
||||
}
|
||||
if err := validateSearchMsgStrictConversationAliases(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The conventional --group/--groups flags are intentionally dual-form. Only
|
||||
// the explicit ID aliases remain strict so scripts that select them retain a
|
||||
// deterministic contract.
|
||||
func validateSearchMsgStrictConversationAliases(rt *shortcut.RuntimeContext) error {
|
||||
values := append([]string{}, rt.StrSlice("chat-id")...)
|
||||
if value := rt.StrFirst("conversation-id", "id"); value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
for _, value := range uniqueSearchStrings(values) {
|
||||
if strings.HasPrefix(strings.ToLower(value), "cid") {
|
||||
continue
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("显式会话 ID 参数收到非 openConversationId 值 %q;已停止消息搜索", value),
|
||||
apperrors.WithReason("target_type_mismatch"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("request_validation"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint(fmt.Sprintf("--chat-id/--conversation-id/--id 只接受 cid 开头的 openConversationId;群名请使用 --group %q 或 --groups %q。", value, value)),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"flags": []string{"chat-id", "conversation-id", "id"},
|
||||
"expectedType": "openConversationId",
|
||||
"providedValue": value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -358,6 +442,7 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
|
||||
// the directory name, so consumers must join the selected stable identity to
|
||||
// each projected message's senderId instead of comparing those two names.
|
||||
type searchResolvedFilters struct {
|
||||
Chats []targetresolver.ChatResolution `json:"chats,omitempty"`
|
||||
Senders []targetresolver.UserResolution `json:"senders,omitempty"`
|
||||
}
|
||||
|
||||
@@ -367,41 +452,61 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve
|
||||
if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" {
|
||||
params["keyword"] = value
|
||||
}
|
||||
conversationIDs := append([]string{}, rt.StrSlice("groups")...)
|
||||
conversationIDs = append(conversationIDs, rt.StrSlice("chat-id")...)
|
||||
if value := rt.StrFirst("group", "conversation-id", "id"); value != "" {
|
||||
conversationIDs := append([]string{}, rt.StrSlice("chat-id")...)
|
||||
if value := rt.StrFirst("conversation-id", "id"); value != "" {
|
||||
conversationIDs = append(conversationIDs, value)
|
||||
}
|
||||
groupTargets := append([]string{}, rt.StrSlice("groups")...)
|
||||
if value := rt.Str("group"); value != "" {
|
||||
groupTargets = append(groupTargets, value)
|
||||
}
|
||||
for _, target := range uniqueSearchStrings(groupTargets) {
|
||||
if strings.HasPrefix(strings.ToLower(target), "cid") {
|
||||
conversationIDs = append(conversationIDs, target)
|
||||
continue
|
||||
}
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, target, "")
|
||||
if err != nil {
|
||||
return nil, searchResolvedFilters{}, err
|
||||
}
|
||||
resolvedFilters.Chats = append(resolvedFilters.Chats, resolved)
|
||||
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
|
||||
}
|
||||
if queries := rt.StrSlice("chat-query"); len(queries) > 0 {
|
||||
for _, query := range queries {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, "", query)
|
||||
if err != nil {
|
||||
return nil, searchResolvedFilters{}, err
|
||||
}
|
||||
resolvedFilters.Chats = append(resolvedFilters.Chats, resolved)
|
||||
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
|
||||
}
|
||||
}
|
||||
if values := uniqueSearchStrings(conversationIDs); len(values) > 0 {
|
||||
params["openConversationIds"] = values
|
||||
}
|
||||
senders := append([]string{}, rt.StrSlice("senders")...)
|
||||
senders = append(senders, rt.StrSlice("sender")...)
|
||||
senderTargets := append([]string{}, rt.StrSlice("senders")...)
|
||||
senderTargets = append(senderTargets, rt.StrSlice("sender")...)
|
||||
for _, target := range uniqueSearchStrings(senderTargets) {
|
||||
resolved, err := targetresolver.ResolveSenderTarget(rt, target, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
return nil, searchResolvedFilters{}, err
|
||||
}
|
||||
resolvedFilters.Senders = append(resolvedFilters.Senders, resolved)
|
||||
}
|
||||
if queries := rt.StrSlice("sender-query"); len(queries) > 0 {
|
||||
resolvedUsers, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
return nil, searchResolvedFilters{}, err
|
||||
}
|
||||
resolvedFilters.Senders = append(resolvedFilters.Senders, resolvedUsers...)
|
||||
for _, resolved := range resolvedUsers {
|
||||
identity := resolved.Selected.OpenDingTalkID
|
||||
if identity == "" {
|
||||
identity = resolved.Selected.UserID
|
||||
}
|
||||
senders = append(senders, identity)
|
||||
}
|
||||
}
|
||||
appendSearchActorIDs(params, senders, "senderUserIds", "senderOpenDingTakIds")
|
||||
appendSearchActorIDs(params, rt.StrSlice("at-ids"), "atUserIds", "atOpenDingTakIds")
|
||||
appendResolvedSearchActorIDs(params, resolvedFilters.Senders, "senderUserIds", "senderOpenDingTakIds")
|
||||
atUsers, err := resolveSearchStableActorTargets(rt, rt.StrSlice("at-ids"))
|
||||
if err != nil {
|
||||
return nil, searchResolvedFilters{}, err
|
||||
}
|
||||
appendResolvedSearchActorIDs(params, atUsers, "atUserIds", "atOpenDingTakIds")
|
||||
if rt.Bool("at-me") || rt.Bool("is-at-me") {
|
||||
params["atMe"] = true
|
||||
}
|
||||
@@ -439,15 +544,41 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve
|
||||
return params, resolvedFilters, nil
|
||||
}
|
||||
|
||||
func appendSearchActorIDs(params map[string]any, values []string, userKey, openIDKey string) {
|
||||
var userIDs, openIDs []string
|
||||
func resolveSearchStableActorTargets(
|
||||
rt *shortcut.RuntimeContext,
|
||||
values []string,
|
||||
) ([]targetresolver.UserResolution, error) {
|
||||
resolvedUsers := make([]targetresolver.UserResolution, 0, len(values))
|
||||
for _, value := range uniqueSearchStrings(values) {
|
||||
if len(value) > 0 && (value[0] == 'D' || value[0] == 'd') {
|
||||
openIDs = append(openIDs, value)
|
||||
} else {
|
||||
userIDs = append(userIDs, value)
|
||||
resolved, err := targetresolver.ResolveStableUserTarget(rt, value, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedUsers = append(resolvedUsers, resolved)
|
||||
}
|
||||
return resolvedUsers, nil
|
||||
}
|
||||
|
||||
// appendResolvedSearchActorIDs prefers openDingTalkId whenever directory
|
||||
// resolution supplies it because the current message-search backend reliably
|
||||
// applies the open-ID field but may ignore senderUserIds.
|
||||
func appendResolvedSearchActorIDs(
|
||||
params map[string]any,
|
||||
resolutions []targetresolver.UserResolution,
|
||||
userKey, openIDKey string,
|
||||
) {
|
||||
var userIDs, openIDs []string
|
||||
for _, resolved := range resolutions {
|
||||
openID := strings.TrimSpace(resolved.Selected.OpenDingTalkID)
|
||||
userID := strings.TrimSpace(resolved.Selected.UserID)
|
||||
if openID != "" {
|
||||
openIDs = append(openIDs, openID)
|
||||
} else if userID != "" {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
userIDs = uniqueSearchStrings(userIDs)
|
||||
openIDs = uniqueSearchStrings(openIDs)
|
||||
if len(userIDs) > 0 {
|
||||
params[userKey] = userIDs
|
||||
}
|
||||
@@ -527,6 +658,79 @@ func searchScopePayload(conversationIDs []string, sourceComplete bool) map[strin
|
||||
}
|
||||
}
|
||||
|
||||
func filterSearchSenderScope(
|
||||
messages []map[string]any,
|
||||
resolutions []targetresolver.UserResolution,
|
||||
) ([]map[string]any, []string) {
|
||||
allowed := map[string]bool{}
|
||||
hasUserIDs := false
|
||||
hasOpenIDs := false
|
||||
for _, resolution := range resolutions {
|
||||
if value := strings.TrimSpace(resolution.Selected.UserID); value != "" {
|
||||
allowed[value] = true
|
||||
hasUserIDs = true
|
||||
}
|
||||
if value := strings.TrimSpace(resolution.Selected.OpenDingTalkID); value != "" {
|
||||
allowed[value] = true
|
||||
hasOpenIDs = true
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(messages))
|
||||
unverifiable := make([]string, 0)
|
||||
for _, message := range messages {
|
||||
senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message)))
|
||||
if senderID == "" || senderID == "<nil>" {
|
||||
messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
|
||||
if messageID == "" || messageID == "<nil>" {
|
||||
messageID = "<unknown>"
|
||||
}
|
||||
unverifiable = append(unverifiable, messageID)
|
||||
continue
|
||||
}
|
||||
if allowed[senderID] {
|
||||
filtered = append(filtered, message)
|
||||
continue
|
||||
}
|
||||
if targetresolver.LooksLikeCurrentDOpenDingTalkID(senderID) {
|
||||
if !hasOpenIDs {
|
||||
unverifiable = append(unverifiable, searchScopeMessageID(message))
|
||||
}
|
||||
} else if !hasUserIDs {
|
||||
unverifiable = append(unverifiable, searchScopeMessageID(message))
|
||||
}
|
||||
}
|
||||
return filtered, uniqueSearchStrings(unverifiable)
|
||||
}
|
||||
|
||||
func searchScopeMessageID(message map[string]any) string {
|
||||
messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
|
||||
if messageID == "" || messageID == "<nil>" {
|
||||
return "<unknown>"
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
func searchSenderScopeUnverifiedError(
|
||||
resolutions []targetresolver.UserResolution,
|
||||
messageIDs []string,
|
||||
) error {
|
||||
queries := make([]string, 0, len(resolutions))
|
||||
for _, resolution := range resolutions {
|
||||
queries = append(queries, resolution.Query)
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
"搜索结果缺少 senderId,无法证明发送者过滤范围;已停止输出",
|
||||
apperrors.WithReason("search_sender_scope_unverified"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"requestedSenders": uniqueSearchStrings(queries),
|
||||
"unverifiableMessageIds": messageIDs,
|
||||
}),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回稳定 senderId"),
|
||||
)
|
||||
}
|
||||
|
||||
func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) {
|
||||
detailsByID := map[string]map[string]any{}
|
||||
failures := make([]map[string]any, 0)
|
||||
|
||||
@@ -15,25 +15,35 @@ import (
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type searchMsgExecutionCaller struct {
|
||||
calls []platformCoverageCall
|
||||
failSecondPage bool
|
||||
failEnrichment bool
|
||||
omitPagination bool
|
||||
omitMgetItem bool
|
||||
failPreflight bool
|
||||
preflightError error
|
||||
searchResponse string
|
||||
wrongMgetScope bool
|
||||
missingMgetCID bool
|
||||
firstResponse string
|
||||
mgetResponse string
|
||||
numericZeroEnd bool
|
||||
calls []platformCoverageCall
|
||||
failSecondPage bool
|
||||
failEnrichment bool
|
||||
omitPagination bool
|
||||
omitMgetItem bool
|
||||
failPreflight bool
|
||||
preflightError error
|
||||
searchResponse string
|
||||
wrongMgetScope bool
|
||||
missingMgetCID bool
|
||||
firstResponse string
|
||||
mgetResponse string
|
||||
numericZeroEnd bool
|
||||
contactResponse string
|
||||
groupResponse string
|
||||
failContactKeyword string
|
||||
failGroupKeyword string
|
||||
}
|
||||
|
||||
const (
|
||||
testCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
testCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
)
|
||||
|
||||
func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args})
|
||||
if product == "chat" && tool == "get_conversation_info" {
|
||||
@@ -45,10 +55,28 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
|
||||
}
|
||||
return searchMsgToolResult(`{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`), nil
|
||||
}
|
||||
if product == "contact" && tool == "search_contact_by_key_word" {
|
||||
if f.failContactKeyword != "" && args["keyword"] == f.failContactKeyword {
|
||||
return nil, errors.New("fixture contact failure")
|
||||
}
|
||||
if f.contactResponse != "" {
|
||||
return searchMsgToolResult(f.contactResponse), nil
|
||||
}
|
||||
keyword := args["keyword"].(string)
|
||||
return searchMsgToolResult(`{"result":[{"userId":"user-` + keyword + `","openDingTalkId":"` + testCurrentDOpenID + `","name":"` + keyword + `"}],"hasMore":false}`), nil
|
||||
}
|
||||
if product != "im" {
|
||||
return nil, errors.New("unexpected product")
|
||||
}
|
||||
switch tool {
|
||||
case "search_groups":
|
||||
if f.failGroupKeyword != "" && args["keyword"] == f.failGroupKeyword {
|
||||
return nil, errors.New("fixture group failure")
|
||||
}
|
||||
if f.groupResponse != "" {
|
||||
return searchMsgToolResult(f.groupResponse), nil
|
||||
}
|
||||
return searchMsgToolResult(`{"result":[{"openConversationId":"cid-resolved-group","title":"` + args["keyword"].(string) + `"}],"hasMore":false}`), nil
|
||||
case "search_messages":
|
||||
if f.searchResponse != "" {
|
||||
return searchMsgToolResult(f.searchResponse), nil
|
||||
@@ -63,12 +91,12 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
|
||||
if f.failSecondPage {
|
||||
return nil, errors.New("second page unavailable")
|
||||
}
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","content":"sparse-2"}],"hasMore":false}}`), nil
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-2"}],"hasMore":false}}`), nil
|
||||
}
|
||||
if f.firstResponse != "" {
|
||||
return searchMsgToolResult(f.firstResponse), nil
|
||||
}
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
|
||||
case "list_messages_by_ids":
|
||||
if f.failEnrichment {
|
||||
return nil, errors.New("mget unavailable")
|
||||
@@ -129,13 +157,175 @@ func executeSearchMsgResult(caller *searchMsgExecutionCaller, args ...string) (m
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func TestSearchMsgMixedSenderClassifiesFormatWithoutIDPreflight(t *testing.T) {
|
||||
tests := []string{"D-prefix-fixture-user", "d-prefix-fixture-user", "测试用户甲", "fixture-user-id"}
|
||||
for _, target := range tests {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
|
||||
payload := executeSearchMsg(t, caller, "--sender", target, "--no-enrich")
|
||||
if payload["complete"] != true || len(caller.calls) != 2 {
|
||||
t.Fatalf("payload=%#v calls=%#v", payload, caller.calls)
|
||||
}
|
||||
if caller.calls[0].product != "contact" || caller.calls[0].tool != "search_contact_by_key_word" ||
|
||||
caller.calls[1].tool != "search_messages" {
|
||||
t.Fatalf("calls=%#v", caller.calls)
|
||||
}
|
||||
if got, want := caller.calls[1].args["senderOpenDingTakIds"], []string{testCurrentDOpenID}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("senderOpenDingTakIds=%#v want=%#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
|
||||
executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "search_messages" {
|
||||
t.Fatalf("format-valid ID unexpectedly preflighted: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgSenderScopeFiltersBackendOverReturn(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[
|
||||
{"openMessageId":"wanted","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"keep"},
|
||||
{"openMessageId":"other","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"drop"}
|
||||
],"hasMore":false}}`}
|
||||
payload := executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
|
||||
if payload["count"] != float64(1) {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
messages := payload["messages"].([]any)
|
||||
if messages[0].(map[string]any)["messageId"] != "wanted" {
|
||||
t.Fatalf("messages=%#v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgSenderScopeRejectsUnmappedIdentityFamily(t *testing.T) {
|
||||
filtered, unverifiable := filterSearchSenderScope(
|
||||
[]map[string]any{{"openMessageId": "m-user-family", "senderUserId": "user-1"}},
|
||||
[]targetresolver.UserResolution{{
|
||||
Selected: targetresolver.User{OpenDingTalkID: testCurrentDOpenID},
|
||||
}},
|
||||
)
|
||||
if len(filtered) != 0 || !reflect.DeepEqual(unverifiable, []string{"m-user-family"}) {
|
||||
t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgResolutionFailureEdges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
caller *searchMsgExecutionCaller
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "group target",
|
||||
caller: &searchMsgExecutionCaller{failGroupKeyword: "missing-group"},
|
||||
args: []string{"--group", "missing-group", "--no-enrich"},
|
||||
},
|
||||
{
|
||||
name: "sender target",
|
||||
caller: &searchMsgExecutionCaller{failContactKeyword: "missing-sender"},
|
||||
args: []string{"--sender", "missing-sender", "--no-enrich"},
|
||||
},
|
||||
{
|
||||
name: "sender query",
|
||||
caller: &searchMsgExecutionCaller{failContactKeyword: "missing-query"},
|
||||
args: []string{"--sender-query", "missing-query", "--no-enrich"},
|
||||
},
|
||||
{
|
||||
name: "at stable id",
|
||||
caller: &searchMsgExecutionCaller{failContactKeyword: "missing-at"},
|
||||
args: []string{"--at-ids", "missing-at", "--no-enrich"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := executeSearchMsgResult(tc.caller, tc.args...); err == nil {
|
||||
t.Fatal("resolution failure unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgSenderScopeFailureEdges(t *testing.T) {
|
||||
t.Run("execute rejects senderless backend rows", func(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[{"content":"missing identity"}],"hasMore":false}}`}
|
||||
_, err := executeSearchMsgResult(caller, "--sender", testCurrentDOpenID, "--no-enrich")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" {
|
||||
t.Fatalf("error=%#v", err)
|
||||
}
|
||||
})
|
||||
|
||||
resolutions := []targetresolver.UserResolution{{
|
||||
Query: "fixture-user",
|
||||
Selected: targetresolver.User{UserID: "wanted-user"},
|
||||
}}
|
||||
filtered, unverifiable := filterSearchSenderScope(
|
||||
[]map[string]any{
|
||||
{"content": "missing identity"},
|
||||
{"openMessageId": "wanted", "senderUserId": "wanted-user"},
|
||||
{"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID},
|
||||
},
|
||||
resolutions,
|
||||
)
|
||||
if len(filtered) != 1 || !reflect.DeepEqual(unverifiable, []string{"<unknown>", "open-family"}) {
|
||||
t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable)
|
||||
}
|
||||
if got := searchScopeMessageID(map[string]any{}); got != "<unknown>" {
|
||||
t.Fatalf("missing message ID=%q", got)
|
||||
}
|
||||
if got := searchScopeMessageID(map[string]any{"openMessageId": "message-1"}); got != "message-1" {
|
||||
t.Fatalf("message ID=%q", got)
|
||||
}
|
||||
err := searchSenderScopeUnverifiedError(
|
||||
append(resolutions, resolutions...),
|
||||
[]string{"message-1"},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" {
|
||||
t.Fatalf("error=%#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgExplicitDaysMetadata(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
|
||||
payload := executeSearchMsg(t, caller, "--query", "fixture", "--days", "3", "--no-enrich")
|
||||
coverage := payload["timeCoverage"].(map[string]any)
|
||||
if coverage["source"] != "explicit_days" || coverage["days"] != float64(3) {
|
||||
t.Fatalf("coverage=%#v", coverage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgRejectsNonConversationIDAlias(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{}
|
||||
_, err := executeSearchMsgResult(caller, "--query", "fixture", "--chat-id", "group-name", "--no-enrich")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
|
||||
t.Fatalf("error=%#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgGroupNameAndDefaultWindowMetadata(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
|
||||
payload := executeSearchMsg(t, caller, "--group", "项目讨论群", "--no-enrich")
|
||||
if len(caller.calls) != 3 || caller.calls[0].tool != "search_groups" ||
|
||||
caller.calls[1].tool != "get_conversation_info" || caller.calls[2].tool != "search_messages" {
|
||||
t.Fatalf("calls=%#v", caller.calls)
|
||||
}
|
||||
coverage := payload["timeCoverage"].(map[string]any)
|
||||
guard := payload["conclusionGuard"].(map[string]any)
|
||||
if coverage["source"] != "implicit_default" || coverage["days"] != float64(7) ||
|
||||
guard["absenceAcrossAllHistoryAllowed"] != false {
|
||||
t.Fatalf("coverage=%#v guard=%#v", coverage, guard)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{}
|
||||
payload := executeSearchMsg(t, caller,
|
||||
"--query", "周报",
|
||||
"--chat-id", "cid-1,cid-2",
|
||||
"--sender", "42,Dsender",
|
||||
"--at-ids", "43,Dat",
|
||||
"--sender", testCurrentDOpenID,
|
||||
"--at-ids", testCurrentDOpenID2,
|
||||
"--is-at-me",
|
||||
"--message-type", "text",
|
||||
"--only-robot",
|
||||
@@ -160,10 +350,8 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te
|
||||
t.Fatalf("first call = %#v", first)
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
"senderUserIds": []string{"42"},
|
||||
"senderOpenDingTakIds": []string{"Dsender"},
|
||||
"atUserIds": []string{"43"},
|
||||
"atOpenDingTakIds": []string{"Dat"},
|
||||
"senderOpenDingTakIds": []string{testCurrentDOpenID},
|
||||
"atOpenDingTakIds": []string{testCurrentDOpenID2},
|
||||
"messageType": "text",
|
||||
"onlyRobotMessages": true,
|
||||
"searchConvType": "group",
|
||||
|
||||
@@ -463,6 +463,7 @@ func collectAllThreadReplies(rt *shortcut.RuntimeContext, params map[string]any)
|
||||
payload["hasMore"] = hasMore
|
||||
payload["stopReason"] = stopReason
|
||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["partial"] = len(failures) > 0 && len(allItems) > 0
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
package targetresolver
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -105,6 +106,274 @@ func ResolveUser(rt Reader, query string, requirement IdentityRequirement) (User
|
||||
return resolveUserCandidates(ExtractUsers(data), query, requirement)
|
||||
}
|
||||
|
||||
// ResolveUserTarget accepts the mixed user target used by public convenience
|
||||
// flags: a natural name, userId, or current-version openDingTalkId. The local
|
||||
// format check is deliberately a classifier rather than an existence check.
|
||||
// Format-valid IDs go directly to the downstream business API; all other
|
||||
// values use the directory resolver, which can match names and exact userIds.
|
||||
func ResolveUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return UserResolution{}, apperrors.NewValidation(
|
||||
"必须指定用户姓名、userId 或 openDingTalkId",
|
||||
apperrors.WithReason("missing_target"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("request_validation"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
)
|
||||
}
|
||||
if !LooksLikeCurrentDOpenDingTalkID(value) {
|
||||
return ResolveUser(rt, value, requirement)
|
||||
}
|
||||
if requirement == IdentityUserID {
|
||||
return UserResolution{}, apperrors.NewValidation(
|
||||
fmt.Sprintf("用户目标参数类型不匹配:%q 符合当前版本 openDingTalkId 格式,但下游只接受 userId", value),
|
||||
apperrors.WithReason("target_type_mismatch"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
)
|
||||
}
|
||||
return UserResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: value,
|
||||
MatchType: "stable_id",
|
||||
Selected: User{OpenDingTalkID: value},
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveSenderTarget resolves the mixed identity accepted specifically by
|
||||
// sender convenience flags. Unlike the shared name resolver, it may select a
|
||||
// unique candidate whose stable userId/openDingTalkId exactly equals the
|
||||
// supplied value. Natural-name candidates retain the shared fail-closed
|
||||
// ambiguity behavior.
|
||||
func ResolveSenderTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || LooksLikeCurrentDOpenDingTalkID(value) {
|
||||
return ResolveUserTarget(rt, value, requirement)
|
||||
}
|
||||
|
||||
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
||||
"keyword": value,
|
||||
})
|
||||
if err != nil {
|
||||
return UserResolution{}, err
|
||||
}
|
||||
users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement)
|
||||
stableMatches := make([]User, 0, 1)
|
||||
for _, user := range users {
|
||||
if strings.TrimSpace(user.UserID) == value ||
|
||||
strings.TrimSpace(user.OpenDingTalkID) == value {
|
||||
stableMatches = append(stableMatches, user)
|
||||
}
|
||||
}
|
||||
if len(stableMatches) == 1 {
|
||||
return UserResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: value,
|
||||
MatchType: "stable_id",
|
||||
Selected: stableMatches[0],
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
if len(stableMatches) > 1 {
|
||||
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches)
|
||||
}
|
||||
if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" {
|
||||
return UserResolution{}, newIncompleteUserResolutionError(value, users, cause)
|
||||
}
|
||||
return resolveUserCandidates(users, value, requirement)
|
||||
}
|
||||
|
||||
// ResolveStableUserTarget accepts only an exact userId or current-version
|
||||
// openDingTalkId. It is intended for flags whose contract explicitly excludes
|
||||
// natural names; no unique-name fallback is allowed.
|
||||
func ResolveStableUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return UserResolution{}, apperrors.NewValidation(
|
||||
"必须指定 userId 或 openDingTalkId",
|
||||
apperrors.WithReason("missing_target"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("request_validation"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
)
|
||||
}
|
||||
if LooksLikeCurrentDOpenDingTalkID(value) {
|
||||
if requirement == IdentityUserID {
|
||||
return UserResolution{}, apperrors.NewValidation(
|
||||
fmt.Sprintf("用户目标参数类型不匹配:%q 是 openDingTalkId,但当前参数只接受 userId", value),
|
||||
apperrors.WithReason("target_type_mismatch"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
)
|
||||
}
|
||||
return UserResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: value,
|
||||
MatchType: "stable_id",
|
||||
Selected: User{OpenDingTalkID: value},
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
||||
"keyword": value,
|
||||
})
|
||||
if err != nil {
|
||||
return UserResolution{}, err
|
||||
}
|
||||
users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement)
|
||||
stableMatches := make([]User, 0, 1)
|
||||
for _, user := range users {
|
||||
if strings.TrimSpace(user.UserID) == value ||
|
||||
strings.TrimSpace(user.OpenDingTalkID) == value {
|
||||
stableMatches = append(stableMatches, user)
|
||||
}
|
||||
}
|
||||
if len(stableMatches) == 1 {
|
||||
return UserResolution{
|
||||
Status: StatusResolved,
|
||||
EntityType: "user",
|
||||
Query: value,
|
||||
MatchType: "stable_id",
|
||||
Selected: stableMatches[0],
|
||||
Profile: profilectx.Get(),
|
||||
}, nil
|
||||
}
|
||||
if len(stableMatches) > 1 {
|
||||
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches)
|
||||
}
|
||||
if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" {
|
||||
return UserResolution{}, newIncompleteUserResolutionError(value, users, cause)
|
||||
}
|
||||
return UserResolution{}, apperrors.NewValidation(
|
||||
fmt.Sprintf("%q 未精确匹配到 userId 或 openDingTalkId", value),
|
||||
apperrors.WithReason("stable_user_identity_required"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("请先解析目标人员,并传入通讯录结果中原样返回的 userId 或 openDingTalkId"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"providedValue": value,
|
||||
"expectedTypes": []string{"userId", "openDingTalkId"},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// LooksLikeCurrentDOpenDingTalkID reports whether value has the canonical
|
||||
// wire format emitted by the current D-version encoder. It does not decrypt
|
||||
// the value and therefore does not prove that the identity exists or is
|
||||
// accessible to the current profile.
|
||||
func LooksLikeCurrentDOpenDingTalkID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 2 || value[0] != 'D' {
|
||||
return false
|
||||
}
|
||||
|
||||
encoded, ok := unescapeCurrentDOpenDingTalkID(value[1:])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.Strict().DecodeString(encoded)
|
||||
if err != nil || len(ciphertext) == 0 || len(ciphertext)%8 != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Require the canonical Base64 and escape spelling. This rejects alternate
|
||||
// but decodable strings and keeps the classifier aligned with the encoder.
|
||||
canonical := escapeCurrentDOpenDingTalkID(base64.StdEncoding.EncodeToString(ciphertext))
|
||||
return canonical == value[1:]
|
||||
}
|
||||
|
||||
func unescapeCurrentDOpenDingTalkID(value string) (string, bool) {
|
||||
var decoded strings.Builder
|
||||
decoded.Grow(len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
current := value[i]
|
||||
if current != 'i' {
|
||||
if !isASCIIAlphaNumeric(current) {
|
||||
return "", false
|
||||
}
|
||||
decoded.WriteByte(current)
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(value) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
switch value[i] {
|
||||
case 'i':
|
||||
decoded.WriteByte('i')
|
||||
case 'P':
|
||||
decoded.WriteByte('+')
|
||||
case 'S':
|
||||
decoded.WriteByte('/')
|
||||
case 'E':
|
||||
decoded.WriteByte('=')
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return decoded.String(), true
|
||||
}
|
||||
|
||||
func escapeCurrentDOpenDingTalkID(value string) string {
|
||||
var encoded strings.Builder
|
||||
encoded.Grow(len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
switch value[i] {
|
||||
case 'i':
|
||||
encoded.WriteString("ii")
|
||||
case '+':
|
||||
encoded.WriteString("iP")
|
||||
case '/':
|
||||
encoded.WriteString("iS")
|
||||
case '=':
|
||||
encoded.WriteString("iE")
|
||||
default:
|
||||
encoded.WriteByte(value[i])
|
||||
}
|
||||
}
|
||||
return encoded.String()
|
||||
}
|
||||
|
||||
func isASCIIAlphaNumeric(value byte) bool {
|
||||
return value >= '0' && value <= '9' ||
|
||||
value >= 'A' && value <= 'Z' ||
|
||||
value >= 'a' && value <= 'z'
|
||||
}
|
||||
|
||||
// ValidateExplicitOpenDingTalkID applies the current-version format contract
|
||||
// to an explicitly typed ID flag. Unlike mixed targets, an invalid explicit ID
|
||||
// must fail locally instead of falling back to a name or userId lookup.
|
||||
func ValidateExplicitOpenDingTalkID(flagName, value string) error {
|
||||
if value == strings.TrimSpace(value) && LooksLikeCurrentDOpenDingTalkID(value) {
|
||||
return nil
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("%s 收到的值不符合当前 D 版本 openDingTalkId 格式", flagName),
|
||||
apperrors.WithReason("target_type_mismatch"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("request_validation"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("请传通讯录或消息结果中原样返回的 openDingTalkId;姓名或 userId 请使用对应的混合目标参数"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"flag": flagName,
|
||||
"expectedType": "current_d_openDingTalkId",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ResolveEnterpriseUser resolves an organization member through the same
|
||||
// name-calibrated enterprise search used by `dws aisearch person`. Unlike the
|
||||
// contact search, this source understands organization nicknames and aliases.
|
||||
|
||||
@@ -686,3 +686,278 @@ func TestCrossPlatformCoverageResolverCompletionBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLooksLikeCurrentDOpenDingTalkID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{name: "canonical current D ciphertext", value: "DAAAAAAAAAAAiE", want: true},
|
||||
{name: "synthetic current D ciphertext with escaped plus", value: "DiPiPiPiPiPiPiPiPiPiP8iE", want: true},
|
||||
{name: "synthetic current D ciphertext with escaped literal i", value: "DYmJiiYmJiiYmIiE", want: true},
|
||||
{name: "surrounding whitespace", value: " DAAAAAAAAAAAiE ", want: true},
|
||||
{name: "empty", value: "", want: false},
|
||||
{name: "prefix only", value: "D", want: false},
|
||||
{name: "lowercase prefix", value: "dAAAAAAAAAAAiE", want: false},
|
||||
{name: "natural uppercase D fixture", value: "D-prefix-fixture-user", want: false},
|
||||
{name: "natural lowercase d fixture", value: "d-prefix-fixture-user", want: false},
|
||||
{name: "invalid escape", value: "DAAAAAiDAAAAAiE", want: false},
|
||||
{name: "non alphanumeric body", value: "DAAAAA/AAAAAiE", want: false},
|
||||
{name: "invalid base64", value: "Dabc", want: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := LooksLikeCurrentDOpenDingTalkID(tc.value); got != tc.want {
|
||||
t.Fatalf("LooksLikeCurrentDOpenDingTalkID(%q) = %v, want %v", tc.value, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUserTargetUsesFormatClassificationWithoutIDPreflight(t *testing.T) {
|
||||
const openID = "DAAAAAAAAAAAiE"
|
||||
calls := 0
|
||||
resolved, err := ResolveUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
calls++
|
||||
return nil, stderrors.New("unexpected remote call")
|
||||
}), openID, IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.OpenDingTalkID != openID {
|
||||
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveSenderTargetScopesStableIDPreferenceToSenderInputs(t *testing.T) {
|
||||
calls := 0
|
||||
reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
calls++
|
||||
if product != "contact" || tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("unexpected tool %s/%s", product, tool)
|
||||
}
|
||||
query := fmt.Sprint(params["keyword"])
|
||||
switch query {
|
||||
case "D-prefix-fixture-user":
|
||||
return map[string]any{"result": []any{map[string]any{
|
||||
"userId": "fixture-user-d-upper", "openDingTalkId": "D-directory-value", "name": "D-prefix-fixture-user",
|
||||
}}}, nil
|
||||
case "d-prefix-fixture-user":
|
||||
return map[string]any{"result": []any{map[string]any{
|
||||
"userId": "fixture-user-d-lower", "openDingTalkId": "D-directory-value-2", "name": "d-prefix-fixture-user",
|
||||
}}}, nil
|
||||
case "fixture-user-id":
|
||||
return map[string]any{"result": []any{
|
||||
map[string]any{"userId": "other", "name": "相似候选"},
|
||||
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "D-directory-value-3", "name": "测试目标用户"},
|
||||
}}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected query %q", query)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
for _, query := range []string{"D-prefix-fixture-user", "d-prefix-fixture-user"} {
|
||||
_, err := ResolveSenderTarget(reader, query, IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", query, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := ResolveUser(reader, "fixture-user-id", IdentityAny)
|
||||
var ambiguous *apperrors.Error
|
||||
if !stderrors.As(err, &ambiguous) || ambiguous.Reason != "resolution_ambiguous" {
|
||||
t.Fatalf("shared name resolver error = %#v, want resolution_ambiguous", err)
|
||||
}
|
||||
|
||||
resolved, err := ResolveSenderTarget(reader, "fixture-user-id", IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
|
||||
t.Fatalf("exact sender userId resolution = %#v", resolved)
|
||||
}
|
||||
if calls != 4 {
|
||||
t.Fatalf("calls=%d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExplicitOpenDingTalkIDNeverFallsBack(t *testing.T) {
|
||||
if err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "DAAAAAAAAAAAiE"); err != nil {
|
||||
t.Fatalf("valid format: %v", err)
|
||||
}
|
||||
err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "D-prefix-fixture-user")
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
|
||||
t.Fatalf("error=%#v", err)
|
||||
}
|
||||
err = ValidateExplicitOpenDingTalkID("--open-dingtalk-id", " DAAAAAAAAAAAiE ")
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
|
||||
t.Fatalf("whitespace-wrapped explicit ID error=%#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveStableUserTargetRejectsNaturalNameButAcceptsExactUserID(t *testing.T) {
|
||||
reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
if product != "contact" || tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("unexpected tool %s/%s", product, tool)
|
||||
}
|
||||
return map[string]any{"result": []any{
|
||||
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "DAAAAAAAAAAAiE", "name": "测试用户甲"},
|
||||
}}, nil
|
||||
})
|
||||
|
||||
resolved, err := ResolveStableUserTarget(reader, "fixture-user-id", IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
|
||||
t.Fatalf("exact userId resolution = %#v", resolved)
|
||||
}
|
||||
|
||||
_, err = ResolveStableUserTarget(reader, "测试用户甲", IdentityAny)
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "stable_user_identity_required" {
|
||||
t.Fatalf("natural name error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStableUserTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) {
|
||||
const openID = "DAAAAAAAAAAAiE"
|
||||
calls := 0
|
||||
resolved, err := ResolveStableUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
calls++
|
||||
return nil, stderrors.New("unexpected remote call")
|
||||
}), openID, IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 || resolved.Selected.OpenDingTalkID != openID {
|
||||
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableIdentityExactMatchWinsEvenWhenDirectoryPageIsIncomplete(t *testing.T) {
|
||||
reader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"result": []any{
|
||||
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "DAAAAAAAAAAAiE", "name": "测试目标用户"},
|
||||
map[string]any{"userId": "other", "name": "其他候选"},
|
||||
},
|
||||
"hasMore": true,
|
||||
}, nil
|
||||
})
|
||||
for _, resolve := range []struct {
|
||||
name string
|
||||
fn func(Reader, string, IdentityRequirement) (UserResolution, error)
|
||||
}{
|
||||
{name: "sender", fn: ResolveSenderTarget},
|
||||
{name: "stable-only", fn: ResolveStableUserTarget},
|
||||
} {
|
||||
t.Run(resolve.name, func(t *testing.T) {
|
||||
resolved, err := resolve.fn(reader, "fixture-user-id", IdentityAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
|
||||
t.Fatalf("resolved=%#v", resolved)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMixedIdentityResolutionFailureEdges(t *testing.T) {
|
||||
unexpectedReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return nil, stderrors.New("directory unavailable")
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolve func(Reader, string, IdentityRequirement) (UserResolution, error)
|
||||
value string
|
||||
requirement IdentityRequirement
|
||||
wantReason string
|
||||
}{
|
||||
{name: "mixed empty", resolve: ResolveUserTarget, wantReason: "missing_target"},
|
||||
{name: "mixed open id requires user id", resolve: ResolveUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"},
|
||||
{name: "stable empty", resolve: ResolveStableUserTarget, wantReason: "missing_target"},
|
||||
{name: "stable open id requires user id", resolve: ResolveStableUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := tc.resolve(unexpectedReader, tc.value, tc.requirement)
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != tc.wantReason {
|
||||
t.Fatalf("error=%#v, want reason %q", err, tc.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := ResolveUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" {
|
||||
t.Fatalf("mixed directory error=%v", err)
|
||||
}
|
||||
if _, err := ResolveSenderTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" {
|
||||
t.Fatalf("sender directory error=%v", err)
|
||||
}
|
||||
if _, err := ResolveStableUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" {
|
||||
t.Fatalf("stable directory error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStableIdentityAmbiguousAndIncompleteEdges(t *testing.T) {
|
||||
duplicateReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"result": []any{
|
||||
map[string]any{"userId": "duplicate", "openDingTalkId": "D-one", "name": "甲"},
|
||||
map[string]any{"userId": "other", "openDingTalkId": "duplicate", "name": "乙"},
|
||||
}}, nil
|
||||
})
|
||||
for _, resolve := range []struct {
|
||||
name string
|
||||
fn func(Reader, string, IdentityRequirement) (UserResolution, error)
|
||||
}{
|
||||
{name: "sender", fn: ResolveSenderTarget},
|
||||
{name: "stable", fn: ResolveStableUserTarget},
|
||||
} {
|
||||
t.Run(resolve.name+" ambiguous", func(t *testing.T) {
|
||||
_, err := resolve.fn(duplicateReader, "duplicate", IdentityAny)
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
|
||||
t.Fatalf("error=%#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
incompleteReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"result": []any{map[string]any{"userId": "other", "name": "其他"}},
|
||||
"hasMore": true,
|
||||
}, nil
|
||||
})
|
||||
for _, resolve := range []struct {
|
||||
name string
|
||||
fn func(Reader, string, IdentityRequirement) (UserResolution, error)
|
||||
}{
|
||||
{name: "sender", fn: ResolveSenderTarget},
|
||||
{name: "stable", fn: ResolveStableUserTarget},
|
||||
} {
|
||||
t.Run(resolve.name+" incomplete", func(t *testing.T) {
|
||||
_, err := resolve.fn(incompleteReader, "missing", IdentityAny)
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" {
|
||||
t.Fatalf("error=%#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCurrentDCodecEdges(t *testing.T) {
|
||||
if _, ok := unescapeCurrentDOpenDingTalkID("abcdi"); ok {
|
||||
t.Fatal("dangling escape unexpectedly accepted")
|
||||
}
|
||||
if decoded, ok := unescapeCurrentDOpenDingTalkID("iS"); !ok || decoded != "/" {
|
||||
t.Fatalf("slash escape decoded=%q ok=%v", decoded, ok)
|
||||
}
|
||||
if got := escapeCurrentDOpenDingTalkID("i+/="); got != "iiiPiSiE" {
|
||||
t.Fatalf("escaped=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// 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 transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAgentMetadataReachesEveryMCPMethod(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
agentVersion = "1.2.3-test+7"
|
||||
agentExt = `{"umt":"test-umt","nested":{"enabled":true}}`
|
||||
cliVersion = "9.8.7-cli"
|
||||
userAgent = "dws-user-agent-test/1.0"
|
||||
)
|
||||
|
||||
seen := make(map[string]int)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request requestEnvelope
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
seen[request.Method]++
|
||||
if got := r.Header.Get(HeaderAgentVersion); got != agentVersion {
|
||||
t.Errorf("%s %s = %q, want %q", request.Method, HeaderAgentVersion, got, agentVersion)
|
||||
}
|
||||
if got := r.Header.Get(HeaderAgentExt); got != agentExt {
|
||||
t.Errorf("%s %s = %q, want %q", request.Method, HeaderAgentExt, got, agentExt)
|
||||
}
|
||||
if got := r.Header.Get(HeaderVersion); got != cliVersion {
|
||||
t.Errorf("%s %s = %q, want %q", request.Method, HeaderVersion, got, cliVersion)
|
||||
}
|
||||
if got := r.Header.Get("User-Agent"); got != userAgent {
|
||||
t.Errorf("%s User-Agent = %q, want preset value %q", request.Method, got, userAgent)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch request.Method {
|
||||
case "initialize":
|
||||
_, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"%s","capabilities":{}}}`, supportedProtocolVersions[0])
|
||||
case "tools/list":
|
||||
_, _ = fmt.Fprint(w, `{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}`)
|
||||
case "tools/call":
|
||||
_, _ = fmt.Fprint(w, `{"jsonrpc":"2.0","id":3,"result":{"content":{"success":true}}}`)
|
||||
default:
|
||||
http.Error(w, "unexpected method", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.Client())
|
||||
client.ExtraHeaders = map[string]string{
|
||||
HeaderAgentVersion: agentVersion,
|
||||
HeaderAgentExt: agentExt,
|
||||
HeaderVersion: cliVersion,
|
||||
"User-Agent": userAgent,
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, err := client.Initialize(ctx, server.URL); err != nil {
|
||||
t.Fatalf("Initialize() error = %v", err)
|
||||
}
|
||||
if _, err := client.ListTools(ctx, server.URL); err != nil {
|
||||
t.Fatalf("ListTools() error = %v", err)
|
||||
}
|
||||
if _, err := client.CallTool(ctx, server.URL, "test_tool", map[string]any{"value": "safe"}); err != nil {
|
||||
t.Fatalf("CallTool() error = %v", err)
|
||||
}
|
||||
|
||||
for _, method := range []string{"initialize", "tools/list", "tools/call"} {
|
||||
if got := seen[method]; got != 1 {
|
||||
t.Errorf("%s request count = %d, want 1", method, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,10 +68,12 @@ const (
|
||||
defaultRetryMaxDelay = 5 * time.Second
|
||||
|
||||
// Security headers
|
||||
HeaderSource = "X-Cli-Source"
|
||||
HeaderVersion = "X-Cli-Version"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
SourceValue = "dws-cli"
|
||||
HeaderSource = "X-Cli-Source"
|
||||
HeaderVersion = "X-Cli-Version"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentVersion = "x-dws-agent-ver"
|
||||
HeaderAgentExt = "x-dws-agent-ext"
|
||||
SourceValue = "dws-cli"
|
||||
)
|
||||
|
||||
// Supported MCP protocol versions, ordered from newest to oldest.
|
||||
@@ -256,20 +258,43 @@ func NewClient(httpClient *http.Client) *Client {
|
||||
|
||||
// safeRedirectPolicy prevents credential headers from being forwarded
|
||||
// when a response redirects to a different host (e.g. API 302 → CDN).
|
||||
// Strips Authorization, x-user-access-token on cross-host redirects;
|
||||
// other headers like X-Cli-* pass through.
|
||||
// Credentials and Agent extension context are bound to the initial origin:
|
||||
// once a redirect chain leaves that origin they remain stripped for every
|
||||
// subsequent hop, including a redirect back to the initial origin.
|
||||
// Non-sensitive headers like X-Cli-* and x-dws-agent-ver pass through.
|
||||
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
|
||||
// Cross-host redirect: strip sensitive headers to prevent credential leakage
|
||||
if redirectChainLeftInitialOrigin(req, via) {
|
||||
req.Header.Del(HeaderAgentExt)
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del("x-user-access-token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func redirectChainLeftInitialOrigin(req *http.Request, via []*http.Request) bool {
|
||||
if len(via) == 0 {
|
||||
return false
|
||||
}
|
||||
initialURL := via[0].URL
|
||||
if !sameOrigin(req.URL, initialURL) {
|
||||
return true
|
||||
}
|
||||
for _, previous := range via[1:] {
|
||||
if !sameOrigin(previous.URL, initialURL) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sameOrigin(left, right *url.URL) bool {
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Host, right.Host)
|
||||
}
|
||||
|
||||
// WithAuth returns a shallow copy of c with the given auth token and extra
|
||||
// headers. The returned client shares the underlying HTTP client but is safe
|
||||
// to use concurrently with the original.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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 transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSafeRedirectPolicyAgentMetadataHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newRequest := func(t *testing.T, rawURL string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set(HeaderAgentVersion, "1.2.3-test")
|
||||
req.Header.Set(HeaderAgentExt, `{"ua":"test-agent-value"}`)
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
req.Header.Set("x-user-access-token", "test-token")
|
||||
return req
|
||||
}
|
||||
|
||||
assertHeaders := func(t *testing.T, request *http.Request, wantExt bool) {
|
||||
t.Helper()
|
||||
if got := request.Header.Get(HeaderAgentVersion); got != "1.2.3-test" {
|
||||
t.Fatalf("agent version = %q, want retained", got)
|
||||
}
|
||||
gotExt := request.Header.Get(HeaderAgentExt)
|
||||
if wantExt && gotExt == "" {
|
||||
t.Fatal("agent extension was removed")
|
||||
}
|
||||
if !wantExt && gotExt != "" {
|
||||
t.Fatalf("agent extension leaked across origins: %q", gotExt)
|
||||
}
|
||||
for _, key := range []string{"Authorization", "x-user-access-token"} {
|
||||
got := request.Header.Get(key)
|
||||
if wantExt && got == "" {
|
||||
t.Fatalf("same-origin redirect removed %s", key)
|
||||
}
|
||||
if !wantExt && got != "" {
|
||||
t.Fatalf("credential %s leaked across origins", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("same origin retains version and extension", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
previous := newRequest(t, "https://api.example.test/start")
|
||||
redirected := newRequest(t, "https://api.example.test/next")
|
||||
|
||||
if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertHeaders(t, redirected, true)
|
||||
})
|
||||
|
||||
t.Run("cross host strips extension and retains version", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
previous := newRequest(t, "https://api.example.test/start")
|
||||
redirected := newRequest(t, "https://cdn.example.test/asset")
|
||||
|
||||
if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertHeaders(t, redirected, false)
|
||||
})
|
||||
|
||||
t.Run("scheme downgrade on same host strips extension", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
previous := newRequest(t, "https://api.example.test/start")
|
||||
redirected := newRequest(t, "http://api.example.test/next")
|
||||
|
||||
if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertHeaders(t, redirected, false)
|
||||
})
|
||||
|
||||
t.Run("extension remains stripped after returning to initial origin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
initial := newRequest(t, "https://api.example.test/start")
|
||||
crossOrigin := newRequest(t, "https://cdn.example.test/asset")
|
||||
redirectedBack := newRequest(t, "https://api.example.test/final")
|
||||
|
||||
if err := safeRedirectPolicy(redirectedBack, []*http.Request{initial, crossOrigin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertHeaders(t, redirectedBack, false)
|
||||
})
|
||||
|
||||
t.Run("extension remains stripped on later cross-origin hop", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
initial := newRequest(t, "https://api.example.test/start")
|
||||
crossOrigin := newRequest(t, "https://cdn.example.test/asset")
|
||||
redirected := newRequest(t, "https://cdn.example.test/final")
|
||||
|
||||
if err := safeRedirectPolicy(redirected, []*http.Request{initial, crossOrigin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertHeaders(t, redirected, false)
|
||||
})
|
||||
|
||||
t.Run("initial request is unchanged", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
request := newRequest(t, "https://api.example.test/start")
|
||||
|
||||
if err := safeRedirectPolicy(request, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertHeaders(t, request, true)
|
||||
})
|
||||
|
||||
t.Run("redirect limit is enforced", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
request := newRequest(t, "https://api.example.test/final")
|
||||
via := make([]*http.Request, 10)
|
||||
for index := range via {
|
||||
via[index] = newRequest(t, "https://api.example.test/"+strings.Repeat("x", index+1))
|
||||
}
|
||||
|
||||
if err := safeRedirectPolicy(request, via); err == nil {
|
||||
t.Fatal("safeRedirectPolicy() error = nil, want redirect limit error")
|
||||
}
|
||||
})
|
||||
}
|
||||
+3
-2
@@ -15,8 +15,9 @@ package cli
|
||||
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
|
||||
// MCPIdentityHeaders returns HTTP headers aligned with MCP tool calls
|
||||
// (identity + edition merge). Overlays may pass this to auxiliary clients.
|
||||
// MCPIdentityHeaders returns the shared identity and edition headers that
|
||||
// overlays may pass to auxiliary clients. MCP-request-only metadata such as
|
||||
// DWS_AGENT_VER and DWS_AGENT_EXT is intentionally excluded.
|
||||
func MCPIdentityHeaders() map[string]string {
|
||||
return app.MCPIdentityHeaders()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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 cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMCPIdentityHeadersExcludeMCPOnlyMetadata(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("DWS_AGENT_VER", "1.2.3")
|
||||
t.Setenv("DWS_AGENT_EXT", `{"ua":"test-agent/1.2.3"}`)
|
||||
|
||||
headers := MCPIdentityHeaders()
|
||||
if headers == nil {
|
||||
t.Fatal("MCPIdentityHeaders() returned nil")
|
||||
}
|
||||
for key := range headers {
|
||||
if strings.EqualFold(key, "x-dws-agent-ver") || strings.EqualFold(key, "x-dws-agent-ext") {
|
||||
t.Fatalf("shared identity export leaked MCP-only Header %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1313,6 +1313,8 @@ func validateRenamedSchemaParameter(
|
||||
oldParameter parameterSchema,
|
||||
newParameter parameterSchema,
|
||||
) error {
|
||||
// The migration authorizes only the CLI spelling change. Requiredness is
|
||||
// part of the parameter contract in both projections and must remain exact.
|
||||
if oldParameter.Type != newParameter.Type ||
|
||||
oldParameter.Property != newParameter.Property ||
|
||||
oldParameter.InterfaceType != newParameter.InterfaceType ||
|
||||
@@ -1328,17 +1330,17 @@ func validateRenamedSchemaParameter(
|
||||
migration.Canonical.Name,
|
||||
)
|
||||
}
|
||||
if oldParameter.Required && !newParameter.Required {
|
||||
if oldParameter.Required != newParameter.Required {
|
||||
return fmt.Errorf(
|
||||
"approved flag migration %q Schema parameter %q -> %q became optional",
|
||||
"approved flag migration %q Schema parameter %q -> %q changed requiredness",
|
||||
migration.Command,
|
||||
migration.Legacy.Name,
|
||||
migration.Canonical.Name,
|
||||
)
|
||||
}
|
||||
if oldParameter.CLIRequired && !newParameter.CLIRequired {
|
||||
if oldParameter.CLIRequired != newParameter.CLIRequired {
|
||||
return fmt.Errorf(
|
||||
"approved flag migration %q Schema parameter %q -> %q stopped being cli_required",
|
||||
"approved flag migration %q Schema parameter %q -> %q changed cli_required",
|
||||
migration.Command,
|
||||
migration.Legacy.Name,
|
||||
migration.Canonical.Name,
|
||||
|
||||
@@ -1218,8 +1218,11 @@ func TestCrossPlatformCoverageSchemaFlagMigrationNormalizesExactRename(t *testin
|
||||
t.Fatalf("normalized baseline retained legacy parameter %q", legacy)
|
||||
}
|
||||
}
|
||||
if canonical := tool.Parameters["conversation-id"]; !canonical.Required || !canonical.CLIRequired {
|
||||
t.Fatalf("canonical required transition was not normalized: %#v", canonical)
|
||||
if canonical := tool.Parameters["conversation-id"]; canonical.Required || canonical.CLIRequired {
|
||||
t.Fatalf("optional canonical rename changed requiredness: %#v", canonical)
|
||||
}
|
||||
if canonical := tool.Parameters["message-id"]; !canonical.Required || !canonical.CLIRequired {
|
||||
t.Fatalf("required canonical rename changed requiredness: %#v", canonical)
|
||||
}
|
||||
if tool.Constraints != current.Products["chat"].Tools["chat.edit_message"].Constraints {
|
||||
t.Fatalf("constraints were not normalized: %s", tool.Constraints)
|
||||
@@ -1269,6 +1272,39 @@ func TestCrossPlatformCoverageSchemaFlagMigrationRejectsSemanticDrift(t *testing
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
want string
|
||||
mutate func(*parameterSchema)
|
||||
}{
|
||||
{name: "optional required promotion", want: "changed requiredness", mutate: func(parameter *parameterSchema) {
|
||||
parameter.Required = true
|
||||
}},
|
||||
{name: "optional cli_required promotion", want: "changed cli_required", mutate: func(parameter *parameterSchema) {
|
||||
parameter.CLIRequired = true
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := schemaFlagMigrationContract(true)
|
||||
product := current.Products["chat"]
|
||||
tool := product.Tools["chat.edit_message"]
|
||||
canonical := tool.Parameters["conversation-id"]
|
||||
test.mutate(&canonical)
|
||||
tool.Parameters["conversation-id"] = canonical
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
current.Products["chat"] = product
|
||||
|
||||
_, err := normalizeSchemaFlagMigrations(
|
||||
schemaFlagMigrationContract(false),
|
||||
current,
|
||||
schemaFlagMigrationAuthorizations(),
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("normalizeSchemaFlagMigrations() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
||||
@@ -1321,9 +1357,10 @@ func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
||||
t.Fatal("missing candidate product was normalized away")
|
||||
}
|
||||
|
||||
canonicalOnly := schemaFlagMigrationAuthorizations()[0]
|
||||
canonicalOnly.Legacy.Name = "legacy-not-published-in-schema"
|
||||
driftedCanonical := cloneContract(current)
|
||||
canonicalOnlyMigration := schemaFlagMigrationAuthorizations()[0]
|
||||
canonicalOnlyMigration.Legacy.Name = "legacy-not-published-in-schema"
|
||||
canonicalOnlyBaseline := schemaFlagMigrationContract(true)
|
||||
driftedCanonical := cloneContract(canonicalOnlyBaseline)
|
||||
product = driftedCanonical.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
canonical := tool.Parameters["conversation-id"]
|
||||
@@ -1331,7 +1368,7 @@ func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
||||
tool.Parameters["conversation-id"] = canonical
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
driftedCanonical.Products["chat"] = product
|
||||
normalized, err = normalizeSchemaFlagMigrations(baseline, driftedCanonical, []interfacesnapshot.FlagMigration{canonicalOnly})
|
||||
normalized, err = normalizeSchemaFlagMigrations(canonicalOnlyBaseline, driftedCanonical, []interfacesnapshot.FlagMigration{canonicalOnlyMigration})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1339,24 +1376,24 @@ func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
||||
t.Fatalf("canonical-only Schema drift was hidden: %s", failures)
|
||||
}
|
||||
|
||||
canonicalOptional := schemaFlagMigrationContract(true)
|
||||
product = canonicalOptional.Products["chat"]
|
||||
promotedCanonical := cloneContract(canonicalOnlyBaseline)
|
||||
product = promotedCanonical.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
canonical = tool.Parameters["conversation-id"]
|
||||
canonical.Required = false
|
||||
canonical.CLIRequired = false
|
||||
canonical.Required = true
|
||||
canonical.CLIRequired = true
|
||||
tool.Parameters["conversation-id"] = canonical
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
canonicalOptional.Products["chat"] = product
|
||||
promotedCanonical.Products["chat"] = product
|
||||
normalized, err = normalizeSchemaFlagMigrations(
|
||||
canonicalOptional,
|
||||
schemaFlagMigrationContract(true),
|
||||
[]interfacesnapshot.FlagMigration{canonicalOnly},
|
||||
canonicalOnlyBaseline,
|
||||
promotedCanonical,
|
||||
[]interfacesnapshot.FlagMigration{canonicalOnlyMigration},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, schemaFlagMigrationContract(true)), "\n"); !strings.Contains(failures, "newly required") || !strings.Contains(failures, "newly cli_required") {
|
||||
if failures := strings.Join(checkCompatibility(normalized, promotedCanonical), "\n"); !strings.Contains(failures, "newly required") || !strings.Contains(failures, "newly cli_required") {
|
||||
t.Fatalf("canonical-only required promotion was hidden: %s", failures)
|
||||
}
|
||||
|
||||
@@ -1392,12 +1429,19 @@ func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
||||
}
|
||||
|
||||
old := parameterSchema{Required: true, CLIRequired: true}
|
||||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{CLIRequired: true}); err == nil || !strings.Contains(err.Error(), "became optional") {
|
||||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{CLIRequired: true}); err == nil || !strings.Contains(err.Error(), "changed requiredness") {
|
||||
t.Fatalf("direct required decline error = %v", err)
|
||||
}
|
||||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{Required: true}); err == nil || !strings.Contains(err.Error(), "stopped being cli_required") {
|
||||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{Required: true}); err == nil || !strings.Contains(err.Error(), "changed cli_required") {
|
||||
t.Fatalf("direct cli_required decline error = %v", err)
|
||||
}
|
||||
optional := parameterSchema{}
|
||||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], optional, parameterSchema{Required: true}); err == nil || !strings.Contains(err.Error(), "changed requiredness") {
|
||||
t.Fatalf("direct required promotion error = %v", err)
|
||||
}
|
||||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], optional, parameterSchema{CLIRequired: true}); err == nil || !strings.Contains(err.Error(), "changed cli_required") {
|
||||
t.Fatalf("direct cli_required promotion error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsPartialAndUnrelatedChanges(t *testing.T) {
|
||||
@@ -1471,9 +1515,8 @@ func TestCrossPlatformCoverageSchemaFlagMigrationRejectsPartialAndUnrelatedChang
|
||||
t.Fatalf("constraint rewrite without Schema parameter evidence was hidden: %s", failures)
|
||||
}
|
||||
|
||||
// A baseline that already contains only the canonical parameter may receive
|
||||
// a required promotion, but that is not evidence that a stray legacy name in
|
||||
// constraints belongs to the migration.
|
||||
// A baseline that already contains only the canonical parameter is not
|
||||
// evidence that a stray legacy name in constraints belongs to the migration.
|
||||
canonicalOnly := schemaFlagMigrationContract(true)
|
||||
product = canonicalOnly.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
@@ -1749,13 +1792,10 @@ func schemaFlagMigrationContract(after bool) schemaContract {
|
||||
InterfaceType: "string",
|
||||
}
|
||||
parameters := map[string]parameterSchema{
|
||||
"conversation-id": conversation,
|
||||
"unrelated": {Type: `"string"`, Property: "unrelated"},
|
||||
"unrelated": {Type: `"string"`, Property: "unrelated"},
|
||||
}
|
||||
constraints := `{"require_one_of":[["conversation-id","group","id"]]}`
|
||||
constraints := `{"require_one_of":[["group","id"]]}`
|
||||
if after {
|
||||
conversation.Required = true
|
||||
conversation.CLIRequired = true
|
||||
parameters["conversation-id"] = conversation
|
||||
parameters["message-id"] = legacyMessage
|
||||
constraints = `{"require_one_of":[["conversation-id"]]}`
|
||||
@@ -1788,7 +1828,6 @@ func schemaFlagMigrationAuthorizations() []interfacesnapshot.FlagMigration {
|
||||
Scope: "local",
|
||||
}
|
||||
conversationAfter := conversationBefore
|
||||
conversationAfter.Required = true
|
||||
messageBefore := interfacesnapshot.FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
@@ -1808,7 +1847,7 @@ func schemaFlagMigrationAuthorizations() []interfacesnapshot.FlagMigration {
|
||||
},
|
||||
Canonical: interfacesnapshot.FlagMigrationSide{
|
||||
Name: "conversation-id",
|
||||
Before: conversationBefore,
|
||||
Before: interfacesnapshot.FlagMigrationState{},
|
||||
After: conversationAfter,
|
||||
},
|
||||
},
|
||||
@@ -1823,7 +1862,7 @@ func schemaFlagMigrationAuthorizations() []interfacesnapshot.FlagMigration {
|
||||
},
|
||||
Canonical: interfacesnapshot.FlagMigrationSide{
|
||||
Name: "conversation-id",
|
||||
Before: conversationBefore,
|
||||
Before: interfacesnapshot.FlagMigrationState{},
|
||||
After: conversationAfter,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -54,7 +54,7 @@ while [ "$#" -gt 0 ]; do
|
||||
done
|
||||
|
||||
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
|
||||
HOME_AGENT_PARENTS="
|
||||
HOME_SPECIFIC_AGENT_PARENTS="
|
||||
.claude
|
||||
.cursor
|
||||
.qoder
|
||||
@@ -72,8 +72,7 @@ HOME_AGENT_PARENTS="
|
||||
.openclaw
|
||||
.hermes
|
||||
"
|
||||
HOME_SKILL_BASES="
|
||||
.agents/skills
|
||||
HOME_SPECIFIC_SKILL_BASES="
|
||||
.claude/skills
|
||||
.cursor/skills
|
||||
.qoder/skills
|
||||
@@ -102,43 +101,55 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
seed_agent_homes() {
|
||||
seed_specific_agent_homes() {
|
||||
home_root="$1"
|
||||
for parent in $HOME_AGENT_PARENTS; do
|
||||
for parent in $HOME_SPECIFIC_AGENT_PARENTS; do
|
||||
mkdir -p "$home_root/$parent"
|
||||
done
|
||||
}
|
||||
|
||||
verify_skill_base() {
|
||||
home_root="$1"
|
||||
base="$2"
|
||||
need_file "$home_root/$base/dingtalk-shared/SKILL.md"
|
||||
need_file "$home_root/$base/dingtalk-misc/SKILL.md"
|
||||
[ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws"
|
||||
}
|
||||
|
||||
verify_skill_targets() {
|
||||
home_root="$1"
|
||||
for base in $HOME_SKILL_BASES; do
|
||||
need_file "$home_root/$base/dingtalk-shared/SKILL.md"
|
||||
need_file "$home_root/$base/dingtalk-misc/SKILL.md"
|
||||
[ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws"
|
||||
found_specific=0
|
||||
for base in $HOME_SPECIFIC_SKILL_BASES; do
|
||||
parent="${base%/skills}"
|
||||
if [ -d "$home_root/$parent" ]; then
|
||||
verify_skill_base "$home_root" "$base"
|
||||
found_specific=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found_specific" -eq 0 ]; then
|
||||
verify_skill_base "$home_root" ".agents/skills"
|
||||
return
|
||||
fi
|
||||
|
||||
for name in dingtalk-shared dingtalk-misc dws; do
|
||||
[ ! -e "$home_root/.agents/skills/$name" ] || \
|
||||
err "unexpected generic Skill copy found in $home_root/.agents/skills/$name"
|
||||
done
|
||||
}
|
||||
|
||||
verify_npm() {
|
||||
need_cmd npm
|
||||
need_cmd node
|
||||
need_cmd tar
|
||||
need_cmd unzip
|
||||
need_file "$NPM_STAGE_DIR/package.json"
|
||||
|
||||
npm_home="$TMP_ROOT/npm-home"
|
||||
npm_prefix="$TMP_ROOT/npm-prefix"
|
||||
npm_cache="$TMP_ROOT/npm-cache"
|
||||
verify_npm_install() {
|
||||
tarball_path="$1"
|
||||
scenario="$2"
|
||||
npm_home="$TMP_ROOT/npm-home-$scenario"
|
||||
npm_prefix="$TMP_ROOT/npm-prefix-$scenario"
|
||||
npm_cache="$TMP_ROOT/npm-cache-$scenario"
|
||||
mkdir -p "$npm_home" "$npm_prefix" "$npm_cache"
|
||||
seed_agent_homes "$npm_home"
|
||||
|
||||
say "==> verifying npm package install"
|
||||
tarball_name="$(
|
||||
cd "$NPM_STAGE_DIR"
|
||||
HOME="$npm_home" npm_config_cache="$npm_cache" npm pack --silent
|
||||
)"
|
||||
tarball_path="$NPM_STAGE_DIR/$tarball_name"
|
||||
need_file "$tarball_path"
|
||||
if [ "$scenario" = "specific-agent-roots" ]; then
|
||||
seed_specific_agent_homes "$npm_home"
|
||||
fi
|
||||
|
||||
say "==> verifying npm package install ($scenario)"
|
||||
HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
|
||||
npm install -g "$tarball_path" >/dev/null
|
||||
|
||||
@@ -158,6 +169,24 @@ verify_npm() {
|
||||
|
||||
HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
|
||||
npm uninstall -g dingtalk-workspace-cli >/dev/null
|
||||
}
|
||||
|
||||
verify_npm() {
|
||||
need_cmd npm
|
||||
need_cmd node
|
||||
need_cmd tar
|
||||
need_cmd unzip
|
||||
need_file "$NPM_STAGE_DIR/package.json"
|
||||
|
||||
tarball_name="$(
|
||||
cd "$NPM_STAGE_DIR"
|
||||
HOME="$TMP_ROOT/npm-pack-home" npm_config_cache="$TMP_ROOT/npm-pack-cache" npm pack --silent
|
||||
)"
|
||||
tarball_path="$NPM_STAGE_DIR/$tarball_name"
|
||||
need_file "$tarball_path"
|
||||
|
||||
verify_npm_install "$tarball_path" "specific-agent-roots"
|
||||
verify_npm_install "$tarball_path" "generic-fallback"
|
||||
|
||||
rm -f "$tarball_path"
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
<!-- DWS_MESSAGE_RESULT_CONTRACT_START -->
|
||||
- `version`: `im.message-list.v1`
|
||||
- `message_fields`: `messageId`, `conversationId`, `threadId`, `sender`, `senderId`, `senderType`, `messageType`, `text`, `createTime`, `updateTime`, `reactions`, `quotedMessage`, `forwarded`, `resourceRefs`
|
||||
- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `scope`, `resourceDownloads`
|
||||
- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncated`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `scope`, `resourceDownloads`
|
||||
<!-- DWS_MESSAGE_RESULT_CONTRACT_END -->
|
||||
|
||||
当 `complete=false` 时不能称为全量成功。`nextPage` 只能来自真实 lower boundary;
|
||||
`failedCount/failures`、`partial` 和两个 truncated 字段必须原样保留。
|
||||
`failedCount/failures`、`partial`、总 `truncated` 和两个原因字段必须原样保留。
|
||||
当 Runtime 解析并应用自然发送者条件时,`resolvedFilters.senders[]` 保留原查询及选中的
|
||||
`userId/openDingTalkId`。消息展示名可以与通讯录姓名不同;只能用稳定 `senderId` 与解析结果关联,
|
||||
不得重新做姓名字符串比较。
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
const (
|
||||
mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER"
|
||||
mockMCPSmokeCAEnv = "DWS_MOCK_MCP_SMOKE_CA_FILE"
|
||||
mockCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
)
|
||||
|
||||
type recordedToolCall struct {
|
||||
@@ -293,7 +294,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
|
||||
stdout, stderr, err := runCLI(t, env,
|
||||
"--token", "ci-smoke-token", "--format", "json",
|
||||
"chat", "+messages-send", "--as", "user",
|
||||
"--user-query", "张三", "--text", "你好", "--yes",
|
||||
"--user-query", "测试用户甲", "--text", "你好", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("natural send failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
@@ -302,7 +303,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
|
||||
if len(calls) != 2 || calls[0].tool != "search_contact_by_key_word" || calls[1].tool != "send_personal_message" {
|
||||
t.Fatalf("calls = %#v, want resolve + one send", calls)
|
||||
}
|
||||
if calls[1].arguments["receiverOpenDingTalkId"] != "D-user-1" {
|
||||
if calls[1].arguments["receiverOpenDingTalkId"] != mockCurrentDOpenID {
|
||||
t.Fatalf("send target = %#v", calls[1].arguments)
|
||||
}
|
||||
assertMultiIMCallsAreLocalAndAuthorized(t, calls)
|
||||
@@ -473,7 +474,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
|
||||
t.Fatalf("messages = %#v", payload["messages"])
|
||||
}
|
||||
message, _ := messages[0].(map[string]any)
|
||||
if message["messageId"] != "msg-1" || message["senderId"] != "D-sender" || message["senderType"] != "user" {
|
||||
if message["messageId"] != "msg-1" || message["senderId"] != mockCurrentDOpenID || message["senderType"] != "user" {
|
||||
t.Fatalf("projected message = %#v", message)
|
||||
}
|
||||
})
|
||||
@@ -483,7 +484,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
|
||||
stdout, stderr, err := runCLI(t, env,
|
||||
"--token", "ci-smoke-token", "--format", "json",
|
||||
"chat", "+chat-create", "--name", "新群",
|
||||
"--member-query", "张三", "--dry-run", "--yes",
|
||||
"--member-query", "测试用户甲", "--dry-run", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat-create dry-run failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
@@ -505,7 +506,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
|
||||
"--token", "ci-smoke-token", "--format", "json",
|
||||
"chat", "+messages-reply",
|
||||
"--conversation-id", "cid-1", "--message-id", "msg-1",
|
||||
"--ref-sender", "D-sender", "--text", "收到",
|
||||
"--ref-sender", mockCurrentDOpenID, "--text", "收到",
|
||||
"--idempotency-key", "reply-key", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -532,7 +533,7 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
|
||||
if arguments["keyword"] == "同名用户" {
|
||||
return `{"result":[{"name":"同名用户","userId":"u1","openDingTalkId":"D1"},{"name":"同名用户","userId":"u2","openDingTalkId":"D2"}]}`
|
||||
}
|
||||
return `{"result":[{"name":"张三","userId":"user-1","openDingTalkId":"D-user-1"}]}`
|
||||
return `{"result":[{"name":"测试用户甲","userId":"user-1","openDingTalkId":"` + mockCurrentDOpenID + `"}]}`
|
||||
case "search_groups":
|
||||
if arguments["keyword"] == "资源群" {
|
||||
return `{"result":[{"title":"资源群","openConversationId":"cid-resources"}]}`
|
||||
@@ -540,9 +541,9 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
|
||||
return `{"result":[{"title":"项目群","openConversationId":"cid-1"}]}`
|
||||
case "list_conversation_message_v2":
|
||||
if arguments["openconversation_id"] == "cid-resources" {
|
||||
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-resource","openConversationId":"cid-resources","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"file","content":"{\"mediaId\":\"@media-1\"}","createTime":"2026-08-03 10:00:00"}]}}`
|
||||
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-resource","openConversationId":"cid-resources","sender":{"name":"测试用户甲","openDingTalkId":"` + mockCurrentDOpenID + `","senderType":"user"},"msgType":"file","content":"{\"mediaId\":\"@media-1\"}","createTime":"2026-08-03 10:00:00"}]}}`
|
||||
}
|
||||
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-1","openConversationId":"cid-1","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"text","content":"进度正常","createTime":"2026-08-03 10:00:00"}]}}`
|
||||
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-1","openConversationId":"cid-1","sender":{"name":"测试用户甲","openDingTalkId":"` + mockCurrentDOpenID + `","senderType":"user"},"msgType":"text","content":"进度正常","createTime":"2026-08-03 10:00:00"}]}}`
|
||||
case "get_resource_download_url":
|
||||
body, _ := json.Marshal(map[string]any{"result": map[string]any{
|
||||
"resourceUrl": resourceURL,
|
||||
@@ -561,7 +562,7 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
|
||||
if arguments["keyword"] == "分页失败" && fmt.Sprint(arguments["cursor"]) == "c2" {
|
||||
return `{"success":false,"code":"MOCK_PAGE_FAILURE","message":"simulated second page failure"}`
|
||||
}
|
||||
return `{"result":{"messages":[{"openMessageId":"search-msg-1","openConversationId":"cid-1","senderOpenDingTalkId":"D-sender","content":"first page"}],"hasMore":true,"nextCursor":"c2"}}`
|
||||
return `{"result":{"messages":[{"openMessageId":"search-msg-1","openConversationId":"cid-1","senderOpenDingTalkId":"` + mockCurrentDOpenID + `","content":"first page"}],"hasMore":true,"nextCursor":"c2"}}`
|
||||
case "get_current_user_profile":
|
||||
return `{"result":[{"orgEmployeeModel":{"userId":"self-user"}}]}`
|
||||
case "create_group_conversation":
|
||||
|
||||
@@ -1019,12 +1019,47 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
}
|
||||
|
||||
admission := readWorkflow(".github/workflows/ci.yml")
|
||||
policyStart := strings.Index(admission, "\n policy:\n")
|
||||
policyEnd := strings.Index(admission, "\n interface-integrity:\n")
|
||||
if policyStart < 0 || policyEnd <= policyStart {
|
||||
t.Fatal("Code Admission workflow missing Policy job boundaries")
|
||||
}
|
||||
policyJob := admission[policyStart:policyEnd]
|
||||
requirePolicyEnv := func(step, nextStep string) {
|
||||
t.Helper()
|
||||
start := strings.Index(policyJob, " - name: "+step+"\n")
|
||||
end := strings.Index(policyJob[start+1:], " - name: "+nextStep+"\n")
|
||||
if start < 0 || end < 0 {
|
||||
t.Fatalf("Policy job missing %q step boundary", step)
|
||||
}
|
||||
block := policyJob[start : start+1+end]
|
||||
if !strings.Contains(block, "RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}") {
|
||||
t.Errorf("Policy %q step must receive release-seal classification", step)
|
||||
}
|
||||
}
|
||||
requirePolicyEnv("Validate changed CHANGELOG content", "Validate release fragment lifecycle")
|
||||
requirePolicyEnv("Validate trusted main metadata-only push", "Record CHANGELOG-only fast path")
|
||||
pushStart := strings.Index(admission, "} else if (context.eventName === 'push') {")
|
||||
pushEnd := strings.Index(admission, "\n core.setOutput('changelog_only'")
|
||||
if pushStart < 0 || pushEnd <= pushStart {
|
||||
t.Fatal("Code Admission workflow missing push classification boundaries")
|
||||
}
|
||||
pushClassification := admission[pushStart:pushEnd]
|
||||
if strings.Contains(pushClassification, "const exactReleaseSealDiff = isExactReleaseSeal(files)") {
|
||||
t.Error("push release-seal fast path must not trust an unguarded compare file list")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"name: CI",
|
||||
"files.length === 1",
|
||||
"files[0].filename === 'CHANGELOG.md'",
|
||||
"files[0].status === 'modified'",
|
||||
"!files[0].previous_filename",
|
||||
"const isExactReleaseSeal",
|
||||
"file.status !== 'renamed'",
|
||||
"file.additions !== 0",
|
||||
"file.deletions !== 0",
|
||||
"releaseSealOnly = isExactReleaseSeal(files)",
|
||||
"release_seal_only: ${{ steps.classify.outputs.release_seal_only }}",
|
||||
"pre-classification",
|
||||
"post-classification",
|
||||
"before.changed_files !== files.length",
|
||||
@@ -1048,7 +1083,8 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
"mode=--fast-path",
|
||||
`"$mode" "$PR_BASE_SHA" HEAD`,
|
||||
`test "$(git rev-parse HEAD)" = "$PUSH_AFTER_SHA"`,
|
||||
`--fast-path "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"`,
|
||||
`"$mode" "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"`,
|
||||
`./scripts/policy/check-release-fragments.sh \`,
|
||||
"needs.lint.outputs.platform_sensitive == 'true'",
|
||||
`COVERAGE_TARGET: "100"`,
|
||||
`COVERAGE_ENFORCE_OVERALL: "false"`,
|
||||
@@ -1060,7 +1096,9 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
`package_output="$(./scripts/ci/test-packages.sh list`,
|
||||
"docs_only: ${{ steps.classify.outputs.docs_only }}",
|
||||
"full_suite: ${{ steps.classify.outputs.full_suite }}",
|
||||
"classifyFiles(files.length < 300)",
|
||||
"const pushFilesComplete = files.length < 300",
|
||||
"classifyFiles(pushFilesComplete)",
|
||||
"pushFilesComplete && isExactReleaseSeal(files)",
|
||||
"platformSensitive =",
|
||||
"files.some(isNativeGoChange)",
|
||||
"paths.some(isEditionSensitive)",
|
||||
|
||||
@@ -70,10 +70,12 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
|
||||
t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"HOME_SKILL_BASES=",
|
||||
"HOME_SPECIFIC_SKILL_BASES=",
|
||||
`$base/dingtalk-shared/SKILL.md`,
|
||||
`$base/dingtalk-misc/SKILL.md`,
|
||||
"unexpected mono Skill layout",
|
||||
`verify_npm_install "$tarball_path" "specific-agent-roots"`,
|
||||
`verify_npm_install "$tarball_path" "generic-fallback"`,
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Errorf("package-manager verifier is missing multi-layout contract %q", want)
|
||||
@@ -84,6 +86,60 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
postGoreleaserPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
|
||||
}
|
||||
verifierPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(verify-package-managers.sh) error = %v", err)
|
||||
}
|
||||
|
||||
distDir := filepath.Join(t.TempDir(), "dist")
|
||||
targets := []string{
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
"dws-linux-amd64.tar.gz",
|
||||
"dws-linux-arm64.tar.gz",
|
||||
}
|
||||
hostArchive := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz"
|
||||
if runtime.GOOS == "windows" {
|
||||
hostArchive = "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".zip"
|
||||
}
|
||||
foundHost := false
|
||||
for _, target := range targets {
|
||||
if target == hostArchive {
|
||||
foundHost = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundHost {
|
||||
targets = append(targets, hostArchive)
|
||||
}
|
||||
seedDistArtifacts(t, distDir, targets)
|
||||
|
||||
packageCmd := exec.Command("sh", postGoreleaserPath)
|
||||
packageCmd.Env = postGoreleaserEnv(t, distDir, "v0.0.0-test", "https://downloads.example.com/dws/releases/v0.0.0-test")
|
||||
if output, err := packageCmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
verifyCmd := exec.Command("sh", verifierPath, "--npm-only")
|
||||
verifyCmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+distDir)
|
||||
output, err := verifyCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("verify-package-managers.sh error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
for _, scenario := range []string{"specific-agent-roots", "generic-fallback"} {
|
||||
if !strings.Contains(string(output), "verifying npm package install ("+scenario+")") {
|
||||
t.Errorf("verifier output is missing %s scenario:\n%s", scenario, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedDistArchive(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
file, err := os.Create(path)
|
||||
@@ -1166,6 +1222,136 @@ func TestReleaseWorkflowParallelizesSealedValidationWithoutWeakeningPublication(
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowHidesOnlyVerifiedSealedTagFromCompatibilityBaseline(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
validation := releaseWorkflowSection(t, workflow, " release-validation:\n", "\n release-plan:\n")
|
||||
preparedView := releaseWorkflowSection(
|
||||
t,
|
||||
validation,
|
||||
" - name: Prepare delivered-stable compatibility ref view\n",
|
||||
"\n - name: Set up Go\n",
|
||||
)
|
||||
script := releaseWorkflowRunScript(
|
||||
t,
|
||||
validation,
|
||||
"Prepare delivered-stable compatibility ref view",
|
||||
"Set up Go",
|
||||
)
|
||||
|
||||
if !strings.Contains(preparedView, "if: ${{ matrix.check == 'compatibility' }}") {
|
||||
t.Fatal("delivered-stable ref view must run only for the compatibility matrix leg")
|
||||
}
|
||||
for _, required := range []string{
|
||||
"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 }}",
|
||||
} {
|
||||
if !strings.Contains(preparedView, required) {
|
||||
t.Errorf("delivered-stable compatibility view is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{
|
||||
`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"`,
|
||||
`git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"`,
|
||||
} {
|
||||
if !strings.Contains(script, required) {
|
||||
t.Errorf("delivered-stable compatibility view is missing %q", required)
|
||||
}
|
||||
}
|
||||
|
||||
verifiedTag := strings.Index(validation, "verify-github-tag-authority.sh")
|
||||
deleteLocalTag := strings.Index(validation, "git update-ref -d")
|
||||
compatibility := strings.Index(validation, "check-command-compatibility.sh")
|
||||
if verifiedTag == -1 || deleteLocalTag == -1 || compatibility == -1 ||
|
||||
verifiedTag > deleteLocalTag || deleteLocalTag > compatibility {
|
||||
t.Fatal("release tag authority verification, local candidate removal, and compatibility checking must stay ordered")
|
||||
}
|
||||
for _, forbidden := range []string{"git push --delete", "github.rest.git.deleteRef"} {
|
||||
if strings.Contains(workflow, forbidden) {
|
||||
t.Errorf("release workflow must not delete a remote ref: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := os.MkdirTemp(".", ".release-compatibility-test-")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp(release compatibility test repository) error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.RemoveAll(repo); err != nil {
|
||||
t.Errorf("RemoveAll(%s) error = %v", repo, err)
|
||||
}
|
||||
})
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
mustRun(t, repo, "git", "config", "user.name", "Release Compatibility Test")
|
||||
mustRun(t, repo, "git", "config", "user.email", "release-compatibility@example.com")
|
||||
mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("stable\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "tracked")
|
||||
mustRun(t, repo, "git", "commit", "-m", "stable release")
|
||||
previousStableCommit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD"))
|
||||
mustRun(t, repo, "git", "tag", "-a", "v1.0.57", "-m", "v1.0.57")
|
||||
mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("sealed\n"), 0o644)
|
||||
mustRun(t, repo, "git", "commit", "-am", "sealed release")
|
||||
releaseCommit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD"))
|
||||
mustRun(t, repo, "git", "tag", "-a", "v1.0.58", "-m", "v1.0.58")
|
||||
releaseTagObject := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.58"))
|
||||
|
||||
run := func(tagObject string) ([]byte, error) {
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
cmd.Dir = repo
|
||||
cmd.Env = make([]string, 0, len(os.Environ())+5)
|
||||
for _, entry := range os.Environ() {
|
||||
if strings.HasPrefix(entry, "RELEASE_VERSION=") ||
|
||||
strings.HasPrefix(entry, "RELEASE_COMMIT=") ||
|
||||
strings.HasPrefix(entry, "RELEASE_TAG_OBJECT=") ||
|
||||
strings.HasPrefix(entry, "PREVIOUS_STABLE=") ||
|
||||
strings.HasPrefix(entry, "PREVIOUS_STABLE_COMMIT=") {
|
||||
continue
|
||||
}
|
||||
cmd.Env = append(cmd.Env, entry)
|
||||
}
|
||||
cmd.Env = append(cmd.Env,
|
||||
"RELEASE_VERSION=v1.0.58",
|
||||
"RELEASE_COMMIT="+releaseCommit,
|
||||
"RELEASE_TAG_OBJECT="+tagObject,
|
||||
"PREVIOUS_STABLE=v1.0.57",
|
||||
"PREVIOUS_STABLE_COMMIT="+previousStableCommit,
|
||||
)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
if output, err := run(releaseTagObject); err != nil {
|
||||
t.Fatalf("prepared compatibility ref view failed: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
candidateTag := exec.Command("git", "rev-parse", "--verify", "refs/tags/v1.0.58")
|
||||
candidateTag.Dir = repo
|
||||
if _, err := candidateTag.CombinedOutput(); err == nil {
|
||||
t.Fatal("prepared compatibility ref view left the sealed candidate tag visible")
|
||||
}
|
||||
mustRun(t, repo, "git", "cat-file", "-e", releaseTagObject+"^{tag}")
|
||||
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.57^{commit}")); got != previousStableCommit {
|
||||
t.Fatalf("previous stable tag commit = %s, want %s", got, previousStableCommit)
|
||||
}
|
||||
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD")); got != releaseCommit {
|
||||
t.Fatalf("HEAD after prepared compatibility ref view = %s, want %s", got, releaseCommit)
|
||||
}
|
||||
|
||||
mustRun(t, repo, "git", "update-ref", "refs/tags/v1.0.58", releaseTagObject)
|
||||
if output, err := run(strings.Repeat("0", 40)); err == nil {
|
||||
t.Fatalf("prepared compatibility ref view unexpectedly accepted a mismatched tag object\noutput:\n%s", output)
|
||||
}
|
||||
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.58")); got != releaseTagObject {
|
||||
t.Fatalf("mismatched tag object removed or changed the local sealed tag: got %s, want %s", got, releaseTagObject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFingerprintRefPatternsMatchAllAllocatedTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := t.TempDir()
|
||||
|
||||
Reference in New Issue
Block a user