Compare commits

...
Author SHA1 Message Date
chichuan 09c1f28862 feat(schema): govern conditional idempotent retries 2026-08-13 13:22:58 +08:00
github-actions[bot] 0a063e3ebd Merge pull request #979 from wxianfeng/feat/85384225-agent-version-ext
feat: forward Agent version and extension context
2026-08-13 05:07:40 +00:00
chichuan e19c54f77e Merge branch 'main' into feat/85384225-agent-version-ext 2026-08-13 12:47:15 +08:00
github-actions[bot] fbc34509f8 Merge pull request #970 from DingTalk-Real-AI/codex/im-page-all
feat(chat): unify shortcut auto-pagination controls
2026-08-13 04:18:43 +00:00
昊淼 ad0cf639c4 Merge branch 'main' into feat/85384225-agent-version-ext 2026-08-13 11:49:27 +08:00
Dennis 2f8e136dc0 fix(chat): fail closed on bounded legacy pages 2026-08-13 11:35:39 +08:00
Dennis fdbd11e0ea docs(changelog): add IM pagination release note 2026-08-13 11:35:37 +08:00
Dennis d07bf39586 fix(chat): bound automatic page delays 2026-08-13 11:35:35 +08:00
Dennis eee41a9b45 fix(chat): preserve safe pagination continuations 2026-08-13 11:35:33 +08:00
Dennis 896801634f fix(chat): preserve max-results visibility 2026-08-13 11:35:30 +08:00
Dennis a203572ee3 feat(chat): unify shortcut auto-pagination controls 2026-08-13 11:35:27 +08:00
github-actions[bot] 6c0ba91414 Merge pull request #963 from DingTalk-Real-AI/codex/drive-readback-verification
fix(drive): verify upload and move readback
2026-08-13 03:26:54 +00:00
昊淼 472d3d321b Merge branch 'main' into feat/85384225-agent-version-ext 2026-08-13 10:40:21 +08:00
john 0b68450709 Merge branch 'main' into codex/drive-readback-verification 2026-08-13 10:38:17 +08:00
john 346444ea38 Merge pull request #981 from typefield/fix/interface-integrity-ledger-validation
fix: restore interface migration ledger compatibility
2026-08-13 10:37:25 +08:00
wxianfeng 54dc8fadb7 feat: forward agent version and extension context 2026-08-13 10:13:04 +08:00
玉澜 b469bb127a docs: clarify hidden canonical promotion 2026-08-13 09:37:22 +08:00
玉澜 c6e810e4d9 fix: restore interface migration ledger compatibility 2026-08-13 09:34:48 +08:00
Dennis 98d03455b1 fix(drive): bind readback to requested objects 2026-08-13 00:12:07 +08:00
Dennis fad41d4d99 fix(drive): verify upload and move readback 2026-08-13 00:12:02 +08:00
github-actions[bot] 5fed80fc0f Merge pull request #966 from wxianfeng/feat/85349380-primary-param-governance
feat: support safe Primary flag rename governance (#85349380)
2026-08-12 14:40:01 +00:00
昊淼 1727025f67 Merge branch 'main' into feat/85349380-primary-param-governance 2026-08-12 22:23:32 +08:00
github-actions[bot] 715f5346da Merge pull request #975 from DingTalk-Real-AI/dws_optimization
fix(skill): clarify document-space routing in doc/drive/wiki descript…
2026-08-12 13:21:57 +00:00
RuiGong01 f875b1bc87 Merge branch 'main' into dws_optimization 2026-08-12 20:54:37 +08:00
github-actions[bot] 3388df1c63 Merge pull request #978 from xlb1130/feat/85387314-chat-image-guide
docs(chat): clarify image markdown guide
2026-08-12 19:54:03 +08:00
xlb1130 d3584077d7 Merge branch 'main' into feat/85387314-chat-image-guide 2026-08-12 18:30:40 +08:00
github-actions[bot] e49ba1ae71 Merge pull request #972 from typefield/feat/zcode-skill-root
feat(skill): support ZCode skill root
2026-08-12 10:20:18 +00:00
长真 2c46213257 docs(chat): to #85387314 clarify image markdown guide 2026-08-12 18:04:17 +08:00
john 77dc7d30a0 Merge branch 'main' into feat/zcode-skill-root 2026-08-12 17:56:45 +08:00
ruigong aa3c279313 chore(policy): align doc skill context budget with event/chat (10000) 2026-08-12 17:55:31 +08:00
ruigong 51dc3df91b fix(skill): clarify document-space routing in doc/drive/wiki descriptions 2026-08-12 17:14:20 +08:00
github-actions[bot] 70e03887d4 Merge pull request #962 from xlb1130/chore/85200556-im-id-flag-migrations-pending
chore(interface): add IM ID flag migration pending approvals
2026-08-12 08:40:45 +00:00
xlb1130 6ac2bbb7cf Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 16:23:15 +08:00
github-actions[bot] 5812276f46 Merge pull request #958 from typefield/codex/upgrade-stream-client-v0.9.2-beta.1
chore(deps): upgrade DingTalk Stream SDK to v0.9.2-beta.1
2026-08-12 08:15:00 +00:00
john 74baac23a1 Merge branch 'main' into codex/upgrade-stream-client-v0.9.2-beta.1 2026-08-12 15:51:30 +08:00
玉澜 b31eaec78d docs: remove ZCode release fragment 2026-08-12 15:47:39 +08:00
xlb1130 34c5118e85 Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 15:36:05 +08:00
玉澜 6e4ea0980f feat(skill): support ZCode skill root 2026-08-12 15:34:38 +08:00
github-actions[bot] 3ce0e001c1 Merge pull request #961 from yutongShe/feat/drive-file-comments
feat(drive): add file comment commands
2026-08-12 15:20:41 +08:00
xlb1130 077a5c3b30 Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 14:57:37 +08:00
之桐 f3567fba71 Merge remote-tracking branch 'upstream/main' into feat/drive-file-comments 2026-08-12 14:54:18 +08:00
github-actions[bot] e7837cdc6b Merge pull request #964 from typefield/fix/upgrade-default-multi
fix(skill): avoid duplicate Agent skill roots
2026-08-12 14:50:57 +08:00
长真 88e2f8e9e2 chore(interface): address migration approval review feedback to #85200556 2026-08-12 14:40:52 +08:00
之桐 b131726497 docs: add drive file comment release fragment 2026-08-12 14:36:26 +08:00
之桐 86ec9733c0 Merge remote-tracking branch 'upstream/main' into feat/drive-file-comments 2026-08-12 14:35:22 +08:00
玉澜 8a60334978 Merge remote-tracking branch 'upstream/main' into fix/upgrade-default-multi 2026-08-12 14:24:52 +08:00
之桐 76a6980244 fix(drive): validate numeric file comment IDs 2026-08-12 14:24:50 +08:00
玉澜 fcbbc0bd9a fix(skill): require explicit nested layout migration 2026-08-12 14:21:47 +08:00
github-actions[bot] 31edcc3c5a Merge pull request #888 from DingTalk-Real-AI/codex/release-fragments
release: use isolated changelog fragments
2026-08-12 14:21:18 +08:00
wxianfeng bfd836064d feat: support optional flag rename governance to #85349380 2026-08-12 13:59:07 +08:00
chichuan 305ccf0984 Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 13:53:37 +08:00
chichuan c2c1131079 fix: match the release archive directory literally, not as a regex
release_version was interpolated into an awk regex, where '.' matches any
character. Version 1.0.1-beta.1 therefore also admitted
.changes/released/1x0x1-betaX1/, letting the archive drift from the
CHANGELOG version while every other seal assertion still passed and
breaking the documented audit trail.

Compare the archive prefix with index() and split the basename off with
substr(), matching the literal-comparison idiom already used throughout
check-changelog-pr.sh. Only the basename, whose character class is fixed,
stays a pattern.
2026-08-12 13:52:25 +08:00
玉澜 24ea2505a5 test(skill): cover upgrade migration branches 2026-08-12 13:44:14 +08:00
玉澜 566e94a31e fix(skill): make generic cleanup deterministic 2026-08-12 13:19:52 +08:00
玉澜 5c68e4d9cc fix(skill): avoid duplicate Agent skill roots 2026-08-12 12:32:53 +08:00
github-actions[bot] 38e387bcd6 Merge pull request #959 from DingTalk-Real-AI/codex/drive-shortcuts
feat(drive): harden and expand shortcut workflows
2026-08-12 12:18:58 +08:00
长真 276ab52aed chore(interface): add im id flag migration pending approvals to #85200556 2026-08-12 12:14:10 +08:00
chichuan 12435e6e54 refactor: stage the .changes diff once for both fragment triggers
Both trigger predicates ran the same git diff, which the script already
avoids elsewhere by staging --name-status into $tmp_root/status. Write the
path list once and let each awk predicate read it, matching that idiom.
2026-08-12 12:07:07 +08:00
chichuan 1d8182bcfb Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 12:01:38 +08:00
chichuan 4243676739 fix: trigger release fragment tree validation on nested .changes paths
Git records no diff entry for a directory itself, so adding
.changes/foo/bar.md only surfaced the nested path, which the single-level
trigger regex skipped. The entry validation and the renderer were both
bypassed, letting a nested directory reach main and break every later
fragment render with 'unexpected directory'.

Trigger the top-level tree validation on any .changes change outside
.changes/released/ (which keeps its own immutability and release-seal
checks), and assert .changes itself is still a tree so replacing it with a
blob or symlink cannot empty the child listing unnoticed.

Re-rendering stays keyed on fragment changes so a README-only edit does
not fail on an empty fragment set.
2026-08-12 12:00:42 +08:00
之桐 bdf3048773 feat(drive): add file comment commands 2026-08-12 11:29:21 +08:00
玉澜 57e23d661d chore(deps): upgrade DingTalk Stream SDK to v0.9.2-beta.1 2026-08-12 10:57:37 +08:00
chichuan e0dd800378 docs: state the release fragment filename and file-kind contract 2026-08-11 21:24:43 +08:00
chichuan 309c39a8e0 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 21:24:25 +08:00
chichuan 39d6caa24d fix: validate every top-level .changes entry in the fragment gate
The fragment gate only ran validation when the changed path matched the
legal fragment name pattern, so `.changes/Foo.md`, `.changes/notes.txt`
and a symlinked fragment slipped through untouched and then broke the
next PR that added a legal fragment. The trigger now fires on any
top-level `.changes/` change other than README.md and rejects every
entry that is not README.md, released/, or a 100644 blob named
^[a-z0-9][a-z0-9._-]*\.md$.

The renderer had the same hole from the other side: `find -type f`
is false for symlinks, so a symlinked fragment was silently dropped
from the rendered notes, and the `[a-z0-9]*.md` glob only constrained
the first character so `chat reply.md` passed. It now walks every
top-level entry and fails on symlinks, unexpected directories,
non-regular files and illegal names. Both scripts pin LC_ALL=C so the
ASCII ranges cannot match uppercase under a different collation.

Adds regression coverage for illegal names, non-markdown entries,
symlinks and executable modes on both the gate and the renderer.
2026-08-11 21:07:12 +08:00
chichuan afb25ae0e9 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 19:49:49 +08:00
chichuan 3af7adaad6 Merge branch 'main' into codex/release-fragments 2026-08-10 17:06:47 +08:00
chichuan 1f127881c9 Merge branch 'main' into codex/release-fragments 2026-08-07 10:24:51 +08:00
chichuan c52f2b6e05 release: use isolated changelog fragments 2026-08-06 10:49:46 +08:00
130 changed files with 9312 additions and 428 deletions
+8
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
+5
View File
@@ -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.
+34
View File
@@ -0,0 +1,34 @@
# Release fragments
普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的
`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免
并行 PR 争用同一文件。
文件名使用能唯一定位变更的短名,通常是 PR 号,例如
`1234-chat-reply-mentions.md`。文件名必须匹配
`^[a-z0-9][a-z0-9._-]*\.md$`,且必须是普通文件,不能是符号链接。本目录顶层
只接受 `README.md`、`released/` 和符合该规则的 fragment:fragment 一律平铺在
顶层,不接受任何其它子目录,本目录自身也不能被替换成文件或符号链接。其余条目
会被 CI 直接拒绝而不是忽略,以免非法条目跳过校验后拖垮下一个 PR。文件格式
严格如下:
```markdown
---
category: Added
---
- **Chat reply mentions** (#1234) — supports mentioning selected members.
```
`category` 只能是 `Added`、`Changed`、`Deprecated`、`Removed`、`Fixed` 或
`Security`。正文至少包含一个 Markdown 列表项,且不得包含 `TODO` 或 `TBD`。
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该
例外是否成立。
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Agent retry safety** — adds a conditional idempotency contract and base-owned compatibility governance for deduplication-key-aware retries.
+9
View File
@@ -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.
+4 -2
View File
@@ -19,8 +19,10 @@ repeat the entire CI suite locally only to fill this checklist: CI expands the
selected tier from documentation checks, through affected-package tests, to
the complete high-risk suite.
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Release fragment added for a user-visible behavior/interface change (otherwise `N/A`):
`.changes/<unique-name>.md`; ordinary PRs must not edit `CHANGELOG.md`.
- [ ] Release-seal validation (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --content-only "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Targeted test/check commands and results:
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
+17 -4
View File
@@ -1288,6 +1288,12 @@ jobs:
./scripts/policy/check-changelog-pr.sh \
"$mode" "$PR_BASE_SHA" HEAD
- name: Validate release fragment lifecycle
if: github.event_name == 'pull_request'
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
- name: Validate trusted main CHANGELOG-only push
if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true'
env:
@@ -1408,10 +1414,17 @@ jobs:
- name: Check complete Schema compatibility
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') }}
run: |
make schema-compatibility \
BASE_REF="$COMPATIBILITY_BASE_REF" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
set -eu
authority_worktree="$RUNNER_TEMP/dws-schema-authority-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
cleanup_schema_authority() {
git worktree remove --force "$authority_worktree" >/dev/null 2>&1 || true
}
trap cleanup_schema_authority EXIT HUP INT TERM
git worktree add --detach "$authority_worktree" "$COMPATIBILITY_BASE_REF"
"$authority_worktree/scripts/policy/check-authoritative-schema-compatibility.sh" \
--base-ref "$COMPATIBILITY_BASE_REF" \
--stable-ref "$COMPATIBILITY_STABLE_REF" \
--candidate-ref "$COMPATIBILITY_CANDIDATE_REF"
- name: Check skill command references
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') }}
+1 -1
View File
@@ -2793,7 +2793,7 @@ jobs:
fi
if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then
echo
echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish."
echo "Plan only: no tag or package was created. Render pending \`.changes/*.md\` fragments into the exact \`CHANGELOG.md\` section, merge the release-seal PR to main, then run publish."
fi
} >> "$GITHUB_STEP_SUMMARY"
+8 -1
View File
@@ -47,7 +47,7 @@ Schema contract) keep separate authorities — do not merge them with
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`)
- Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`.
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. `idempotency=conditional` additionally requires `Contract.RetryPolicy`: reference a declared string parameter with an explicit interface property and require the same payload; never infer retry safety from a flag/property name. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
- Package seam:
- types / ProductDecl → `corecmd/contract` (DTO only; **no** Cobra-keyed ContractFinal store)
- AnnotateRuntime* writers → `internal/corecmd/runtimeannotate` (framework-owned)
@@ -423,6 +423,13 @@ lower description, mapping, `effect`, `risk`, `confirmation`, or `idempotency`.
Preserve all candidates and the selected source in provenance, and fail
same-precedence conflicts rather than silently merging them.
`conditional` is a static command classification, not an unconditional retry
grant. It must carry a typed `retry_policy`; runtime computes effective
idempotency from the actual invocation. A missing/empty/non-string
deduplication key is `non_idempotent` for that invocation. Only the same key
and same business payload may be replayed, and the transport/error
`retryable` signal must fail closed when the invocation is not safe to replay.
`required` is the exception. Cobra `MarkFlagRequired` is a hard floor: the
final Agent projection must keep `required=true` and cannot be lowered by a
lower-precedence source. A higher-precedence declaration may still raise an
+4 -1
View File
@@ -83,7 +83,10 @@ coverage is additionally selected for platform-sensitive code.
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
installer surfaces change (run `make package` first).
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
7. Update docs and add one `.changes/<unique-name>.md` release fragment for
behavior/interface changes. Do not edit `CHANGELOG.md` in an ordinary PR;
the release-seal workflow renders and archives fragments into the versioned
changelog section.
## Submission Flow
+3 -3
View File
@@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
The upgrade process follows a two-phase atomic flow to ensure consistency:
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
@@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` installs under `$HOME/.agents/skills/` (global; multi layout is per-product siblings, mono is the `dws/` subdirectory); `install-skills.sh` installs under `./.agents/skills/` (current project).
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
@@ -433,7 +433,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| Flag | Values | Description |
|------|--------|-------------|
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home, including ZCode at `~/.zcode/skills` |
| `--source` | path | Local source directory (overrides bundled skills) |
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
+3 -3
View File
@@ -207,7 +207,7 @@ bash verify-all-channels.sh
升级过程采用两阶段原子流程,确保一致性:
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包平铺到已检测到的具体 Agent 目录(例如 `~/.codex/skills/dingtalk-chat`、`~/.claude/skills/dingtalk-chat`)。只有未检测到具体 Agent 时才使用 `~/.agents/skills`;检测到具体 Agent 后会备份迁走旧的 DWS 通用副本,避免同一 Skill 被重复发现。
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
@@ -399,7 +399,7 @@ Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / Con
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` 安装到 `$HOME/.agents/skills/`(全局,multi 为按产品平铺,mono 为 `dws/` 子目录);`install-skills.sh` 安装到 `./.agents/skills/`(当前项目)。
> 安装器优先使用检测到的具体 Agent 根目录(如 `$HOME/.codex/skills/`);仅在未检测到具体 Agent 时回退到 `.agents/skills/`。multi 为按产品平铺,mono 为 `dws/` 子目录。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
@@ -427,7 +427,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| 参数 | 取值 | 说明 |
|------|------|------|
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | 安装目标;`all` 表示铺到检测到的具体 Agent home(ZCode 为 `~/.zcode/skills`),仅在未检测到具体 Agent 时回退到 `~/.agents/skills` |
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
+67
View File
@@ -17,6 +17,7 @@ const AGENT_DIRS = [
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
@@ -233,6 +234,10 @@ function installSkillsToHomes(skillRoot) {
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
@@ -252,6 +257,9 @@ function installSkillsToHomes(skillRoot) {
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
@@ -265,6 +273,15 @@ function installSkillsToHomes(skillRoot) {
}
});
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
@@ -329,6 +346,40 @@ function isManagedMultiSkillDir(dir, managedNames) {
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
}
function retireGenericSkillRoot(homeDir, managedNames) {
const baseDir = path.join(homeDir, ".agents", "skills");
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
}
const backups = [];
try {
for (const victim of victims) {
if (!backupAndRemoveSkillDir(homeDir, victim, backups)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
} catch (err) {
const restoreErrors = [];
for (let i = backups.length - 1; i >= 0; i -= 1) {
try {
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
fs.renameSync(backups[i].backup, backups[i].original);
} catch (restoreErr) {
restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`);
}
throw err;
}
}
function skillDirectoryDigest(dir) {
const files = [];
const visit = (current, prefix) => {
@@ -577,6 +628,10 @@ function installMultiSkillsToHomes(multiRoot) {
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
fs.mkdirSync(baseDir, { recursive: true });
const victims = [path.join(baseDir, "dws")];
@@ -604,6 +659,9 @@ function installMultiSkillsToHomes(multiRoot) {
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
@@ -617,6 +675,15 @@ function installMultiSkillsToHomes(multiRoot) {
}
});
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
+60 -8
View File
@@ -48,8 +48,12 @@ It then runs:
--fast-path "$PR_BASE_SHA" HEAD
```
Because the verified PR diff contains only `CHANGELOG.md`, the validator and
its policy dependencies in that merge tree are byte-for-byte the current base
The exact fast path remains limited to historic one-file maintenance. A
release-seal PR uses `--content-only`, which permits the generated
`CHANGELOG.md` change together with archival moves from `.changes/` to
`.changes/released/`; it receives the normal scoped admission instead of this
fast path. Ordinary PRs must not modify `CHANGELOG.md`; they add a standalone
release fragment instead. The validator and its policy dependencies in that merge tree are byte-for-byte the current base
versions. Validation targets the synthetic merge tree, not the feature-branch
tree, so a stale branch cannot supply an older validator or combine with newer
base notes into an invalid final CHANGELOG.
@@ -79,10 +83,12 @@ to the complete main admission suite. A source change can therefore never
inherit the CHANGELOG-only result.
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
content contract in `Policy` with `--content-only`. That mode permits the
second file but still rejects invalid dates or versions, missing bullets,
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
Adding a second file therefore cannot bypass CHANGELOG validation.
content contract in `Policy` with `--content-only`. That mode accepts only
fragment archival moves (`.changes/<name>.md` to
`.changes/released/<version>/<name>.md`) alongside the changelog; source and
documentation changes are rejected. It still rejects invalid dates or
versions, missing bullets, placeholder `TODO`/`TBD`, unmanaged-section
changes, and unsafe tree modes.
## Risk tiers and downstream boundaries
@@ -184,11 +190,11 @@ Schema,并让 candidate 对两份历史 contract 独立执行检查;它只
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
放宽其他 Schema 历史字段。
For an exact CHANGELOG-only branch:
For a release-seal branch that archives rendered fragments:
```sh
base_ref=$(git merge-base HEAD origin/main)
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
./scripts/policy/check-changelog-pr.sh --content-only "$base_ref" HEAD
```
`make coverage-gate` is an enforcement step, not a profile generator. For a
@@ -223,6 +229,52 @@ tool、parameter、mapping、positional execution、constraint 与 safety 语义
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
## 条件幂等与重试策略迁移
静态 `idempotency` 描述命令级分类;当它是 `conditional` 时,再由实际调用参数计算
本次调用的 effective idempotency。不能因为命令“存在一个可选 `--uuid` flag”就把
整个命令从 `unknown` 改成无条件 `idempotent`。Schema 增加 `conditional` 状态以及
与它配套的 `retry_policy`:
```json
{
"idempotency": "conditional",
"retry_policy": {
"mode": "deduplication_key",
"key_parameter": "uuid",
"same_payload_required": true
}
}
```
只有调用实际提供 `uuid`,并且框架重用同一个 key 和同一份请求 payload 时,调用级
有效幂等性才是 `idempotent`;未提供时仍是 `non_idempotent`。本契约没有发布后端
未证明的去重时间窗。`conditional` 仅允许用于单 RPC 的 `mcp` leaf,key 必须对应
一个存在且有非空 interface property 的真实参数。其余三种静态状态不得携带
`retry_policy`。
兼容迁移分三步治理:
1. 独立治理 PR 在 Schema surface 不变时先加入契约、校验器和精确登记。本轮只登记
57 个 Chat 写命令:5 个 `unknown -> conditional`,52 个
`unknown -> non_idempotent`。
2. 后续业务 PR 只消费这些已登记转换。5 个条件幂等命令必须逐字发布上面的策略;
PR #965 原先的 `unknown -> idempotent` 仍会失败。未登记工具、反向迁移、策略漂移
以及同一 PR 夹带的其它历史契约变化也仍会失败。
3. 当 `main` 与当前 stable baseline 都已经覆盖迁移后,再由独立治理 PR 删除失效的
静态登记,避免把一次迁移审批永久留在规则中。
Interface Integrity job 的兼容性 step 在 PR merge-base 建立独立 authority worktree,
并从该 worktree 启动权威 wrapper;它不经过 candidate checkout 中的
Makefile 或 wrapper。权威 wrapper 再从 merge-base 构建 `schema-compat` checker,
分别比较 merge-base 和 stable。因此,在该 step 实际执行时,业务 candidate
修改 candidate checkout 中的 Makefile、wrapper、checker 或登记表都不能给
自己授权;只有先合入主干、经过独立评审的登记会被后续 PR 使用。
CLI 评测和 Agent 消费方应把 `conditional` 与 `retry_policy` 作为一个整体读取:静态
`conditional` 不是扣分意义上的“不知道”,也不是无条件可重试;评测应根据
`key_parameter` 是否出现在实际调用中计算本次调用的有效幂等性。
## Required GitHub repository settings
The `main` quality ruleset must enable strict required-status-check policy
+5 -5
View File
@@ -1,6 +1,6 @@
# CLI flag 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
@@ -41,7 +41,7 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
## 两阶段迁移与回执清理
@@ -50,7 +50,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
@@ -122,7 +122,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
@@ -145,7 +145,7 @@ legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interfa
`required` / `cli_required` 或重写 constraint;
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
`required_when` 必须完全一致;
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
- `required` / `cli_required` 必须在 rename 前后完全一致,升高或降低都失败;
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
+2
View File
@@ -81,6 +81,7 @@ command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷
| `Flags[]`(`FlagSpec` / `LeafFlag`) | 用户可见参数面:名、类型、默认、必填、usage | 注册 cobra flag;装配 toolArgs | `dws.schema.property` / `type` / `required`;`--help` Flags |
| `Constraints[]` | 跨 flag 关系:`at_least_one` / `exactly_one` / `mutually_exclusive`;`custom` 记录钩子校验 | 通用关系由 `ValidateConstraints` 执行;`custom` 由 `Validate` 执行 | `dws.schema.constraints`;`--help`「参数约束」 |
| `Safety`(`contract.SafetySpec`) | effect/risk/confirmation/idempotency 四个独立事实 | `confirmation=user_required` 时 `ConfirmSafety`;`--yes` / `--dry-run` 跳过 | 同一个 SafetySpec 原样进入 Contract Final(`HOM-S1`) |
| `Contract.RetryPolicy` | `idempotency=conditional` 时的调用级重试条件;当前仅支持显式去重 key | 结合实际参数计算 effective idempotency;缺 key 时禁止自动重试 | 与 `conditional` 成组进入 full/compact leaf 和 `ResolveMeta`;禁止按 flag 名推断 |
| `ConstParams` | 固定载荷(不上 flag 表) | 并入 toolArgs;不满足 Required | **不**投影为用户 parameter |
| `Use` / `Short` / `Long` / `Example` | 命令身份文案与示例 | cobra 自身 | help;identity 以 collector 收集的 `ContractFinal.Identity` 声明为准(reviewed registry 已退役) |
@@ -170,6 +171,7 @@ NewLeafCommand(LeafSpec{
| Constraints | **声明** | `Constraints` |
| Positionals | **声明** 或显式 annotate | 目标 `Args`;禁止推断 |
| Safety.`effect/risk/confirmation/idempotency` | **声明**完整 `Safety`,或迁移期 `runtime_gate` / reviewed Safety | 四字段独立;不得互相推导 |
| RetryPolicy | **声明**(仅 `idempotency=conditional`) | `ContractDecl.RetryPolicy`;key 必须引用显式映射到 RPC property 的 string `ParamDecl`,并要求相同 payload |
| DryRun | 评审源 | dry-run capabilities registry |
| Interface | 评审源 | MCP + 内存 inject 的 Agent metadata |
| Selection | 声明(ContractFinal / ProductDecl) | `ContractDecl.Selection` / `ProductDecl` |
+43
View File
@@ -7,6 +7,8 @@
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent as `x-dws-agent-product` (for example `qwenwork`) for downstream logs/BI and used as the IM `clawType` display label when `--ai-tag` is enabled. `--ai-tag` defaults to `true`, so a configured Product changes the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls send an empty `clawType`, while shortcut calls omit the argument. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values omit the Header and use the edition's IM display default. This client never uses Product to change the separate HTTP `claw-type` PAT/routing label. / 可选、由调用方声明的 Agent 产品标识,经校验后作为 `x-dws-agent-product` 发送,并用于 IM 小尾巴;`--ai-tag` 默认为 `true`,因此配置 Product 后默认会改变展示标签。使用 `--ai-tag=false` 时,原生 `chat message send` / `reply` 发送空的 `clawType`,shortcut 调用则省略该参数。未设置时省略请求头且 IM 使用发行版默认值;本客户端不会用 Product 修改独立的 HTTP `claw-type` |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`) for downstream logs/BI. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. This client does not use Host for PAT, authentication, Discovery, or MCP endpoint selection. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送给下游日志/BI;本客户端不使用该值进行 PAT、鉴权、Discovery 或 MCP 端点选择,未设置时省略 |
| `DWS_AGENT_VER` | Optional caller-declared Agent version / 可选、由调用方声明的 Agent 版本。After trimming surrounding ASCII spaces/tabs, the value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9._+-]*$`; a non-empty valid value is sent as `x-dws-agent-ver`, while unset or empty values omit the Header. / 去除首尾 ASCII 空格和 Tab 后,值不得超过 64 字节且必须匹配上述格式;合法非空值通过 `x-dws-agent-ver` 发送,未设置或空值则省略请求头 |
| `DWS_AGENT_EXT` | Optional caller-declared Agent extended context / 可选、由调用方声明的 Agent 扩展上下文。The value must be a UTF-8 JSON object no larger than 8 KiB, is compacted before being sent as the sensitive `x-dws-agent-ext` Header, and may use the recommended keys `umt`, `miniwua`, and `ua`; unknown keys remain supported. Unset or empty values omit the Header. / 值必须是 UTF-8 JSON 对象且不得超过 8 KiB,压缩后通过敏感请求头 `x-dws-agent-ext` 发送;推荐使用 `umt`、`miniwua`、`ua`,同时允许未知扩展键。未设置或空值则省略请求头 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
@@ -14,6 +16,47 @@
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
### Agent Version and Extended Context / Agent 版本与扩展上下文
`DWS_AGENT_VER` and `DWS_AGENT_EXT` are sent only on the CLI's ordinary,
non-plugin MCP requests. They do not change the standard HTTP `User-Agent` or
the separate `X-Cli-Version` that identifies the DWS CLI version, and they are
not forwarded to A2A, OAuth, Discovery, or third-party plugin requests.
`DWS_AGENT_EXT` is one JSON-object Header rather than a set of Headers. The
recommended keys are `umt`, `miniwua`, and `ua`, but the open-source CLI keeps
the object extensible and does not enforce a key allowlist. For example, using
fictional, redacted values:
```bash
DWS_AGENT_VER=0.1.5
DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}'
```
The shell's outer single quotes group the JSON and are not part of the
environment-variable value. The CLI trims surrounding ASCII spaces/tabs,
omits either Header when its value is empty, and compacts EXT to a single-line
JSON object. A representative current payload is about 657 bytes, well below
the 8 KiB limit; integrations must still enforce the limit because values can
grow. EXT may contain sensitive device or runtime signals: the CLI masks it in
configuration and logs, and removes it on a cross-host redirect.
Both values are declared by the caller and are therefore forgeable. They can
support compatibility checks, diagnostics, and observability, but they are not
credentials or attestations and must never be sufficient on their own to
authenticate a caller or authorize access.
`DWS_AGENT_VER` 与 `DWS_AGENT_EXT` 仅随 CLI 发起的普通非插件 MCP 请求发送,不会
改变标准 HTTP `User-Agent`,也不会覆盖标识 DWS CLI 自身版本的 `X-Cli-Version`;
二者不会进入 A2A、OAuth、Discovery 或第三方插件请求。EXT 使用单个 JSON 对象
请求头,不拆成多个子请求头;推荐键为 `umt`、`miniwua`、`ua`,但开源 CLI 不限制
扩展键。Shell 示例中的外层单引号只用于保护 JSON,不属于环境变量值。当前典型负载
约为 657 字节,远低于 8 KiB 上限,但集成方仍须遵守大小限制。EXT 可能包含敏感的
设备或运行时信号,配置展示和日志会对其脱敏,跨主机重定向时也会移除该请求头。
这两个值都由调用方自行声明,可以被伪造;它们可用于兼容性判断、诊断和可观测性,
但不是凭据或可信证明,不能单独用于身份认证或访问授权。
### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type`
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation
+13 -2
View File
@@ -17,7 +17,8 @@
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
3. workflow summary 会给出唯一的下一版本。运行 `prepare-changelog.sh` 将已合入的
release fragments 汇总成对应的精确 `CHANGELOG.md` 章节,并通过唯一的 release-seal PR 合入 `main`。
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
@@ -94,7 +95,8 @@ main 上的候选代码 + beta CHANGELOG
dws-release v1.2.3-beta.1
```
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
如果 CHANGELOG 尚不存在,该命令会从 `.changes/*.md` 生成 beta 章节并归档已消费的
fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
```bash
dws-release v1.2.3-beta.1
@@ -132,6 +134,15 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
### Release fragments
普通 PR 不修改 `CHANGELOG.md` 的 `Unreleased` 区域。需要面向用户发布说明的改动在
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
[`.changes/README.md`](../.changes/README.md)。预发封板时
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
+2 -2
View File
@@ -357,7 +357,7 @@ Definition(仅声明;不可编译)
| **Constraints** | `require_one_of`, `mutually_exclusive`, `require_together` | **声明** | `Constraints` → `AnnotateConstraints` | **是** |
| **Positionals** | 位置参数名/必填/说明 | **声明** 或显式 annotate | 目标 `Args`/`PositionalSpec`;今日少量 cobra Args + 注解 | 受管命令应声明,禁止推断 |
| **Safety** | `effect`, `risk`, `confirmation`, `idempotency` | **声明**(完整 `contract.SafetySpec`)**或标注**(`runtime_gate`) | `Safety` / `AnnotateRuntimeGate`(metadata 壳 `tools: {}`,不再承载 reviewed Safety) | 四字段独立;confirmation 单独驱动运行时 |
| | `idempotency` | 评审源(或未来 Contract) | reviewed metadata | 今日非框架声明;不得推断 |
| | `retry_policy` | **声明**(仅 `idempotency=conditional`) | `ContractDecl.RetryPolicy` + 显式 `ParamDecl.Property` + MCP `interface_ref` | 从实际调用参数计算 effective idempotency;缺 key 时失败闭合,禁止按参数名推断 |
| | `effect_source` / provenance | 组装派生物 | resolver 写入 `FieldProvenance` | 派生,不手写 |
| **DryRun** | `preview_kind`, `remote_reads` | 评审源 | `schema_dry_run_capabilities`(正能力声明) | 否;无条目 ≠ 推断「不支持」之外的假能力 |
| **Interface** | `interface_mode`, `interface_ref`, `availability`, `reason` | 评审源 | MCP meta + agent metadata 解析 | 否;与 CLI Identity 分离 |
@@ -1167,7 +1167,7 @@ cmd.RunE = func(cmd *cobra.Command, args []string) error {
| 非 Shortcut 受管定义的 Cobra 命令 Hidden | 可执行 Contract | 挂载的命令可见性与声明匹配 |
| Shortcut 列表成员资格与语义 disposition | 经评审的 Shortcut 可见性解析器 | public/all 列表成员资格与经评审决策匹配 |
| Runtime Schema / Agent 暴露 | identity collector 收集结果加精确排除 | 每个暴露叶子解析到活 Contract;排除显式且不重叠 |
| Safety 与运行时确认 | 可执行 Contract 的完整 `contract.SafetySpec`,或迁移期显式 annotate(如 `runtime_gate`);见 §5.0 | `confirmation` 单独驱动运行时门,`effect` / `risk` / `idempotency` 原样发布,禁止跨字段机械推导;任一 Safety 字段非空时四字段必须齐全,否则构造期 panic;`ConfirmFirst` 只在 `confirmation=user_required` 时合法 |
| Safety、条件幂等与运行时确认 | 可执行 Contract 的完整 `contract.SafetySpec`,或迁移期显式 annotate(如 `runtime_gate`);条件幂等另由 `ContractDecl.RetryPolicy` 声明;见 §5.0 | `confirmation` 单独驱动运行时门,`effect` / `risk` / `idempotency` 原样发布,禁止跨字段机械推导;任一 Safety 字段非空时四字段必须齐全,否则构造期 panic;`conditional` 必须引用显式映射到 RPC property 的 string 参数,并在缺 key 时禁用重试;`ConfirmFirst` 只在 `confirmation=user_required` 时合法 |
| 后端 product/tool/载荷绑定 | mcpbind + 后端元数据 | 每个绑定引用真实的 flag/属性 |
| Agent 选择文案(`use_when`、`avoid_when`、摘要) | 声明:`ContractDecl.Selection` / `ProductDecl`(交付 provenance `contract_final`);`schema_hints/` 已退役,禁止回潮 | 身份解析到活契约;选择文案不得创建 CLI 表面 |
@@ -142,6 +142,9 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
升级器对每个 Agent 目标执行:
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
2. 在目标文件系统的 staging 中复制完整新集合;
3. staging 全部成功后,才将旧集合移入备份目录;
+1 -1
View File
@@ -14,7 +14,7 @@ require (
github.com/itchyny/gojq v0.12.18
github.com/mattn/go-isatty v0.0.20
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
+2 -2
View File
@@ -88,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1 h1:5WwR5TV6A12taXMH7SggT8yCMMJMF9jWE7Wj+4AuHck=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+1 -1
View File
@@ -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)
+248
View File
@@ -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
}
+743
View File
@@ -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
}
+1 -1
View File
@@ -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)
+11 -14
View File
@@ -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()
+10 -5
View File
@@ -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)
}
}
+3 -3
View File
@@ -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()
}
+308
View File
@@ -0,0 +1,308 @@
// 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"
"errors"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
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/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func retryGateTestRunner() *runtimeRunner {
client := transport.NewClient(nil)
return &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "test-token"},
auditSink: audit.NopSink{},
}
}
func TestCrossPlatformCoverageRuntimeRetryGateControlsTransportAndAPIHint(t *testing.T) {
testseam.Swap(t, &runnerPreflightDocDownload, func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return nil
})
testseam.Swap(t, &runnerCaptureRuntimeFailure, func(executor.Invocation, error, error) {})
tests := []struct {
name string
decision *contract.RetryDecision
wantRetries int
wantRetryable bool
}{
{name: "missing contract fails closed", wantRetries: 0, wantRetryable: false},
{
name: "conditional contract without key fails closed",
decision: &contract.RetryDecision{
EffectiveIdempotency: "non_idempotent",
SafeToRetry: false,
Reason: "deduplication_key_missing",
},
wantRetries: 0,
wantRetryable: false,
},
{
name: "resolved idempotent contract preserves retry",
decision: &contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "deduplication_key_present",
},
wantRetries: 1,
wantRetryable: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotRetries := -1
testseam.Swap(t, &runnerCallTool, func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
gotRetries = client.MaxRetries
return transport.ToolCallResult{}, apperrors.NewAPI(
"server unavailable",
apperrors.WithReason("http_503"),
apperrors.WithRetryable(true),
apperrors.WithRetryAfterSeconds(3),
)
})
runner := retryGateTestRunner()
_, err := runner.executeInvocation(context.Background(), "https://example.test", executor.Invocation{
CanonicalProduct: "chat",
Tool: "send_message",
Params: map[string]any{"uuid": "stable-key"},
Retry: tt.decision,
})
if gotRetries != tt.wantRetries {
t.Fatalf("transport MaxRetries = %d, want %d", gotRetries, tt.wantRetries)
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T %v, want typed API error", err, err)
}
if !typed.RetryableSet || typed.Retryable != tt.wantRetryable {
t.Fatalf("retryable = (%v, %v), want (true, %v)", typed.RetryableSet, typed.Retryable, tt.wantRetryable)
}
if tt.wantRetryable {
if typed.RetryAfterSeconds == nil || *typed.RetryAfterSeconds != 3 {
t.Fatalf("safe retry_after_seconds = %v, want 3", typed.RetryAfterSeconds)
}
} else if typed.RetryAfterSeconds != nil || typed.NextRetryAt != nil {
t.Fatalf("unsafe retry kept backoff hints: after=%v at=%v", typed.RetryAfterSeconds, typed.NextRetryAt)
}
if runner.transport.MaxRetries != 1 {
t.Fatalf("shared transport MaxRetries = %d, want unchanged 1", runner.transport.MaxRetries)
}
})
}
}
func TestCrossPlatformCoverageRuntimeRetryGateDoesNotClampAuthErrors(t *testing.T) {
testseam.Swap(t, &runnerPreflightDocDownload, func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return nil
})
testseam.Swap(t, &runnerCaptureRuntimeFailure, func(executor.Invocation, error, error) {})
authErr := apperrors.NewAuth("expired", apperrors.WithRetryable(true))
testseam.Swap(t, &runnerCallTool, func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, authErr
})
_, err := retryGateTestRunner().executeInvocation(context.Background(), "https://example.test", executor.Invocation{
CanonicalProduct: "chat",
Tool: "send_message",
Params: map[string]any{},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T %v, want typed auth error", err, err)
}
if typed.Category != apperrors.CategoryAuth || !typed.RetryableSet || !typed.Retryable {
t.Fatalf("auth retryability was changed: %#v", typed)
}
}
func TestCrossPlatformCoverageRuntimeRetryDecisionPrecedesDryRun(t *testing.T) {
resolverCalls := 0
runner := retryGateTestRunner()
runner.globalFlags.DryRun = true
runner.resolveToolCallRetry = func(productID, rpcName string, args map[string]any) contract.RetryDecision {
resolverCalls++
if productID != "chat" || rpcName != "send_message" || args["uuid"] != "stable-key" {
t.Fatalf("resolver input = %q/%q %#v", productID, rpcName, args)
}
return contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "deduplication_key_present",
}
}
result, err := runner.Run(context.Background(), executor.NewHelperInvocation(
"chat send",
"chat",
"send_message",
map[string]any{"uuid": "stable-key"},
))
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if resolverCalls != 1 || result.Invocation.Retry == nil || !result.Invocation.Retry.SafeToRetry {
t.Fatalf("dry-run retry decision = %#v, resolver calls = %d", result.Invocation.Retry, resolverCalls)
}
if got, ok := result.Response["retry"].(*contract.RetryDecision); !ok || !got.SafeToRetry || got.EffectiveIdempotency != "idempotent" {
t.Fatalf("dry-run response retry = %#v", result.Response["retry"])
}
explicit := contract.RetryDecision{EffectiveIdempotency: "unknown", SafeToRetry: false, Reason: "explicit"}
invocation := executor.NewHelperInvocation("chat send", "chat", "send_message", nil)
invocation.Retry = &explicit
result, err = runner.Run(context.Background(), invocation)
if err != nil {
t.Fatalf("Run(explicit) error = %v", err)
}
if resolverCalls != 1 || result.Invocation.Retry == nil || result.Invocation.Retry.Reason != "explicit" {
t.Fatalf("explicit retry decision was overwritten: %#v, resolver calls = %d", result.Invocation.Retry, resolverCalls)
}
}
func TestCrossPlatformCoverageExecuteInvocationDryRunIncludesRetryDecision(t *testing.T) {
decision := contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "deduplication_key_present",
}
result, err := retryGateTestRunner().executeInvocation(context.Background(), "https://example.test", executor.Invocation{
CanonicalProduct: "chat",
Tool: "send_message",
DryRun: true,
Retry: &decision,
})
if err != nil {
t.Fatalf("executeInvocation() error = %v", err)
}
got, ok := result.Response["retry"].(*contract.RetryDecision)
if !ok || got != &decision || !got.SafeToRetry {
t.Fatalf("dry-run response retry = %#v, want original decision", result.Response["retry"])
}
}
func TestCrossPlatformCoverageToolCallerDryRunIncludesRetryDecision(t *testing.T) {
runner := retryGateTestRunner()
runner.resolveToolCallRetry = func(_ string, _ string, args map[string]any) contract.RetryDecision {
if key, _ := args["uuid"].(string); key == "" {
return contract.RetryDecision{
EffectiveIdempotency: "non_idempotent",
Reason: "deduplication_key_missing",
}
}
return contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "deduplication_key_present",
}
}
var captured executor.Invocation
testseam.Swap(t, &toolCallerDryRun, func(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
captured = invocation
return (executor.EchoRunner{}).Run(ctx, invocation)
})
caller := newToolCallerAdapter(runner, &GlobalFlags{DryRun: true})
result, err := caller.CallTool(context.Background(), "chat", "send_message", map[string]any{"uuid": "stable-key"})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if captured.Retry == nil || !captured.Retry.SafeToRetry || captured.Retry.EffectiveIdempotency != "idempotent" {
t.Fatalf("captured retry decision = %#v", captured.Retry)
}
if len(result.Content) != 1 || !bytes.Contains([]byte(result.Content[0].Text), []byte(`"effective_idempotency":"idempotent"`)) {
t.Fatalf("ToolResult does not expose effective idempotency: %#v", result)
}
result, err = caller.CallTool(context.Background(), "chat", "send_message", map[string]any{"content": "hello"})
if err != nil {
t.Fatalf("CallTool(without uuid) error = %v", err)
}
if captured.Retry == nil || captured.Retry.SafeToRetry || captured.Retry.EffectiveIdempotency != "non_idempotent" {
t.Fatalf("missing-key retry decision = %#v", captured.Retry)
}
if len(result.Content) != 1 || !bytes.Contains([]byte(result.Content[0].Text), []byte(`"effective_idempotency":"non_idempotent"`)) {
t.Fatalf("ToolResult does not expose missing-key idempotency: %#v", result)
}
}
func TestCrossPlatformCoverageRuntimeSafeRetryReusesRequestBody(t *testing.T) {
var mu sync.Mutex
var bodies [][]byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("ReadAll(request) error = %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
mu.Lock()
bodies = append(bodies, append([]byte(nil), body...))
attempt := len(bodies)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if attempt == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = io.WriteString(w, `{"jsonrpc":"2.0","id":3,"result":{"content":{"messageId":"m1"}}}`)
}))
defer server.Close()
runner := retryGateTestRunner()
runner.transport = transport.NewClient(server.Client())
runner.transport.RetryDelay = 0
runner.transport.RetryMaxDelay = 0
runner.auditSink = audit.NopSink{}
decision := contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "deduplication_key_present",
}
result, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "chat",
Tool: "send_message",
Params: map[string]any{"uuid": "stable-key", "content": "hello"},
Retry: &decision,
})
if err != nil {
t.Fatalf("executeInvocation() error = %v", err)
}
if !result.Invocation.Implemented {
t.Fatal("successful retry was not marked implemented")
}
mu.Lock()
defer mu.Unlock()
if len(bodies) != 2 {
t.Fatalf("request attempts = %d, want 2", len(bodies))
}
if !bytes.Equal(bodies[0], bodies[1]) {
t.Fatalf("retry body changed:\nfirst: %s\nsecond: %s", bodies[0], bodies[1])
}
}
+133 -24
View File
@@ -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,34 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
// --output sink instead opens at Run entry (after Cobra's own
// validation), so validation failures still cannot strand a
// temporary file.
// Validate caller-provided identity labels before any edition hook
// or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead.
// Validate caller-provided identity and MCP metadata before command
// execution hooks or network activity. The process entry point additionally
// validates Agent metadata before command-tree construction; direct Cobra
// embedding retains this execution-boundary guard.
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
return err
}
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
return err
}
agentMetadata, cached := agentMetadataSnapshotFromContext(cmd.Context())
if !cached {
agentMetadata = readAgentMetadataSnapshot()
}
if err := agentMetadata.validationError(); err != nil {
return err
}
if runtime, ok := runner.(*runtimeRunner); ok {
// Retain the exact validated pair for this command execution so a
// concurrently mutating embedding environment cannot change what is
// later applied after edition and credential hooks.
runtime.agentMetadata = &agentMetadata
}
if shouldDetectNestedSkillLayout(cmd) {
if found, err := detectNestedMultiSkillLayout(); err == nil && found {
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ 检测到旧升级器留下的嵌套 Skill;请运行 dws skill setup --mode multi 查看迁移计划并确认")
}
}
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
@@ -772,10 +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
@@ -786,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)
@@ -1750,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 {
@@ -1771,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
+86 -31
View File
@@ -30,6 +30,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
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/logging"
@@ -137,31 +139,34 @@ 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,
globalFlags: flags,
scanner: newRuntimeContentScanner(),
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
transport: transportClient,
globalFlags: flags,
resolveToolCallRetry: cli.ResolveToolCallRetry,
scanner: newRuntimeContentScanner(),
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
}
}
type runtimeRunner struct {
transport *transport.Client
globalFlags *GlobalFlags
fallback executor.Runner
scanner safety.Scanner
enforceContentScan bool
includeScanReport bool
auditSink audit.Sink
transport *transport.Client
globalFlags *GlobalFlags
fallback executor.Runner
scanner safety.Scanner
enforceContentScan bool
includeScanReport bool
auditSink audit.Sink
agentMetadata *agentMetadataSnapshot
resolveToolCallRetry func(productID, rpcName string, args map[string]any) contract.RetryDecision
}
var (
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
runnerResolveProfile = authpkg.ResolveProfile
runnerGetCachedRuntimeToken = getCachedRuntimeToken
runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
runnerCallTool = (*transport.Client).CallTool
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
@@ -173,6 +178,7 @@ var (
)
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
r.resolveInvocationRetry(&invocation)
// Global dry-run is an execution barrier, not merely a transport option.
// Return a deterministic local preview before profile resolution, catalog
// discovery, Keychain/token prefetch, auth, stateful preflight or transport.
@@ -215,6 +221,14 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
return r.runSingle(ctx, invocation, true)
}
func (r *runtimeRunner) resolveInvocationRetry(invocation *executor.Invocation) {
if invocation == nil || invocation.Retry != nil || r == nil || r.resolveToolCallRetry == nil {
return
}
decision := r.resolveToolCallRetry(invocation.CanonicalProduct, invocation.Tool, invocation.Params)
invocation.Retry = &decision
}
// RunReadOnly executes one already-classified read lookup for a semantic
// Shortcut that is building a dry-run plan. It clones the runtime flags and
// clears DryRun only on that clone: the process-wide caller and every ordinary
@@ -237,7 +251,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 {
@@ -500,6 +513,7 @@ func endpointNotResolvedError(productID, toolName, detail string) error {
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
if IsStdioEndpoint(endpoint) {
defer func() { enforceInvocationRetrySafety(invocation, retErr) }()
return r.executeStdioInvocationAtEndpoint(ctx, endpoint, invocation)
}
@@ -542,10 +556,13 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
retErr == nil, time.Since(invokeStart), errCat, errReason)
emitAudit(auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
}()
// Register this defer after the audit defer so the fail-closed safety value
// is what logging and audit observers see as well as what the caller gets.
defer func() { enforceInvocationRetrySafety(invocation, retErr) }()
// 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 +570,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)
}
@@ -575,14 +592,18 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if argsJSON, err := json.Marshal(invocation.Params); err == nil {
fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON)
}
response := map[string]any{
"dry_run": true,
"endpoint": transport.RedactURL(endpoint),
"request": executor.ToolCallRequest(invocation.Tool, invocation.Params),
"note": "execution skipped by --dry-run",
}
if invocation.Retry != nil {
response["retry"] = invocation.Retry
}
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"dry_run": true,
"endpoint": transport.RedactURL(endpoint),
"request": executor.ToolCallRequest(invocation.Tool, invocation.Params),
"note": "execution skipped by --dry-run",
},
Response: response,
}, nil
}
@@ -603,9 +624,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 +638,23 @@ 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))
}
}
if invocation.Retry == nil || !invocation.Retry.SafeToRetry {
tc.MaxRetries = 0
}
callCtx := ctx
@@ -777,6 +810,24 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
return executor.Result{Invocation: invocation, Response: response}, nil
}
// enforceInvocationRetrySafety intersects server/API retry hints with the
// invocation's reviewed idempotency decision. Auth and PAT recovery have
// independent one-shot retry protocols and are deliberately left untouched.
func enforceInvocationRetrySafety(invocation executor.Invocation, err error) {
if invocation.Retry != nil && invocation.Retry.SafeToRetry {
return
}
var typed *apperrors.Error
if errors.As(err, &typed) && typed.Category == apperrors.CategoryAPI {
typed.Retryable = false
typed.RetryableSet = true
// Server backoff hints describe transience, not replay safety. Keeping
// them next to retryable=false invites an Agent to bypass the contract.
typed.RetryAfterSeconds = nil
typed.NextRetryAt = nil
}
}
func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
ctx context.Context,
endpoint string,
@@ -1069,6 +1120,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
}
@@ -22,6 +22,7 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
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"
@@ -205,10 +206,16 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
retry := contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "static_idempotent",
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "im",
Tool: "list_conversations",
Params: map[string]any{"pageSize": 100},
Retry: &retry,
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
+1
View File
@@ -126,6 +126,7 @@ var agentSkillPaths = map[string]string{
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"zcode": ".zcode/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
// IDE / agent registries also probed by `dws skill setup --target all`.
"gemini": ".gemini/skills",
+1 -1
View File
@@ -452,7 +452,7 @@ func TestSupportedTargets(t *testing.T) {
// Should contain all predefined targets — including the agents/* sentinel
// and the IDE/agent registries we share with skillSetupAgentHomes.
expectedTargets := []string{
"agents", "claude", "cursor", "codex", "opencode", "qoder",
"agents", "claude", "cursor", "codex", "zcode", "opencode", "qoder",
"gemini", "github", "windsurf", "augment", "cline",
"amp", "kiro", "trae", "openclaw", "hermes",
".",
+97 -7
View File
@@ -29,6 +29,7 @@ var skillSetupAgentHomes = []string{
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
@@ -92,6 +93,7 @@ type skillSetupBackup struct {
type skillSetupTargetPlan struct {
Destination string
Backups []skillSetupBackup
CleanupOnly bool
}
type skillSetupPlan struct {
@@ -147,7 +149,8 @@ multi 模式支持按产品挑选:
备份失败时保留原目录并跳过该目标,绝不静默删除。
· 所有将被移除的目录都会在确认前逐条列出。
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录;
未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
Example: ` dws skill setup --mode multi --target claude --dry-run
dws skill setup --mode multi --target claude`,
@@ -940,18 +943,79 @@ func agentHomeForMode(base, mode string) string {
}
func detectExistingAgentHomes(home, mode string) []string {
var out []string
var specific []string
for i, rel := range skillSetupAgentHomes {
if i == 0 {
continue
}
base := filepath.Join(home, rel)
parent := filepath.Dir(base)
if i > 0 {
if _, err := skillSetupStat(parent); errors.Is(err, os.ErrNotExist) {
if info, err := skillSetupStat(parent); err != nil || !info.IsDir() {
continue
}
specific = append(specific, agentHomeForMode(base, mode))
}
if len(specific) > 0 {
return specific
}
return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)}
}
func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) {
// Derive HOME from a concrete Agent destination instead of resolving it a
// second time. The destinations were already resolved from HOME by the
// caller, and a later/transient UserHomeDir failure must not turn an
// otherwise valid setup plan into an error. Direct/custom destinations that
// do not match a known concrete Agent root have no generic-root migration.
home := ""
for _, dest := range dests {
base := dest
if filepath.Base(dest) == "dws" {
base = filepath.Dir(dest)
}
base = filepath.Clean(base)
for i, rel := range skillSetupAgentHomes {
if i == 0 {
continue
}
suffix := filepath.Clean(filepath.FromSlash(rel))
needle := string(filepath.Separator) + suffix
if strings.HasSuffix(base, needle) {
home = strings.TrimSuffix(base, needle)
break
}
}
if home != "" {
break
}
out = append(out, agentHomeForMode(base, mode))
}
return out
if home == "" {
return nil, nil
}
genericBase := filepath.Join(home, ".agents", "skills")
target := &skillSetupTargetPlan{Destination: genericBase, CleanupOnly: true}
add := func(path, reason string) {
if info, statErr := skillSetupStat(path); statErr == nil && info.IsDir() {
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason})
}
}
add(filepath.Join(genericBase, "dws"), skillSetupBackupMutual)
entries, readErr := skillSetupReadDir(genericBase)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return nil, fmt.Errorf("扫描通用 Skill 根目录失败 %s: %w", genericBase, readErr)
}
for _, entry := range entries {
path := filepath.Join(genericBase, entry.Name())
if entry.IsDir() && isManagedDWSMultiSkillDir(path, managed) {
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupStale})
}
}
if len(target.Backups) == 0 {
return nil, nil
}
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
return target, nil
}
func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) {
@@ -1027,6 +1091,13 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
plan.Targets = append(plan.Targets, target)
}
cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames)
if cleanupErr != nil {
return nil, cleanupErr
}
if cleanupTarget != nil {
plan.Targets = append(plan.Targets, *cleanupTarget)
}
return plan, nil
}
@@ -1079,7 +1150,11 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
}
fmt.Fprintln(out, " destinations:")
for _, target := range plan.Targets {
fmt.Fprintf(out, " - %s\n", target.Destination)
if target.CleanupOnly {
fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination)
} else {
fmt.Fprintf(out, " - %s\n", target.Destination)
}
}
fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):")
count := 0
@@ -1676,6 +1751,21 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
perTarget = len(plan.MultiSkillNames)
}
for _, target := range plan.Targets {
if target.CleanupOnly {
if skipped > 0 {
continue
}
if homeErr != nil {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr)
skipped++
continue
}
if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil {
fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
skipped++
}
continue
}
if len(target.Backups) > 0 && homeErr != nil {
if plan.Mode == skillSetupModeMono {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr)
+80
View File
@@ -146,6 +146,86 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin
}
}
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".codex", "skills")
genericMono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(genericMono, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
return "", errors.New("transient HOME failure")
})
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
t.Fatalf("generic cleanup target = %#v", plan.Targets)
}
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
}
var preview bytes.Buffer
renderSkillSetupPlan(&preview, plan)
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
t.Fatalf("generic cleanup preview missing: %s", preview.String())
}
t.Run("managed multi and scan failure", func(t *testing.T) {
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true})
if targetErr != nil || target == nil || len(target.Backups) != 2 {
t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr)
}
failure := errors.New("generic scan failure")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
t.Fatalf("generic scan error = %v", targetErr)
}
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
t.Fatalf("generic cleanup plan error = %v", planErr)
}
})
}
func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) {
failure := errors.New("cleanup failure")
cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}}
t.Run("prior skip suppresses cleanup", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}}
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("cleanup after skip = (%d, %d, %v)", installed, skipped, err)
}
})
t.Run("home failure keeps generic copy", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
t.Run("backup failure is reported", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") {
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
}
func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
+44
View File
@@ -330,6 +330,50 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
}
}
func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills", ".codex/skills"})
if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".codex", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
}
}
func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
if err := os.MkdirAll(filepath.Join(home, ".zcode"), 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".zcode", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
}
explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono)
if err != nil {
t.Fatal(err)
}
wantMono := filepath.Join(want, "dws")
if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) {
t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono)
}
}
func TestCrossPlatformCoverageInstallSkillToHomesEndToEnd(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil {
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"path/filepath"
"github.com/spf13/cobra"
)
// detectNestedMultiSkillLayout detects the compatibility gap where an older
// running dws process replaces itself with a newer binary, but then installs
// the new release bundle with its legacy mono copier. That produces the
// impossible layout <agent>/dws/multi/<skill>/SKILL.md.
//
// Detection is deliberately read-only. Ordinary commands must not turn a
// compatibility warning into an unconfirmed, cross-Agent Skill refresh.
// A legitimate mono install has no dws/multi product tree and is ignored.
func detectNestedMultiSkillLayout() (bool, error) {
home, err := skillSetupUserHomeDir()
if err != nil {
return false, err
}
for _, rel := range skillSetupAgentHomes {
nested := filepath.Join(home, rel, "dws", "multi")
if isSkillSourceRoot(nested, skillSetupModeMulti) {
return true, nil
}
}
return false, nil
}
func shouldDetectNestedSkillLayout(cmd *cobra.Command) bool {
if cmd == nil {
return true
}
if cmd.Name() == "upgrade" {
return false
}
return !(cmd.Name() == "setup" && cmd.Parent() != nil && cmd.Parent().Name() == "skill")
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageStartupDetectsNestedUpgradeLayoutWithoutMutation(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills", ".codex/skills"})
nested := filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
found, err := detectNestedMultiSkillLayout()
if err != nil || !found {
t.Fatalf("detect nested layout = (%v, %v), want (true, nil)", found, err)
}
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
if err != nil || string(data) != "old nested" {
t.Fatalf("detection changed nested Skill: data=%q err=%v", data, err)
}
if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat")); !os.IsNotExist(err) {
t.Fatalf("detection unexpectedly installed a canonical Skill: %v", err)
}
}
func TestCrossPlatformCoverageStartupDetectionIgnoresMonoAndReportsReadFailure(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills"})
mono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(mono, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("valid mono"), 0o644); err != nil {
t.Fatal(err)
}
found, err := detectNestedMultiSkillLayout()
if err != nil || found {
t.Fatalf("valid mono detection = (%v, %v), want (false, nil)", found, err)
}
failure := errors.New("HOME failure")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
found, err = detectNestedMultiSkillLayout()
if found || !errors.Is(err, failure) {
t.Fatalf("HOME failure detection = (%v, %v)", found, err)
}
}
func TestCrossPlatformCoverageStartupDetectionSkipsExplicitSkillManagers(t *testing.T) {
upgradeCmd := &cobra.Command{Use: "upgrade"}
if shouldDetectNestedSkillLayout(upgradeCmd) {
t.Fatal("upgrade must manage its own Skill lifecycle")
}
skillCmd := &cobra.Command{Use: "skill"}
setupCmd := &cobra.Command{Use: "setup"}
skillCmd.AddCommand(setupCmd)
if shouldDetectNestedSkillLayout(setupCmd) {
t.Fatal("skill setup must manage its own Skill lifecycle")
}
if !shouldDetectNestedSkillLayout(&cobra.Command{Use: "version"}) || !shouldDetectNestedSkillLayout(nil) {
t.Fatal("ordinary commands must trigger read-only detection")
}
}
func TestCrossPlatformCoverageStartupWarningIsReadOnlyAndDoesNotBypassConfirmation(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".codex/skills"})
nested := filepath.Join(home, ".codex", "skills", "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
// Any accidental return to startup mutation must fail this test before it
// can touch the fixture.
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
t.Fatal("ordinary command attempted to copy Skills")
return nil
})
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
t.Fatal("ordinary command attempted to back up or remove Skills")
return "", nil
})
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
t.Fatal("ordinary command attempted to write Skill state")
return nil
})
root := newRootCommandWithEngine(context.Background(), nil, false, true)
cmd := &cobra.Command{Use: "version"}
cmd.SetContext(context.Background())
var stderr bytes.Buffer
cmd.SetErr(&stderr)
if err := root.PersistentPreRunE(cmd, nil); err != nil {
t.Fatal(err)
}
warning := stderr.String()
if !strings.Contains(warning, "dws skill setup --mode multi") {
t.Fatalf("safe migration hint missing: %q", warning)
}
if strings.Contains(warning, "--yes") {
t.Fatalf("migration hint bypasses confirmation: %q", warning)
}
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
if err != nil || string(data) != "old nested" {
t.Fatalf("ordinary command changed nested Skill: data=%q err=%v", data, err)
}
}
+5
View File
@@ -44,6 +44,11 @@ func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.To
func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
if a != nil {
if runtime, ok := a.runner.(*runtimeRunner); ok {
runtime.resolveInvocationRetry(&inv)
}
}
// Defense in depth for direct helper callers: global dry-run must never
// reach an injected/real Runner, even if a command bypasses the normal
// Schema leaf wrapper. EchoRunner produces the same stable dry_run envelope
@@ -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)
})
}
+19 -2
View File
@@ -28,6 +28,8 @@ import (
"strings"
"sync"
"sync/atomic"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// CommandMeta is the complete runtime metadata view for a single command.
@@ -56,8 +58,9 @@ type CommandSelection struct {
}
var (
metaByCLIPathOnce sync.Once
metaByCLIPath map[string]CommandMeta
metaByCLIPathOnce sync.Once
metaByCLIPath map[string]CommandMeta
toolCallRetryByInterface map[InterfaceRefKey]toolCallRetryResolution
)
// Counter names retain "MetaIndex" for RuntimeSchemaMetadataLoadCounts
@@ -75,10 +78,12 @@ func installDeliveryCommandMeta(loaded loadedSchemaCatalog, err error) {
if err != nil {
runtimeDeliverySchemaMetaIndexErr = err
metaByCLIPath = nil
toolCallRetryByInterface = nil
metaByCLIPathOnce.Do(func() {})
return
}
metaByCLIPath = buildMetaByCLIPath(loaded)
toolCallRetryByInterface = buildToolCallRetryLookup(loaded)
runtimeDeliverySchemaMetaIndexErr = nil
metaByCLIPathOnce.Do(func() {})
}
@@ -124,6 +129,7 @@ func buildMetaByCLIPathFromRegistry(registry SchemaRegistry) map[string]CommandM
Risk: tool.Safety.Risk,
Confirmation: tool.Safety.Confirmation,
Idempotency: tool.Safety.Idempotency,
RetryPolicy: cloneRetryPolicy(tool.RetryPolicy),
},
Selection: CommandSelection{
AgentSummary: tool.Selection.AgentSummary,
@@ -163,6 +169,7 @@ func buildMetaByCLIPathFromSnapshotTools(tools map[string]map[string]any) map[st
Risk: schemaString(tool["risk"]),
Confirmation: schemaString(tool["confirmation"]),
Idempotency: schemaString(tool["idempotency"]),
RetryPolicy: retryPolicyFromSchemaValue(tool["retry_policy"]),
},
Selection: CommandSelection{
AgentSummary: schemaString(tool["agent_summary"]),
@@ -212,3 +219,13 @@ func ResolveMeta(cliPath string) (CommandMeta, bool) {
m, ok := metaByCLIPath[cliPath]
return m, ok
}
// ResolveInvocationSafety returns the effective retry decision for one CLI
// invocation. The arguments map is keyed by declared CLI parameter names.
func ResolveInvocationSafety(cliPath string, arguments map[string]any) (contract.RetryDecision, bool) {
meta, ok := ResolveMeta(cliPath)
if !ok {
return contract.RetryDecision{}, false
}
return meta.Safety.Resolve(arguments), true
}
+13 -1
View File
@@ -17,6 +17,7 @@ import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -30,7 +31,18 @@ type CommandSafety struct {
Effect string // read / write / destructive
Risk string // low / medium / high
Confirmation string // not_required / user_required
Idempotency string // idempotent / non_idempotent
Idempotency string // idempotent / non_idempotent / unknown / conditional
RetryPolicy *contract.RetryPolicySpec
}
// Resolve derives invocation-scoped retry safety from reviewed metadata and
// actual CLI arguments. It never changes the static Schema contract.
func (s CommandSafety) Resolve(arguments map[string]any) contract.RetryDecision {
key := ""
if s.RetryPolicy != nil {
key = s.RetryPolicy.KeyParameter
}
return contract.ResolveRetryDecision(s.Idempotency, s.RetryPolicy, arguments, key)
}
// ShouldRender returns true when the safety metadata warrants a visible
+3 -2
View File
@@ -15,6 +15,7 @@ package cli
import (
"fmt"
"reflect"
"strings"
"testing"
)
@@ -79,7 +80,7 @@ func TestSafetyForCLIPathTrimsWhitespace(t *testing.T) {
if !ok2 {
t.Fatal("trimmed lookup failed; whitespace not trimmed")
}
if s1 != s2 {
if !reflect.DeepEqual(s1, s2) {
t.Errorf("whitespace-trimmed result differs: %+v vs %+v", s1, s2)
}
}
@@ -161,7 +162,7 @@ func TestResolveMetaAliasLookup(t *testing.T) {
if aliased.Identity.CLIPath != primary.Identity.CLIPath || aliased.Identity.Canonical != primary.Identity.Canonical {
t.Fatalf("alias metadata = %+v, want same identity as primary %+v", aliased.Identity, primary.Identity)
}
if aliased.Safety != primary.Safety {
if !reflect.DeepEqual(aliased.Safety, primary.Safety) {
t.Fatalf("alias safety = %+v, want %+v", aliased.Safety, primary.Safety)
}
}
+26 -2
View File
@@ -35,7 +35,13 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
Title: "Final Title",
Safety: &contract.SafetySpec{
Effect: "write", Confirmation: "user_required", Idempotency: "none",
Effect: "write", Confirmation: "user_required", Idempotency: "conditional",
},
RetryPolicy: &contract.RetryPolicySpec{Mode: contract.RetryModeDeduplicationKey, KeyParameter: "mode", SamePayloadRequired: true},
Parameters: []contract.ParamDecl{{Name: "mode", Property: "mode"}},
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP, Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "dev", RPCName: "create_thing"},
},
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation},
Result: &contract.ResultSpec{
@@ -69,9 +75,12 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
if spec.Title != "Final Title" {
t.Fatalf("title = %q", spec.Title)
}
if spec.Safety.Confirmation != "user_required" || spec.Safety.Idempotency != "none" {
if spec.Safety.Confirmation != "user_required" || spec.Safety.Idempotency != "conditional" {
t.Fatalf("safety = %#v", spec.Safety)
}
if spec.RetryPolicy == nil || spec.RetryPolicy.KeyParameter != "mode" {
t.Fatalf("retry_policy = %#v", spec.RetryPolicy)
}
if spec.DryRun == nil || spec.DryRun.PreviewKind != contract.DryRunPreviewInvocation {
t.Fatalf("dry_run = %#v", spec.DryRun)
}
@@ -89,6 +98,21 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
}
}
func TestCrossPlatformCoverageRuntimeToolSpecRejectsInvalidRetryPolicy(t *testing.T) {
cmd := &cobra.Command{Use: "send"}
entry := runtimeSchemaEntry{
ProductID: "chat", ToolName: "send", CLIName: "send",
CLIPath: "chat send", PrimaryCLIPath: "chat send", ProductName: "Chat", Command: cmd,
}
_, err := runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{
Safety: &contract.SafetySpec{Idempotency: "conditional"},
RetryPolicy: &contract.RetryPolicySpec{Mode: "inferred", KeyParameter: "uuid", SamePayloadRequired: true},
}, runtimeSchemaMetadataSources{})
if err == nil || !strings.Contains(err.Error(), "unsupported mode") {
t.Fatalf("invalid retry policy error = %v", err)
}
}
func TestRuntimeToolSpecHidesUnifiedResultForInactiveRollout(t *testing.T) {
for _, state := range []output.RolloutState{output.RolloutLegacyOnly, output.RolloutDualValidate} {
t.Run(string(state), func(t *testing.T) {
+261
View File
@@ -0,0 +1,261 @@
// 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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
func conditionalRetryTool() RuntimeToolSpecInput {
return RuntimeToolSpecInput{
Identity: contract.ToolIdentitySpec{ProductID: "chat", Name: "send", CLIPath: "chat send"},
Parameters: []ParameterSpec{{
Name: "uuid", Type: "string", Property: "requestUuid",
FieldProvenance: map[string]contract.FieldProvenance{
"property": resolvedFieldProvenance(
"requestUuid", "native_annotation", "contract.ParamDecl", "native_annotation",
"contract_pass_through", "explicit retry key property",
),
},
}},
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "conditional"},
RetryPolicy: &contract.RetryPolicySpec{
Mode: contract.RetryModeDeduplicationKey,
KeyParameter: "uuid",
SamePayloadRequired: true,
},
Interface: contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send"},
},
FieldProvenance: map[string]contract.FieldProvenance{
"retry_policy": resolvedFieldProvenance(
contract.RetryPolicySpec{Mode: contract.RetryModeDeduplicationKey, KeyParameter: "uuid", SamePayloadRequired: true},
"corecmd.contract", "corecmd.ContractDecl", "contract_final", "contract_pass_through", "reviewed retry policy",
),
},
}
}
func TestCrossPlatformCoverageConditionalRetryPolicyValidatesAndProjects(t *testing.T) {
spec, err := ToolSpecFromRuntime(conditionalRetryTool())
if err != nil {
t.Fatalf("ToolSpecFromRuntime() error = %v", err)
}
full, err := spec.ToPayload()
if err != nil {
t.Fatalf("ToPayload() error = %v", err)
}
policy, ok := full["retry_policy"].(map[string]any)
if !ok || policy["mode"] != contract.RetryModeDeduplicationKey || policy["key_parameter"] != "uuid" || policy["same_payload_required"] != true {
t.Fatalf("retry_policy = %#v", full["retry_policy"])
}
compact := stripSchemaPayloadCompact(full)
if !schemaJSONEqual(compact["retry_policy"], full["retry_policy"]) {
t.Fatalf("compact retry_policy = %#v, want %#v", compact["retry_policy"], full["retry_policy"])
}
summary, err := spec.ToSummaryPayload()
if err != nil {
t.Fatalf("ToSummaryPayload() error = %v", err)
}
if _, exists := summary["retry_policy"]; exists {
t.Fatalf("navigation summary must omit retry_policy: %#v", summary)
}
wire, err := schemaToolWireFromPayload(full)
if err != nil {
t.Fatalf("schemaToolWireFromPayload() error = %v", err)
}
roundTrip, err := schemaToolSpecFromWire(wire)
if err != nil || roundTrip.RetryPolicy == nil || roundTrip.RetryPolicy.KeyParameter != "uuid" {
t.Fatalf("snapshot retry_policy = %#v, error = %v", roundTrip.RetryPolicy, err)
}
registry := SchemaRegistry{Products: []ProductSpec{{ID: "chat", Tools: []ToolSpec{spec}}}}
meta := buildMetaByCLIPathFromRegistry(registry)["chat send"]
if meta.Safety.RetryPolicy == nil || meta.Safety.RetryPolicy.KeyParameter != "uuid" {
t.Fatalf("ResolveMeta projection lost retry_policy: %#v", meta.Safety)
}
if decision := meta.Safety.Resolve(map[string]any{"uuid": "request-1"}); !decision.SafeToRetry || decision.EffectiveIdempotency != "idempotent" {
t.Fatalf("invocation decision = %#v", decision)
}
if decision := meta.Safety.Resolve(nil); decision.SafeToRetry || decision.EffectiveIdempotency != "non_idempotent" {
t.Fatalf("missing-key invocation decision = %#v", decision)
}
t.Cleanup(restorePackageCLISchemaDeliveryForTest)
storeSchemaSourceRootFn(func() *cobra.Command { return &cobra.Command{Use: "dws"} })
assembleDeliverySchemaCatalogFn = func(*cobra.Command) (loadedSchemaCatalog, error) {
return loadedSchemaCatalog{Registry: registry}, nil
}
resetSchemaDeliveryState()
decision, ok := ResolveInvocationSafety("chat send", map[string]any{"uuid": "request-1"})
if !ok || !decision.SafeToRetry || decision.EffectiveIdempotency != "idempotent" {
t.Fatalf("ResolveInvocationSafety() = %#v, ok=%v", decision, ok)
}
if _, ok := ResolveInvocationSafety("chat missing", nil); ok {
t.Fatal("ResolveInvocationSafety() must not invent missing commands")
}
transportDecision := ResolveToolCallRetry("chat", "send", map[string]any{"requestUuid": "request-1"})
if !transportDecision.SafeToRetry || transportDecision.EffectiveIdempotency != "idempotent" {
t.Fatalf("ResolveToolCallRetry() = %#v", transportDecision)
}
if missing := ResolveToolCallRetry("chat", "missing", nil); missing.SafeToRetry || missing.Reason != "interface_not_declared" {
t.Fatalf("missing interface decision = %#v", missing)
}
lookup := buildToolCallRetryLookup(loadedSchemaCatalog{Registry: registry})
resolution := lookup[retryInterfaceRefKey("chat", "send")]
resolvedDecision := contract.ResolveRetryDecision(resolution.idempotency, resolution.policy, map[string]any{"requestUuid": "request-1"}, resolution.argumentKey)
if !resolvedDecision.SafeToRetry {
t.Fatalf("interface retry decision = %#v", resolvedDecision)
}
fullTool, _ := spec.ToPayload()
metaSnapshot := SchemaCatalogSnapshot{Version: SchemaCatalogSnapshotVersion, SourceHash: "retry-source", Tools: map[string]map[string]any{"chat.send": fullTool}}
metaIndex, err := BuildSchemaMetaIndex(metaSnapshot)
if err != nil || len(metaIndex.Entries) != 1 || metaIndex.Entries[0].RetryPolicy == nil {
t.Fatalf("BuildSchemaMetaIndex() = %#v, error = %v", metaIndex, err)
}
encodedIndex, err := EncodeSchemaMetaIndex(metaIndex)
if err != nil {
t.Fatalf("EncodeSchemaMetaIndex() error = %v", err)
}
decodedIndex, err := DecodeSchemaMetaIndex(encodedIndex)
if err != nil || decodedIndex.Entries[0].RetryPolicy == nil || decodedIndex.Entries[0].RetryPolicy.KeyParameter != "uuid" {
t.Fatalf("DecodeSchemaMetaIndex() = %#v, error = %v", decodedIndex, err)
}
if err := ValidateSchemaMetaIndexAgainstSnapshot(metaIndex, metaSnapshot); err != nil {
t.Fatalf("ValidateSchemaMetaIndexAgainstSnapshot() error = %v", err)
}
snapshotLookup := buildToolCallRetryLookup(loadedSchemaCatalog{Snapshot: SchemaCatalogSnapshot{Tools: map[string]map[string]any{
"chat.send": fullTool,
"chat.local": {"interface_mode": contract.InterfaceModeLocal},
}}})
snapshotResolution := snapshotLookup[retryInterfaceRefKey("chat", "send")]
if snapshotResolution.argumentKey != "requestUuid" || snapshotResolution.policy == nil {
t.Fatalf("snapshot retry resolution = %#v", snapshotResolution)
}
invalidSnapshot := map[string]any{
"interface_mode": contract.InterfaceModeMCP,
"interface_ref": map[string]any{"product_id": "chat", "rpc_name": "invalid"},
"idempotency": "conditional",
"retry_policy": map[string]any{"mode": "inferred", "key_parameter": "uuid", "same_payload_required": true},
}
invalidLookup := buildToolCallRetryLookup(loadedSchemaCatalog{Snapshot: SchemaCatalogSnapshot{Tools: map[string]map[string]any{"chat.invalid": invalidSnapshot}}})
invalidResolution := invalidLookup[retryInterfaceRefKey("chat", "invalid")]
if invalidResolution.policy != nil || contract.ResolveRetryDecision(invalidResolution.idempotency, nil, nil, "").Reason != "invalid_retry_policy" {
t.Fatalf("invalid snapshot retry resolution = %#v", invalidResolution)
}
missingBindingSnapshot := map[string]any{
"interface_mode": contract.InterfaceModeMCP,
"interface_ref": map[string]any{"product_id": "chat", "rpc_name": "missing_binding"},
"idempotency": "conditional",
"retry_policy": map[string]any{"mode": contract.RetryModeDeduplicationKey, "key_parameter": "uuid", "same_payload_required": true},
"parameters": map[string]any{"uuid": map[string]any{"type": "string"}},
}
missingBindingLookup := buildToolCallRetryLookup(loadedSchemaCatalog{Snapshot: SchemaCatalogSnapshot{Tools: map[string]map[string]any{
"chat.missing_binding": missingBindingSnapshot,
}}})
missingBindingResolution := missingBindingLookup[retryInterfaceRefKey("chat", "missing_binding")]
missingBindingDecision := contract.ResolveRetryDecision(
missingBindingResolution.idempotency,
missingBindingResolution.policy,
map[string]any{"uuid": "request-1"},
missingBindingResolution.argumentKey,
)
if missingBindingDecision.SafeToRetry || missingBindingDecision.Reason != "deduplication_key_binding_missing" {
t.Fatalf("snapshot missing property inferred a key binding: %#v", missingBindingDecision)
}
if retryPolicyFromSchemaValue(func() {}) != nil || retryPolicyFromSchemaValue("invalid") != nil || retryPolicyFromSchemaValue(nil) != nil {
t.Fatal("invalid snapshot policy values must fail closed")
}
conflict := spec
conflict.Identity.Name = "reply"
conflict.Identity.CanonicalPath = "chat.reply"
conflict.Identity.CLIPath = "chat reply"
conflict.Identity.PrimaryCLIPath = "chat reply"
conflict.Safety.Idempotency = "unknown"
conflict.RetryPolicy = nil
conflict.FieldProvenance = nil
conflictRegistry := SchemaRegistry{Products: []ProductSpec{{ID: "chat", Tools: []ToolSpec{spec, conflict}}}}
assembleDeliverySchemaCatalogFn = func(*cobra.Command) (loadedSchemaCatalog, error) {
return loadedSchemaCatalog{Registry: conflictRegistry}, nil
}
resetSchemaDeliveryState()
conflictDecision := ResolveToolCallRetry("chat", "send", map[string]any{"requestUuid": "request-1"})
if conflictDecision.SafeToRetry || conflictDecision.Reason != "interface_retry_policy_conflict" {
t.Fatalf("conflicting interface decision = %#v", conflictDecision)
}
}
func TestCrossPlatformCoverageConditionalRetryPolicyFailsClosed(t *testing.T) {
for name, mutate := range map[string]func(*RuntimeToolSpecInput){
"missing policy": func(in *RuntimeToolSpecInput) { in.RetryPolicy = nil },
"missing parameter": func(in *RuntimeToolSpecInput) { in.Parameters = nil },
"missing property": func(in *RuntimeToolSpecInput) { in.Parameters[0].Property = "" },
"non string parameter": func(in *RuntimeToolSpecInput) { in.Parameters[0].Type = "integer" },
"inferred property": func(in *RuntimeToolSpecInput) {
in.Parameters[0].FieldProvenance["property"] = resolvedFieldProvenance(
"requestUuid", "flag_name_inference", "cobra.flag", "inference", "fallback", "inferred",
)
},
"non MCP interface": func(in *RuntimeToolSpecInput) {
in.Interface = contract.InterfaceSpec{Mode: contract.InterfaceModeLocal, Availability: contract.InterfaceAvailable, Reason: "local"}
},
"policy on static idempotent": func(in *RuntimeToolSpecInput) { in.Safety.Idempotency = "idempotent" },
"invalid policy mode": func(in *RuntimeToolSpecInput) { in.RetryPolicy.Mode = "inferred" },
} {
t.Run(name, func(t *testing.T) {
input := conditionalRetryTool()
mutate(&input)
if _, err := ToolSpecFromRuntime(input); err == nil {
t.Fatal("invalid retry contract must fail closed")
}
})
}
}
func TestCrossPlatformCoverageConditionalRetryPolicyRequiresFinalProvenance(t *testing.T) {
spec, err := ToolSpecFromRuntime(conditionalRetryTool())
if err != nil {
t.Fatalf("ToolSpecFromRuntime() error = %v", err)
}
delete(spec.FieldProvenance, "retry_policy")
err = validateFinalSchemaProvenanceCoverage(SchemaRegistry{Products: []ProductSpec{{
ID: "chat", Tools: []ToolSpec{spec},
}}})
if err == nil || !strings.Contains(err.Error(), "has no provenance for retry_policy") {
t.Fatalf("validateFinalSchemaProvenanceCoverage() error = %v", err)
}
}
func TestCrossPlatformCoverageInterfaceRetryPolicyConflictFailsClosed(t *testing.T) {
lookup := map[InterfaceRefKey]toolCallRetryResolution{}
key := retryInterfaceRefKey("chat", "send")
idempotent := toolCallRetryResolution{idempotency: "idempotent"}
mergeToolCallRetryResolution(lookup, key, idempotent)
mergeToolCallRetryResolution(lookup, key, idempotent)
mergeToolCallRetryResolution(lookup, key, toolCallRetryResolution{idempotency: "unknown"})
mergeToolCallRetryResolution(lookup, key, idempotent)
if got := lookup[key]; !got.conflict {
t.Fatalf("conflicting interface retry semantics = %#v", got)
}
decision := contract.RetryDecision{EffectiveIdempotency: "unknown", Reason: "interface_retry_policy_conflict"}
if decision.SafeToRetry || !strings.Contains(decision.Reason, "conflict") {
t.Fatalf("conflict decision = %#v", decision)
}
}
+135
View File
@@ -0,0 +1,135 @@
// 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 (
"encoding/json"
"reflect"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
type toolCallRetryResolution struct {
idempotency string
policy *contract.RetryPolicySpec
argumentKey string
conflict bool
}
func retryInterfaceRefKey(productID, rpcName string) InterfaceRefKey {
return InterfaceRefKey{
ProductID: strings.TrimSpace(productID),
RPCName: strings.TrimSpace(rpcName),
}
}
func buildToolCallRetryLookup(loaded loadedSchemaCatalog) map[InterfaceRefKey]toolCallRetryResolution {
if len(loaded.Registry.Products) == 0 {
return buildToolCallRetryLookupFromSnapshot(loaded.Snapshot.Tools)
}
lookup := make(map[InterfaceRefKey]toolCallRetryResolution)
for _, product := range loaded.Registry.Products {
for _, tool := range product.Tools {
if tool.Interface.Mode != contract.InterfaceModeMCP || tool.Interface.Ref == nil {
continue
}
resolution := toolCallRetryResolution{idempotency: tool.Safety.Idempotency, policy: cloneRetryPolicy(tool.RetryPolicy)}
if tool.RetryPolicy != nil {
for _, parameter := range tool.Parameters {
if parameter.Name == tool.RetryPolicy.KeyParameter {
resolution.argumentKey = parameter.Property
break
}
}
}
mergeToolCallRetryResolution(lookup, retryInterfaceRefKey(tool.Interface.Ref.ProductID, tool.Interface.Ref.RPCName), resolution)
}
}
return lookup
}
func buildToolCallRetryLookupFromSnapshot(tools map[string]map[string]any) map[InterfaceRefKey]toolCallRetryResolution {
lookup := make(map[InterfaceRefKey]toolCallRetryResolution)
for _, tool := range tools {
ref, _ := tool["interface_ref"].(map[string]any)
productID, rpcName := schemaString(ref["product_id"]), schemaString(ref["rpc_name"])
if schemaString(tool["interface_mode"]) != contract.InterfaceModeMCP || productID == "" || rpcName == "" {
continue
}
policy := retryPolicyFromSchemaValue(tool["retry_policy"])
resolution := toolCallRetryResolution{idempotency: schemaString(tool["idempotency"]), policy: policy}
if policy != nil {
parameters, _ := tool["parameters"].(map[string]any)
parameter, _ := parameters[policy.KeyParameter].(map[string]any)
resolution.argumentKey = schemaString(parameter["property"])
}
mergeToolCallRetryResolution(lookup, retryInterfaceRefKey(productID, rpcName), resolution)
}
return lookup
}
func mergeToolCallRetryResolution(lookup map[InterfaceRefKey]toolCallRetryResolution, key InterfaceRefKey, candidate toolCallRetryResolution) {
current, exists := lookup[key]
if !exists {
lookup[key] = candidate
return
}
if current.conflict || current.idempotency != candidate.idempotency || current.argumentKey != candidate.argumentKey || !reflect.DeepEqual(current.policy, candidate.policy) {
lookup[key] = toolCallRetryResolution{conflict: true}
}
}
func cloneRetryPolicy(in *contract.RetryPolicySpec) *contract.RetryPolicySpec {
if in == nil {
return nil
}
out := *in
return &out
}
func retryPolicyFromSchemaValue(value any) *contract.RetryPolicySpec {
if value == nil {
return nil
}
data, err := json.Marshal(value)
if err != nil {
return nil
}
var policy contract.RetryPolicySpec
if err := json.Unmarshal(data, &policy); err != nil {
return nil
}
normalized, err := contract.NormalizeRetryPolicySpec(&policy, "<snapshot>")
if err != nil {
return nil
}
return normalized
}
// ResolveToolCallRetry resolves transport retry safety from an exact reviewed
// MCP interface identity and actual RPC arguments. Missing or conflicting
// interface mappings fail closed; no command, flag, or property name is inferred.
func ResolveToolCallRetry(productID, rpcName string, arguments map[string]any) contract.RetryDecision {
_ = deliverySchemaCatalog()
panicIfMetaIndexUnusable(runtimeDeliverySchemaMetaIndexErr)
resolution, ok := toolCallRetryByInterface[retryInterfaceRefKey(productID, rpcName)]
if !ok {
return contract.RetryDecision{EffectiveIdempotency: "unknown", Reason: "interface_not_declared"}
}
if resolution.conflict {
return contract.RetryDecision{EffectiveIdempotency: "unknown", Reason: "interface_retry_policy_conflict"}
}
return contract.ResolveRetryDecision(resolution.idempotency, resolution.policy, arguments, resolution.argumentKey)
}
+1
View File
@@ -1057,6 +1057,7 @@ var schemaCompactPayloadKeys = map[string]bool{
"canonical_path": true, "cli_path": true,
"agent_summary": true, "description": true,
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
"retry_policy": true,
"interface_mode": true, "availability": true, "interface_reason": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
"result": true, "pagination": true,
+63
View File
@@ -81,6 +81,7 @@ var schemaCatalogToolOptionalKeys = []string{
"pagination",
"positionals",
"result",
"retry_policy",
}
var schemaCatalogToolEnums = map[string][]string{
@@ -89,6 +90,7 @@ var schemaCatalogToolEnums = map[string][]string{
"confirmation": {"not_required", "user_required"},
"interface_mode": {contract.InterfaceModeMCP, contract.InterfaceModeComposite, contract.InterfaceModeLocal},
"availability": {contract.InterfaceAvailable, contract.InterfaceUnavailable},
"idempotency": {"idempotent", "non_idempotent", "unknown", "conditional"},
}
// schemaCatalogParamRequiredKeys is the required core of every parameter.
@@ -239,6 +241,7 @@ func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[
}
validateCatalogInterface(toolID, entry, violations)
validateCatalogRetryPolicy(toolID, entry, parameters, paramsOK, violations)
if result, exists := entry["result"]; exists {
if _, ok := result.(map[string]any); !ok {
report("field %q must be an object", "result")
@@ -280,6 +283,66 @@ func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[
}
}
func validateCatalogRetryPolicy(toolID string, entry, parameters map[string]any, paramsOK bool, violations *[]schemaCatalogStructureViolation) {
report := func(format string, args ...any) {
*violations = append(*violations, schemaCatalogStructureViolation{tool: toolID, message: fmt.Sprintf(format, args...)})
}
idempotency, _ := entry["idempotency"].(string)
raw, hasPolicy := entry["retry_policy"]
if idempotency == "conditional" && !hasPolicy {
report("idempotency=conditional requires retry_policy")
return
}
if idempotency != "conditional" && hasPolicy {
report("retry_policy requires idempotency=conditional")
}
if !hasPolicy {
return
}
policy, ok := raw.(map[string]any)
if !ok {
report("retry_policy must be an object")
return
}
for key := range policy {
if key != "mode" && key != "key_parameter" && key != "same_payload_required" {
report("retry_policy has unknown field %q", key)
}
}
if mode, _ := policy["mode"].(string); mode != contract.RetryModeDeduplicationKey {
report("retry_policy.mode = %q, want %q", mode, contract.RetryModeDeduplicationKey)
}
keyParameter, _ := policy["key_parameter"].(string)
if strings.TrimSpace(keyParameter) == "" {
report("retry_policy.key_parameter must be a non-empty string")
} else if paramsOK {
rawParameter, exists := parameters[keyParameter]
parameter, parameterOK := rawParameter.(map[string]any)
if !exists || !parameterOK {
report("retry_policy.key_parameter references missing parameter %q", keyParameter)
} else {
if property, _ := parameter["property"].(string); strings.TrimSpace(property) == "" {
report("retry_policy key parameter %q must have a non-empty property", keyParameter)
}
if parameterType, _ := parameter["type"].(string); parameterType != "string" {
report("retry_policy key parameter %q must have type string", keyParameter)
}
fieldProvenance, _ := parameter["field_provenance"].(map[string]any)
propertyProvenance, _ := fieldProvenance["property"].(map[string]any)
if source, _ := propertyProvenance["source"].(string); source != "native_annotation" {
report("retry_policy key parameter %q property must come from an explicit ParamDecl", keyParameter)
}
}
}
if required, ok := policy["same_payload_required"].(bool); !ok || !required {
report("retry_policy.same_payload_required must be true")
}
mode, _ := entry["interface_mode"].(string)
if mode != contract.InterfaceModeMCP || entry["interface_ref"] == nil {
report("retry_policy requires interface_mode=mcp with interface_ref")
}
}
func validateCatalogInterface(toolID string, entry map[string]any, violations *[]schemaCatalogStructureViolation) {
report := func(format string, args ...any) {
*violations = append(*violations, schemaCatalogStructureViolation{
@@ -102,6 +102,63 @@ func TestValidateCatalogStructureAcceptsValidEntry(t *testing.T) {
}
}
func TestCrossPlatformCoverageValidateCatalogStructureConditionalRetryPolicy(t *testing.T) {
valid := func() map[string]any {
entry := validCatalogToolEntry()
entry["idempotency"] = "conditional"
parameter := entry["parameters"].(map[string]any)["base-id"].(map[string]any)
parameter["property"] = "baseId"
parameter["field_provenance"].(map[string]any)["property"] = map[string]any{"source": "native_annotation"}
entry["retry_policy"] = map[string]any{
"mode": contract.RetryModeDeduplicationKey,
"key_parameter": "base-id",
"same_payload_required": true,
}
return entry
}
entry := valid()
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err != nil {
t.Fatalf("ValidateCatalogStructure() error = %v", err)
}
for _, test := range []struct {
name string
mutate func(map[string]any)
want string
}{
{name: "missing policy", mutate: func(entry map[string]any) { delete(entry, "retry_policy") }, want: "requires retry_policy"},
{name: "policy on non conditional", mutate: func(entry map[string]any) { entry["idempotency"] = "unknown" }, want: "retry_policy requires idempotency=conditional"},
{name: "non object", mutate: func(entry map[string]any) { entry["retry_policy"] = "invalid" }, want: "retry_policy must be an object"},
{name: "unknown field", mutate: func(entry map[string]any) { entry["retry_policy"].(map[string]any)["inferred"] = true }, want: "unknown field"},
{name: "bad mode", mutate: func(entry map[string]any) { entry["retry_policy"].(map[string]any)["mode"] = "always" }, want: "retry_policy.mode"},
{name: "empty key", mutate: func(entry map[string]any) { entry["retry_policy"].(map[string]any)["key_parameter"] = " " }, want: "key_parameter must be a non-empty string"},
{name: "missing key parameter", mutate: func(entry map[string]any) { entry["retry_policy"].(map[string]any)["key_parameter"] = "uuid" }, want: "references missing parameter"},
{name: "missing property", mutate: func(entry map[string]any) {
delete(entry["parameters"].(map[string]any)["base-id"].(map[string]any), "property")
}, want: "non-empty property"},
{name: "non string key", mutate: func(entry map[string]any) {
entry["parameters"].(map[string]any)["base-id"].(map[string]any)["type"] = "integer"
}, want: "must have type string"},
{name: "inferred property", mutate: func(entry map[string]any) {
entry["parameters"].(map[string]any)["base-id"].(map[string]any)["field_provenance"].(map[string]any)["property"].(map[string]any)["source"] = "flag_name_inference"
}, want: "must come from an explicit ParamDecl"},
{name: "same payload false", mutate: func(entry map[string]any) { entry["retry_policy"].(map[string]any)["same_payload_required"] = false }, want: "same_payload_required must be true"},
{name: "non MCP", mutate: func(entry map[string]any) {
entry["interface_mode"] = contract.InterfaceModeLocal
delete(entry, "interface_ref")
entry["interface_reason"] = "local"
}, want: "requires interface_mode=mcp"},
} {
t.Run(test.name, func(t *testing.T) {
entry := valid()
test.mutate(entry)
err := ValidateCatalogStructure(catalogPayload(t, entry))
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateCatalogStructure() error = %v, want %q", err, test.want)
}
})
}
}
func TestValidateCatalogStructureAcceptsOptionalResultObject(t *testing.T) {
entry := validCatalogToolEntry()
entry["result"] = map[string]any{
+51 -1
View File
@@ -62,6 +62,7 @@ type ToolSpec struct {
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
RetryPolicy *contract.RetryPolicySpec
Safety contract.SafetySpec
Interface contract.InterfaceSpec
Selection contract.SelectionSpec
@@ -137,6 +138,7 @@ type RuntimeToolSpecInput struct {
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
RetryPolicy *contract.RetryPolicySpec
Safety contract.SafetySpec
Interface contract.InterfaceSpec
Selection contract.SelectionSpec
@@ -224,6 +226,8 @@ func (t ToolSpec) provenanceValue(field string) (any, bool) {
return t.Safety.Confirmation, true
case "idempotency":
return t.Safety.Idempotency, true
case "retry_policy":
return t.RetryPolicy, true
case "interface_ref":
return t.Interface.Ref, true
case "interface_mode":
@@ -556,6 +560,42 @@ func (t ToolSpec) Validate() error {
return fmt.Errorf("tool %s pagination cursor_parameter %q is not a declared parameter", id.CanonicalPath, pagination.CursorParameter)
}
}
retryPolicy, err := contract.NormalizeRetryPolicySpec(t.RetryPolicy, id.CanonicalPath)
if err != nil {
return err
}
if t.Safety.Idempotency == "conditional" {
if retryPolicy == nil {
return fmt.Errorf("tool %s idempotency=conditional requires retry_policy", id.CanonicalPath)
}
} else if retryPolicy != nil {
return fmt.Errorf("tool %s retry_policy requires idempotency=conditional", id.CanonicalPath)
}
if retryPolicy != nil {
if t.Interface.Mode != contract.InterfaceModeMCP || t.Interface.Ref == nil {
return fmt.Errorf("tool %s retry_policy requires an MCP interface_ref", id.CanonicalPath)
}
var keyProperty, keyType string
var keyPropertyProvenance contract.FieldProvenance
for _, parameter := range t.Parameters {
if parameter.Name == retryPolicy.KeyParameter {
keyProperty = strings.TrimSpace(parameter.Property)
keyType = strings.TrimSpace(parameter.Type)
keyPropertyProvenance = parameter.FieldProvenance["property"]
break
}
}
if keyProperty == "" {
return fmt.Errorf("tool %s retry_policy key_parameter %q must name a declared parameter with a non-empty interface property", id.CanonicalPath, retryPolicy.KeyParameter)
}
if keyType != "string" {
return fmt.Errorf("tool %s retry_policy key_parameter %q must be a string parameter", id.CanonicalPath, retryPolicy.KeyParameter)
}
if strings.TrimSpace(keyPropertyProvenance.Source) != "native_annotation" ||
strings.TrimSpace(keyPropertyProvenance.Precedence) != "native_annotation" {
return fmt.Errorf("tool %s retry_policy key_parameter %q property must come from an explicit ParamDecl", id.CanonicalPath, retryPolicy.KeyParameter)
}
}
if t.Interface.Mode != "" || t.Interface.Availability != "" || t.Interface.Reason != "" || t.Interface.Ref != nil {
if err := t.Interface.Validate(id.CanonicalPath); err != nil {
return err
@@ -756,6 +796,12 @@ func (t ToolSpec) normalized() ToolSpec {
out.Pagination = pagination
}
}
if t.RetryPolicy != nil {
retryPolicy := *t.RetryPolicy
retryPolicy.Mode = strings.TrimSpace(retryPolicy.Mode)
retryPolicy.KeyParameter = strings.TrimSpace(retryPolicy.KeyParameter)
out.RetryPolicy = &retryPolicy
}
out.Positionals = append([]contract.RuntimeSchemaPositional(nil), t.Positionals...)
sort.Slice(out.Positionals, func(i, j int) bool {
if out.Positionals[i].Index != out.Positionals[j].Index {
@@ -990,6 +1036,10 @@ func (t ToolSpec) ToPayload() (map[string]any, error) {
value, _ := typedJSONValue(t.Pagination)
payload["pagination"] = value
}
if t.RetryPolicy != nil {
value, _ := typedJSONValue(t.RetryPolicy)
payload["retry_policy"] = value
}
applySafetyPayload(payload, t.Safety)
applyInterfacePayload(payload, t.Interface)
applySelectionPayload(payload, t.Selection, true)
@@ -1013,7 +1063,7 @@ func (t ToolSpec) ToSummaryPayload() (map[string]any, error) {
}
for _, key := range []string{
"parameters", "has_parameters", "parameter_count", "constraints",
"positionals", "result", "examples", "effect_source", "agent_source_refs",
"positionals", "result", "retry_policy", "examples", "effect_source", "agent_source_refs",
"field_provenance", "path", "source", "product_id", "display", "is_alias",
} {
delete(payload, key)
+19 -14
View File
@@ -21,6 +21,8 @@ import (
"reflect"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// SchemaMetaIndexVersion is the CommandMeta summary index format used by CI
@@ -42,19 +44,20 @@ type SchemaMetaIndexSnapshot struct {
// SchemaMetaIndexEntry is one primary-path CommandMeta record. Aliases are
// expanded into the ResolveMeta lookup at decode time.
type SchemaMetaIndexEntry struct {
CLIPath string `json:"cli_path"`
Canonical string `json:"canonical_path"`
Aliases []string `json:"aliases,omitempty"`
ProductID string `json:"product_id,omitempty"`
Title string `json:"title,omitempty"`
Effect string `json:"effect,omitempty"`
Risk string `json:"risk,omitempty"`
Confirmation string `json:"confirmation,omitempty"`
Idempotency string `json:"idempotency,omitempty"`
AgentSummary string `json:"agent_summary,omitempty"`
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`
Examples []string `json:"examples,omitempty"`
CLIPath string `json:"cli_path"`
Canonical string `json:"canonical_path"`
Aliases []string `json:"aliases,omitempty"`
ProductID string `json:"product_id,omitempty"`
Title string `json:"title,omitempty"`
Effect string `json:"effect,omitempty"`
Risk string `json:"risk,omitempty"`
Confirmation string `json:"confirmation,omitempty"`
Idempotency string `json:"idempotency,omitempty"`
RetryPolicy *contract.RetryPolicySpec `json:"retry_policy,omitempty"`
AgentSummary string `json:"agent_summary,omitempty"`
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`
Examples []string `json:"examples,omitempty"`
}
// BuildSchemaMetaIndex extracts the ResolveMeta summary from a full Catalog
@@ -91,6 +94,7 @@ func BuildSchemaMetaIndex(snapshot SchemaCatalogSnapshot) (SchemaMetaIndexSnapsh
Risk: schemaString(tool["risk"]),
Confirmation: schemaString(tool["confirmation"]),
Idempotency: schemaString(tool["idempotency"]),
RetryPolicy: retryPolicyFromSchemaValue(tool["retry_policy"]),
AgentSummary: schemaString(tool["agent_summary"]),
UseWhen: schemaStringSlice(tool["use_when"]),
AvoidWhen: schemaStringSlice(tool["avoid_when"]),
@@ -203,6 +207,7 @@ func commandMetaLookupFromIndex(index SchemaMetaIndexSnapshot) (map[string]Comma
Risk: entry.Risk,
Confirmation: entry.Confirmation,
Idempotency: entry.Idempotency,
RetryPolicy: cloneRetryPolicy(entry.RetryPolicy),
},
Selection: CommandSelection{
AgentSummary: entry.AgentSummary,
@@ -294,7 +299,7 @@ func commandMetaEqual(got, want CommandMeta) error {
!metaStringSlicesEqual(got.Identity.Aliases, want.Identity.Aliases) {
return fmt.Errorf("identity mismatch: got %+v want %+v", got.Identity, want.Identity)
}
if got.Safety != want.Safety {
if !reflect.DeepEqual(got.Safety, want.Safety) {
return fmt.Errorf("safety mismatch: got %+v want %+v", got.Safety, want.Safety)
}
if got.Selection.AgentSummary != want.Selection.AgentSummary ||
+13 -2
View File
@@ -294,6 +294,10 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
} else if gate, ok := RuntimeContractGate(entry.Command); ok {
safety = applyContractGateToSafety(safety, gate)
}
retryPolicy, err := contract.NormalizeRetryPolicySpec(final.RetryPolicy, canonicalPath)
if err != nil {
return ToolSpec{}, err
}
positionals := final.Positionals
if len(positionals) == 0 {
@@ -333,7 +337,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
// part of the public ToolSpec / Schema wire contract.
selection.ExampleDispositions = nil
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, retryPolicy, interfaceSpec, selection, final.DryRun)
result, pagination := final.Result, final.Pagination
if !output.UsesUnifiedResult(entry.Command) {
@@ -356,6 +360,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
DryRun: final.DryRun,
Result: result,
Pagination: pagination,
RetryPolicy: retryPolicy,
Safety: safety,
Interface: interfaceSpec,
Selection: selection,
@@ -402,7 +407,7 @@ func contractFinalTextProvenance(declared, cobra string, preferCobra bool) (stri
// dry_run when present), so declared leaves must emit the full set, not only
// the fields they happened to author. Title/description provenance must match
// the real text winner (cobra_help vs contract_final).
func contractFinalProvenance(identity contract.ToolIdentitySpec, title, description string, titleProv, descriptionProv contract.FieldProvenance, safety contract.SafetySpec, iface contract.InterfaceSpec, selection contract.SelectionSpec, dryRun *contract.DryRunSpec) map[string]contract.FieldProvenance {
func contractFinalProvenance(identity contract.ToolIdentitySpec, title, description string, titleProv, descriptionProv contract.FieldProvenance, safety contract.SafetySpec, retryPolicy *contract.RetryPolicySpec, iface contract.InterfaceSpec, selection contract.SelectionSpec, dryRun *contract.DryRunSpec) map[string]contract.FieldProvenance {
prov := func(value any, sourceRef string) contract.FieldProvenance {
return resolvedFieldProvenance(
value,
@@ -454,6 +459,9 @@ func contractFinalProvenance(identity contract.ToolIdentitySpec, title, descript
if dryRun != nil {
out["dry_run"] = prov(*dryRun, "corecmd.ContractDecl")
}
if retryPolicy != nil {
out["retry_policy"] = prov(*retryPolicy, "corecmd.ContractDecl")
}
return out
}
@@ -693,6 +701,9 @@ func validateFinalSchemaProvenanceCoverage(registry SchemaRegistry) error {
if tool.DryRun != nil {
require("tool "+canonical, "dry_run", tool.FieldProvenance)
}
if tool.RetryPolicy != nil {
require("tool "+canonical, "retry_policy", tool.FieldProvenance)
}
// interface_reason is part of the final interface contract only when
// the disposition requires or actually delivers a reason. An MCP or
// local available command with no reason has no resolver winner to
+2
View File
@@ -67,6 +67,7 @@ type schemaToolWire struct {
DryRun *contract.DryRunSpec `json:"dry_run"`
Result *contract.ResultSpec `json:"result"`
Pagination *contract.PaginationSpec `json:"pagination"`
RetryPolicy *contract.RetryPolicySpec `json:"retry_policy"`
Effect string `json:"effect"`
EffectSource string `json:"effect_source"`
Risk string `json:"risk"`
@@ -271,6 +272,7 @@ func schemaToolSpecFromWire(wire schemaToolWire) (ToolSpec, error) {
DryRun: wire.DryRun,
Result: wire.Result,
Pagination: wire.Pagination,
RetryPolicy: wire.RetryPolicy,
Safety: contract.SafetySpec{
Effect: wire.Effect,
EffectSource: wire.EffectSource,
+1
View File
@@ -77,6 +77,7 @@ func resetDeliverySchemaCatalogState() {
func resetSchemaDeliveryState() {
metaByCLIPathOnce = sync.Once{}
metaByCLIPath = nil
toolCallRetryByInterface = nil
runtimeDeliverySchemaMetaIndexErr = nil
runtimeDeliverySchemaMetaIndexLazyCount.Store(0)
resetDeliverySchemaCatalogState()
+1
View File
@@ -32,6 +32,7 @@ type ContractFinalPayload struct {
DryRun *DryRunSpec
Result *ResultSpec
Pagination *PaginationSpec
RetryPolicy *RetryPolicySpec
Interface *InterfaceSpec
Selection *SelectionSpec
Identity *ToolIdentitySpec
+93
View File
@@ -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 contract
import (
"fmt"
"strings"
)
const RetryModeDeduplicationKey = "deduplication_key"
// RetryPolicySpec declares the reviewed condition that makes an otherwise
// non-idempotent operation safe to retry. KeyParameter names the CLI parameter;
// Schema assembly binds it to one explicit interface property before runtime.
// No flag-name or request-property inference is permitted.
type RetryPolicySpec struct {
Mode string `json:"mode"`
KeyParameter string `json:"key_parameter"`
SamePayloadRequired bool `json:"same_payload_required"`
}
// RetryDecision is the invocation-scoped safety result consumed by Agent and
// transport code. Static metadata remains unchanged; EffectiveIdempotency is
// derived only from the reviewed policy and the actual invocation arguments.
type RetryDecision struct {
EffectiveIdempotency string `json:"effective_idempotency"`
SafeToRetry bool `json:"safe_to_retry"`
Reason string `json:"reason"`
}
// NormalizeRetryPolicySpec validates and defensively copies a retry policy.
func NormalizeRetryPolicySpec(in *RetryPolicySpec, canonical string) (*RetryPolicySpec, error) {
if in == nil {
return nil, nil
}
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
out := &RetryPolicySpec{
Mode: strings.TrimSpace(in.Mode),
KeyParameter: strings.TrimSpace(in.KeyParameter),
SamePayloadRequired: in.SamePayloadRequired,
}
if out.Mode != RetryModeDeduplicationKey {
return nil, fmt.Errorf("schema tool %s retry_policy has unsupported mode %q", canonical, out.Mode)
}
if out.KeyParameter == "" {
return nil, fmt.Errorf("schema tool %s retry_policy has no key_parameter", canonical)
}
if !out.SamePayloadRequired {
return nil, fmt.Errorf("schema tool %s retry_policy must require the same payload", canonical)
}
return out, nil
}
// ResolveRetryDecision derives invocation safety without mutating the static
// command contract. argumentKey is the CLI parameter name for Agent calls or
// the explicitly declared interface property for transport calls.
func ResolveRetryDecision(idempotency string, policy *RetryPolicySpec, arguments map[string]any, argumentKey string) RetryDecision {
switch strings.TrimSpace(idempotency) {
case "idempotent":
return RetryDecision{EffectiveIdempotency: "idempotent", SafeToRetry: true, Reason: "static_idempotent"}
case "non_idempotent":
return RetryDecision{EffectiveIdempotency: "non_idempotent", Reason: "static_non_idempotent"}
case "unknown":
return RetryDecision{EffectiveIdempotency: "unknown", Reason: "idempotency_unknown"}
case "conditional":
normalized, err := NormalizeRetryPolicySpec(policy, "<invocation>")
if err != nil || normalized == nil {
return RetryDecision{EffectiveIdempotency: "unknown", Reason: "invalid_retry_policy"}
}
argumentKey = strings.TrimSpace(argumentKey)
if argumentKey == "" {
return RetryDecision{EffectiveIdempotency: "unknown", Reason: "deduplication_key_binding_missing"}
}
value, ok := arguments[argumentKey].(string)
if !ok || strings.TrimSpace(value) == "" {
return RetryDecision{EffectiveIdempotency: "non_idempotent", Reason: "deduplication_key_missing"}
}
return RetryDecision{EffectiveIdempotency: "idempotent", SafeToRetry: true, Reason: "deduplication_key_present"}
default:
return RetryDecision{EffectiveIdempotency: "unknown", Reason: "idempotency_unknown"}
}
}
@@ -0,0 +1,79 @@
// 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 contract
import "testing"
func TestCrossPlatformCoverageNormalizeRetryPolicySpec(t *testing.T) {
if got, err := NormalizeRetryPolicySpec(nil, "chat.send"); err != nil || got != nil {
t.Fatalf("nil NormalizeRetryPolicySpec() = %#v, %v", got, err)
}
got, err := NormalizeRetryPolicySpec(&RetryPolicySpec{
Mode: " deduplication_key ",
KeyParameter: " uuid ",
SamePayloadRequired: true,
}, "chat.send")
if err != nil {
t.Fatalf("NormalizeRetryPolicySpec() error = %v", err)
}
if got.Mode != RetryModeDeduplicationKey || got.KeyParameter != "uuid" || !got.SamePayloadRequired {
t.Fatalf("NormalizeRetryPolicySpec() = %#v", got)
}
if _, err := NormalizeRetryPolicySpec(&RetryPolicySpec{Mode: RetryModeDeduplicationKey, SamePayloadRequired: true}, "chat.send"); err == nil {
t.Fatal("empty key_parameter must fail closed")
}
if _, err := NormalizeRetryPolicySpec(&RetryPolicySpec{Mode: RetryModeDeduplicationKey, KeyParameter: "uuid"}, "chat.send"); err == nil {
t.Fatal("same_payload_required=false must fail closed")
}
if _, err := NormalizeRetryPolicySpec(&RetryPolicySpec{Mode: "inferred", KeyParameter: "uuid", SamePayloadRequired: true}, "chat.send"); err == nil {
t.Fatal("unsupported retry mode must fail closed")
}
}
func TestCrossPlatformCoverageResolveRetryDecision(t *testing.T) {
policy := &RetryPolicySpec{Mode: RetryModeDeduplicationKey, KeyParameter: "uuid", SamePayloadRequired: true}
if got := ResolveRetryDecision("conditional", policy, map[string]any{"uuid": "request-1"}, "uuid"); !got.SafeToRetry || got.EffectiveIdempotency != "idempotent" {
t.Fatalf("conditional with key = %#v", got)
}
for name, args := range map[string]map[string]any{
"missing": {},
"empty": {"uuid": " "},
"non-string": {"uuid": 123},
} {
t.Run(name, func(t *testing.T) {
got := ResolveRetryDecision("conditional", policy, args, "uuid")
if got.SafeToRetry || got.EffectiveIdempotency != "non_idempotent" {
t.Fatalf("decision = %#v", got)
}
})
}
if got := ResolveRetryDecision("idempotent", nil, nil, ""); !got.SafeToRetry || got.EffectiveIdempotency != "idempotent" {
t.Fatalf("static idempotent = %#v", got)
}
if got := ResolveRetryDecision("non_idempotent", nil, nil, ""); got.SafeToRetry || got.EffectiveIdempotency != "non_idempotent" {
t.Fatalf("static non-idempotent = %#v", got)
}
if got := ResolveRetryDecision("unknown", nil, nil, ""); got.SafeToRetry || got.EffectiveIdempotency != "unknown" {
t.Fatalf("unknown = %#v", got)
}
if got := ResolveRetryDecision("conditional", policy, map[string]any{"uuid": "request-1"}, ""); got.SafeToRetry || got.EffectiveIdempotency != "unknown" || got.Reason != "deduplication_key_binding_missing" {
t.Fatalf("missing key binding = %#v", got)
}
if got := ResolveRetryDecision("conditional", nil, nil, ""); got.SafeToRetry || got.Reason != "invalid_retry_policy" {
t.Fatalf("invalid conditional policy = %#v", got)
}
if got := ResolveRetryDecision("retryable", nil, nil, ""); got.SafeToRetry || got.EffectiveIdempotency != "unknown" {
t.Fatalf("unknown legacy value = %#v", got)
}
}
+34 -3
View File
@@ -44,12 +44,14 @@ type ContractDecl struct {
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
RetryPolicy *contract.RetryPolicySpec
Interface *contract.InterfaceSpec
Selection contract.SelectionSpec
Identity contract.ToolIdentitySpec
}
// validateContractDecl enforces authoring-time homology for declared commands.
// validateContractDecl enforces authoring-time homology for declared commands
// and returns the normalized retry policy for the ContractFinal projection.
// A declared Contract is the sole final source for its fields: downstream
// catalog/Agent gates hard-require description and the selection prose for
// every effective tool — so a declaration missing any of these fields could
@@ -61,9 +63,34 @@ type ContractDecl struct {
// self-description: identity is collected from ContractFinal on the live
// leaves, so an incomplete or inconsistent declared Identity fails collection,
// binding, and policy downstream.
func validateContractDecl(spec Spec) {
func validateContractDecl(spec Spec) *contract.RetryPolicySpec {
idempotency := strings.TrimSpace(spec.Safety.Idempotency)
if idempotency == "conditional" && spec.Contract.RetryPolicy == nil {
panic(fmt.Sprintf("command %q Safety.Idempotency=conditional requires Contract.RetryPolicy", spec.Use))
}
if spec.Contract.RetryPolicy != nil && idempotency != "conditional" {
panic(fmt.Sprintf("command %q Contract.RetryPolicy requires Safety.Idempotency=conditional", spec.Use))
}
retryPolicy, err := contract.NormalizeRetryPolicySpec(spec.Contract.RetryPolicy, spec.Contract.Identity.CanonicalPath)
if err != nil {
panic(fmt.Sprintf("command %q has invalid Contract.RetryPolicy: %v", spec.Use, err))
}
if retryPolicy != nil {
declared := false
for _, parameter := range spec.Contract.Parameters {
if strings.TrimSpace(parameter.Name) == retryPolicy.KeyParameter && strings.TrimSpace(parameter.Property) != "" {
declared = true
break
}
}
if !declared {
panic(fmt.Sprintf(
"command %q Contract.RetryPolicy key_parameter %q requires Contract.Parameters with an explicit ParamDecl.Property",
spec.Use, retryPolicy.KeyParameter))
}
}
if spec.Contract.empty() {
return
return retryPolicy
}
missing := make([]string, 0, 8)
if strings.TrimSpace(spec.Contract.Description) == "" {
@@ -125,6 +152,7 @@ func validateContractDecl(spec Spec) {
"command %q Contract.Identity.CLIPath %q and PrimaryCLIPath %q must agree on the primary leaf path",
spec.Use, cliPath, primary))
}
return retryPolicy
}
// Empty reports whether no ContractDecl field was authored.
@@ -152,6 +180,9 @@ func (s ContractDecl) empty() bool {
if s.Pagination != nil {
return false
}
if s.RetryPolicy != nil {
return false
}
if s.Interface != nil {
iface := s.Interface
if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" ||
+54 -2
View File
@@ -14,6 +14,7 @@
package corecmd
import (
"fmt"
"strings"
"testing"
@@ -21,6 +22,43 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
)
func TestCrossPlatformCoverageContractRetryPolicyFailsFast(t *testing.T) {
if (ContractDecl{RetryPolicy: &contract.RetryPolicySpec{}}).Empty() {
t.Fatal("RetryPolicy-only ContractDecl must not be empty")
}
assertPanic := func(name, want string, spec Spec) {
t.Helper()
t.Run(name, func(t *testing.T) {
defer func() {
recovered := recover()
if recovered == nil || !strings.Contains(fmt.Sprint(recovered), want) {
t.Fatalf("panic = %v, want %q", recovered, want)
}
}()
validateContractDecl(spec)
})
}
assertPanic("conditional without contract", "requires Contract.RetryPolicy", Spec{
Use: "send", Safety: contract.SafetySpec{Idempotency: "conditional"},
})
minimal := ContractDecl{Description: "description"}
assertPanic("conditional without policy", "requires Contract.RetryPolicy", Spec{
Use: "send", Safety: contract.SafetySpec{Idempotency: "conditional"}, Contract: minimal,
})
minimal.RetryPolicy = &contract.RetryPolicySpec{Mode: contract.RetryModeDeduplicationKey, KeyParameter: "uuid", SamePayloadRequired: true}
assertPanic("policy without explicit ParamDecl property", "requires Contract.Parameters with an explicit ParamDecl.Property", Spec{
Use: "send", Safety: contract.SafetySpec{Idempotency: "conditional"}, Contract: minimal,
})
minimal.Parameters = []contract.ParamDecl{{Name: "uuid", Property: "requestUuid"}}
assertPanic("policy without conditional", "requires Safety.Idempotency=conditional", Spec{
Use: "send", Safety: contract.SafetySpec{Idempotency: "unknown"}, Contract: minimal,
})
minimal.RetryPolicy.SamePayloadRequired = false
assertPanic("invalid policy", "invalid Contract.RetryPolicy", Spec{
Use: "send", Safety: contract.SafetySpec{Idempotency: "conditional"}, Contract: minimal,
})
}
func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *testing.T) {
cmd := New(Spec{
Use: "create",
@@ -35,11 +73,12 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
},
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "retryable",
Confirmation: "user_required", Idempotency: "conditional",
},
Contract: ContractDecl{
Title: "Create Title",
Description: "Create Desc",
Parameters: []contract.ParamDecl{{Name: "mode", Property: "mode"}},
Positionals: []contract.RuntimeSchemaPositional{{Name: "id", Required: true, Index: 0}},
DryRun: &contract.DryRunSpec{PreviewKind: "invocation", RemoteReads: true},
Result: &contract.ResultSpec{
@@ -47,6 +86,11 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
DataSchema: []byte(`{"type":"object"}`),
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
RetryPolicy: &contract.RetryPolicySpec{
Mode: contract.RetryModeDeduplicationKey,
KeyParameter: "mode",
SamePayloadRequired: true,
},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
@@ -78,7 +122,7 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
if final.Title != "Create Title" || final.Description != "Create Desc" {
t.Fatalf("title/desc = %q %q (payload stores declared Contract text; Catalog may prefer Cobra Long)", final.Title, final.Description)
}
if final.Safety == nil || final.Safety.Confirmation != "user_required" || final.Safety.Idempotency != "retryable" {
if final.Safety == nil || final.Safety.Confirmation != "user_required" || final.Safety.Idempotency != "conditional" {
t.Fatalf("safety = %#v", final.Safety)
}
if final.DryRun == nil || final.DryRun.PreviewKind != "invocation" || !final.DryRun.RemoteReads {
@@ -90,6 +134,14 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
if final.Pagination == nil || final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath {
t.Fatalf("pagination = %#v", final.Pagination)
}
if final.RetryPolicy == nil || final.RetryPolicy.KeyParameter != "mode" || !final.RetryPolicy.SamePayloadRequired {
t.Fatalf("retry_policy = %#v", final.RetryPolicy)
}
final.RetryPolicy.KeyParameter = "mutated"
again, ok := contractfinal.RuntimeContractFinal(cmd)
if !ok || again.RetryPolicy == nil || again.RetryPolicy.KeyParameter != "mode" {
t.Fatalf("stored retry_policy was not defensively copied: %#v", again.RetryPolicy)
}
if final.Interface == nil || final.Interface.Mode != "mcp" || final.Interface.Ref == nil || final.Interface.Ref.RPCName != "create_thing" {
t.Fatalf("interface = %#v", final.Interface)
}
+4
View File
@@ -85,6 +85,10 @@ func cloneContractFinalPayload(in contract.ContractFinalPayload) contract.Contra
value := *in.Pagination
out.Pagination = &value
}
if in.RetryPolicy != nil {
value := *in.RetryPolicy
out.RetryPolicy = &value
}
if in.Interface != nil {
value := *in.Interface
if in.Interface.Ref != nil {
+4 -1
View File
@@ -1364,7 +1364,7 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
_, _ = short, long
// Reuse NewCommand's completeness rules so bind-time attaches cannot ship
// a partial declaration that would only fail in generated artifacts.
validateContractDecl(Spec{Use: cmd.Name(), Safety: safety, Contract: decl})
retryPolicy := validateContractDecl(Spec{Use: cmd.Name(), Safety: safety, Contract: decl})
validateSafetySpec(Spec{Use: cmd.Name(), Safety: safety})
payload := contract.ContractFinalPayload{
@@ -1394,6 +1394,9 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
}
payload.Pagination = pagination
}
if retryPolicy != nil {
payload.RetryPolicy = retryPolicy
}
if decl.Interface != nil {
iface := &contract.InterfaceSpec{
Mode: strings.TrimSpace(decl.Interface.Mode),
+20 -14
View File
@@ -19,19 +19,21 @@ import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type Invocation struct {
Kind string `json:"kind"`
Stage string `json:"stage"`
Implemented bool `json:"implemented"`
DryRun bool `json:"dry_run,omitempty"`
CanonicalProduct string `json:"canonical_product"`
Tool string `json:"tool"`
CanonicalPath string `json:"canonical_path"`
LegacyPath string `json:"legacy_path,omitempty"`
Params map[string]any `json:"params"`
Kind string `json:"kind"`
Stage string `json:"stage"`
Implemented bool `json:"implemented"`
DryRun bool `json:"dry_run,omitempty"`
CanonicalProduct string `json:"canonical_product"`
Tool string `json:"tool"`
CanonicalPath string `json:"canonical_path"`
LegacyPath string `json:"legacy_path,omitempty"`
Params map[string]any `json:"params"`
Retry *contract.RetryDecision `json:"retry,omitempty"`
}
type Result struct {
@@ -47,13 +49,17 @@ type EchoRunner struct{}
func (EchoRunner) Run(_ context.Context, invocation Invocation) (Result, error) {
if invocation.DryRun {
response := map[string]any{
"dry_run": true,
"request": ToolCallRequest(invocation.Tool, invocation.Params),
"note": "execution skipped by --dry-run",
}
if invocation.Retry != nil {
response["retry"] = invocation.Retry
}
return Result{
Invocation: invocation,
Response: map[string]any{
"dry_run": true,
"request": ToolCallRequest(invocation.Tool, invocation.Params),
"note": "execution skipped by --dry-run",
},
Response: response,
}, nil
}
return Result{Invocation: invocation}, nil
+7 -3
View File
@@ -4,19 +4,21 @@ import (
"context"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestCrossPlatformCoverageInvocationBuildersAndEchoRunner(t *testing.T) {
params := map[string]any{"name": "value"}
compat := NewCompatibilityInvocation("old path", "doc", "read", params)
if compat.Kind != "compat_invocation" || compat.CanonicalPath != "doc.read" || !reflect.DeepEqual(compat.Params, params) {
if compat.Kind != "compat_invocation" || compat.CanonicalPath != "doc.read" || compat.Retry != nil || !reflect.DeepEqual(compat.Params, params) {
t.Fatalf("unexpected compatibility invocation: %#v", compat)
}
if got := NewCompatibilityInvocation("old", "doc", "read", nil).Params; got == nil {
t.Fatal("nil compatibility params were not normalized")
}
help := NewHelperInvocation("old path", "chat", "send", params)
if help.Kind != "helper_invocation" || help.Stage != "helper_override" {
if help.Kind != "helper_invocation" || help.Stage != "helper_override" || help.Retry != nil {
t.Fatalf("unexpected helper invocation: %#v", help)
}
if got := NewHelperInvocation("old", "chat", "send", nil).Params; got == nil {
@@ -29,8 +31,10 @@ func TestCrossPlatformCoverageInvocationBuildersAndEchoRunner(t *testing.T) {
t.Fatalf("EchoRunner normal result = %#v, %v", result, err)
}
compat.DryRun = true
retry := contract.RetryDecision{EffectiveIdempotency: "idempotent", SafeToRetry: true, Reason: "static_idempotent"}
compat.Retry = &retry
result, err = runner.Run(context.Background(), compat)
if err != nil || result.Response["dry_run"] != true {
if err != nil || result.Response["dry_run"] != true || result.Response["retry"] != &retry {
t.Fatalf("EchoRunner dry-run result = %#v, %v", result, err)
}
}
+4
View File
@@ -2799,6 +2799,8 @@ func newChatCommand() *cobra.Command {
纯文本 / Markdown 消息(默认):
无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
图文混排时,公网图片 URL 需要写成 Markdown 图片语法:![图片标题](https://example.com/image.png),才会以内联图片展示。
如果省略开头的 !,例如 [图片标题](https://example.com/image.png),将按链接/URL 展示,不会渲染为图片。
返回值与后续操作:
发送后会返回 openTaskId。如需编辑或撤回刚发送的消息,使用
@@ -2816,6 +2818,8 @@ func newChatCommand() *cobra.Command {
dws chat message send --user <userId> "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> "请查收"
dws chat message send --group <openconversation_id> --title "周报提醒" "请大家本周五前提交周报"
# 图文混排 Markdown:公网图片 URL 需要写成 ![图片标题](URL) 才会以内联图片展示
dws chat message send --group <openconversation_id> --text $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)'
# 发送本地图片或文件(图片会作为可下载的 file 附件发送)
dws chat message send --group <openconversation_id> --msg-type file --file-path ./screenshot.png
dws chat message send --group <openconversation_id> --msg-type file --file-path ./report.pdf
+1
View File
@@ -3283,6 +3283,7 @@ func newDriveCommand() *cobra.Command {
driveListCmd,
driveListSpacesCmd,
driveInfoCmd,
newDriveFileCommentCmd(),
driveDownloadCmd,
driveDownloadVersionCmd,
driveMkdirCmd,
+576
View File
@@ -0,0 +1,576 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
const (
fileCommentServer = "doc-comment"
listFileCommentsTool = "list_file_comments"
createFileCommentTool = "create_file_comment"
fileCommentMaxPageSize = 200
fileCommentMaxAutoPages = 10
fileCommentMaxContentLength = 2099
)
type fileCommentPage struct {
nodeID string
total any
hasMore bool
nextCursor string
comments []map[string]any
}
func fileCommentNodeFlag() LeafFlag {
return LeafFlag{Name: "node", Usage: "文件 ID (dentryUuid)、数字 dentry ID 或钉盘文件 URL", Required: true, Aliases: []string{"url", "id", "node-id", "file-id"}, Bind: "fileId", Trim: true}
}
func fileCommentSpaceIDFlag() LeafFlag {
return LeafFlag{Name: "space-id", Usage: "钉盘空间 ID;仅数字 dentry ID 必填", Bind: "spaceId", OmitEmpty: true, Trim: true, RequiredWhen: "--node is a numeric dentry ID"}
}
// newDriveFileCommentCmd follows the existing resource-first comment surface:
// doc comment, sheet comment, and drive comment. The public command belongs to
// Drive, while the implementation routes to the shared doc-comment MCP server.
func newDriveFileCommentCmd() *cobra.Command {
commentCmd := &cobra.Command{
Use: "comment",
Short: "普通文件评论管理",
Long: "管理钉盘普通预览文件的评论:查询评论列表或创建全文纯文本评论。",
RunE: groupRunE,
}
listCmd := NewLeafCommand(LeafSpec{
Use: "list",
Short: "查询普通文件评论列表",
Long: `查询钉盘普通预览文件的评论。支持 dentryUuid、钉盘文件 URL,以及配合
--space-id 使用的数字 dentry ID。支持的文件类型由服务端判定。
默认返回一页;--all 固定按每页 200 条自动翻页,最多 10 页。--scope 在 CLI
侧按服务端返回的统一 anchor 过滤;total 始终表示文件的全部有效评论数,count
表示本次输出且符合 scope 的评论数。`,
Example: ` dws drive comment list --node <dentryUuid> --format json
dws drive comment list --node <dentryUuid> --all --format json`,
Tool: listFileCommentsTool,
Flags: []LeafFlag{
fileCommentNodeFlag(),
fileCommentSpaceIDFlag(),
{Name: "limit", Usage: "每页评论数,范围 1-200", Kind: LeafInt, Default: "200", Aliases: []string{"page-size"}, Bind: "maxResults"},
{Name: "cursor", Usage: "分页游标,取自上页 nextCursor", Bind: "nextToken", OmitEmpty: true, Trim: true},
{Name: "all", Usage: "自动拉取全部评论,最多 10 页 / 2000 条", Kind: LeafBool, Bind: "all"},
{Name: "scope", Usage: "评论范围: all(全部) / whole(全文) / partial(历史局部)", Default: "all", Bind: "scope", Trim: true, Enum: []string{"all", "whole", "partial"}},
},
Constraints: []LeafConstraint{
{Kind: LeafMutuallyExclusive, Flags: []string{"all", "cursor"}, Description: "--all 与 --cursor 互斥"},
{Kind: LeafMutuallyExclusive, Flags: []string{"all", "limit"}, Description: "--all 与显式 --limit/--page-size 互斥"},
{Kind: "custom", Flags: []string{"limit"}, Description: "--limit/--page-size 必须在 1-200 之间"},
{Kind: "custom", Flags: []string{"cursor"}, Description: "--cursor 必须是服务端返回的非负数字游标"},
},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "list_file_comments",
CanonicalPath: "drive.list_file_comments",
CLIPath: "drive comment list",
PrimaryCLIPath: "drive comment list",
},
Description: "查询钉盘普通预览文件评论,支持安全分页聚合和全文/局部范围过滤",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "The CLI wraps doc-comment/list_file_comments with bounded auto-pagination, cursor anomaly guards, local anchor-scope filtering, and a stable output projection, so no single direct MCP interface represents the complete command contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询 PDF、DOCX、XLSX 等钉盘普通预览文件上的评论",
UseWhen: []string{
"用户要查看普通文件上的全文评论或历史高亮/矩形评论时",
"需要从评论列表取得 commentId、作者、时间和统一 anchor,或用 --all 拉取完整列表时",
},
AvoidWhen: []string{
"在线文字文档评论使用 dws doc comment list",
"在线表格单元格评论使用 dws sheet comment list",
},
Examples: []string{
"dws drive comment list --node <dentryUuid> --format json",
"dws drive comment list --node <dentryUuid> --all --format json",
},
},
},
Validate: validateFileCommentList,
Call: runFileCommentList,
})
createCmd := NewLeafCommand(LeafSpec{
Use: "create",
Short: "创建普通文件全文评论",
Long: `在钉盘普通预览文件上创建一条全文纯文本评论。当前不支持 @人、通知选项或
局部锚点;content 按服务端规则使用 UTF-16 长度计数,最多 2099。`,
Example: ` dws drive comment create --node <dentryUuid> --content "请补充最终结论" --format json`,
Tool: createFileCommentTool,
Flags: []LeafFlag{
fileCommentNodeFlag(),
fileCommentSpaceIDFlag(),
{Name: "content", Usage: "全文评论内容,纯文本且 UTF-16 长度不超过 2099", Required: true, Bind: "content"},
},
Constraints: []LeafConstraint{
{Kind: "custom", Flags: []string{"content"}, Description: "--content 去除首尾空白后必须非空,且 UTF-16 长度不超过 2099"},
},
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "create_file_comment",
CanonicalPath: "drive.create_file_comment",
CLIPath: "drive comment create",
PrimaryCLIPath: "drive comment create",
},
Description: "在钉盘普通预览文件上创建一条全文纯文本评论",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "The CLI wraps doc-comment/create_file_comment with exact local content validation, runtime confirmation, and a stable flattened output projection, so no single direct MCP interface represents the complete command contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: "在 PDF、DOCX、XLSX 等钉盘普通预览文件上创建全文纯文本评论",
UseWhen: []string{
"用户明确要求在普通文件上留下不绑定具体位置的评论时",
},
AvoidWhen: []string{
"在线文字文档评论使用 dws doc comment create",
"在线表格单元格评论使用 dws sheet comment create;当前普通文件评论不支持 @人或局部锚点",
},
Examples: []string{
"dws drive comment create --node <dentryUuid> --content \"请补充最终结论\" --format json",
},
},
},
Validate: validateFileCommentCreate,
Call: runFileCommentCreate,
})
commentCmd.AddCommand(listCmd, createCmd)
return commentCmd
}
func validateFileCommentList(cmd *cobra.Command, _ []string) error {
if err := validateFileCommentNodeSpace(cmd); err != nil {
return err
}
limit, _ := cmd.Flags().GetInt("limit")
if cmd.Flags().Changed("page-size") {
limit, _ = cmd.Flags().GetInt("page-size")
}
if limit < 1 || limit > fileCommentMaxPageSize {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--limit/--page-size 必须在 1-%d 之间", fileCommentMaxPageSize),
}
}
if cursor, _ := cmd.Flags().GetString("cursor"); strings.TrimSpace(cursor) != "" {
cursor = strings.TrimSpace(cursor)
if !allASCIIDigits(cursor) {
return &CLIError{Code: CodeInvalidParam, Message: "--cursor 必须是服务端返回的非负数字游标"}
}
if _, err := strconv.ParseInt(cursor, 10, 64); err != nil {
return &CLIError{Code: CodeInvalidParam, Message: "--cursor 超出 64 位整数范围"}
}
}
return nil
}
func validateFileCommentCreate(cmd *cobra.Command, _ []string) error {
if err := validateFileCommentNodeSpace(cmd); err != nil {
return err
}
content, _ := cmd.Flags().GetString("content")
if strings.TrimSpace(content) == "" {
return &CLIError{Code: CodeInvalidParam, Message: "--content 去除首尾空白后不能为空"}
}
length := fileCommentUTF16Length(content)
if length > fileCommentMaxContentLength {
return &CLIError{
Code: CodeInputTooLarge,
Message: fmt.Sprintf("--content 最多 %d 个 UTF-16 代码单元,当前为 %d", fileCommentMaxContentLength, length),
}
}
return nil
}
func validateFileCommentNodeSpace(cmd *cobra.Command) error {
node := corecmd.EffectiveValue(cmd, fileCommentNodeFlag())
if !allASCIIDigits(node) {
return nil
}
if spaceID := corecmd.EffectiveValue(cmd, fileCommentSpaceIDFlag()); spaceID == "" {
return &CLIError{
Code: CodeInvalidParam,
Message: "--node 为数字 dentry ID 时必须同时提供 --space-id",
}
}
return nil
}
func fileCommentUTF16Length(value string) int {
length := 0
for _, r := range value {
length++
if r > 0xffff {
length++
}
}
return length
}
func allASCIIDigits(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func runFileCommentList(cmd *cobra.Command, tool string, args map[string]any) error {
fetchAll, _ := args["all"].(bool)
scope, _ := args["scope"].(string)
if scope == "" {
scope = "all"
}
request := fileCommentMCPArgs(args)
if fetchAll {
request["maxResults"] = fileCommentMaxPageSize
delete(request, "nextToken")
}
if deps != nil && deps.Caller != nil && deps.Caller.DryRun() {
return callMCPToolOnServer(fileCommentServer, tool, request)
}
if !fetchAll {
page, err := fetchFileCommentPage(tool, request)
if err != nil {
return err
}
if err := validateFileCommentNextCursor(page, stringArg(request, "nextToken")); err != nil {
return err
}
page.comments = filterFileCommentsByScope(page.comments, scope)
return output.WriteCommandPayload(cmd, fileCommentListPayload(page, scope), output.FormatJSON)
}
var aggregate fileCommentPage
aggregate.comments = make([]map[string]any, 0)
seenCursors := map[string]bool{}
currentCursor := ""
for pageNumber := 0; pageNumber < fileCommentMaxAutoPages; pageNumber++ {
if currentCursor == "" {
delete(request, "nextToken")
} else {
request["nextToken"] = currentCursor
}
page, err := fetchFileCommentPage(tool, request)
if err != nil {
return err
}
if pageNumber == 0 {
aggregate.nodeID = page.nodeID
aggregate.total = page.total
}
aggregate.comments = append(aggregate.comments, filterFileCommentsByScope(page.comments, scope)...)
if !page.hasMore {
aggregate.hasMore = false
aggregate.nextCursor = ""
return output.WriteCommandPayload(cmd, fileCommentListPayload(aggregate, scope), output.FormatJSON)
}
if err := validateFileCommentNextCursor(page, currentCursor); err != nil {
return err
}
if seenCursors[page.nextCursor] {
return fileCommentPaginationError("分页游标发生循环,结果可能不完整")
}
seenCursors[page.nextCursor] = true
currentCursor = page.nextCursor
}
return fileCommentPaginationError(fmt.Sprintf("自动翻页达到 %d 页上限但服务端仍返回 hasMore=true,结果可能不完整", fileCommentMaxAutoPages))
}
func runFileCommentCreate(cmd *cobra.Command, tool string, args map[string]any) error {
request := fileCommentMCPArgs(args)
if deps != nil && deps.Caller != nil && deps.Caller.DryRun() {
return callMCPToolOnServer(fileCommentServer, tool, request)
}
raw, err := callMCPToolReturnTextOnServer(context.Background(), fileCommentServer, tool, request)
if err != nil {
return err
}
payload, err := decodeFileCommentPayload(tool, raw)
if err != nil {
return err
}
nodeID, ok := nonEmptyStringField(payload, "fileId")
if !ok {
return invalidFileCommentResponse(tool, "缺少 fileId", nil)
}
comment, ok := payload["comment"].(map[string]any)
if !ok {
return invalidFileCommentResponse(tool, "缺少 comment 对象", nil)
}
if err := validateFileCommentItem(tool, "comment", comment); err != nil {
return err
}
out := map[string]any{"nodeId": nodeID}
for key, value := range projectFileComment(comment) {
out[key] = value
}
return output.WriteCommandPayload(cmd, out, output.FormatJSON)
}
func fileCommentMCPArgs(args map[string]any) map[string]any {
out := make(map[string]any, len(args))
for key, value := range args {
switch key {
case "all", "scope":
continue
default:
out[key] = value
}
}
return out
}
func fetchFileCommentPage(tool string, request map[string]any) (fileCommentPage, error) {
raw, err := callMCPToolReturnTextOnServer(context.Background(), fileCommentServer, tool, request)
if err != nil {
return fileCommentPage{}, err
}
payload, err := decodeFileCommentPayload(tool, raw)
if err != nil {
return fileCommentPage{}, err
}
nodeID, ok := nonEmptyStringField(payload, "fileId")
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 fileId", nil)
}
total, ok := payload["total"]
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 total", nil)
}
hasMore, ok := payload["hasMore"].(bool)
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少布尔字段 hasMore", nil)
}
nextCursor := ""
if value, exists := payload["nextToken"]; exists && value != nil {
var stringValue bool
nextCursor, stringValue = value.(string)
if !stringValue {
return fileCommentPage{}, invalidFileCommentResponse(tool, "nextToken 不是字符串", nil)
}
}
rawItems, exists := payload["items"]
if !exists {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 items", nil)
}
items, ok := rawItems.([]any)
if rawItems == nil {
items = []any{}
ok = true
}
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "items 不是数组", nil)
}
comments := make([]map[string]any, 0, len(items))
for index, item := range items {
comment, ok := item.(map[string]any)
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, fmt.Sprintf("items[%d] 不是对象", index), nil)
}
if err := validateFileCommentItem(tool, fmt.Sprintf("items[%d]", index), comment); err != nil {
return fileCommentPage{}, err
}
comments = append(comments, projectFileComment(comment))
}
return fileCommentPage{
nodeID: nodeID, total: total, hasMore: hasMore,
nextCursor: strings.TrimSpace(nextCursor), comments: comments,
}, nil
}
func decodeFileCommentPayload(tool, raw string) (map[string]any, error) {
if strings.TrimSpace(raw) == "" {
return nil, invalidFileCommentResponse(tool, "返回为空", nil)
}
var payload map[string]any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, invalidFileCommentResponse(tool, "返回不是有效 JSON", err)
}
for depth := 0; depth < 2; depth++ {
if _, ok := payload["fileId"]; ok {
break
}
var nested map[string]any
for _, key := range []string{"result", "data"} {
if value, ok := payload[key].(map[string]any); ok {
nested = value
break
}
}
if nested == nil {
break
}
payload = nested
}
return payload, nil
}
func projectFileComment(comment map[string]any) map[string]any {
out := map[string]any{}
for _, key := range []string{"commentId", "parentCommentId", "content", "createdAt", "updatedAt", "options", "anchor"} {
if value, ok := comment[key]; ok {
out[key] = value
}
}
if value, ok := comment["commentCustomType"]; ok {
out["customType"] = value
}
creator := map[string]any{}
for source, target := range map[string]string{
"creatorId": "userId", "creatorName": "name", "creatorAvatar": "avatar",
} {
if value, ok := comment[source]; ok {
creator[target] = value
}
}
if len(creator) > 0 {
out["creator"] = creator
}
return out
}
func validateFileCommentItem(tool, path string, comment map[string]any) error {
if _, ok := nonEmptyStringField(comment, "commentId"); !ok {
return invalidFileCommentResponse(tool, path+" 缺少 commentId", nil)
}
if _, ok := comment["anchor"].(map[string]any); !ok {
return invalidFileCommentResponse(tool, path+" 缺少 anchor 对象", nil)
}
return nil
}
func filterFileCommentsByScope(comments []map[string]any, scope string) []map[string]any {
if scope == "" || scope == "all" {
return comments
}
out := make([]map[string]any, 0, len(comments))
for _, comment := range comments {
anchor, _ := comment["anchor"].(map[string]any)
commentScope, _ := anchor["scope"].(string)
if commentScope == scope {
out = append(out, comment)
}
}
return out
}
func fileCommentListPayload(page fileCommentPage, scope string) map[string]any {
comments := page.comments
if comments == nil {
comments = make([]map[string]any, 0)
}
nextCursor := any(nil)
if page.nextCursor != "" {
nextCursor = page.nextCursor
}
return map[string]any{
"nodeId": page.nodeID,
"total": page.total,
"count": len(page.comments),
"hasMore": page.hasMore,
"nextCursor": nextCursor,
"complete": !page.hasMore,
"scope": scope,
"comments": comments,
}
}
func validateFileCommentNextCursor(page fileCommentPage, currentCursor string) error {
if !page.hasMore {
return nil
}
if page.nextCursor == "" {
return fileCommentPaginationError("服务端返回 hasMore=true 但 nextCursor 为空,结果可能不完整")
}
if !allASCIIDigits(page.nextCursor) {
return fileCommentPaginationError("服务端返回的 nextCursor 不是非负数字游标,结果可能不完整")
}
if _, err := strconv.ParseInt(page.nextCursor, 10, 64); err != nil {
return fileCommentPaginationError("服务端返回的 nextCursor 超出 64 位整数范围,结果可能不完整")
}
if page.nextCursor == currentCursor {
return fileCommentPaginationError("服务端分页游标未前进,结果可能不完整")
}
return nil
}
func fileCommentPaginationError(message string) error {
return &CLIError{
Code: CodeContentTruncated,
Message: message,
Suggestion: "请稍后重试,或去掉 --all 后使用服务端返回的 --cursor 分页读取",
Operation: fileCommentServer + "/" + listFileCommentsTool,
}
}
func invalidFileCommentResponse(tool, reason string, cause error) error {
return &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("%s 返回结构异常:%s", tool, reason),
Suggestion: "请确认 doc-comment MCP 与当前 DWS 契约一致",
Operation: fileCommentServer + "/" + tool,
Cause: cause,
}
}
func nonEmptyStringField(payload map[string]any, key string) (string, bool) {
value, ok := payload[key].(string)
value = strings.TrimSpace(value)
return value, ok && value != ""
}
func stringArg(args map[string]any, key string) string {
value, _ := args[key].(string)
return strings.TrimSpace(value)
}
+647
View File
@@ -0,0 +1,647 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type fileCommentTestCall struct {
server string
tool string
args map[string]any
}
type fileCommentTestCaller struct {
calls []fileCommentTestCall
responses []string
err error
dryRun bool
}
func (c *fileCommentTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for key, value := range args {
copied[key] = value
}
c.calls = append(c.calls, fileCommentTestCall{server: server, tool: tool, args: copied})
if c.err != nil {
return nil, c.err
}
response := `{"result":{"fileId":"file-1","comment":{"commentId":"1","content":"ok","anchor":{"version":"v1","surface":"file","scope":"whole"}}}}`
if len(c.responses) > 0 {
response = c.responses[0]
c.responses = c.responses[1:]
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
}
func (c *fileCommentTestCaller) Format() string { return "json" }
func (c *fileCommentTestCaller) DryRun() bool { return c.dryRun }
func (c *fileCommentTestCaller) Fields() string { return "" }
func (c *fileCommentTestCaller) JQ() string { return "" }
func executeFileCommentCommand(t *testing.T, caller *fileCommentTestCaller, stdin string, args ...string) ([]byte, error) {
t.Helper()
testseam.Protect(t, &deps)
InitDeps(caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
deps.Out.errW = io.Discard
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.SetOut(&stdout)
root.SetErr(io.Discard)
root.SetIn(strings.NewReader(stdin))
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
drive := &cobra.Command{Use: "drive"}
drive.AddCommand(newDriveFileCommentCmd())
root.AddCommand(drive)
var setOutput func(*cobra.Command)
setOutput = func(command *cobra.Command) {
command.SetOut(&stdout)
command.SetErr(io.Discard)
for _, child := range command.Commands() {
setOutput(child)
}
}
setOutput(root)
root.SetArgs(args)
err := root.Execute()
return stdout.Bytes(), err
}
func decodeFileCommentTestOutput(t *testing.T, raw []byte) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode output %q: %v", raw, err)
}
return payload
}
func TestDriveFileCommentListMapsFiltersAndProjects(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{
"result": {
"fileId": "resolved-file",
"total": 2,
"count": 2,
"hasMore": false,
"nextToken": null,
"items": [
{
"commentId": "101",
"parentCommentId": null,
"content": "whole",
"creatorId": "user-1",
"creatorName": "Alice",
"creatorAvatar": "https://avatar/1",
"createdAt": 1785920000000,
"updatedAt": 1785920000100,
"commentCustomType": "common",
"anchor": {"version":"v1","surface":"file","scope":"whole"}
},
{
"commentId": "102",
"content": "partial",
"commentCustomType": "highlight",
"options": {"page":"1"},
"anchor": {"version":"v1","surface":"file","scope":"partial","selector":{"kind":"legacy-highlight"}}
}
]
}
}`}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list",
"--node", "https://alidocs.dingtalk.com/i/drive/file",
"--space-id", "123", "--limit", "20", "--scope", "whole",
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.server != fileCommentServer || call.tool != listFileCommentsTool {
t.Fatalf("target = %s/%s", call.server, call.tool)
}
wantArgs := map[string]any{
"fileId": "https://alidocs.dingtalk.com/i/drive/file",
"spaceId": "123",
"maxResults": 20,
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", call.args, wantArgs)
}
payload := decodeFileCommentTestOutput(t, out)
if payload["nodeId"] != "resolved-file" || payload["total"] != float64(2) ||
payload["count"] != float64(1) || payload["complete"] != true || payload["scope"] != "whole" {
t.Fatalf("list output = %#v", payload)
}
if payload["nextCursor"] != nil || payload["hasMore"] != false {
t.Fatalf("pagination output = %#v", payload)
}
comments := payload["comments"].([]any)
comment := comments[0].(map[string]any)
if comment["commentId"] != "101" || comment["customType"] != "common" {
t.Fatalf("comment projection = %#v", comment)
}
creator := comment["creator"].(map[string]any)
if creator["userId"] != "user-1" || creator["name"] != "Alice" || creator["avatar"] != "https://avatar/1" {
t.Fatalf("creator projection = %#v", creator)
}
if _, leaked := comment["creatorId"]; leaked {
t.Fatalf("raw creator fields leaked: %#v", comment)
}
}
func TestDriveFileCommentListKeepsEmptyCommentsAsArray(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":0,"count":0,"hasMore":false,"nextToken":null,"items":[]
}`}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list", "--node", "file-1",
)
if err != nil {
t.Fatal(err)
}
payload := decodeFileCommentTestOutput(t, out)
comments, ok := payload["comments"].([]any)
if !ok || len(comments) != 0 {
t.Fatalf("comments = %#v, want an empty JSON array", payload["comments"])
}
if payload["total"] != float64(0) || payload["count"] != float64(0) ||
payload["hasMore"] != false || payload["complete"] != true || payload["nextCursor"] != nil {
t.Fatalf("empty list output = %#v", payload)
}
}
func TestDriveFileCommentListAllAggregatesPages(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{
`{"fileId":"file-1","total":3,"count":2,"hasMore":true,"nextToken":"2","items":[
{"commentId":"1","anchor":{"scope":"whole"}},
{"commentId":"2","anchor":{"scope":"partial"}}
]}`,
`{"fileId":"file-1","total":3,"count":1,"hasMore":false,"nextToken":null,"items":[
{"commentId":"3","anchor":{"scope":"partial"}}
]}`,
}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list", "--node", "file-1", "--all", "--scope", "partial",
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["maxResults"] != fileCommentMaxPageSize {
t.Fatalf("first args = %#v", caller.calls[0].args)
}
if _, ok := caller.calls[0].args["nextToken"]; ok {
t.Fatalf("first page unexpectedly has cursor: %#v", caller.calls[0].args)
}
if caller.calls[1].args["nextToken"] != "2" {
t.Fatalf("second args = %#v", caller.calls[1].args)
}
for _, call := range caller.calls {
if _, ok := call.args["all"]; ok {
t.Fatalf("local --all leaked to MCP: %#v", call.args)
}
if _, ok := call.args["scope"]; ok {
t.Fatalf("local --scope leaked to MCP: %#v", call.args)
}
}
payload := decodeFileCommentTestOutput(t, out)
if payload["total"] != float64(3) || payload["count"] != float64(2) ||
payload["hasMore"] != false || payload["complete"] != true || payload["nextCursor"] != nil {
t.Fatalf("aggregate output = %#v", payload)
}
comments := payload["comments"].([]any)
if comments[0].(map[string]any)["commentId"] != "2" || comments[1].(map[string]any)["commentId"] != "3" {
t.Fatalf("scope-filtered comments = %#v", comments)
}
}
func TestDriveFileCommentListRejectsPaginationAnomalies(t *testing.T) {
tests := []struct {
name string
args []string
response string
message string
}{
{
name: "missing cursor",
args: []string{"drive", "comment", "list", "--node", "file-1", "--all"},
response: `{"fileId":"file-1","total":1,"count":1,"hasMore":true,"nextToken":null,"items":[]}`,
message: "nextCursor 为空",
},
{
name: "stalled cursor",
args: []string{"drive", "comment", "list", "--node", "file-1", "--cursor", "2"},
response: `{"fileId":"file-1","total":2,"count":1,"hasMore":true,"nextToken":"2","items":[]}`,
message: "游标未前进",
},
{
name: "invalid server cursor",
args: []string{"drive", "comment", "list", "--node", "file-1"},
response: `{"fileId":"file-1","total":2,"count":1,"hasMore":true,"nextToken":"next","items":[]}`,
message: "不是非负数字游标",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{tt.response}}
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, tt.message) {
t.Fatalf("error = %#v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
})
}
}
func TestDriveFileCommentListValidatesLocalParameters(t *testing.T) {
tests := []struct {
name string
args []string
}{
{"all with cursor", []string{"--all", "--cursor", "1"}},
{"all with limit", []string{"--all", "--limit", "20"}},
{"zero limit", []string{"--limit", "0"}},
{"large limit", []string{"--page-size", "201"}},
{"nonnumeric cursor", []string{"--cursor", "next"}},
{"overflow cursor", []string{"--cursor", "999999999999999999999999999999"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{}
args := []string{"drive", "comment", "list", "--node", "file-1"}
args = append(args, tt.args...)
if _, err := executeFileCommentCommand(t, caller, "", args...); err == nil {
t.Fatal("invalid arguments unexpectedly succeeded")
}
if len(caller.calls) != 0 {
t.Fatalf("invalid arguments reached MCP: %#v", caller.calls)
}
})
}
}
func TestDriveFileCommentNumericNodeRequiresSpaceIDForListAndCreateAliases(t *testing.T) {
tests := []struct {
name string
args []string
}{
{
name: "list id alias",
args: []string{"drive", "comment", "list", "--id", "231773999335"},
},
{
name: "create file-id alias",
args: []string{"drive", "comment", "create", "--file-id", "231773999335", "--content", "test", "--yes"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{}
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeInvalidParam || !strings.Contains(cliErr.Message, "--space-id") {
t.Fatalf("error = %#v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("numeric node without space-id reached MCP: %#v", caller.calls)
}
})
}
caller := &fileCommentTestCaller{responses: []string{`{
"fileId":"resolved-file","total":0,"count":0,"hasMore":false,"nextToken":null,"items":[]
}`}}
if _, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list", "--url", "231773999335", "--space-id", "2402756201",
); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].args["fileId"] != "231773999335" || caller.calls[0].args["spaceId"] != "2402756201" {
t.Fatalf("numeric node with space-id args = %#v", caller.calls)
}
if allASCIIDigits("") {
t.Fatal("empty string unexpectedly accepted as numeric")
}
}
func TestDriveFileCommentCreateValidatesMapsAndProjects(t *testing.T) {
validContent := strings.Repeat("a", fileCommentMaxContentLength)
caller := &fileCommentTestCaller{responses: []string{`{"result":{"fileId":"resolved-file","comment":{
"commentId":"912345","parentCommentId":null,"content":"created","creatorId":"user-1",
"createdAt":1785920000000,"commentCustomType":"common",
"anchor":{"version":"v1","surface":"file","scope":"whole","selector":null}
}}}`}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "create", "--node", "file-1", "--space-id", "123",
"--content", validContent, "--yes",
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != fileCommentServer || caller.calls[0].tool != createFileCommentTool {
t.Fatalf("calls = %#v", caller.calls)
}
wantArgs := map[string]any{"fileId": "file-1", "spaceId": "123", "content": validContent}
if !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
t.Fatalf("args = %#v, want %#v", caller.calls[0].args, wantArgs)
}
payload := decodeFileCommentTestOutput(t, out)
if payload["nodeId"] != "resolved-file" || payload["commentId"] != "912345" ||
payload["content"] != "created" || payload["customType"] != "common" {
t.Fatalf("create output = %#v", payload)
}
if _, nested := payload["comment"]; nested {
t.Fatalf("create output was not flattened: %#v", payload)
}
for _, tc := range []struct {
name string
content string
}{
{"blank", " \t"},
{"ascii over limit", strings.Repeat("a", fileCommentMaxContentLength+1)},
{"utf16 over limit", strings.Repeat("😀", 1050)},
} {
t.Run(tc.name, func(t *testing.T) {
invalidCaller := &fileCommentTestCaller{}
_, err := executeFileCommentCommand(t, invalidCaller, "",
"drive", "comment", "create", "--node", "file-1", "--content", tc.content, "--yes",
)
if err == nil {
t.Fatal("invalid content unexpectedly succeeded")
}
if len(invalidCaller.calls) != 0 {
t.Fatalf("invalid content reached MCP: %#v", invalidCaller.calls)
}
})
}
}
func TestDriveFileCommentCreatePublishesAndEnforcesConfirmation(t *testing.T) {
group := newDriveFileCommentCmd()
create, remaining, err := group.Find([]string{"create"})
if err != nil || len(remaining) != 0 {
t.Fatalf("find create: remaining=%v err=%v", remaining, err)
}
final, ok := contractfinal.RuntimeContractFinal(create)
if !ok || final.Safety == nil {
t.Fatal("create command is missing ContractFinal Safety")
}
if safety := *final.Safety; safety.Effect != "write" || safety.Risk != "medium" ||
safety.Confirmation != "user_required" || safety.Idempotency != "unknown" {
t.Fatalf("create safety = %#v", safety)
}
caller := &fileCommentTestCaller{}
_, err = executeFileCommentCommand(t, caller, "",
"drive", "comment", "create", "--node", "file-1", "--content", "需要确认",
)
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
t.Fatalf("closed-stdin error = %#v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed create reached MCP: %#v", caller.calls)
}
}
func TestDriveFileCommentRejectsMalformedResponseInsteadOfReturningEmpty(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{"result":{"fileId":"file-1","total":0,"hasMore":false}}`}}
_, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, "缺少 items") {
t.Fatalf("error = %#v", err)
}
}
func TestDriveFileCommentDryRunAndCallErrors(t *testing.T) {
for _, tt := range []struct {
name string
args []string
tool string
}{
{
name: "list dry run",
args: []string{"drive", "comment", "list", "--node", "file-1"},
tool: listFileCommentsTool,
},
{
name: "create dry run",
args: []string{"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes"},
tool: createFileCommentTool,
},
} {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{dryRun: true}
out, err := executeFileCommentCommand(t, caller, "", tt.args...)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 || !strings.Contains(string(out), `"tool": "`+tt.tool+`"`) {
t.Fatalf("calls = %#v, output = %s", caller.calls, out)
}
})
}
sentinel := errors.New("mcp unavailable")
for _, tt := range []struct {
name string
args []string
}{
{
name: "list call error",
args: []string{"drive", "comment", "list", "--node", "file-1", "--all"},
},
{
name: "create call error",
args: []string{"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes"},
},
} {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{err: sentinel}
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
if !errors.Is(err, sentinel) {
t.Fatalf("error = %#v", err)
}
})
}
}
func TestDriveFileCommentListResponseValidationBranches(t *testing.T) {
tests := []struct {
name string
response string
message string
}{
{name: "empty", response: "", message: "返回为空"},
{name: "invalid json", response: "{", message: "不是有效 JSON"},
{name: "missing file id", response: `{}`, message: "缺少 fileId"},
{name: "missing total", response: `{"fileId":"file-1","hasMore":false,"items":[]}`, message: "缺少 total"},
{name: "missing has more", response: `{"fileId":"file-1","total":0,"items":[]}`, message: "缺少布尔字段 hasMore"},
{name: "next token type", response: `{"fileId":"file-1","total":1,"hasMore":true,"nextToken":2,"items":[]}`, message: "nextToken 不是字符串"},
{name: "items type", response: `{"fileId":"file-1","total":0,"hasMore":false,"items":{}}`, message: "items 不是数组"},
{name: "item type", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[1]}`, message: "items[0] 不是对象"},
{name: "missing comment id", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[{"anchor":{}}]}`, message: "缺少 commentId"},
{name: "missing anchor", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[{"commentId":"1"}]}`, message: "缺少 anchor 对象"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{tt.response}}
_, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, tt.message) {
t.Fatalf("error = %#v", err)
}
})
}
caller := &fileCommentTestCaller{responses: []string{`{
"data":{"fileId":"file-1","total":0,"hasMore":false,"nextToken":null,"items":null}
}`}}
out, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
if err != nil {
t.Fatal(err)
}
if comments := decodeFileCommentTestOutput(t, out)["comments"].([]any); len(comments) != 0 {
t.Fatalf("comments = %#v", comments)
}
}
func TestDriveFileCommentCreateResponseValidationBranches(t *testing.T) {
tests := []struct {
name string
response string
message string
}{
{name: "invalid json", response: "{", message: "不是有效 JSON"},
{name: "missing file id", response: `{"comment":{"commentId":"1","anchor":{}}}`, message: "缺少 fileId"},
{name: "missing comment", response: `{"fileId":"file-1"}`, message: "缺少 comment 对象"},
{name: "missing comment id", response: `{"fileId":"file-1","comment":{"anchor":{}}}`, message: "缺少 commentId"},
{name: "missing anchor", response: `{"fileId":"file-1","comment":{"commentId":"1"}}`, message: "缺少 anchor 对象"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{tt.response}}
_, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes",
)
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, tt.message) {
t.Fatalf("error = %#v", err)
}
})
}
}
func TestDriveFileCommentPaginationCycleLimitAndPartialPage(t *testing.T) {
cycleCaller := &fileCommentTestCaller{responses: []string{
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"2","items":[]}`,
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"3","items":[]}`,
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"2","items":[]}`,
}}
_, err := executeFileCommentCommand(t, cycleCaller, "", "drive", "comment", "list", "--node", "file-1", "--all")
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "发生循环") {
t.Fatalf("cycle error = %#v", err)
}
limitResponses := make([]string, 0, fileCommentMaxAutoPages)
for index := 0; index < fileCommentMaxAutoPages; index++ {
limitResponses = append(limitResponses, fmt.Sprintf(
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"%d","items":[]}`,
index+1,
))
}
limitCaller := &fileCommentTestCaller{responses: limitResponses}
_, err = executeFileCommentCommand(t, limitCaller, "", "drive", "comment", "list", "--node", "file-1", "--all")
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "10 页上限") {
t.Fatalf("page limit error = %#v", err)
}
partialCaller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":1,"hasMore":true,"nextToken":"2",
"items":[{"commentId":"1","anchor":{"scope":"whole"}}]
}`}}
out, err := executeFileCommentCommand(t, partialCaller, "", "drive", "comment", "list", "--node", "file-1")
if err != nil {
t.Fatal(err)
}
payload := decodeFileCommentTestOutput(t, out)
if payload["nextCursor"] != "2" || payload["complete"] != false || payload["hasMore"] != true {
t.Fatalf("partial page = %#v", payload)
}
overflowCaller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":0,"hasMore":true,
"nextToken":"999999999999999999999999999999","items":[]
}`}}
_, err = executeFileCommentCommand(t, overflowCaller, "", "drive", "comment", "list", "--node", "file-1")
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "超出 64 位整数范围") {
t.Fatalf("overflow cursor error = %#v", err)
}
}
func TestDriveFileCommentInternalDefaults(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":0,"hasMore":false,"nextToken":null,"items":[]
}`}}
testseam.Protect(t, &deps)
InitDeps(caller)
var stdout bytes.Buffer
cmd := &cobra.Command{Use: "list"}
cmd.SetOut(&stdout)
if err := runFileCommentList(cmd, listFileCommentsTool, map[string]any{"fileId": "file-1", "maxResults": 200}); err != nil {
t.Fatal(err)
}
payload := decodeFileCommentTestOutput(t, stdout.Bytes())
if payload["scope"] != "all" {
t.Fatalf("scope = %#v", payload["scope"])
}
if comments := fileCommentListPayload(fileCommentPage{}, "all")["comments"].([]map[string]any); len(comments) != 0 {
t.Fatalf("nil comments projection = %#v", comments)
}
}
+17 -4
View File
@@ -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{}}
}
+62 -8
View File
@@ -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") {
+2
View File
@@ -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,
+21 -8
View File
@@ -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())
+76 -19
View File
@@ -313,17 +313,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 +338,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 +356,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 +385,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 +394,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 +408,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 +426,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}}`,
+45 -8
View File
@@ -878,16 +878,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 +908,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 +961,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 +992,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 +1004,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 +1023,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 +1052,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 +1068,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
}
+1
View File
@@ -634,6 +634,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
@@ -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"},
} {
+57 -20
View File
@@ -510,21 +510,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 +548,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 +601,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 +627,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 +636,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 +651,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 +669,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 +699,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
}
@@ -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) {
@@ -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) {
+13
View File
@@ -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{
+15 -1
View File
@@ -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})
},
}
+43
View File
@@ -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),
+26
View File
@@ -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"}}`},
+97
View File
@@ -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
}
}
+91
View File
@@ -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)
}
}
+44 -6
View File
@@ -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
}
+34 -10
View File
@@ -60,7 +60,7 @@ var ChatMessages = shortcut.Shortcut{
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 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{
@@ -86,7 +86,7 @@ var ChatMessages = shortcut.Shortcut{
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 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{
@@ -117,9 +117,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{
@@ -138,7 +140,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 +184,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 {
@@ -477,7 +491,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 +510,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 +679,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
+52 -11
View File
@@ -503,7 +503,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"},
@@ -613,21 +635,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 +674,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()
+57 -7
View File
@@ -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 ""
@@ -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{})
@@ -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
+93
View File
@@ -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)
}
}
}
+33 -8
View File
@@ -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.

Some files were not shown because too many files have changed in this diff Show More