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
wxianfeng 54dc8fadb7 feat: forward agent version and extension context 2026-08-13 10:13:04 +08:00
75 changed files with 4469 additions and 242 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: 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.
+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.
+11 -4
View File
@@ -1414,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') }}
+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
+46
View File
@@ -229,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
+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
+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 表面 |
+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])
}
}
+128 -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,29 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
// --output sink instead opens at Run entry (after Cobra's own
// validation), so validation failures still cannot strand a
// temporary file.
// Validate caller-provided identity labels before any edition hook
// or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead.
// Validate caller-provided identity and MCP metadata before command
// execution hooks or network activity. The process entry point additionally
// validates Agent metadata before command-tree construction; direct Cobra
// embedding retains this execution-boundary guard.
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
return err
}
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
return err
}
agentMetadata, cached := agentMetadataSnapshotFromContext(cmd.Context())
if !cached {
agentMetadata = readAgentMetadataSnapshot()
}
if err := agentMetadata.validationError(); err != nil {
return err
}
if runtime, ok := runner.(*runtimeRunner); ok {
// Retain the exact validated pair for this command execution so a
// concurrently mutating embedding environment cannot change what is
// later applied after edition and credential hooks.
runtime.agentMetadata = &agentMetadata
}
if shouldDetectNestedSkillLayout(cmd) {
if found, err := detectNestedMultiSkillLayout(); err == nil && found {
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ 检测到旧升级器留下的嵌套 Skill;请运行 dws skill setup --mode multi 查看迁移计划并确认")
@@ -777,10 +880,12 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
// PAT authorization commands (open-source core)
pat.RegisterCommands(root, patCaller)
if fn := edition.Get().RegisterExtraCommands; fn != nil {
caller := newToolCallerAdapter(runner, flags)
fn(root, caller)
deduplicateCommands(root)
if !presentationOnly {
if fn := edition.Get().RegisterExtraCommands; fn != nil {
caller := newToolCallerAdapter(runner, flags)
fn(root, caller)
deduplicateCommands(root)
}
}
if loadRuntimeExtensions {
// Resolve plugins only after the complete distribution command tree is
@@ -791,7 +896,9 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
addPluginCommandsSafe(root, pluginCmds)
}
}
hideNonDirectRuntimeCommands(root)
if !presentationOnly {
hideNonDirectRuntimeCommands(root)
}
configureRootHelp(root)
// Set custom flag error handler for better UX
root.SetFlagErrorFunc(flagErrorWithSuggestions)
@@ -1755,17 +1862,16 @@ func distributionRootOwns(root *cobra.Command, name string) bool {
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
AppendDynamicServer(srv)
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
ClearPluginAuth(productID)
if len(srv.AuthHeaders) > 0 {
registerPluginAuthFromHeaders(srv)
}
// Register ownership for every accepted HTTP plugin, including anonymous
// plugins. Execution must never fall back to the built-in DingTalk OAuth or
// Agent-metadata path merely because a plugin has no Authorization Header.
RegisterPluginAuth(productID, pluginAuthFromServerDescriptor(srv))
}
// registerPluginAuthFromHeaders extracts authentication credentials from
// a server descriptor's AuthHeaders and registers them in the global
// PluginAuth registry. The runner uses this registry at execution time
// to inject the correct Bearer token for third-party MCP servers.
func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
// pluginAuthFromServerDescriptor extracts plugin-owned credentials and custom
// Headers. A non-nil result also acts as the HTTP plugin ownership marker for
// anonymous plugins.
func pluginAuthFromServerDescriptor(srv mcptypes.ServerDescriptor) *PluginAuth {
authToken := ""
extraHeaders := make(map[string]string)
for key, value := range srv.AuthHeaders {
@@ -1776,20 +1882,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
extraHeaders[key] = value
}
}
if authToken == "" {
return
}
var trustedDomains []string
if parsed, err := url.Parse(srv.Endpoint); err == nil {
host := parsed.Hostname()
trustedDomains = []string{host, "*." + host}
if host != "" {
trustedDomains = []string{host, "*." + host}
}
}
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
RegisterPluginAuth(productID, &PluginAuth{
return &PluginAuth{
Token: authToken,
ExtraHeaders: extraHeaders,
TrustedDomains: trustedDomains,
})
}
}
// newPipelineEngine creates and configures the pipeline engine with
+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) {
+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)
}
}
+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{
+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.
+140
View File
@@ -0,0 +1,140 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package transport
import (
"net/http"
"strings"
"testing"
)
func TestCrossPlatformCoverageSafeRedirectPolicyAgentMetadataHeaders(t *testing.T) {
t.Parallel()
newRequest := func(t *testing.T, rawURL string) *http.Request {
t.Helper()
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set(HeaderAgentVersion, "1.2.3-test")
req.Header.Set(HeaderAgentExt, `{"ua":"test-agent-value"}`)
req.Header.Set("Authorization", "Bearer test-token")
req.Header.Set("x-user-access-token", "test-token")
return req
}
assertHeaders := func(t *testing.T, request *http.Request, wantExt bool) {
t.Helper()
if got := request.Header.Get(HeaderAgentVersion); got != "1.2.3-test" {
t.Fatalf("agent version = %q, want retained", got)
}
gotExt := request.Header.Get(HeaderAgentExt)
if wantExt && gotExt == "" {
t.Fatal("agent extension was removed")
}
if !wantExt && gotExt != "" {
t.Fatalf("agent extension leaked across origins: %q", gotExt)
}
for _, key := range []string{"Authorization", "x-user-access-token"} {
got := request.Header.Get(key)
if wantExt && got == "" {
t.Fatalf("same-origin redirect removed %s", key)
}
if !wantExt && got != "" {
t.Fatalf("credential %s leaked across origins", key)
}
}
}
t.Run("same origin retains version and extension", func(t *testing.T) {
t.Parallel()
previous := newRequest(t, "https://api.example.test/start")
redirected := newRequest(t, "https://api.example.test/next")
if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil {
t.Fatal(err)
}
assertHeaders(t, redirected, true)
})
t.Run("cross host strips extension and retains version", func(t *testing.T) {
t.Parallel()
previous := newRequest(t, "https://api.example.test/start")
redirected := newRequest(t, "https://cdn.example.test/asset")
if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil {
t.Fatal(err)
}
assertHeaders(t, redirected, false)
})
t.Run("scheme downgrade on same host strips extension", func(t *testing.T) {
t.Parallel()
previous := newRequest(t, "https://api.example.test/start")
redirected := newRequest(t, "http://api.example.test/next")
if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil {
t.Fatal(err)
}
assertHeaders(t, redirected, false)
})
t.Run("extension remains stripped after returning to initial origin", func(t *testing.T) {
t.Parallel()
initial := newRequest(t, "https://api.example.test/start")
crossOrigin := newRequest(t, "https://cdn.example.test/asset")
redirectedBack := newRequest(t, "https://api.example.test/final")
if err := safeRedirectPolicy(redirectedBack, []*http.Request{initial, crossOrigin}); err != nil {
t.Fatal(err)
}
assertHeaders(t, redirectedBack, false)
})
t.Run("extension remains stripped on later cross-origin hop", func(t *testing.T) {
t.Parallel()
initial := newRequest(t, "https://api.example.test/start")
crossOrigin := newRequest(t, "https://cdn.example.test/asset")
redirected := newRequest(t, "https://cdn.example.test/final")
if err := safeRedirectPolicy(redirected, []*http.Request{initial, crossOrigin}); err != nil {
t.Fatal(err)
}
assertHeaders(t, redirected, false)
})
t.Run("initial request is unchanged", func(t *testing.T) {
t.Parallel()
request := newRequest(t, "https://api.example.test/start")
if err := safeRedirectPolicy(request, nil); err != nil {
t.Fatal(err)
}
assertHeaders(t, request, true)
})
t.Run("redirect limit is enforced", func(t *testing.T) {
t.Parallel()
request := newRequest(t, "https://api.example.test/final")
via := make([]*http.Request, 10)
for index := range via {
via[index] = newRequest(t, "https://api.example.test/"+strings.Repeat("x", index+1))
}
if err := safeRedirectPolicy(request, via); err == nil {
t.Fatal("safeRedirectPolicy() error = nil, want redirect limit error")
}
})
}
+3 -2
View File
@@ -15,8 +15,9 @@ package cli
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
// MCPIdentityHeaders returns HTTP headers aligned with MCP tool calls
// (identity + edition merge). Overlays may pass this to auxiliary clients.
// MCPIdentityHeaders returns the shared identity and edition headers that
// overlays may pass to auxiliary clients. MCP-request-only metadata such as
// DWS_AGENT_VER and DWS_AGENT_EXT is intentionally excluded.
func MCPIdentityHeaders() map[string]string {
return app.MCPIdentityHeaders()
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"strings"
"testing"
)
func TestCrossPlatformCoverageMCPIdentityHeadersExcludeMCPOnlyMetadata(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv("DWS_AGENT_VER", "1.2.3")
t.Setenv("DWS_AGENT_EXT", `{"ua":"test-agent/1.2.3"}`)
headers := MCPIdentityHeaders()
if headers == nil {
t.Fatal("MCPIdentityHeaders() returned nil")
}
for key := range headers {
if strings.EqualFold(key, "x-dws-agent-ver") || strings.EqualFold(key, "x-dws-agent-ext") {
t.Fatalf("shared identity export leaked MCP-only Header %q", key)
}
}
}
+20 -1
View File
@@ -90,7 +90,26 @@ if ! jq -e --arg registry_count "$registry_count" '
(.effect == "read" or .effect == "write" or .effect == "destructive") and
(.risk == "low" or .risk == "medium" or .risk == "high") and
(.confirmation == "not_required" or .confirmation == "user_required") and
(.idempotency == "idempotent" or .idempotency == "non_idempotent" or .idempotency == "unknown") and
(.idempotency == "idempotent" or .idempotency == "non_idempotent" or .idempotency == "unknown" or .idempotency == "conditional") and
(if .idempotency == "conditional" then
(.retry_policy | type) == "object" and
(.retry_policy | keys) == ["key_parameter", "mode", "same_payload_required"] and
.retry_policy.mode == "deduplication_key" and
((.retry_policy.key_parameter // "") | type) == "string" and
((.retry_policy.key_parameter // "") | length) > 0 and
.retry_policy.same_payload_required == true and
(.retry_policy.key_parameter as $key |
(.parameters[$key] | type) == "object" and
.parameters[$key].type == "string" and
((.parameters[$key].property // "") | length) > 0 and
.parameters[$key].field_provenance.property.source == "native_annotation") and
.interface_mode == "mcp" and
(.interface_ref | type) == "object" and
((.interface_ref.product_id // "") | length) > 0 and
((.interface_ref.rpc_name // "") | length) > 0
else
(has("retry_policy") | not)
end) and
(has("use_when") and (.use_when | type) == "array") and
(has("avoid_when") and (.avoid_when | type) == "array") and
(has("examples") and (.examples | type) == "array") and
+157 -1
View File
@@ -55,6 +55,7 @@ type toolSchema struct {
Risk string `json:"risk"`
Confirmation string `json:"confirmation"`
Idempotency string `json:"idempotency"`
RetryPolicy string `json:"retry_policy,omitempty"`
}
type positionalSchema struct {
@@ -355,6 +356,7 @@ func normalizeTool(raw json.RawMessage) (string, toolSchema, error) {
Risk string `json:"risk"`
Confirmation string `json:"confirmation"`
Idempotency string `json:"idempotency"`
RetryPolicy json.RawMessage `json:"retry_policy"`
}
if err := json.Unmarshal(raw, &tool); err != nil {
return "", toolSchema{}, err
@@ -403,6 +405,9 @@ func normalizeTool(raw json.RawMessage) (string, toolSchema, error) {
if err != nil {
return "", toolSchema{}, fmt.Errorf("dry_run: %w", err)
}
// The outer json.Unmarshal above has already validated this RawMessage;
// canonicalization cannot fail for a decoded JSON value.
retryPolicy, _ := canonicalRawJSON(tool.RetryPolicy)
return id, toolSchema{
PrimaryCLIPath: strings.TrimSpace(tool.PrimaryCLIPath),
@@ -417,6 +422,7 @@ func normalizeTool(raw json.RawMessage) (string, toolSchema, error) {
Risk: strings.TrimSpace(tool.Risk),
Confirmation: strings.TrimSpace(tool.Confirmation),
Idempotency: strings.TrimSpace(tool.Idempotency),
RetryPolicy: retryPolicy,
}, nil
}
@@ -568,6 +574,9 @@ func readContract(path string) (schemaContract, error) {
}
func checkCompatibility(baseline, current schemaContract) []string {
if err := validateReviewedIdempotencyTransitions(); err != nil {
return []string{fmt.Sprintf("invalid reviewed idempotency transition table: %v", err)}
}
var failures []string
for productID, oldProduct := range baseline.Products {
newProduct, ok := current.Products[productID]
@@ -591,6 +600,7 @@ func checkCompatibility(baseline, current schemaContract) []string {
func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []string {
var failures []string
reviewedIdempotencyChange := compatibleReviewedIdempotencyTransition(toolPath, oldTool, newTool)
for _, field := range []struct {
name string
old string
@@ -602,12 +612,17 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
{name: "effect", old: oldTool.Effect, new: newTool.Effect},
{name: "risk", old: oldTool.Risk, new: newTool.Risk},
{name: "confirmation", old: oldTool.Confirmation, new: newTool.Confirmation},
{name: "idempotency", old: oldTool.Idempotency, new: newTool.Idempotency},
} {
if field.old != field.new {
failures = append(failures, fmt.Sprintf("schema tool %q changed %s", toolPath, field.name))
}
}
if oldTool.Idempotency != newTool.Idempotency && !reviewedIdempotencyChange {
failures = append(failures, fmt.Sprintf("schema tool %q changed idempotency", toolPath))
}
if oldTool.RetryPolicy != newTool.RetryPolicy && !reviewedIdempotencyChange {
failures = append(failures, fmt.Sprintf("schema tool %q changed retry_policy", toolPath))
}
if oldTool.Constraints != newTool.Constraints &&
!compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) &&
!compatibleAdditiveConstraintEvolution(oldTool, newTool) &&
@@ -640,6 +655,147 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
return failures
}
const reviewedUUIDRetryPolicy = `{"key_parameter":"uuid","mode":"deduplication_key","same_payload_required":true}`
type idempotencyTransitionKey struct {
ToolPath string
From string
To string
}
type reviewedIdempotencyTransitionSpec struct {
RetryPolicy string
Reason string
}
// reviewedIdempotencyTransitions enumerates exact, independently reviewed
// safety migrations. Idempotency is an execution contract: a candidate cannot
// authorize its own change by merely adding a flag or editing a fixture. The
// authoritative compatibility wrapper builds this checker from the PR
// merge-base, so additions to this table take effect only after the governance
// PR that owns them has landed on main.
//
// A conditional migration also pins the complete canonical retry_policy. The
// policy deliberately does not claim a retry window: the current backend
// evidence only establishes that a supplied uuid is a deduplication key and
// that retries must reuse the same payload. Every unlisted tool, direction, or
// policy value remains incompatible.
var reviewedIdempotencyTransitions = map[idempotencyTransitionKey]reviewedIdempotencyTransitionSpec{
{ToolPath: "chat/chat.combine_forward_messages", From: "unknown", To: "conditional"}: {
RetryPolicy: reviewedUUIDRetryPolicy,
Reason: "A supplied uuid is the reviewed deduplication key; retry safety is conditional on reusing the same payload.",
},
{ToolPath: "chat/chat.forward_message", From: "unknown", To: "conditional"}: {
RetryPolicy: reviewedUUIDRetryPolicy,
Reason: "A supplied uuid is the reviewed deduplication key; retry safety is conditional on reusing the same payload.",
},
{ToolPath: "chat/chat.reply_personal_message", From: "unknown", To: "conditional"}: {
RetryPolicy: reviewedUUIDRetryPolicy,
Reason: "A supplied uuid is the reviewed deduplication key; retry safety is conditional on reusing the same payload.",
},
{ToolPath: "chat/chat.send_personal_message", From: "unknown", To: "conditional"}: {
RetryPolicy: reviewedUUIDRetryPolicy,
Reason: "A supplied uuid is the reviewed deduplication key; retry safety is conditional on reusing the same payload.",
},
{ToolPath: "chat/chat.share_group_invite_url", From: "unknown", To: "conditional"}: {
RetryPolicy: reviewedUUIDRetryPolicy,
Reason: "A supplied uuid is the reviewed deduplication key; retry safety is conditional on reusing the same payload.",
},
{ToolPath: "chat/chat.add_custom_group_role", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.add_emoji_reaction", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.add_group_member", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.add_message_favorite", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.add_robot_to_group", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.add_text_emotion", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.audit_join_group", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.chat_permission_grant", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.chat_permission_grant_cross_org_data", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.clear_conversation_messages", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_and_send_card", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_conv_category", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_group_conversation", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_group_notice", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_smart_conv_category", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_text_emotion", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.delete_conv_category", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.dismiss_group", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.edit_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.forward_topic", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.hide_conversation", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.quit_group", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.recall_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.recall_robot_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_custom_group_role", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_custom_user_roles", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_emoji_reaction", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_group_member", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_message_favorite", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_robot_in_group", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_text_emotion", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.send_message_by_custom_robot", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.send_robot_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.set_custom_user_roles", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.set_group_member_mute_list", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.set_group_mute", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.set_pin_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.set_top_conversation", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.transfer_group_owner", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.unset_pin_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_at_all_notification_off", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_conv_member_roles", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_custom_group_role", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_group_icon", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_group_name", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_group_settings", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_notification_off", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_red_env_notification_off", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_show_history_msg_option", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_streaming_card", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_user_group_alias", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.upgrade_group_to_external", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
}
func validateReviewedIdempotencyTransitions() error {
for key, transition := range reviewedIdempotencyTransitions {
if strings.TrimSpace(key.ToolPath) == "" || !strings.Contains(key.ToolPath, "/") {
return fmt.Errorf("invalid tool path %q", key.ToolPath)
}
if key.From != "unknown" {
return fmt.Errorf("%s has unsupported from value %q", key.ToolPath, key.From)
}
if strings.TrimSpace(transition.Reason) == "" {
return fmt.Errorf("%s %s->%s has no review reason", key.ToolPath, key.From, key.To)
}
switch key.To {
case "conditional":
canonical, err := canonicalRawJSON(json.RawMessage(transition.RetryPolicy))
if err != nil || canonical == "" || canonical != transition.RetryPolicy {
return fmt.Errorf("%s has a non-canonical retry policy", key.ToolPath)
}
case "non_idempotent":
if transition.RetryPolicy != "" {
return fmt.Errorf("%s non_idempotent transition has retry policy", key.ToolPath)
}
default:
return fmt.Errorf("%s has unsupported to value %q", key.ToolPath, key.To)
}
}
return nil
}
func compatibleReviewedIdempotencyTransition(toolPath string, oldTool, newTool toolSchema) bool {
transition, ok := reviewedIdempotencyTransitions[idempotencyTransitionKey{
ToolPath: toolPath,
From: oldTool.Idempotency,
To: newTool.Idempotency,
}]
if !ok {
return false
}
return oldTool.RetryPolicy == "" && newTool.RetryPolicy == transition.RetryPolicy
}
// reviewedInterfaceRefRedirect enumerates the exact, individually reviewed
// backend RPC migrations this gate accepts. Schema shape alone cannot prove two
// RPCs share business semantics, permissions, error behaviour, or side effects,
+224
View File
@@ -177,6 +177,25 @@ func TestNormalizeCompleteSchemaPayload(t *testing.T) {
}
}
func TestCrossPlatformCoverageNormalizeRetryPolicyCanonicalJSON(t *testing.T) {
body := strings.Replace(
completeSchemaJSON,
`"idempotency":"unknown",`,
`"idempotency":"conditional","retry_policy":{"same_payload_required":true,"mode":"deduplication_key","key_parameter":"uuid"},`,
1,
)
path := filepath.Join(t.TempDir(), "schema.json")
writeTestFile(t, path, body)
contract, err := normalizeRawFile(path)
if err != nil {
t.Fatal(err)
}
if got := contract.Products["doc"].Tools["doc.create"].RetryPolicy; got != reviewedUUIDRetryPolicy {
t.Fatalf("retry_policy = %s, want canonical %s", got, reviewedUUIDRetryPolicy)
}
}
func TestSchemaCompatibilityIgnoresPositionalDescription(t *testing.T) {
directory := t.TempDir()
baselinePath := filepath.Join(directory, "baseline.json")
@@ -415,6 +434,208 @@ func TestSchemaCompatibilityRejectsContractDrift(t *testing.T) {
}
}
func TestCrossPlatformCoverageReviewedIdempotencyTransitionTableIsExact(t *testing.T) {
if err := validateReviewedIdempotencyTransitions(); err != nil {
t.Fatal(err)
}
if got, want := len(reviewedIdempotencyTransitions), 57; got != want {
t.Fatalf("reviewed idempotency transitions = %d, want %d", got, want)
}
conditional := map[string]bool{}
nonIdempotent := 0
for key, transition := range reviewedIdempotencyTransitions {
switch key.To {
case "conditional":
conditional[key.ToolPath] = true
if transition.RetryPolicy != reviewedUUIDRetryPolicy {
t.Errorf("%s retry_policy = %s", key.ToolPath, transition.RetryPolicy)
}
case "non_idempotent":
nonIdempotent++
}
}
wantConditional := map[string]bool{
"chat/chat.combine_forward_messages": true,
"chat/chat.forward_message": true,
"chat/chat.reply_personal_message": true,
"chat/chat.send_personal_message": true,
"chat/chat.share_group_invite_url": true,
}
if !reflect.DeepEqual(conditional, wantConditional) {
t.Fatalf("conditional transitions = %#v, want %#v", conditional, wantConditional)
}
if nonIdempotent != 52 {
t.Fatalf("non_idempotent transitions = %d, want 52", nonIdempotent)
}
}
func TestCrossPlatformCoverageReviewedIdempotencyTransitionTableRejectsInvalidEntries(t *testing.T) {
original := reviewedIdempotencyTransitions
defer func() { reviewedIdempotencyTransitions = original }()
tests := []struct {
name string
key idempotencyTransitionKey
transition reviewedIdempotencyTransitionSpec
want string
}{
{
name: "invalid tool path",
key: idempotencyTransitionKey{From: "unknown", To: "non_idempotent"},
transition: reviewedIdempotencyTransitionSpec{Reason: "reviewed"},
want: "invalid tool path",
},
{
name: "invalid from",
key: idempotencyTransitionKey{ToolPath: "chat/chat.send", From: "idempotent", To: "non_idempotent"},
transition: reviewedIdempotencyTransitionSpec{Reason: "reviewed"},
want: "unsupported from",
},
{
name: "missing reason",
key: idempotencyTransitionKey{ToolPath: "chat/chat.send", From: "unknown", To: "non_idempotent"},
transition: reviewedIdempotencyTransitionSpec{},
want: "has no review reason",
},
{
name: "invalid conditional policy",
key: idempotencyTransitionKey{ToolPath: "chat/chat.send", From: "unknown", To: "conditional"},
transition: reviewedIdempotencyTransitionSpec{RetryPolicy: `{`, Reason: "reviewed"},
want: "non-canonical retry policy",
},
{
name: "non idempotent policy",
key: idempotencyTransitionKey{ToolPath: "chat/chat.send", From: "unknown", To: "non_idempotent"},
transition: reviewedIdempotencyTransitionSpec{RetryPolicy: reviewedUUIDRetryPolicy, Reason: "reviewed"},
want: "non_idempotent transition has retry policy",
},
{
name: "invalid to",
key: idempotencyTransitionKey{ToolPath: "chat/chat.send", From: "unknown", To: "idempotent"},
transition: reviewedIdempotencyTransitionSpec{Reason: "reviewed"},
want: "unsupported to",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
reviewedIdempotencyTransitions = map[idempotencyTransitionKey]reviewedIdempotencyTransitionSpec{
test.key: test.transition,
}
if err := validateReviewedIdempotencyTransitions(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("validateReviewedIdempotencyTransitions() error = %v, want %q", err, test.want)
}
})
}
reviewedIdempotencyTransitions = map[idempotencyTransitionKey]reviewedIdempotencyTransitionSpec{
{From: "unknown", To: "non_idempotent"}: {Reason: "reviewed"},
}
failures := checkCompatibility(baselineContract(), baselineContract())
if len(failures) != 1 || !strings.Contains(failures[0], "invalid reviewed idempotency transition table") {
t.Fatalf("checkCompatibility() failures = %v", failures)
}
}
func TestCrossPlatformCoverageReviewedIdempotencyTransitionsFailClosed(t *testing.T) {
oldTool := toolSchema{Idempotency: "unknown", Risk: "medium"}
conditional := oldTool
conditional.Idempotency = "conditional"
conditional.RetryPolicy = reviewedUUIDRetryPolicy
nonIdempotent := oldTool
nonIdempotent.Idempotency = "non_idempotent"
if failures := checkToolCompatibility("chat/chat.send_personal_message", oldTool, conditional); len(failures) != 0 {
t.Fatalf("exact conditional transition failed: %v", failures)
}
if failures := checkToolCompatibility("chat/chat.dismiss_group", oldTool, nonIdempotent); len(failures) != 0 {
t.Fatalf("exact non_idempotent transition failed: %v", failures)
}
baseline := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{
"chat": {Tools: map[string]toolSchema{"chat.send_personal_message": oldTool}},
}}
current := cloneContract(baseline)
current.Products["chat"].Tools["chat.send_personal_message"] = conditional
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
t.Fatalf("exact transition through final compatibility seam failed: %v", failures)
}
tests := []struct {
name string
toolPath string
oldTool toolSchema
newTool toolSchema
want string
}{
{
name: "unlisted tool",
toolPath: "chat/chat.unreviewed_write",
oldTool: oldTool,
newTool: conditional,
want: "changed idempotency",
},
{
name: "old unconditional idempotent proposal stays blocked",
toolPath: "chat/chat.send_personal_message",
oldTool: oldTool,
newTool: toolSchema{Idempotency: "idempotent", Risk: "medium"},
want: "changed idempotency",
},
{
name: "wrong policy",
toolPath: "chat/chat.send_personal_message",
oldTool: oldTool,
newTool: toolSchema{Idempotency: "conditional", RetryPolicy: `{"key_parameter":"request_id","mode":"deduplication_key","same_payload_required":true}`, Risk: "medium"},
want: "changed retry_policy",
},
{
name: "missing policy",
toolPath: "chat/chat.send_personal_message",
oldTool: oldTool,
newTool: toolSchema{Idempotency: "conditional", Risk: "medium"},
want: "changed idempotency",
},
{
name: "reverse direction",
toolPath: "chat/chat.send_personal_message",
oldTool: conditional,
newTool: oldTool,
want: "changed idempotency",
},
{
name: "non idempotent cannot carry policy",
toolPath: "chat/chat.dismiss_group",
oldTool: oldTool,
newTool: toolSchema{Idempotency: "non_idempotent", RetryPolicy: reviewedUUIDRetryPolicy, Risk: "medium"},
want: "changed retry_policy",
},
{
name: "conditional policy drift after publication",
toolPath: "chat/chat.send_personal_message",
oldTool: conditional,
newTool: toolSchema{Idempotency: "conditional", RetryPolicy: `{"key_parameter":"uuid","mode":"deduplication_key","same_payload_required":false}`, Risk: "medium"},
want: "changed retry_policy",
},
{
name: "unrelated drift cannot hitchhike",
toolPath: "chat/chat.send_personal_message",
oldTool: oldTool,
newTool: toolSchema{Idempotency: "conditional", RetryPolicy: reviewedUUIDRetryPolicy, Risk: "high"},
want: "changed risk",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
failures := strings.Join(checkToolCompatibility(test.toolPath, test.oldTool, test.newTool), "\n")
if !strings.Contains(failures, test.want) {
t.Fatalf("failures = %q, want %q", failures, test.want)
}
})
}
}
func TestMergeContracts(t *testing.T) {
historical := baselineContract()
current := cloneContract(historical)
@@ -2026,6 +2247,9 @@ func writeRawSchemaContractFile(t *testing.T, path string, contract schemaContra
if tool.DryRun != "" {
rawTool["dry_run"] = json.RawMessage(tool.DryRun)
}
if tool.RetryPolicy != "" {
rawTool["retry_policy"] = json.RawMessage(tool.RetryPolicy)
}
tools = append(tools, rawTool)
}
products = append(products, map[string]any{"id": productID, "tools": tools})
@@ -12,11 +12,11 @@
<!-- DWS_MESSAGE_RESULT_CONTRACT_START -->
- `version`: `im.message-list.v1`
- `message_fields`: `messageId`, `conversationId`, `threadId`, `sender`, `senderId`, `senderType`, `messageType`, `text`, `createTime`, `updateTime`, `reactions`, `quotedMessage`, `forwarded`, `resourceRefs`
- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `scope`, `resourceDownloads`
- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncated`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `scope`, `resourceDownloads`
<!-- DWS_MESSAGE_RESULT_CONTRACT_END -->
当 `complete=false` 时不能称为全量成功。`nextPage` 只能来自真实 lower boundary;
`failedCount/failures`、`partial` 和两个 truncated 字段必须原样保留。
`failedCount/failures`、`partial`、总 `truncated` 和两个原因字段必须原样保留。
当 Runtime 解析并应用自然发送者条件时,`resolvedFilters.senders[]` 保留原查询及选中的
`userId/openDingTalkId`。消息展示名可以与通讯录姓名不同;只能用稳定 `senderId` 与解析结果关联,
不得重新做姓名字符串比较。
+16 -4
View File
@@ -841,15 +841,27 @@ func TestInterfaceIntegrityWorkflowContract(t *testing.T) {
}
schemaStep := schemaRemainder[:schemaEnd]
for _, want := range []string{
"make schema-compatibility",
`BASE_REF="$COMPATIBILITY_BASE_REF"`,
`STABLE_REF="$COMPATIBILITY_STABLE_REF"`,
`CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"`,
`authority_worktree="$RUNNER_TEMP/dws-schema-authority-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"`,
`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"`,
`trap cleanup_schema_authority EXIT HUP INT TERM`,
} {
if !strings.Contains(schemaStep, want) {
t.Errorf("Schema compatibility step missing authoritative contract %q", want)
}
}
for _, forbidden := range []string{
"make schema-compatibility",
"./scripts/policy/check-authoritative-schema-compatibility.sh",
"./scripts/policy/schema-compat",
} {
if strings.Contains(schemaStep, forbidden) {
t.Errorf("Schema compatibility step must not execute candidate checkout authority %q", forbidden)
}
}
}
func TestLocalInterfaceIntegrityUsesAuthoritativeSeam(t *testing.T) {