Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09c1f28862 | ||
|
|
0a063e3ebd | ||
|
|
e19c54f77e | ||
|
|
fbc34509f8 | ||
|
|
ad0cf639c4 | ||
|
|
2f8e136dc0 | ||
|
|
fdbd11e0ea | ||
|
|
d07bf39586 | ||
|
|
eee41a9b45 | ||
|
|
896801634f | ||
|
|
a203572ee3 | ||
|
|
6c0ba91414 | ||
|
|
472d3d321b | ||
|
|
0b68450709 | ||
|
|
346444ea38 | ||
|
|
54dc8fadb7 | ||
|
|
b469bb127a | ||
|
|
c6e810e4d9 | ||
|
|
98d03455b1 | ||
|
|
fad41d4d99 | ||
|
|
5fed80fc0f | ||
|
|
1727025f67 | ||
|
|
715f5346da | ||
|
|
bfd836064d |
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
category: Added
|
||||||
|
---
|
||||||
|
|
||||||
|
- **Agent version and extended context passthrough** (Aone 85384225) — adds
|
||||||
|
validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to
|
||||||
|
ordinary non-plugin MCP requests without forwarding it to A2A, OAuth,
|
||||||
|
Discovery, or third-party plugins.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
category: Added
|
||||||
|
---
|
||||||
|
|
||||||
|
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
category: Changed
|
||||||
|
---
|
||||||
|
|
||||||
|
- **Agent retry safety** — adds a conditional idempotency contract and base-owned compatibility governance for deduplication-key-aware retries.
|
||||||
@@ -19,3 +19,12 @@
|
|||||||
|
|
||||||
# Cache directory (optional, defaults to ~/.dws/cache)
|
# Cache directory (optional, defaults to ~/.dws/cache)
|
||||||
# DWS_CACHE_DIR=
|
# 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.
|
||||||
|
|||||||
@@ -1414,10 +1414,17 @@ jobs:
|
|||||||
- name: Check complete Schema compatibility
|
- 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') }}
|
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: |
|
run: |
|
||||||
make schema-compatibility \
|
set -eu
|
||||||
BASE_REF="$COMPATIBILITY_BASE_REF" \
|
authority_worktree="$RUNNER_TEMP/dws-schema-authority-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
|
||||||
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
|
cleanup_schema_authority() {
|
||||||
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
|
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
|
- 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') }}
|
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') }}
|
||||||
|
|||||||
@@ -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`
|
- 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.*`)
|
- **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`.
|
- 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:
|
- Package seam:
|
||||||
- types / ProductDecl → `corecmd/contract` (DTO only; **no** Cobra-keyed ContractFinal store)
|
- types / ProductDecl → `corecmd/contract` (DTO only; **no** Cobra-keyed ContractFinal store)
|
||||||
- AnnotateRuntime* writers → `internal/corecmd/runtimeannotate` (framework-owned)
|
- 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
|
Preserve all candidates and the selected source in provenance, and fail
|
||||||
same-precedence conflicts rather than silently merging them.
|
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
|
`required` is the exception. Cobra `MarkFlagRequired` is a hard floor: the
|
||||||
final Agent projection must keep `required=true` and cannot be lowered by a
|
final Agent projection must keep `required=true` and cannot be lowered by a
|
||||||
lower-precedence source. A higher-precedence declaration may still raise an
|
lower-precedence source. A higher-precedence declaration may still raise an
|
||||||
|
|||||||
@@ -229,6 +229,52 @@ tool、parameter、mapping、positional execution、constraint 与 safety 语义
|
|||||||
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
|
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
|
||||||
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
|
前拒绝冲突输入。当前迁移清单为空,不授权 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
|
## Required GitHub repository settings
|
||||||
|
|
||||||
The `main` quality ruleset must enable strict required-status-check policy
|
The `main` quality ruleset must enable strict required-status-check policy
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CLI flag 兼容迁移治理
|
# CLI flag 兼容迁移治理
|
||||||
|
|
||||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||||
|
|
||||||
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
||||||
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
||||||
@@ -50,7 +50,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
|||||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
|
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
|
||||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
|||||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||||
|
|
||||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||||
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
|
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
|
||||||
|
|
||||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interfa
|
|||||||
`required` / `cli_required` 或重写 constraint;
|
`required` / `cli_required` 或重写 constraint;
|
||||||
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
|
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
|
||||||
`required_when` 必须完全一致;
|
`required_when` 必须完全一致;
|
||||||
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
|
- `required` / `cli_required` 必须在 rename 前后完全一致,升高或降低都失败;
|
||||||
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
|
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
|
||||||
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
|
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
|
||||||
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
|
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
|
||||||
|
|||||||
@@ -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 |
|
| `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`「参数约束」 |
|
| `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`) |
|
| `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 |
|
| `ConstParams` | 固定载荷(不上 flag 表) | 并入 toolArgs;不满足 Required | **不**投影为用户 parameter |
|
||||||
| `Use` / `Short` / `Long` / `Example` | 命令身份文案与示例 | cobra 自身 | help;identity 以 collector 收集的 `ContractFinal.Identity` 声明为准(reviewed registry 已退役) |
|
| `Use` / `Short` / `Long` / `Example` | 命令身份文案与示例 | cobra 自身 | help;identity 以 collector 收集的 `ContractFinal.Identity` 声明为准(reviewed registry 已退役) |
|
||||||
|
|
||||||
@@ -170,6 +171,7 @@ NewLeafCommand(LeafSpec{
|
|||||||
| Constraints | **声明** | `Constraints` |
|
| Constraints | **声明** | `Constraints` |
|
||||||
| Positionals | **声明** 或显式 annotate | 目标 `Args`;禁止推断 |
|
| Positionals | **声明** 或显式 annotate | 目标 `Args`;禁止推断 |
|
||||||
| Safety.`effect/risk/confirmation/idempotency` | **声明**完整 `Safety`,或迁移期 `runtime_gate` / reviewed Safety | 四字段独立;不得互相推导 |
|
| 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 |
|
| DryRun | 评审源 | dry-run capabilities registry |
|
||||||
| Interface | 评审源 | MCP + 内存 inject 的 Agent metadata |
|
| Interface | 评审源 | MCP + 内存 inject 的 Agent metadata |
|
||||||
| Selection | 声明(ContractFinal / ProductDecl) | `ContractDecl.Selection` / `ProductDecl` |
|
| Selection | 声明(ContractFinal / ProductDecl) | `ContractDecl.Selection` / `ProductDecl` |
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
|
| `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_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_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_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
|
||||||
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
|
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
|
||||||
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
|
| `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_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 与密文同目录,保护强度低于默认方案 |
|
| `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`
|
### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type`
|
||||||
|
|
||||||
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation
|
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ Definition(仅声明;不可编译)
|
|||||||
| **Constraints** | `require_one_of`, `mutually_exclusive`, `require_together` | **声明** | `Constraints` → `AnnotateConstraints` | **是** |
|
| **Constraints** | `require_one_of`, `mutually_exclusive`, `require_together` | **声明** | `Constraints` → `AnnotateConstraints` | **是** |
|
||||||
| **Positionals** | 位置参数名/必填/说明 | **声明** 或显式 annotate | 目标 `Args`/`PositionalSpec`;今日少量 cobra Args + 注解 | 受管命令应声明,禁止推断 |
|
| **Positionals** | 位置参数名/必填/说明 | **声明** 或显式 annotate | 目标 `Args`/`PositionalSpec`;今日少量 cobra Args + 注解 | 受管命令应声明,禁止推断 |
|
||||||
| **Safety** | `effect`, `risk`, `confirmation`, `idempotency` | **声明**(完整 `contract.SafetySpec`)**或标注**(`runtime_gate`) | `Safety` / `AnnotateRuntimeGate`(metadata 壳 `tools: {}`,不再承载 reviewed Safety) | 四字段独立;confirmation 单独驱动运行时 |
|
| **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` | 派生,不手写 |
|
| | `effect_source` / provenance | 组装派生物 | resolver 写入 `FieldProvenance` | 派生,不手写 |
|
||||||
| **DryRun** | `preview_kind`, `remote_reads` | 评审源 | `schema_dry_run_capabilities`(正能力声明) | 否;无条目 ≠ 推断「不支持」之外的假能力 |
|
| **DryRun** | `preview_kind`, `remote_reads` | 评审源 | `schema_dry_run_capabilities`(正能力声明) | 否;无条目 ≠ 推断「不支持」之外的假能力 |
|
||||||
| **Interface** | `interface_mode`, `interface_ref`, `availability`, `reason` | 评审源 | MCP meta + agent metadata 解析 | 否;与 CLI Identity 分离 |
|
| **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 受管定义的 Cobra 命令 Hidden | 可执行 Contract | 挂载的命令可见性与声明匹配 |
|
||||||
| Shortcut 列表成员资格与语义 disposition | 经评审的 Shortcut 可见性解析器 | public/all 列表成员资格与经评审决策匹配 |
|
| Shortcut 列表成员资格与语义 disposition | 经评审的 Shortcut 可见性解析器 | public/all 列表成员资格与经评审决策匹配 |
|
||||||
| Runtime Schema / Agent 暴露 | identity collector 收集结果加精确排除 | 每个暴露叶子解析到活 Contract;排除显式且不重叠 |
|
| 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/属性 |
|
| 后端 product/tool/载荷绑定 | mcpbind + 后端元数据 | 每个绑定引用真实的 flag/属性 |
|
||||||
| Agent 选择文案(`use_when`、`avoid_when`、摘要) | 声明:`ContractDecl.Selection` / `ProductDecl`(交付 provenance `contract_final`);`schema_hints/` 已退役,禁止回潮 | 身份解析到活契约;选择文案不得创建 CLI 表面 |
|
| Agent 选择文案(`use_when`、`avoid_when`、摘要) | 声明:`ContractDecl.Selection` / `ProductDecl`(交付 provenance `contract_final`);`schema_hints/` 已退役,禁止回潮 | 身份解析到活契约;选择文案不得创建 CLI 表面 |
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
const invalidValue = "DO_NOT_ECHO"
|
const invalidValue = "DO_NOT_ECHO"
|
||||||
t.Setenv(envDWSAgentHost, invalidValue)
|
t.Setenv(envDWSAgentHost, invalidValue)
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
envDWSAgentVersion = "DWS_AGENT_VER"
|
||||||
|
envDWSAgentExt = "DWS_AGENT_EXT"
|
||||||
|
maxAgentVersionBytes = 64
|
||||||
|
maxAgentExtensionBytes = 8 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
var agentVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]*$`)
|
||||||
|
|
||||||
|
type agentMetadataSnapshot struct {
|
||||||
|
version string
|
||||||
|
ext string
|
||||||
|
versionErr error
|
||||||
|
extErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentMetadataSnapshotContextKey struct{}
|
||||||
|
|
||||||
|
func (snapshot agentMetadataSnapshot) validationError() error {
|
||||||
|
if snapshot.versionErr != nil {
|
||||||
|
return snapshot.versionErr
|
||||||
|
}
|
||||||
|
return snapshot.extErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextWithAgentMetadataSnapshot(ctx context.Context, snapshot agentMetadataSnapshot) context.Context {
|
||||||
|
return context.WithValue(ctx, agentMetadataSnapshotContextKey{}, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentMetadataSnapshotFromContext(ctx context.Context) (agentMetadataSnapshot, bool) {
|
||||||
|
if ctx == nil {
|
||||||
|
return agentMetadataSnapshot{}, false
|
||||||
|
}
|
||||||
|
snapshot, ok := ctx.Value(agentMetadataSnapshotContextKey{}).(agentMetadataSnapshot)
|
||||||
|
return snapshot, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
configmeta.Register(configmeta.ConfigItem{
|
||||||
|
Name: envDWSAgentVersion,
|
||||||
|
Category: configmeta.CategoryExternal,
|
||||||
|
Description: "调用 DWS 的 Agent 版本;仅作为 x-dws-agent-ver 透传到非插件 MCP 请求",
|
||||||
|
Example: "1.2.3-beta.1+build.7",
|
||||||
|
})
|
||||||
|
configmeta.Register(configmeta.ConfigItem{
|
||||||
|
Name: envDWSAgentExt,
|
||||||
|
Category: configmeta.CategoryExternal,
|
||||||
|
Description: "调用 DWS 的 Agent 扩展上下文 JSON;仅作为 x-dws-agent-ext 透传到非插件 MCP 请求",
|
||||||
|
Example: `{"umt":"<token>","miniwua":"<token>","ua":"agent/1.0"}`,
|
||||||
|
Sensitive: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAgentVersion normalizes and validates the caller-declared Agent
|
||||||
|
// version. Only surrounding ASCII spaces and tabs are trimmed. An unset or
|
||||||
|
// ASCII-whitespace-only value means "do not emit".
|
||||||
|
func parseAgentVersion(raw string) (string, error) {
|
||||||
|
value := strings.Trim(raw, " \t")
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if len(value) > maxAgentVersionBytes || !agentVersionPattern.MatchString(value) {
|
||||||
|
return "", invalidAgentVersionError()
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAgentExt validates one generic JSON object and returns its compact
|
||||||
|
// one-line representation. Raw control characters other than horizontal tab
|
||||||
|
// are rejected before JSON parsing; escaped JSON control characters remain
|
||||||
|
// valid because they are safe on the HTTP header wire.
|
||||||
|
func parseAgentExt(raw string) (string, error) {
|
||||||
|
if len(raw) > maxAgentExtensionBytes || !utf8.ValidString(raw) {
|
||||||
|
return "", invalidAgentExtError()
|
||||||
|
}
|
||||||
|
for _, r := range raw {
|
||||||
|
if unicode.IsControl(r) && r != '\t' {
|
||||||
|
return "", invalidAgentExtError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value := strings.Trim(raw, " \t")
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var compact bytes.Buffer
|
||||||
|
if err := json.Compact(&compact, []byte(value)); err != nil {
|
||||||
|
return "", invalidAgentExtError()
|
||||||
|
}
|
||||||
|
compactBytes := compact.Bytes()
|
||||||
|
if len(compactBytes) > maxAgentExtensionBytes || len(compactBytes) < 2 || compactBytes[0] != '{' {
|
||||||
|
return "", invalidAgentExtError()
|
||||||
|
}
|
||||||
|
return compact.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidAgentVersionError() error {
|
||||||
|
return apperrors.NewValidation(
|
||||||
|
"DWS_AGENT_VER must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9._+-]*$",
|
||||||
|
apperrors.WithReason("invalid_agent_version"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidAgentExtError() error {
|
||||||
|
return apperrors.NewValidation(
|
||||||
|
"DWS_AGENT_EXT must be a UTF-8 JSON object of at most 8192 bytes without raw control characters",
|
||||||
|
apperrors.WithReason("invalid_agent_ext"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAgentMetadataSnapshot reads both environment variables from one
|
||||||
|
// os.Environ snapshot, then parses them once. Normal CLI execution retains the
|
||||||
|
// validated result through the invocation so hooks and transport observe the
|
||||||
|
// same pair even in an embedding process that mutates its environment.
|
||||||
|
func readAgentMetadataSnapshot() agentMetadataSnapshot {
|
||||||
|
var rawVersion, rawExt string
|
||||||
|
for _, entry := range os.Environ() {
|
||||||
|
key, value, _ := strings.Cut(entry, "=")
|
||||||
|
switch key {
|
||||||
|
case envDWSAgentVersion:
|
||||||
|
rawVersion = value
|
||||||
|
case envDWSAgentExt:
|
||||||
|
rawExt = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
version, versionErr := parseAgentVersion(rawVersion)
|
||||||
|
ext, extErr := parseAgentExt(rawExt)
|
||||||
|
return agentMetadataSnapshot{
|
||||||
|
version: version,
|
||||||
|
ext: ext,
|
||||||
|
versionErr: versionErr,
|
||||||
|
extErr: extErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeAgentMetadataHeaders removes every case variant so edition or
|
||||||
|
// credential hooks cannot smuggle MCP-only metadata into shared transports.
|
||||||
|
func removeAgentMetadataHeaders(headers map[string]string) {
|
||||||
|
for key := range headers {
|
||||||
|
if strings.EqualFold(key, transport.HeaderAgentVersion) ||
|
||||||
|
strings.EqualFold(key, transport.HeaderAgentExt) {
|
||||||
|
delete(headers, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAgentMetadataHeaders applies validated environment values as the final
|
||||||
|
// authority for non-plugin MCP requests. Invalid values are omitted on
|
||||||
|
// library paths that bypass root validation; normal CLI execution rejects
|
||||||
|
// them before hooks or network access.
|
||||||
|
func applyAgentMetadataHeaders(headers map[string]string) map[string]string {
|
||||||
|
return applyAgentMetadataSnapshot(headers, readAgentMetadataSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAgentMetadataSnapshot(headers map[string]string, snapshot agentMetadataSnapshot) map[string]string {
|
||||||
|
removeAgentMetadataHeaders(headers)
|
||||||
|
|
||||||
|
if (snapshot.versionErr != nil || snapshot.version == "") && (snapshot.extErr != nil || snapshot.ext == "") {
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
if headers == nil {
|
||||||
|
headers = make(map[string]string)
|
||||||
|
}
|
||||||
|
if snapshot.versionErr == nil && snapshot.version != "" {
|
||||||
|
headers[transport.HeaderAgentVersion] = snapshot.version
|
||||||
|
}
|
||||||
|
if snapshot.extErr == nil && snapshot.ext != "" {
|
||||||
|
headers[transport.HeaderAgentExt] = snapshot.ext
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMCPRequestHeaders adds Agent version and extension metadata only to
|
||||||
|
// the built-in DingTalk MCP request path. Shared identity consumers (notably
|
||||||
|
// A2A) continue to use resolveIdentityHeaders and never receive these fields.
|
||||||
|
func resolveMCPRequestHeaders() map[string]string {
|
||||||
|
return resolveMCPRequestHeadersWithSnapshot(readAgentMetadataSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveMCPRequestHeadersWithSnapshot(snapshot agentMetadataSnapshot) map[string]string {
|
||||||
|
return applyAgentMetadataSnapshot(resolveIdentityHeaders(), snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMCPRequestHeadersForInvocation resolves one immutable Header snapshot
|
||||||
|
// for an invocation. The helper-only mcp-meta server performs endpoint
|
||||||
|
// discovery rather than an ordinary MCP product call, so caller-declared
|
||||||
|
// Agent metadata must not cross that boundary.
|
||||||
|
func resolveMCPRequestHeadersForInvocation(invocation executor.Invocation, snapshots ...agentMetadataSnapshot) map[string]string {
|
||||||
|
headers := resolveIdentityHeaders()
|
||||||
|
if strings.EqualFold(strings.TrimSpace(invocation.CanonicalProduct), mcpMetaServerID) {
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
snapshot := readAgentMetadataSnapshot()
|
||||||
|
if len(snapshots) > 0 {
|
||||||
|
snapshot = snapshots[0]
|
||||||
|
}
|
||||||
|
return applyAgentMetadataSnapshot(headers, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pluginRequestHeaders returns a private, sanitized copy of plugin-owned
|
||||||
|
// Headers. Third-party plugins never receive DWS-owned Agent metadata, even if
|
||||||
|
// their manifest tries to declare the reserved Header names itself.
|
||||||
|
func pluginRequestHeaders(pluginAuth *PluginAuth) map[string]string {
|
||||||
|
if pluginAuth == nil || len(pluginAuth.ExtraHeaders) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
headers := make(map[string]string, len(pluginAuth.ExtraHeaders))
|
||||||
|
for key, value := range pluginAuth.ExtraHeaders {
|
||||||
|
headers[key] = value
|
||||||
|
}
|
||||||
|
removeAgentMetadataHeaders(headers)
|
||||||
|
if len(headers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
outputpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageParseAgentVersion(t *testing.T) {
|
||||||
|
var nilContext context.Context
|
||||||
|
if _, ok := agentMetadataSnapshotFromContext(nilContext); ok {
|
||||||
|
t.Fatal("nil context unexpectedly contained Agent metadata")
|
||||||
|
}
|
||||||
|
wantSnapshot := agentMetadataSnapshot{version: "context-version", ext: "{}"}
|
||||||
|
if got, ok := agentMetadataSnapshotFromContext(contextWithAgentMetadataSnapshot(context.Background(), wantSnapshot)); !ok || got != wantSnapshot {
|
||||||
|
t.Fatalf("context Agent metadata = %#v, %v; want %#v", got, ok, wantSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "unset", raw: "", want: ""},
|
||||||
|
{name: "ASCII whitespace only", raw: " \t ", want: ""},
|
||||||
|
{name: "semantic version", raw: "1.2.3", want: "1.2.3"},
|
||||||
|
{name: "pre-release and build", raw: " v1.2.3-rc.1+build_7 ", want: "v1.2.3-rc.1+build_7"},
|
||||||
|
{name: "maximum length", raw: strings.Repeat("a", maxAgentVersionBytes), want: strings.Repeat("a", maxAgentVersionBytes)},
|
||||||
|
}
|
||||||
|
for _, tc := range valid {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := parseAgentVersion(tc.raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseAgentVersion() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("parseAgentVersion() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
}{
|
||||||
|
{name: "leading punctuation", raw: "-1.2.3"},
|
||||||
|
{name: "internal space", raw: "1.2 3"},
|
||||||
|
{name: "slash", raw: "1.2/3"},
|
||||||
|
{name: "line feed", raw: "1.2.3\n"},
|
||||||
|
{name: "carriage return", raw: "1.2.3\r"},
|
||||||
|
{name: "NUL", raw: "1.2\x003"},
|
||||||
|
{name: "Unicode", raw: "版本1"},
|
||||||
|
{name: "too long", raw: strings.Repeat("a", maxAgentVersionBytes+1)},
|
||||||
|
}
|
||||||
|
for _, tc := range invalid {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := parseAgentVersion(tc.raw)
|
||||||
|
if err == nil || got != "" {
|
||||||
|
t.Fatalf("parseAgentVersion(%q) = %q, %v; want validation error", tc.raw, got, err)
|
||||||
|
}
|
||||||
|
assertAgentMetadataValidationError(t, err, "invalid_agent_version", tc.raw)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageParseAgentExt(t *testing.T) {
|
||||||
|
boundary := `{"x":"` + strings.Repeat("a", maxAgentExtensionBytes-8) + `"}`
|
||||||
|
if len(boundary) != maxAgentExtensionBytes {
|
||||||
|
t.Fatalf("invalid boundary fixture size: %d", len(boundary))
|
||||||
|
}
|
||||||
|
|
||||||
|
valid := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "unset", raw: "", want: ""},
|
||||||
|
{name: "ASCII whitespace only", raw: " \t ", want: ""},
|
||||||
|
{name: "empty object", raw: "{}", want: "{}"},
|
||||||
|
{name: "compact generic object", raw: " \t{ \"umt\": \"masked\",\t \"nested\": { \"ok\": true }, \"unknown\": [1, 2] }\t ", want: `{"umt":"masked","nested":{"ok":true},"unknown":[1,2]}`},
|
||||||
|
{name: "Unicode value", raw: `{"ua":"千问办公/1.0"}`, want: `{"ua":"千问办公/1.0"}`},
|
||||||
|
{name: "escaped control remains safe", raw: `{"ua":"line\nnext"}`, want: `{"ua":"line\nnext"}`},
|
||||||
|
{name: "maximum length", raw: boundary, want: boundary},
|
||||||
|
}
|
||||||
|
for _, tc := range valid {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := parseAgentExt(tc.raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseAgentExt() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("parseAgentExt() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidUTF8 := string([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'})
|
||||||
|
invalid := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
}{
|
||||||
|
{name: "too long raw input", raw: strings.Repeat(" ", maxAgentExtensionBytes+1)},
|
||||||
|
{name: "invalid UTF-8", raw: invalidUTF8},
|
||||||
|
{name: "array", raw: `[]`},
|
||||||
|
{name: "string", raw: `"value"`},
|
||||||
|
{name: "number", raw: `1`},
|
||||||
|
{name: "boolean", raw: `true`},
|
||||||
|
{name: "null", raw: `null`},
|
||||||
|
{name: "malformed object", raw: `{"secret":"DO_NOT_ECHO"`},
|
||||||
|
{name: "trailing value", raw: `{} {}`},
|
||||||
|
{name: "line feed", raw: "{\n}"},
|
||||||
|
{name: "carriage return", raw: "{\r}"},
|
||||||
|
{name: "NUL", raw: "{\x00}"},
|
||||||
|
{name: "vertical tab", raw: "{\v}"},
|
||||||
|
{name: "form feed", raw: "{\f}"},
|
||||||
|
{name: "DEL", raw: "{\x7f}"},
|
||||||
|
{name: "C1 control", raw: "{\u0085}"},
|
||||||
|
}
|
||||||
|
for _, tc := range invalid {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := parseAgentExt(tc.raw)
|
||||||
|
if err == nil || got != "" {
|
||||||
|
t.Fatalf("parseAgentExt() = %q, %v; want validation error", got, err)
|
||||||
|
}
|
||||||
|
assertAgentMetadataValidationError(t, err, "invalid_agent_ext", tc.raw)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAgentMetadataValidationError(t *testing.T, err error, reason, raw string) {
|
||||||
|
t.Helper()
|
||||||
|
var appErr *apperrors.Error
|
||||||
|
if !errors.As(err, &appErr) {
|
||||||
|
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||||
|
}
|
||||||
|
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != reason {
|
||||||
|
t.Fatalf("error = category %q reason %q, want validation/%s", appErr.Category, appErr.Reason, reason)
|
||||||
|
}
|
||||||
|
if strings.Contains(raw, "DO_NOT_ECHO") && strings.Contains(err.Error(), "DO_NOT_ECHO") {
|
||||||
|
t.Fatalf("error must not echo invalid value: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageAgentMetadataConfigRegistrationAndMasking(t *testing.T) {
|
||||||
|
items := configmeta.All()
|
||||||
|
var versionItem, extItem *configmeta.ConfigItem
|
||||||
|
for i := range items {
|
||||||
|
switch items[i].Name {
|
||||||
|
case envDWSAgentVersion:
|
||||||
|
versionItem = &items[i]
|
||||||
|
case envDWSAgentExt:
|
||||||
|
extItem = &items[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if versionItem == nil || extItem == nil {
|
||||||
|
t.Fatalf("Agent metadata config registration missing: version=%v ext=%v", versionItem != nil, extItem != nil)
|
||||||
|
}
|
||||||
|
if versionItem.Category != configmeta.CategoryExternal || versionItem.Sensitive {
|
||||||
|
t.Fatalf("version config metadata = %#v", *versionItem)
|
||||||
|
}
|
||||||
|
if extItem.Category != configmeta.CategoryExternal || !extItem.Sensitive {
|
||||||
|
t.Fatalf("extension config metadata = %#v", *extItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canary = `{"umt":"SENSITIVE_CANARY"}`
|
||||||
|
t.Setenv(envDWSAgentExt, canary)
|
||||||
|
got, ok := configmeta.Resolve(envDWSAgentExt)
|
||||||
|
if !ok || got == "" || strings.Contains(got, "SENSITIVE_CANARY") || got == canary {
|
||||||
|
t.Fatalf("sensitive extension was not masked: value=%q ok=%v", got, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(envDWSAgentVersion, "9.8.7")
|
||||||
|
command := newConfigListCommand()
|
||||||
|
var output strings.Builder
|
||||||
|
command.SetOut(&output)
|
||||||
|
command.SetArgs([]string{"--category", string(configmeta.CategoryExternal), "--show-values", "--json"})
|
||||||
|
if err := command.Execute(); err != nil {
|
||||||
|
t.Fatalf("config list failed: %v", err)
|
||||||
|
}
|
||||||
|
rawOutput := output.String()
|
||||||
|
if !json.Valid([]byte(rawOutput)) {
|
||||||
|
t.Fatalf("config list emitted invalid JSON: %q", rawOutput)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rawOutput, envDWSAgentVersion) || !strings.Contains(rawOutput, envDWSAgentExt) {
|
||||||
|
t.Fatalf("config list omitted Agent metadata variables: %s", rawOutput)
|
||||||
|
}
|
||||||
|
if strings.Contains(rawOutput, "SENSITIVE_CANARY") || strings.Contains(rawOutput, canary) {
|
||||||
|
t.Fatalf("config list leaked Agent extension: %s", rawOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageResolveMCPRequestHeadersScopesAndFinalizesAgentMetadata(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv(envDWSAgentHost, "")
|
||||||
|
t.Setenv(agentproduct.EnvName, "")
|
||||||
|
t.Setenv(envDWSAgentVersion, " 1.2.3-rc.1 ")
|
||||||
|
t.Setenv(envDWSAgentExt, " { \"umt\": \"masked\", \"unknown\": true } ")
|
||||||
|
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||||
|
edition.Override(&edition.Hooks{
|
||||||
|
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||||
|
headers["X-Dws-Agent-Ver"] = "merge-must-not-win"
|
||||||
|
headers["X-Dws-Agent-Ext"] = `{"source":"merge"}`
|
||||||
|
return headers
|
||||||
|
},
|
||||||
|
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||||
|
headers[transport.HeaderAgentVersion] = "credential-must-not-win"
|
||||||
|
headers[transport.HeaderAgentExt] = `{"source":"credential"}`
|
||||||
|
return headers
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for name, headers := range map[string]map[string]string{
|
||||||
|
"shared identity": resolveIdentityHeaders(),
|
||||||
|
"A2A export": MCPIdentityHeaders(),
|
||||||
|
} {
|
||||||
|
if hasHeaderFold(headers, transport.HeaderAgentVersion) || hasHeaderFold(headers, transport.HeaderAgentExt) {
|
||||||
|
t.Fatalf("%s leaked MCP-only metadata: %#v", name, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := resolveMCPRequestHeaders()
|
||||||
|
if got := headers[transport.HeaderAgentVersion]; got != "1.2.3-rc.1" {
|
||||||
|
t.Fatalf("%s = %q, want 1.2.3-rc.1", transport.HeaderAgentVersion, got)
|
||||||
|
}
|
||||||
|
if got := headers[transport.HeaderAgentExt]; got != `{"umt":"masked","unknown":true}` {
|
||||||
|
t.Fatalf("%s = %q", transport.HeaderAgentExt, got)
|
||||||
|
}
|
||||||
|
if got := headers[transport.HeaderVersion]; got != version {
|
||||||
|
t.Fatalf("%s = %q, want CLI version %q", transport.HeaderVersion, got, version)
|
||||||
|
}
|
||||||
|
if _, ok := headers["User-Agent"]; ok {
|
||||||
|
t.Fatal("Agent extension must not create or replace the standard User-Agent header")
|
||||||
|
}
|
||||||
|
for _, key := range []string{"umt", "miniwua", "ua", "x-dws-agent-umt", "x-dws-agent-miniwua", "x-dws-agent-ua"} {
|
||||||
|
if hasHeaderFold(headers, key) {
|
||||||
|
t.Fatalf("Agent extension was split into an extra header %q: %#v", key, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library paths are best-effort: one invalid value is omitted without
|
||||||
|
// suppressing the other valid field or preserving hook-injected values.
|
||||||
|
t.Setenv(envDWSAgentExt, `{"secret":"DO_NOT_ECHO"`)
|
||||||
|
headers = resolveMCPRequestHeaders()
|
||||||
|
if got := headers[transport.HeaderAgentVersion]; got != "1.2.3-rc.1" {
|
||||||
|
t.Fatalf("valid version was suppressed: %q", got)
|
||||||
|
}
|
||||||
|
if hasHeaderFold(headers, transport.HeaderAgentExt) {
|
||||||
|
t.Fatalf("invalid extension or hook value leaked: %#v", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise the nil-map and empty-input library paths. An absent environment
|
||||||
|
// must not allocate a map, while an EXT-only value must allocate one and
|
||||||
|
// remain a single compact Header.
|
||||||
|
t.Setenv(envDWSAgentVersion, "")
|
||||||
|
t.Setenv(envDWSAgentExt, "")
|
||||||
|
if got := applyAgentMetadataHeaders(nil); got != nil {
|
||||||
|
t.Fatalf("empty metadata allocated headers: %#v", got)
|
||||||
|
}
|
||||||
|
t.Setenv(envDWSAgentExt, " { } ")
|
||||||
|
headers = applyAgentMetadataHeaders(nil)
|
||||||
|
if got := headers[transport.HeaderAgentExt]; got != "{}" {
|
||||||
|
t.Fatalf("EXT-only metadata = %q, want {}", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageRootRejectsInvalidAgentMetadataBeforeEditionHook(t *testing.T) {
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
value string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{name: "version", env: envDWSAgentVersion, value: "DO_NOT ECHO", reason: "invalid_agent_version"},
|
||||||
|
{name: "extension", env: envDWSAgentExt, value: `{"secret":"DO_NOT_ECHO"`, reason: "invalid_agent_ext"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv(envDWSAgentHost, "")
|
||||||
|
t.Setenv(agentproduct.EnvName, "")
|
||||||
|
t.Setenv(envDWSAgentVersion, "")
|
||||||
|
t.Setenv(envDWSAgentExt, "")
|
||||||
|
t.Setenv(tc.env, tc.value)
|
||||||
|
|
||||||
|
headerHookCalled := false
|
||||||
|
afterHookCalled := false
|
||||||
|
edition.Override(&edition.Hooks{
|
||||||
|
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||||
|
headerHookCalled = true
|
||||||
|
return headers
|
||||||
|
},
|
||||||
|
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||||
|
headerHookCalled = true
|
||||||
|
return headers
|
||||||
|
},
|
||||||
|
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
|
||||||
|
afterHookCalled = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
root := NewRootCommand()
|
||||||
|
root.SetOut(io.Discard)
|
||||||
|
root.SetErr(io.Discard)
|
||||||
|
root.SetArgs([]string{"version"})
|
||||||
|
err := root.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("root command accepted invalid %s", tc.env)
|
||||||
|
}
|
||||||
|
if headerHookCalled || afterHookCalled {
|
||||||
|
t.Fatalf("edition hook ran before %s validation", tc.env)
|
||||||
|
}
|
||||||
|
assertAgentMetadataValidationError(t, err, tc.reason, tc.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageAgentMetadataProcessEntryValidationPrecedesRootConstruction(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "default JSON", args: []string{"version"}, want: true},
|
||||||
|
{name: "long JSON", args: []string{"version", "--format", "JSON"}, want: true},
|
||||||
|
{name: "long table", args: []string{"--format=table", "version"}, want: false},
|
||||||
|
{name: "short attached JSON", args: []string{"version", "-fjson"}, want: true},
|
||||||
|
{name: "short table", args: []string{"version", "-f", "table"}, want: false},
|
||||||
|
{name: "last wins", args: []string{"--format", "table", "version", "-f=json"}, want: true},
|
||||||
|
{name: "terminator", args: []string{"version", "--format", "table", "--", "--format", "json"}, want: false},
|
||||||
|
{name: "missing value", args: []string{"version", "--format"}, want: false},
|
||||||
|
} {
|
||||||
|
t.Run("presentation/"+tc.name, func(t *testing.T) {
|
||||||
|
if got := processArgsRequestJSON(tc.args); got != tc.want {
|
||||||
|
t.Fatalf("processArgsRequestJSON(%q) = %v, want %v", tc.args, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv(envDWSAgentHost, "")
|
||||||
|
t.Setenv(agentproduct.EnvName, "")
|
||||||
|
t.Setenv(envDWSAgentVersion, "")
|
||||||
|
sensitiveRaw := "{\"umt\":\"must-not-leak\"}\n"
|
||||||
|
t.Setenv(envDWSAgentExt, sensitiveRaw)
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||||
|
extensionHookCalls := 0
|
||||||
|
edition.Override(&edition.Hooks{
|
||||||
|
Name: "presentation-test",
|
||||||
|
RegisterExtraCommands: func(*cobra.Command, edition.ToolCaller) {
|
||||||
|
extensionHookCalls++
|
||||||
|
},
|
||||||
|
VisibleProducts: func() []string {
|
||||||
|
extensionHookCalls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
StaticServers: func() []edition.ServerInfo {
|
||||||
|
extensionHookCalls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
oldArgs := os.Args
|
||||||
|
os.Args = []string{"dws", "version"}
|
||||||
|
t.Cleanup(func() { os.Args = oldArgs })
|
||||||
|
|
||||||
|
rootConstructed := false
|
||||||
|
preParseCalled := false
|
||||||
|
testseam.Swap(t, &rootNewRootCommandWithEngine, func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||||
|
rootConstructed = true
|
||||||
|
return &cobra.Command{Use: "dws"}
|
||||||
|
})
|
||||||
|
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error {
|
||||||
|
preParseCalled = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
stderrFile, err := os.CreateTemp(t.TempDir(), "agent-metadata-stderr-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create stderr capture: %v", err)
|
||||||
|
}
|
||||||
|
oldStderr := os.Stderr
|
||||||
|
os.Stderr = stderrFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Stderr = oldStderr
|
||||||
|
_ = stderrFile.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
if code := Execute(); code == 0 {
|
||||||
|
t.Fatal("process entry accepted invalid Agent metadata")
|
||||||
|
}
|
||||||
|
if rootConstructed || preParseCalled {
|
||||||
|
t.Fatalf("invalid Agent metadata reached root hooks: constructed=%v preParse=%v", rootConstructed, preParseCalled)
|
||||||
|
}
|
||||||
|
if extensionHookCalls != 0 {
|
||||||
|
t.Fatalf("invalid Agent metadata executed %d extension hooks", extensionHookCalls)
|
||||||
|
}
|
||||||
|
if err := stderrFile.Sync(); err != nil {
|
||||||
|
t.Fatalf("sync stderr capture: %v", err)
|
||||||
|
}
|
||||||
|
stderrOutput, err := os.ReadFile(stderrFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read stderr capture: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(stderrOutput), "must-not-leak") || strings.Contains(string(stderrOutput), sensitiveRaw) {
|
||||||
|
t.Fatalf("process validation error leaked raw EXT: %q", stderrOutput)
|
||||||
|
}
|
||||||
|
if !json.Valid(stderrOutput) || !strings.Contains(string(stderrOutput), `"reason": "invalid_agent_ext"`) {
|
||||||
|
t.Fatalf("default JSON error presentation = %q", stderrOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdoutFile, err := os.CreateTemp(t.TempDir(), "agent-metadata-stdout-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create stdout capture: %v", err)
|
||||||
|
}
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
os.Stdout = stdoutFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
_ = stdoutFile.Close()
|
||||||
|
})
|
||||||
|
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"drive", "+list", "--format", "json"})
|
||||||
|
if err := stdoutFile.Sync(); err != nil {
|
||||||
|
t.Fatalf("sync stdout capture: %v", err)
|
||||||
|
}
|
||||||
|
unifiedOutput, err := os.ReadFile(stdoutFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read stdout capture: %v", err)
|
||||||
|
}
|
||||||
|
if !json.Valid(unifiedOutput) || !strings.Contains(string(unifiedOutput), `"outcome": "failure"`) ||
|
||||||
|
!strings.Contains(string(unifiedOutput), `"subtype": "invalid_agent_ext"`) {
|
||||||
|
t.Fatalf("unified JSON error presentation = %q", unifiedOutput)
|
||||||
|
}
|
||||||
|
if extensionHookCalls != 0 {
|
||||||
|
t.Fatalf("presentation-only root executed %d extension hooks", extensionHookCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stderrFile.Truncate(0); err != nil {
|
||||||
|
t.Fatalf("truncate fallback stderr capture: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := stderrFile.Seek(0, io.SeekStart); err != nil {
|
||||||
|
t.Fatalf("rewind fallback stderr capture: %v", err)
|
||||||
|
}
|
||||||
|
testseam.Swap(t, &rootEmitResult, func(*cobra.Command, outputpkg.CommandResult) (int, error) {
|
||||||
|
return 0, errors.New("injected result emission failure")
|
||||||
|
})
|
||||||
|
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"drive", "+list", "--format", "json"})
|
||||||
|
if err := stderrFile.Sync(); err != nil {
|
||||||
|
t.Fatalf("sync fallback stderr capture: %v", err)
|
||||||
|
}
|
||||||
|
fallbackOutput, err := os.ReadFile(stderrFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fallback stderr capture: %v", err)
|
||||||
|
}
|
||||||
|
if !json.Valid(fallbackOutput) || !strings.Contains(string(fallbackOutput), `"reason": "invalid_agent_ext"`) ||
|
||||||
|
strings.Contains(string(fallbackOutput), "must-not-leak") {
|
||||||
|
t.Fatalf("fallback validation error presentation = %q", fallbackOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stderrFile.Truncate(0); err != nil {
|
||||||
|
t.Fatalf("truncate stderr capture: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := stderrFile.Seek(0, io.SeekStart); err != nil {
|
||||||
|
t.Fatalf("rewind stderr capture: %v", err)
|
||||||
|
}
|
||||||
|
emitEarlyAgentMetadataValidationError(invalidAgentExtError(), []string{"version", "--format", "table"})
|
||||||
|
if err := stderrFile.Sync(); err != nil {
|
||||||
|
t.Fatalf("sync human stderr capture: %v", err)
|
||||||
|
}
|
||||||
|
humanOutput, err := os.ReadFile(stderrFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read human stderr capture: %v", err)
|
||||||
|
}
|
||||||
|
if json.Valid(humanOutput) || !strings.Contains(string(humanOutput), "DWS_AGENT_EXT") ||
|
||||||
|
strings.Contains(string(humanOutput), "must-not-leak") {
|
||||||
|
t.Fatalf("human validation error presentation = %q", humanOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
var capturedRunner *runtimeRunner
|
||||||
|
testseam.Swap(t, &rootNewCommandRunnerWithFlags, func(flags *GlobalFlags) executor.Runner {
|
||||||
|
capturedRunner = newCommandRunnerWithFlags(flags).(*runtimeRunner)
|
||||||
|
return capturedRunner
|
||||||
|
})
|
||||||
|
cachedSnapshot := agentMetadataSnapshot{version: "9.8.7", ext: `{"ua":"cached"}`}
|
||||||
|
_ = newRootCommandWithMode(
|
||||||
|
contextWithAgentMetadataSnapshot(context.Background(), cachedSnapshot),
|
||||||
|
nil,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
if capturedRunner == nil || capturedRunner.agentMetadata == nil || *capturedRunner.agentMetadata != cachedSnapshot {
|
||||||
|
t.Fatalf("root runner Agent metadata = %#v, want %#v", capturedRunner, cachedSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageAgentMetadataExcludedFromServiceDiscovery(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv(envDWSAgentHost, "")
|
||||||
|
t.Setenv(agentproduct.EnvName, "")
|
||||||
|
t.Setenv(envDWSAgentVersion, "3.0.0")
|
||||||
|
t.Setenv(envDWSAgentExt, `{"umt":"test-value"}`)
|
||||||
|
|
||||||
|
headers := resolveMCPRequestHeadersForInvocation(executor.Invocation{
|
||||||
|
CanonicalProduct: mcpMetaServerID,
|
||||||
|
Tool: mcpMetaURLTool,
|
||||||
|
})
|
||||||
|
if hasHeaderFold(headers, transport.HeaderAgentVersion) || hasHeaderFold(headers, transport.HeaderAgentExt) {
|
||||||
|
t.Fatalf("service-discovery request leaked Agent metadata: %#v", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = resolveMCPRequestHeadersForInvocation(executor.Invocation{CanonicalProduct: "doc", Tool: "read"})
|
||||||
|
if headers[transport.HeaderAgentVersion] != "3.0.0" || headers[transport.HeaderAgentExt] == "" {
|
||||||
|
t.Fatalf("ordinary MCP request omitted Agent metadata: %#v", headers)
|
||||||
|
}
|
||||||
|
cached := agentMetadataSnapshot{version: "3.1.0", ext: "{}"}
|
||||||
|
headers = resolveMCPRequestHeadersForInvocation(executor.Invocation{CanonicalProduct: "doc", Tool: "read"}, cached)
|
||||||
|
if headers[transport.HeaderAgentVersion] != "3.1.0" || headers[transport.HeaderAgentExt] != "{}" {
|
||||||
|
t.Fatalf("ordinary MCP request ignored its validated snapshot: %#v", headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv(envDWSAgentHost, "")
|
||||||
|
t.Setenv(agentproduct.EnvName, "")
|
||||||
|
t.Setenv(envDWSAgentVersion, "2.0.0")
|
||||||
|
t.Setenv(envDWSAgentExt, `{"ua":"test-agent/2.0"}`)
|
||||||
|
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
|
||||||
|
pluginAuthMu.Lock()
|
||||||
|
oldPluginRegistry := pluginAuthRegistry
|
||||||
|
pluginAuthRegistry = make(map[string]*PluginAuth)
|
||||||
|
pluginAuthMu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pluginAuthMu.Lock()
|
||||||
|
pluginAuthRegistry = oldPluginRegistry
|
||||||
|
pluginAuthMu.Unlock()
|
||||||
|
})
|
||||||
|
dynamicMu.Lock()
|
||||||
|
oldDynamicEndpoints := dynamicEndpoints
|
||||||
|
oldDynamicProducts := dynamicProducts
|
||||||
|
oldDynamicAliases := dynamicAliases
|
||||||
|
oldDynamicToolEndpoints := dynamicToolEndpoints
|
||||||
|
dynamicEndpoints = nil
|
||||||
|
dynamicProducts = nil
|
||||||
|
dynamicAliases = nil
|
||||||
|
dynamicToolEndpoints = nil
|
||||||
|
dynamicMu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
dynamicMu.Lock()
|
||||||
|
dynamicEndpoints = oldDynamicEndpoints
|
||||||
|
dynamicProducts = oldDynamicProducts
|
||||||
|
dynamicAliases = oldDynamicAliases
|
||||||
|
dynamicToolEndpoints = oldDynamicToolEndpoints
|
||||||
|
dynamicMu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
testseam.Swap(t, &runnerPreflightDocDownload, func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
type capturedRequest struct {
|
||||||
|
headers map[string]string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
var captured []capturedRequest
|
||||||
|
testseam.Swap(t, &runnerCallTool, func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
copyHeaders := make(map[string]string, len(client.ExtraHeaders))
|
||||||
|
for key, value := range client.ExtraHeaders {
|
||||||
|
copyHeaders[key] = value
|
||||||
|
}
|
||||||
|
captured = append(captured, capturedRequest{headers: copyHeaders, token: client.AuthToken})
|
||||||
|
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
created := newCommandRunnerWithFlags(&GlobalFlags{}).(*runtimeRunner)
|
||||||
|
if hasHeaderFold(created.transport.ExtraHeaders, transport.HeaderAgentVersion) ||
|
||||||
|
hasHeaderFold(created.transport.ExtraHeaders, transport.HeaderAgentExt) {
|
||||||
|
t.Fatalf("new runner resolved Agent metadata before invocation validation: %#v", created.transport.ExtraHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSingle must not cache ambient MCP metadata on the shared base transport.
|
||||||
|
// Use mock mode to exercise the path without authentication or network I/O.
|
||||||
|
t.Setenv(envDWSAgentVersion, "2.0.1")
|
||||||
|
refreshRunner := &runtimeRunner{
|
||||||
|
transport: transport.NewClient(nil),
|
||||||
|
globalFlags: &GlobalFlags{Mock: true},
|
||||||
|
auditSink: audit.NopSink{},
|
||||||
|
}
|
||||||
|
refreshInvocation := executor.Invocation{CanonicalProduct: "refresh", Tool: "tool", Params: map[string]any{}}
|
||||||
|
if _, err := refreshRunner.runSingle(context.Background(), refreshInvocation, false); err != nil {
|
||||||
|
t.Fatalf("mock runSingle failed: %v", err)
|
||||||
|
}
|
||||||
|
if hasHeaderFold(refreshRunner.transport.ExtraHeaders, transport.HeaderAgentVersion) {
|
||||||
|
t.Fatalf("runSingle mutated the shared transport Header map: %#v", refreshRunner.transport.ExtraHeaders)
|
||||||
|
}
|
||||||
|
t.Setenv(envDWSAgentVersion, "2.0.0")
|
||||||
|
|
||||||
|
r := &runtimeRunner{
|
||||||
|
transport: transport.NewClient(nil),
|
||||||
|
globalFlags: &GlobalFlags{Token: "test-token"},
|
||||||
|
auditSink: audit.NopSink{},
|
||||||
|
agentMetadata: &agentMetadataSnapshot{
|
||||||
|
version: "2.0.0",
|
||||||
|
ext: `{"ua":"test-agent/2.0"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
builtIn := executor.Invocation{CanonicalProduct: "built-in", Tool: "tool", Params: map[string]any{}}
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", builtIn); err != nil {
|
||||||
|
t.Fatalf("built-in invocation failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginDescriptor := mcptypes.ServerDescriptor{
|
||||||
|
Key: "third-party",
|
||||||
|
Endpoint: "https://plugin.example.test",
|
||||||
|
CLI: mcptypes.CLIOverlay{ID: "third-party"},
|
||||||
|
AuthHeaders: map[string]string{
|
||||||
|
"X-Plugin": "yes",
|
||||||
|
"X-Dws-Agent-Ver": "plugin-must-not-forge-version",
|
||||||
|
"X-Dws-Agent-Ext": `{"source":"plugin"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registerPluginHTTPServer(pluginDescriptor)
|
||||||
|
registeredPlugin, pluginOwned := LookupPluginAuth("third-party")
|
||||||
|
if !pluginOwned || registeredPlugin == nil || registeredPlugin.Token != "" {
|
||||||
|
t.Fatalf("anonymous HTTP plugin ownership = %#v, %v", registeredPlugin, pluginOwned)
|
||||||
|
}
|
||||||
|
registerPluginHTTPServer(mcptypes.ServerDescriptor{
|
||||||
|
Key: "anonymous-empty",
|
||||||
|
Endpoint: "https://anonymous.example.test",
|
||||||
|
CLI: mcptypes.CLIOverlay{ID: "anonymous-empty"},
|
||||||
|
})
|
||||||
|
if emptyPlugin, owned := LookupPluginAuth("anonymous-empty"); !owned || emptyPlugin == nil || emptyPlugin.Token != "" || len(emptyPlugin.ExtraHeaders) != 0 {
|
||||||
|
t.Fatalf("headerless HTTP plugin ownership = %#v, %v", emptyPlugin, owned)
|
||||||
|
}
|
||||||
|
originalPluginHeaders := maps.Clone(registeredPlugin.ExtraHeaders)
|
||||||
|
pluginInvocation := executor.Invocation{CanonicalProduct: "third-party", Tool: "tool", Params: map[string]any{}}
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://plugin.example.test", pluginInvocation); err != nil {
|
||||||
|
t.Fatalf("plugin invocation failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(captured) != 2 {
|
||||||
|
t.Fatalf("captured %d calls, want 2", len(captured))
|
||||||
|
}
|
||||||
|
if captured[0].headers[transport.HeaderAgentVersion] != "2.0.0" || captured[0].headers[transport.HeaderAgentExt] != `{"ua":"test-agent/2.0"}` {
|
||||||
|
t.Fatalf("built-in MCP metadata = %#v", captured[0].headers)
|
||||||
|
}
|
||||||
|
if hasHeaderFold(captured[1].headers, transport.HeaderAgentVersion) || hasHeaderFold(captured[1].headers, transport.HeaderAgentExt) {
|
||||||
|
t.Fatalf("plugin request leaked Agent metadata: %#v", captured[1].headers)
|
||||||
|
}
|
||||||
|
if got := captured[1].headers["X-Plugin"]; got != "yes" {
|
||||||
|
t.Fatalf("plugin-owned header = %q, want yes", got)
|
||||||
|
}
|
||||||
|
if captured[1].token != "" {
|
||||||
|
t.Fatalf("anonymous plugin unexpectedly received default OAuth token")
|
||||||
|
}
|
||||||
|
if !maps.Equal(registeredPlugin.ExtraHeaders, originalPluginHeaders) {
|
||||||
|
t.Fatalf("plugin Header sanitization mutated registry state: got %#v want %#v", registeredPlugin.ExtraHeaders, originalPluginHeaders)
|
||||||
|
}
|
||||||
|
if got := pluginRequestHeaders(nil); got != nil {
|
||||||
|
t.Fatalf("nil plugin auth produced Headers: %#v", got)
|
||||||
|
}
|
||||||
|
if got := pluginRequestHeaders(&PluginAuth{ExtraHeaders: map[string]string{
|
||||||
|
"X-DWS-AGENT-VER": "forged",
|
||||||
|
"X-DWS-AGENT-EXT": `{"forged":true}`,
|
||||||
|
}}); got != nil {
|
||||||
|
t.Fatalf("reserved-only plugin Headers survived sanitization: %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the execution-boundary auth guard independently testable: even if a
|
||||||
|
// future token provider returns an empty token without an error, built-in MCP
|
||||||
|
// calls must fail before preflight or transport while anonymous plugins remain
|
||||||
|
// valid above.
|
||||||
|
resolveCalled := false
|
||||||
|
testseam.Swap(t, &runnerResolveAuthToken, func(*runtimeRunner, context.Context) (string, error) {
|
||||||
|
resolveCalled = true
|
||||||
|
return "", nil
|
||||||
|
})
|
||||||
|
callsBefore := len(captured)
|
||||||
|
unauthenticated := &runtimeRunner{
|
||||||
|
transport: transport.NewClient(nil),
|
||||||
|
globalFlags: &GlobalFlags{},
|
||||||
|
auditSink: audit.NopSink{},
|
||||||
|
}
|
||||||
|
if _, err := unauthenticated.executeInvocation(context.Background(), "https://example.test", executor.Invocation{CanonicalProduct: "built-in-unauthenticated", Tool: "tool"}); err == nil || !isAuthError(err) {
|
||||||
|
t.Fatalf("unauthenticated built-in request = %v, want auth error", err)
|
||||||
|
}
|
||||||
|
if !resolveCalled {
|
||||||
|
t.Fatal("unauthenticated request did not exercise the token resolver")
|
||||||
|
}
|
||||||
|
if len(captured) != callsBefore {
|
||||||
|
t.Fatalf("unauthenticated built-in request reached transport: calls %d -> %d", callsBefore, len(captured))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasHeaderFold(headers map[string]string, want string) bool {
|
||||||
|
for key := range headers {
|
||||||
|
if strings.EqualFold(key, want) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -158,7 +158,7 @@ func TestApplyAgentProductHeader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
|
func TestCrossPlatformCoverageRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
|
||||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
const invalidValue = "DO_NOT ECHO"
|
const invalidValue = "DO_NOT ECHO"
|
||||||
t.Setenv(agentproduct.EnvName, invalidValue)
|
t.Setenv(agentproduct.EnvName, invalidValue)
|
||||||
|
|||||||
@@ -15,11 +15,10 @@ package app
|
|||||||
|
|
||||||
import "sync"
|
import "sync"
|
||||||
|
|
||||||
// PluginAuth holds authentication credentials for a plugin-owned
|
// PluginAuth marks ownership of a plugin-owned streamable-http MCP server and
|
||||||
// streamable-http MCP server. Each server is keyed by its canonical
|
// holds its optional authentication credentials. Every accepted HTTP plugin,
|
||||||
// product ID (CLI.ID) so that different servers can use independent
|
// including an anonymous one, has a non-nil record keyed by canonical product
|
||||||
// tokens without interfering with each other or with the default
|
// ID (CLI.ID) so execution never falls back to built-in DingTalk OAuth.
|
||||||
// DingTalk OAuth token.
|
|
||||||
type PluginAuth struct {
|
type PluginAuth struct {
|
||||||
// Token is the Bearer token extracted from the plugin's
|
// Token is the Bearer token extracted from the plugin's
|
||||||
// "Authorization" header (e.g. a third-party API key).
|
// "Authorization" header (e.g. a third-party API key).
|
||||||
@@ -39,27 +38,25 @@ var (
|
|||||||
pluginAuthRegistry = make(map[string]*PluginAuth)
|
pluginAuthRegistry = make(map[string]*PluginAuth)
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterPluginAuth stores authentication credentials for a plugin
|
// RegisterPluginAuth stores ownership and optional authentication credentials
|
||||||
// server keyed by its canonical product ID. The runner looks up these
|
// for a plugin server keyed by its canonical product ID.
|
||||||
// credentials at execution time to inject the correct Bearer token
|
|
||||||
// instead of the default DingTalk OAuth token.
|
|
||||||
func RegisterPluginAuth(productID string, auth *PluginAuth) {
|
func RegisterPluginAuth(productID string, auth *PluginAuth) {
|
||||||
pluginAuthMu.Lock()
|
pluginAuthMu.Lock()
|
||||||
defer pluginAuthMu.Unlock()
|
defer pluginAuthMu.Unlock()
|
||||||
pluginAuthRegistry[productID] = auth
|
pluginAuthRegistry[productID] = auth
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearPluginAuth removes credentials for a plugin product. Registration uses
|
// ClearPluginAuth removes the ownership and credential record for a plugin
|
||||||
// this before applying an accepted descriptor so a descriptor without custom
|
// product.
|
||||||
// auth cannot inherit stale credentials from an earlier root construction.
|
|
||||||
func ClearPluginAuth(productID string) {
|
func ClearPluginAuth(productID string) {
|
||||||
pluginAuthMu.Lock()
|
pluginAuthMu.Lock()
|
||||||
defer pluginAuthMu.Unlock()
|
defer pluginAuthMu.Unlock()
|
||||||
delete(pluginAuthRegistry, productID)
|
delete(pluginAuthRegistry, productID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupPluginAuth returns the authentication credentials registered
|
// LookupPluginAuth returns plugin ownership and optional authentication
|
||||||
// for the given product ID, or nil if none exists.
|
// credentials for the product ID. The bool denotes ownership, not whether a
|
||||||
|
// Bearer token is present.
|
||||||
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
|
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
|
||||||
pluginAuthMu.RLock()
|
pluginAuthMu.RLock()
|
||||||
defer pluginAuthMu.RUnlock()
|
defer pluginAuthMu.RUnlock()
|
||||||
|
|||||||
@@ -61,11 +61,16 @@ func appRPCServer(t *testing.T, initOK, listOK bool) *httptest.Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoveragePluginAuthCoverage(t *testing.T) {
|
func TestCrossPlatformCoveragePluginAuthCoverage(t *testing.T) {
|
||||||
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
|
fallback := pluginAuthFromServerDescriptor(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"}})
|
if fallback == nil || fallback.Token != "token" || len(fallback.TrustedDomains) != 0 {
|
||||||
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "none"})
|
t.Fatalf("fallback plugin auth = %#v", fallback)
|
||||||
if got, ok := LookupPluginAuth("cli"); !ok || got == nil || got.Token != "token" {
|
}
|
||||||
t.Fatalf("registered plugin auth = %#v, %v", got, ok)
|
got := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
|
||||||
|
if got == nil || got.Token != "token" || got.ExtraHeaders["X"] != "Y" || len(got.TrustedDomains) != 2 {
|
||||||
|
t.Fatalf("plugin auth = %#v", got)
|
||||||
|
}
|
||||||
|
if anonymous := pluginAuthFromServerDescriptor(mcptypes.ServerDescriptor{Key: "none"}); anonymous == nil || anonymous.Token != "" {
|
||||||
|
t.Fatalf("anonymous plugin ownership = %#v", anonymous)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
|
|
||||||
package app
|
package app
|
||||||
|
|
||||||
// MCPIdentityHeaders returns the same header map used for MCP HTTP requests
|
// MCPIdentityHeaders returns the shared identity header map used by non-MCP
|
||||||
// (agent identity, env trace headers, edition MergeHeaders). Intended for
|
// transports such as the A2A gateway client. MCP-only Agent version and
|
||||||
// non-MCP transports such as the A2A gateway client.
|
// extension metadata are intentionally excluded.
|
||||||
func MCPIdentityHeaders() map[string]string {
|
func MCPIdentityHeaders() map[string]string {
|
||||||
return resolveIdentityHeaders()
|
return resolveIdentityHeaders()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -79,6 +79,7 @@ var (
|
|||||||
rootPluginSyncSkills = plugin.SyncSkills
|
rootPluginSyncSkills = plugin.SyncSkills
|
||||||
rootAuthLoadTokenData = authpkg.LoadTokenData
|
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||||
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
|
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
|
||||||
|
rootEmitResult = output.EmitResult
|
||||||
)
|
)
|
||||||
|
|
||||||
// Execute runs the root command and returns the process exit code.
|
// Execute runs the root command and returns the process exit code.
|
||||||
@@ -136,6 +137,16 @@ func Execute() (exitCode int) {
|
|||||||
restoreArgs := rootNormalizeProcessProfileArgs()
|
restoreArgs := rootNormalizeProcessProfileArgs()
|
||||||
defer restoreArgs()
|
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()
|
timing := NewTimingCollector()
|
||||||
defer func() {
|
defer func() {
|
||||||
rootStopAllStdioClients() // Ensure child processes are terminated on exit
|
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
|
// Attach timing collector to context for use by child components
|
||||||
ctx := WithTimingCollector(context.Background(), timing)
|
ctx := WithTimingCollector(context.Background(), timing)
|
||||||
|
ctx = contextWithAgentMetadataSnapshot(ctx, agentMetadata)
|
||||||
ctx, resultStore = output.WithResultStore(ctx)
|
ctx, resultStore = output.WithResultStore(ctx)
|
||||||
var signalState *processSignalState
|
var signalState *processSignalState
|
||||||
var stopSignals func()
|
var stopSignals func()
|
||||||
@@ -260,6 +272,70 @@ func Execute() (exitCode int) {
|
|||||||
return 0
|
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
|
// errorInfoFromExecutionError projects the repository error model into the unified
|
||||||
// failure body. Exit code and category are derived from the same error value,
|
// failure body. Exit code and category are derived from the same error value,
|
||||||
// preventing the wire and process status from drifting apart.
|
// 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 {
|
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 {
|
if rootCtx == nil {
|
||||||
rootCtx = context.Background()
|
rootCtx = context.Background()
|
||||||
}
|
}
|
||||||
flags := &GlobalFlags{}
|
flags := &GlobalFlags{}
|
||||||
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
|
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
|
||||||
runner := rootNewCommandRunnerWithFlags(flags)
|
runner := rootNewCommandRunnerWithFlags(flags)
|
||||||
|
if snapshot, ok := agentMetadataSnapshotFromContext(rootCtx); ok {
|
||||||
|
if runtime, ok := runner.(*runtimeRunner); ok {
|
||||||
|
runtime.agentMetadata = &snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
root := &cobra.Command{
|
root := &cobra.Command{
|
||||||
Use: "dws",
|
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
|
// --output sink instead opens at Run entry (after Cobra's own
|
||||||
// validation), so validation failures still cannot strand a
|
// validation), so validation failures still cannot strand a
|
||||||
// temporary file.
|
// temporary file.
|
||||||
// Validate caller-provided identity labels before any edition hook
|
// Validate caller-provided identity and MCP metadata before command
|
||||||
// or command network activity can run. Header-only library callers
|
// execution hooks or network activity. The process entry point additionally
|
||||||
// use the best-effort path in resolveIdentityHeaders instead.
|
// validates Agent metadata before command-tree construction; direct Cobra
|
||||||
|
// embedding retains this execution-boundary guard.
|
||||||
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
|
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
|
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
|
||||||
return err
|
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 shouldDetectNestedSkillLayout(cmd) {
|
||||||
if found, err := detectNestedMultiSkillLayout(); err == nil && found {
|
if found, err := detectNestedMultiSkillLayout(); err == nil && found {
|
||||||
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ 检测到旧升级器留下的嵌套 Skill;请运行 dws skill setup --mode multi 查看迁移计划并确认")
|
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 authorization commands (open-source core)
|
||||||
pat.RegisterCommands(root, patCaller)
|
pat.RegisterCommands(root, patCaller)
|
||||||
|
|
||||||
if fn := edition.Get().RegisterExtraCommands; fn != nil {
|
if !presentationOnly {
|
||||||
caller := newToolCallerAdapter(runner, flags)
|
if fn := edition.Get().RegisterExtraCommands; fn != nil {
|
||||||
fn(root, caller)
|
caller := newToolCallerAdapter(runner, flags)
|
||||||
deduplicateCommands(root)
|
fn(root, caller)
|
||||||
|
deduplicateCommands(root)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if loadRuntimeExtensions {
|
if loadRuntimeExtensions {
|
||||||
// Resolve plugins only after the complete distribution command tree is
|
// 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)
|
addPluginCommandsSafe(root, pluginCmds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hideNonDirectRuntimeCommands(root)
|
if !presentationOnly {
|
||||||
|
hideNonDirectRuntimeCommands(root)
|
||||||
|
}
|
||||||
configureRootHelp(root)
|
configureRootHelp(root)
|
||||||
// Set custom flag error handler for better UX
|
// Set custom flag error handler for better UX
|
||||||
root.SetFlagErrorFunc(flagErrorWithSuggestions)
|
root.SetFlagErrorFunc(flagErrorWithSuggestions)
|
||||||
@@ -1755,17 +1862,16 @@ func distributionRootOwns(root *cobra.Command, name string) bool {
|
|||||||
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
|
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
|
||||||
AppendDynamicServer(srv)
|
AppendDynamicServer(srv)
|
||||||
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
|
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
|
||||||
ClearPluginAuth(productID)
|
// Register ownership for every accepted HTTP plugin, including anonymous
|
||||||
if len(srv.AuthHeaders) > 0 {
|
// plugins. Execution must never fall back to the built-in DingTalk OAuth or
|
||||||
registerPluginAuthFromHeaders(srv)
|
// Agent-metadata path merely because a plugin has no Authorization Header.
|
||||||
}
|
RegisterPluginAuth(productID, pluginAuthFromServerDescriptor(srv))
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerPluginAuthFromHeaders extracts authentication credentials from
|
// pluginAuthFromServerDescriptor extracts plugin-owned credentials and custom
|
||||||
// a server descriptor's AuthHeaders and registers them in the global
|
// Headers. A non-nil result also acts as the HTTP plugin ownership marker for
|
||||||
// PluginAuth registry. The runner uses this registry at execution time
|
// anonymous plugins.
|
||||||
// to inject the correct Bearer token for third-party MCP servers.
|
func pluginAuthFromServerDescriptor(srv mcptypes.ServerDescriptor) *PluginAuth {
|
||||||
func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
|
||||||
authToken := ""
|
authToken := ""
|
||||||
extraHeaders := make(map[string]string)
|
extraHeaders := make(map[string]string)
|
||||||
for key, value := range srv.AuthHeaders {
|
for key, value := range srv.AuthHeaders {
|
||||||
@@ -1776,20 +1882,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
|||||||
extraHeaders[key] = value
|
extraHeaders[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if authToken == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var trustedDomains []string
|
var trustedDomains []string
|
||||||
if parsed, err := url.Parse(srv.Endpoint); err == nil {
|
if parsed, err := url.Parse(srv.Endpoint); err == nil {
|
||||||
host := parsed.Hostname()
|
host := parsed.Hostname()
|
||||||
trustedDomains = []string{host, "*." + host}
|
if host != "" {
|
||||||
|
trustedDomains = []string{host, "*." + host}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
|
return &PluginAuth{
|
||||||
RegisterPluginAuth(productID, &PluginAuth{
|
|
||||||
Token: authToken,
|
Token: authToken,
|
||||||
ExtraHeaders: extraHeaders,
|
ExtraHeaders: extraHeaders,
|
||||||
TrustedDomains: trustedDomains,
|
TrustedDomains: trustedDomains,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newPipelineEngine creates and configures the pipeline engine with
|
// newPipelineEngine creates and configures the pipeline engine with
|
||||||
|
|||||||
+86
-31
@@ -30,6 +30,8 @@ import (
|
|||||||
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
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"
|
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/executor"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
"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}
|
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
|
||||||
}
|
}
|
||||||
transportClient := transport.NewClient(httpClient)
|
transportClient := transport.NewClient(httpClient)
|
||||||
transportClient.ExtraHeaders = resolveIdentityHeaders()
|
|
||||||
transportClient.FileLogger = FileLoggerInstance()
|
transportClient.FileLogger = FileLoggerInstance()
|
||||||
return &runtimeRunner{
|
return &runtimeRunner{
|
||||||
transport: transportClient,
|
transport: transportClient,
|
||||||
globalFlags: flags,
|
globalFlags: flags,
|
||||||
scanner: newRuntimeContentScanner(),
|
resolveToolCallRetry: cli.ResolveToolCallRetry,
|
||||||
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
|
scanner: newRuntimeContentScanner(),
|
||||||
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
|
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
|
||||||
|
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type runtimeRunner struct {
|
type runtimeRunner struct {
|
||||||
transport *transport.Client
|
transport *transport.Client
|
||||||
globalFlags *GlobalFlags
|
globalFlags *GlobalFlags
|
||||||
fallback executor.Runner
|
fallback executor.Runner
|
||||||
scanner safety.Scanner
|
scanner safety.Scanner
|
||||||
enforceContentScan bool
|
enforceContentScan bool
|
||||||
includeScanReport bool
|
includeScanReport bool
|
||||||
auditSink audit.Sink
|
auditSink audit.Sink
|
||||||
|
agentMetadata *agentMetadataSnapshot
|
||||||
|
resolveToolCallRetry func(productID, rpcName string, args map[string]any) contract.RetryDecision
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
|
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
|
||||||
runnerResolveProfile = authpkg.ResolveProfile
|
runnerResolveProfile = authpkg.ResolveProfile
|
||||||
runnerGetCachedRuntimeToken = getCachedRuntimeToken
|
runnerGetCachedRuntimeToken = getCachedRuntimeToken
|
||||||
|
runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken
|
||||||
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
|
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
|
||||||
runnerCallTool = (*transport.Client).CallTool
|
runnerCallTool = (*transport.Client).CallTool
|
||||||
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
|
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
|
||||||
@@ -173,6 +178,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
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.
|
// Global dry-run is an execution barrier, not merely a transport option.
|
||||||
// Return a deterministic local preview before profile resolution, catalog
|
// Return a deterministic local preview before profile resolution, catalog
|
||||||
// discovery, Keychain/token prefetch, auth, stateful preflight or transport.
|
// 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)
|
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
|
// RunReadOnly executes one already-classified read lookup for a semantic
|
||||||
// Shortcut that is building a dry-run plan. It clones the runtime flags and
|
// 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
|
// 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 {
|
if r.transport == nil {
|
||||||
return r.fallback.Run(ctx, invocation)
|
return r.fallback.Run(ctx, invocation)
|
||||||
}
|
}
|
||||||
r.transport.ExtraHeaders = resolveIdentityHeaders()
|
|
||||||
|
|
||||||
// Mock mode: skip endpoint resolution, use a placeholder endpoint.
|
// Mock mode: skip endpoint resolution, use a placeholder endpoint.
|
||||||
if r.globalFlags != nil && r.globalFlags.Mock {
|
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) {
|
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.
|
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
|
||||||
if IsStdioEndpoint(endpoint) {
|
if IsStdioEndpoint(endpoint) {
|
||||||
|
defer func() { enforceInvocationRetrySafety(invocation, retErr) }()
|
||||||
return r.executeStdioInvocationAtEndpoint(ctx, endpoint, invocation)
|
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)
|
retErr == nil, time.Since(invokeStart), errCat, errReason)
|
||||||
emitAudit(auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
|
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.
|
// Check whether this product belongs to an HTTP plugin. Every accepted
|
||||||
// If so, use the plugin's token instead of the default DingTalk OAuth token.
|
// plugin has an ownership record; credentials within that record are
|
||||||
// This allows third-party MCP servers (e.g. Bailian) to use their own API keys.
|
// optional. Plugin requests never fall back to the default DingTalk OAuth.
|
||||||
pluginAuth, hasPluginAuth := LookupPluginAuth(invocation.CanonicalProduct)
|
pluginAuth, hasPluginAuth := LookupPluginAuth(invocation.CanonicalProduct)
|
||||||
|
|
||||||
authToken := ""
|
authToken := ""
|
||||||
@@ -553,7 +570,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
authToken = pluginAuth.Token
|
authToken = pluginAuth.Token
|
||||||
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
|
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
|
||||||
var tokenErr error
|
var tokenErr error
|
||||||
authToken, tokenErr = r.resolveAuthToken(ctx)
|
authToken, tokenErr = runnerResolveAuthToken(r, ctx)
|
||||||
if tokenErr != nil {
|
if tokenErr != nil {
|
||||||
return executor.Result{}, tokenResolutionError(tokenErr)
|
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 {
|
if argsJSON, err := json.Marshal(invocation.Params); err == nil {
|
||||||
fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON)
|
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{
|
return executor.Result{
|
||||||
Invocation: invocation,
|
Invocation: invocation,
|
||||||
Response: map[string]any{
|
Response: response,
|
||||||
"dry_run": true,
|
|
||||||
"endpoint": transport.RedactURL(endpoint),
|
|
||||||
"request": executor.ToolCallRequest(invocation.Tool, invocation.Params),
|
|
||||||
"note": "execution skipped by --dry-run",
|
|
||||||
},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,9 +624,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fail-fast: reject unauthenticated requests before making network calls.
|
// Preserve a final execution-boundary guard even though the built-in token
|
||||||
// This provides a clear error message instead of cryptic HTTP 400 from MCP.
|
// resolver normally returns either a non-empty token or an error. HTTP
|
||||||
if strings.TrimSpace(authToken) == "" {
|
// plugins are ownership-scoped separately and may intentionally be anonymous.
|
||||||
|
if !hasPluginAuth && strings.TrimSpace(authToken) == "" {
|
||||||
return executor.Result{}, apperrors.NewAuth(
|
return executor.Result{}, apperrors.NewAuth(
|
||||||
"未登录,请先执行 dws auth login",
|
"未登录,请先执行 dws auth login",
|
||||||
apperrors.WithReason("not_authenticated"),
|
apperrors.WithReason("not_authenticated"),
|
||||||
@@ -616,12 +638,23 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
|
|
||||||
var tc *transport.Client
|
var tc *transport.Client
|
||||||
if hasPluginAuth {
|
if hasPluginAuth {
|
||||||
// Use plugin-level auth: inject the plugin's token and trust its domains.
|
// Plugin ownership is authoritative even when the plugin is anonymous.
|
||||||
tc = r.transport.WithAuth(authToken, pluginAuth.ExtraHeaders)
|
// 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
|
tc.TrustedDomains = pluginAuth.TrustedDomains
|
||||||
} else {
|
} else {
|
||||||
// Default path: use DingTalk OAuth token with identity headers.
|
// Default path: use DingTalk OAuth token with identity headers. Agent
|
||||||
tc = r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
// 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
|
callCtx := ctx
|
||||||
@@ -777,6 +810,24 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
return executor.Result{Invocation: invocation, Response: response}, nil
|
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(
|
func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
endpoint string,
|
endpoint string,
|
||||||
@@ -1069,6 +1120,10 @@ func resolveIdentityHeaders() map[string]string {
|
|||||||
} else {
|
} else {
|
||||||
delete(headers, agentproduct.HeaderName)
|
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
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
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/executor"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||||
@@ -205,10 +206,16 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
|
|||||||
transport: client,
|
transport: client,
|
||||||
globalFlags: &GlobalFlags{Token: "local-test-token"},
|
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{
|
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||||
CanonicalProduct: "im",
|
CanonicalProduct: "im",
|
||||||
Tool: "list_conversations",
|
Tool: "list_conversations",
|
||||||
Params: map[string]any{"pageSize": 100},
|
Params: map[string]any{"pageSize": 100},
|
||||||
|
Retry: &retry,
|
||||||
})
|
})
|
||||||
var typed *apperrors.Error
|
var typed *apperrors.Error
|
||||||
if !errors.As(err, &typed) {
|
if !errors.As(err, &typed) {
|
||||||
|
|||||||
@@ -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) {
|
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)
|
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
|
// Defense in depth for direct helper callers: global dry-run must never
|
||||||
// reach an injected/real Runner, even if a command bypasses the normal
|
// reach an injected/real Runner, even if a command bypasses the normal
|
||||||
// Schema leaf wrapper. EchoRunner produces the same stable dry_run envelope
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CommandMeta is the complete runtime metadata view for a single command.
|
// CommandMeta is the complete runtime metadata view for a single command.
|
||||||
@@ -56,8 +58,9 @@ type CommandSelection struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
metaByCLIPathOnce sync.Once
|
metaByCLIPathOnce sync.Once
|
||||||
metaByCLIPath map[string]CommandMeta
|
metaByCLIPath map[string]CommandMeta
|
||||||
|
toolCallRetryByInterface map[InterfaceRefKey]toolCallRetryResolution
|
||||||
)
|
)
|
||||||
|
|
||||||
// Counter names retain "MetaIndex" for RuntimeSchemaMetadataLoadCounts
|
// Counter names retain "MetaIndex" for RuntimeSchemaMetadataLoadCounts
|
||||||
@@ -75,10 +78,12 @@ func installDeliveryCommandMeta(loaded loadedSchemaCatalog, err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
runtimeDeliverySchemaMetaIndexErr = err
|
runtimeDeliverySchemaMetaIndexErr = err
|
||||||
metaByCLIPath = nil
|
metaByCLIPath = nil
|
||||||
|
toolCallRetryByInterface = nil
|
||||||
metaByCLIPathOnce.Do(func() {})
|
metaByCLIPathOnce.Do(func() {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
metaByCLIPath = buildMetaByCLIPath(loaded)
|
metaByCLIPath = buildMetaByCLIPath(loaded)
|
||||||
|
toolCallRetryByInterface = buildToolCallRetryLookup(loaded)
|
||||||
runtimeDeliverySchemaMetaIndexErr = nil
|
runtimeDeliverySchemaMetaIndexErr = nil
|
||||||
metaByCLIPathOnce.Do(func() {})
|
metaByCLIPathOnce.Do(func() {})
|
||||||
}
|
}
|
||||||
@@ -124,6 +129,7 @@ func buildMetaByCLIPathFromRegistry(registry SchemaRegistry) map[string]CommandM
|
|||||||
Risk: tool.Safety.Risk,
|
Risk: tool.Safety.Risk,
|
||||||
Confirmation: tool.Safety.Confirmation,
|
Confirmation: tool.Safety.Confirmation,
|
||||||
Idempotency: tool.Safety.Idempotency,
|
Idempotency: tool.Safety.Idempotency,
|
||||||
|
RetryPolicy: cloneRetryPolicy(tool.RetryPolicy),
|
||||||
},
|
},
|
||||||
Selection: CommandSelection{
|
Selection: CommandSelection{
|
||||||
AgentSummary: tool.Selection.AgentSummary,
|
AgentSummary: tool.Selection.AgentSummary,
|
||||||
@@ -163,6 +169,7 @@ func buildMetaByCLIPathFromSnapshotTools(tools map[string]map[string]any) map[st
|
|||||||
Risk: schemaString(tool["risk"]),
|
Risk: schemaString(tool["risk"]),
|
||||||
Confirmation: schemaString(tool["confirmation"]),
|
Confirmation: schemaString(tool["confirmation"]),
|
||||||
Idempotency: schemaString(tool["idempotency"]),
|
Idempotency: schemaString(tool["idempotency"]),
|
||||||
|
RetryPolicy: retryPolicyFromSchemaValue(tool["retry_policy"]),
|
||||||
},
|
},
|
||||||
Selection: CommandSelection{
|
Selection: CommandSelection{
|
||||||
AgentSummary: schemaString(tool["agent_summary"]),
|
AgentSummary: schemaString(tool["agent_summary"]),
|
||||||
@@ -212,3 +219,13 @@ func ResolveMeta(cliPath string) (CommandMeta, bool) {
|
|||||||
m, ok := metaByCLIPath[cliPath]
|
m, ok := metaByCLIPath[cliPath]
|
||||||
return m, ok
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +31,18 @@ type CommandSafety struct {
|
|||||||
Effect string // read / write / destructive
|
Effect string // read / write / destructive
|
||||||
Risk string // low / medium / high
|
Risk string // low / medium / high
|
||||||
Confirmation string // not_required / user_required
|
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
|
// ShouldRender returns true when the safety metadata warrants a visible
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -79,7 +80,7 @@ func TestSafetyForCLIPathTrimsWhitespace(t *testing.T) {
|
|||||||
if !ok2 {
|
if !ok2 {
|
||||||
t.Fatal("trimmed lookup failed; whitespace not trimmed")
|
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)
|
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 {
|
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)
|
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)
|
t.Fatalf("alias safety = %+v, want %+v", aliased.Safety, primary.Safety)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
|
|||||||
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||||
Title: "Final Title",
|
Title: "Final Title",
|
||||||
Safety: &contract.SafetySpec{
|
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},
|
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation},
|
||||||
Result: &contract.ResultSpec{
|
Result: &contract.ResultSpec{
|
||||||
@@ -69,9 +75,12 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
|
|||||||
if spec.Title != "Final Title" {
|
if spec.Title != "Final Title" {
|
||||||
t.Fatalf("title = %q", spec.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)
|
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 {
|
if spec.DryRun == nil || spec.DryRun.PreviewKind != contract.DryRunPreviewInvocation {
|
||||||
t.Fatalf("dry_run = %#v", spec.DryRun)
|
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) {
|
func TestRuntimeToolSpecHidesUnifiedResultForInactiveRollout(t *testing.T) {
|
||||||
for _, state := range []output.RolloutState{output.RolloutLegacyOnly, output.RolloutDualValidate} {
|
for _, state := range []output.RolloutState{output.RolloutLegacyOnly, output.RolloutDualValidate} {
|
||||||
t.Run(string(state), func(t *testing.T) {
|
t.Run(string(state), func(t *testing.T) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1057,6 +1057,7 @@ var schemaCompactPayloadKeys = map[string]bool{
|
|||||||
"canonical_path": true, "cli_path": true,
|
"canonical_path": true, "cli_path": true,
|
||||||
"agent_summary": true, "description": true,
|
"agent_summary": true, "description": true,
|
||||||
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
|
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
|
||||||
|
"retry_policy": true,
|
||||||
"interface_mode": true, "availability": true, "interface_reason": true,
|
"interface_mode": true, "availability": true, "interface_reason": true,
|
||||||
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
|
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
|
||||||
"result": true, "pagination": true,
|
"result": true, "pagination": true,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ var schemaCatalogToolOptionalKeys = []string{
|
|||||||
"pagination",
|
"pagination",
|
||||||
"positionals",
|
"positionals",
|
||||||
"result",
|
"result",
|
||||||
|
"retry_policy",
|
||||||
}
|
}
|
||||||
|
|
||||||
var schemaCatalogToolEnums = map[string][]string{
|
var schemaCatalogToolEnums = map[string][]string{
|
||||||
@@ -89,6 +90,7 @@ var schemaCatalogToolEnums = map[string][]string{
|
|||||||
"confirmation": {"not_required", "user_required"},
|
"confirmation": {"not_required", "user_required"},
|
||||||
"interface_mode": {contract.InterfaceModeMCP, contract.InterfaceModeComposite, contract.InterfaceModeLocal},
|
"interface_mode": {contract.InterfaceModeMCP, contract.InterfaceModeComposite, contract.InterfaceModeLocal},
|
||||||
"availability": {contract.InterfaceAvailable, contract.InterfaceUnavailable},
|
"availability": {contract.InterfaceAvailable, contract.InterfaceUnavailable},
|
||||||
|
"idempotency": {"idempotent", "non_idempotent", "unknown", "conditional"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// schemaCatalogParamRequiredKeys is the required core of every parameter.
|
// 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)
|
validateCatalogInterface(toolID, entry, violations)
|
||||||
|
validateCatalogRetryPolicy(toolID, entry, parameters, paramsOK, violations)
|
||||||
if result, exists := entry["result"]; exists {
|
if result, exists := entry["result"]; exists {
|
||||||
if _, ok := result.(map[string]any); !ok {
|
if _, ok := result.(map[string]any); !ok {
|
||||||
report("field %q must be an object", "result")
|
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) {
|
func validateCatalogInterface(toolID string, entry map[string]any, violations *[]schemaCatalogStructureViolation) {
|
||||||
report := func(format string, args ...any) {
|
report := func(format string, args ...any) {
|
||||||
*violations = append(*violations, schemaCatalogStructureViolation{
|
*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) {
|
func TestValidateCatalogStructureAcceptsOptionalResultObject(t *testing.T) {
|
||||||
entry := validCatalogToolEntry()
|
entry := validCatalogToolEntry()
|
||||||
entry["result"] = map[string]any{
|
entry["result"] = map[string]any{
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ type ToolSpec struct {
|
|||||||
DryRun *contract.DryRunSpec
|
DryRun *contract.DryRunSpec
|
||||||
Result *contract.ResultSpec
|
Result *contract.ResultSpec
|
||||||
Pagination *contract.PaginationSpec
|
Pagination *contract.PaginationSpec
|
||||||
|
RetryPolicy *contract.RetryPolicySpec
|
||||||
Safety contract.SafetySpec
|
Safety contract.SafetySpec
|
||||||
Interface contract.InterfaceSpec
|
Interface contract.InterfaceSpec
|
||||||
Selection contract.SelectionSpec
|
Selection contract.SelectionSpec
|
||||||
@@ -137,6 +138,7 @@ type RuntimeToolSpecInput struct {
|
|||||||
DryRun *contract.DryRunSpec
|
DryRun *contract.DryRunSpec
|
||||||
Result *contract.ResultSpec
|
Result *contract.ResultSpec
|
||||||
Pagination *contract.PaginationSpec
|
Pagination *contract.PaginationSpec
|
||||||
|
RetryPolicy *contract.RetryPolicySpec
|
||||||
Safety contract.SafetySpec
|
Safety contract.SafetySpec
|
||||||
Interface contract.InterfaceSpec
|
Interface contract.InterfaceSpec
|
||||||
Selection contract.SelectionSpec
|
Selection contract.SelectionSpec
|
||||||
@@ -224,6 +226,8 @@ func (t ToolSpec) provenanceValue(field string) (any, bool) {
|
|||||||
return t.Safety.Confirmation, true
|
return t.Safety.Confirmation, true
|
||||||
case "idempotency":
|
case "idempotency":
|
||||||
return t.Safety.Idempotency, true
|
return t.Safety.Idempotency, true
|
||||||
|
case "retry_policy":
|
||||||
|
return t.RetryPolicy, true
|
||||||
case "interface_ref":
|
case "interface_ref":
|
||||||
return t.Interface.Ref, true
|
return t.Interface.Ref, true
|
||||||
case "interface_mode":
|
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)
|
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 t.Interface.Mode != "" || t.Interface.Availability != "" || t.Interface.Reason != "" || t.Interface.Ref != nil {
|
||||||
if err := t.Interface.Validate(id.CanonicalPath); err != nil {
|
if err := t.Interface.Validate(id.CanonicalPath); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -756,6 +796,12 @@ func (t ToolSpec) normalized() ToolSpec {
|
|||||||
out.Pagination = pagination
|
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...)
|
out.Positionals = append([]contract.RuntimeSchemaPositional(nil), t.Positionals...)
|
||||||
sort.Slice(out.Positionals, func(i, j int) bool {
|
sort.Slice(out.Positionals, func(i, j int) bool {
|
||||||
if out.Positionals[i].Index != out.Positionals[j].Index {
|
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)
|
value, _ := typedJSONValue(t.Pagination)
|
||||||
payload["pagination"] = value
|
payload["pagination"] = value
|
||||||
}
|
}
|
||||||
|
if t.RetryPolicy != nil {
|
||||||
|
value, _ := typedJSONValue(t.RetryPolicy)
|
||||||
|
payload["retry_policy"] = value
|
||||||
|
}
|
||||||
applySafetyPayload(payload, t.Safety)
|
applySafetyPayload(payload, t.Safety)
|
||||||
applyInterfacePayload(payload, t.Interface)
|
applyInterfacePayload(payload, t.Interface)
|
||||||
applySelectionPayload(payload, t.Selection, true)
|
applySelectionPayload(payload, t.Selection, true)
|
||||||
@@ -1013,7 +1063,7 @@ func (t ToolSpec) ToSummaryPayload() (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
for _, key := range []string{
|
for _, key := range []string{
|
||||||
"parameters", "has_parameters", "parameter_count", "constraints",
|
"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",
|
"field_provenance", "path", "source", "product_id", "display", "is_alias",
|
||||||
} {
|
} {
|
||||||
delete(payload, key)
|
delete(payload, key)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SchemaMetaIndexVersion is the CommandMeta summary index format used by CI
|
// 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
|
// SchemaMetaIndexEntry is one primary-path CommandMeta record. Aliases are
|
||||||
// expanded into the ResolveMeta lookup at decode time.
|
// expanded into the ResolveMeta lookup at decode time.
|
||||||
type SchemaMetaIndexEntry struct {
|
type SchemaMetaIndexEntry struct {
|
||||||
CLIPath string `json:"cli_path"`
|
CLIPath string `json:"cli_path"`
|
||||||
Canonical string `json:"canonical_path"`
|
Canonical string `json:"canonical_path"`
|
||||||
Aliases []string `json:"aliases,omitempty"`
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
ProductID string `json:"product_id,omitempty"`
|
ProductID string `json:"product_id,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Effect string `json:"effect,omitempty"`
|
Effect string `json:"effect,omitempty"`
|
||||||
Risk string `json:"risk,omitempty"`
|
Risk string `json:"risk,omitempty"`
|
||||||
Confirmation string `json:"confirmation,omitempty"`
|
Confirmation string `json:"confirmation,omitempty"`
|
||||||
Idempotency string `json:"idempotency,omitempty"`
|
Idempotency string `json:"idempotency,omitempty"`
|
||||||
AgentSummary string `json:"agent_summary,omitempty"`
|
RetryPolicy *contract.RetryPolicySpec `json:"retry_policy,omitempty"`
|
||||||
UseWhen []string `json:"use_when,omitempty"`
|
AgentSummary string `json:"agent_summary,omitempty"`
|
||||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
UseWhen []string `json:"use_when,omitempty"`
|
||||||
Examples []string `json:"examples,omitempty"`
|
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||||
|
Examples []string `json:"examples,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSchemaMetaIndex extracts the ResolveMeta summary from a full Catalog
|
// BuildSchemaMetaIndex extracts the ResolveMeta summary from a full Catalog
|
||||||
@@ -91,6 +94,7 @@ func BuildSchemaMetaIndex(snapshot SchemaCatalogSnapshot) (SchemaMetaIndexSnapsh
|
|||||||
Risk: schemaString(tool["risk"]),
|
Risk: schemaString(tool["risk"]),
|
||||||
Confirmation: schemaString(tool["confirmation"]),
|
Confirmation: schemaString(tool["confirmation"]),
|
||||||
Idempotency: schemaString(tool["idempotency"]),
|
Idempotency: schemaString(tool["idempotency"]),
|
||||||
|
RetryPolicy: retryPolicyFromSchemaValue(tool["retry_policy"]),
|
||||||
AgentSummary: schemaString(tool["agent_summary"]),
|
AgentSummary: schemaString(tool["agent_summary"]),
|
||||||
UseWhen: schemaStringSlice(tool["use_when"]),
|
UseWhen: schemaStringSlice(tool["use_when"]),
|
||||||
AvoidWhen: schemaStringSlice(tool["avoid_when"]),
|
AvoidWhen: schemaStringSlice(tool["avoid_when"]),
|
||||||
@@ -203,6 +207,7 @@ func commandMetaLookupFromIndex(index SchemaMetaIndexSnapshot) (map[string]Comma
|
|||||||
Risk: entry.Risk,
|
Risk: entry.Risk,
|
||||||
Confirmation: entry.Confirmation,
|
Confirmation: entry.Confirmation,
|
||||||
Idempotency: entry.Idempotency,
|
Idempotency: entry.Idempotency,
|
||||||
|
RetryPolicy: cloneRetryPolicy(entry.RetryPolicy),
|
||||||
},
|
},
|
||||||
Selection: CommandSelection{
|
Selection: CommandSelection{
|
||||||
AgentSummary: entry.AgentSummary,
|
AgentSummary: entry.AgentSummary,
|
||||||
@@ -294,7 +299,7 @@ func commandMetaEqual(got, want CommandMeta) error {
|
|||||||
!metaStringSlicesEqual(got.Identity.Aliases, want.Identity.Aliases) {
|
!metaStringSlicesEqual(got.Identity.Aliases, want.Identity.Aliases) {
|
||||||
return fmt.Errorf("identity mismatch: got %+v want %+v", got.Identity, want.Identity)
|
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)
|
return fmt.Errorf("safety mismatch: got %+v want %+v", got.Safety, want.Safety)
|
||||||
}
|
}
|
||||||
if got.Selection.AgentSummary != want.Selection.AgentSummary ||
|
if got.Selection.AgentSummary != want.Selection.AgentSummary ||
|
||||||
|
|||||||
@@ -294,6 +294,10 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
|||||||
} else if gate, ok := RuntimeContractGate(entry.Command); ok {
|
} else if gate, ok := RuntimeContractGate(entry.Command); ok {
|
||||||
safety = applyContractGateToSafety(safety, gate)
|
safety = applyContractGateToSafety(safety, gate)
|
||||||
}
|
}
|
||||||
|
retryPolicy, err := contract.NormalizeRetryPolicySpec(final.RetryPolicy, canonicalPath)
|
||||||
|
if err != nil {
|
||||||
|
return ToolSpec{}, err
|
||||||
|
}
|
||||||
|
|
||||||
positionals := final.Positionals
|
positionals := final.Positionals
|
||||||
if len(positionals) == 0 {
|
if len(positionals) == 0 {
|
||||||
@@ -333,7 +337,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
|||||||
// part of the public ToolSpec / Schema wire contract.
|
// part of the public ToolSpec / Schema wire contract.
|
||||||
selection.ExampleDispositions = nil
|
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
|
result, pagination := final.Result, final.Pagination
|
||||||
if !output.UsesUnifiedResult(entry.Command) {
|
if !output.UsesUnifiedResult(entry.Command) {
|
||||||
@@ -356,6 +360,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
|||||||
DryRun: final.DryRun,
|
DryRun: final.DryRun,
|
||||||
Result: result,
|
Result: result,
|
||||||
Pagination: pagination,
|
Pagination: pagination,
|
||||||
|
RetryPolicy: retryPolicy,
|
||||||
Safety: safety,
|
Safety: safety,
|
||||||
Interface: interfaceSpec,
|
Interface: interfaceSpec,
|
||||||
Selection: selection,
|
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
|
// 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 fields they happened to author. Title/description provenance must match
|
||||||
// the real text winner (cobra_help vs contract_final).
|
// 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 {
|
prov := func(value any, sourceRef string) contract.FieldProvenance {
|
||||||
return resolvedFieldProvenance(
|
return resolvedFieldProvenance(
|
||||||
value,
|
value,
|
||||||
@@ -454,6 +459,9 @@ func contractFinalProvenance(identity contract.ToolIdentitySpec, title, descript
|
|||||||
if dryRun != nil {
|
if dryRun != nil {
|
||||||
out["dry_run"] = prov(*dryRun, "corecmd.ContractDecl")
|
out["dry_run"] = prov(*dryRun, "corecmd.ContractDecl")
|
||||||
}
|
}
|
||||||
|
if retryPolicy != nil {
|
||||||
|
out["retry_policy"] = prov(*retryPolicy, "corecmd.ContractDecl")
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,6 +701,9 @@ func validateFinalSchemaProvenanceCoverage(registry SchemaRegistry) error {
|
|||||||
if tool.DryRun != nil {
|
if tool.DryRun != nil {
|
||||||
require("tool "+canonical, "dry_run", tool.FieldProvenance)
|
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
|
// interface_reason is part of the final interface contract only when
|
||||||
// the disposition requires or actually delivers a reason. An MCP or
|
// the disposition requires or actually delivers a reason. An MCP or
|
||||||
// local available command with no reason has no resolver winner to
|
// local available command with no reason has no resolver winner to
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ type schemaToolWire struct {
|
|||||||
DryRun *contract.DryRunSpec `json:"dry_run"`
|
DryRun *contract.DryRunSpec `json:"dry_run"`
|
||||||
Result *contract.ResultSpec `json:"result"`
|
Result *contract.ResultSpec `json:"result"`
|
||||||
Pagination *contract.PaginationSpec `json:"pagination"`
|
Pagination *contract.PaginationSpec `json:"pagination"`
|
||||||
|
RetryPolicy *contract.RetryPolicySpec `json:"retry_policy"`
|
||||||
Effect string `json:"effect"`
|
Effect string `json:"effect"`
|
||||||
EffectSource string `json:"effect_source"`
|
EffectSource string `json:"effect_source"`
|
||||||
Risk string `json:"risk"`
|
Risk string `json:"risk"`
|
||||||
@@ -271,6 +272,7 @@ func schemaToolSpecFromWire(wire schemaToolWire) (ToolSpec, error) {
|
|||||||
DryRun: wire.DryRun,
|
DryRun: wire.DryRun,
|
||||||
Result: wire.Result,
|
Result: wire.Result,
|
||||||
Pagination: wire.Pagination,
|
Pagination: wire.Pagination,
|
||||||
|
RetryPolicy: wire.RetryPolicy,
|
||||||
Safety: contract.SafetySpec{
|
Safety: contract.SafetySpec{
|
||||||
Effect: wire.Effect,
|
Effect: wire.Effect,
|
||||||
EffectSource: wire.EffectSource,
|
EffectSource: wire.EffectSource,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ func resetDeliverySchemaCatalogState() {
|
|||||||
func resetSchemaDeliveryState() {
|
func resetSchemaDeliveryState() {
|
||||||
metaByCLIPathOnce = sync.Once{}
|
metaByCLIPathOnce = sync.Once{}
|
||||||
metaByCLIPath = nil
|
metaByCLIPath = nil
|
||||||
|
toolCallRetryByInterface = nil
|
||||||
runtimeDeliverySchemaMetaIndexErr = nil
|
runtimeDeliverySchemaMetaIndexErr = nil
|
||||||
runtimeDeliverySchemaMetaIndexLazyCount.Store(0)
|
runtimeDeliverySchemaMetaIndexLazyCount.Store(0)
|
||||||
resetDeliverySchemaCatalogState()
|
resetDeliverySchemaCatalogState()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type ContractFinalPayload struct {
|
|||||||
DryRun *DryRunSpec
|
DryRun *DryRunSpec
|
||||||
Result *ResultSpec
|
Result *ResultSpec
|
||||||
Pagination *PaginationSpec
|
Pagination *PaginationSpec
|
||||||
|
RetryPolicy *RetryPolicySpec
|
||||||
Interface *InterfaceSpec
|
Interface *InterfaceSpec
|
||||||
Selection *SelectionSpec
|
Selection *SelectionSpec
|
||||||
Identity *ToolIdentitySpec
|
Identity *ToolIdentitySpec
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,12 +44,14 @@ type ContractDecl struct {
|
|||||||
DryRun *contract.DryRunSpec
|
DryRun *contract.DryRunSpec
|
||||||
Result *contract.ResultSpec
|
Result *contract.ResultSpec
|
||||||
Pagination *contract.PaginationSpec
|
Pagination *contract.PaginationSpec
|
||||||
|
RetryPolicy *contract.RetryPolicySpec
|
||||||
Interface *contract.InterfaceSpec
|
Interface *contract.InterfaceSpec
|
||||||
Selection contract.SelectionSpec
|
Selection contract.SelectionSpec
|
||||||
Identity contract.ToolIdentitySpec
|
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
|
// A declared Contract is the sole final source for its fields: downstream
|
||||||
// catalog/Agent gates hard-require description and the selection prose for
|
// catalog/Agent gates hard-require description and the selection prose for
|
||||||
// every effective tool — so a declaration missing any of these fields could
|
// 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
|
// self-description: identity is collected from ContractFinal on the live
|
||||||
// leaves, so an incomplete or inconsistent declared Identity fails collection,
|
// leaves, so an incomplete or inconsistent declared Identity fails collection,
|
||||||
// binding, and policy downstream.
|
// 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() {
|
if spec.Contract.empty() {
|
||||||
return
|
return retryPolicy
|
||||||
}
|
}
|
||||||
missing := make([]string, 0, 8)
|
missing := make([]string, 0, 8)
|
||||||
if strings.TrimSpace(spec.Contract.Description) == "" {
|
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",
|
"command %q Contract.Identity.CLIPath %q and PrimaryCLIPath %q must agree on the primary leaf path",
|
||||||
spec.Use, cliPath, primary))
|
spec.Use, cliPath, primary))
|
||||||
}
|
}
|
||||||
|
return retryPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty reports whether no ContractDecl field was authored.
|
// Empty reports whether no ContractDecl field was authored.
|
||||||
@@ -152,6 +180,9 @@ func (s ContractDecl) empty() bool {
|
|||||||
if s.Pagination != nil {
|
if s.Pagination != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if s.RetryPolicy != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if s.Interface != nil {
|
if s.Interface != nil {
|
||||||
iface := s.Interface
|
iface := s.Interface
|
||||||
if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" ||
|
if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" ||
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
package corecmd
|
package corecmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -21,6 +22,43 @@ import (
|
|||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
"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) {
|
func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *testing.T) {
|
||||||
cmd := New(Spec{
|
cmd := New(Spec{
|
||||||
Use: "create",
|
Use: "create",
|
||||||
@@ -35,11 +73,12 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
|||||||
},
|
},
|
||||||
Safety: contract.SafetySpec{
|
Safety: contract.SafetySpec{
|
||||||
Effect: "write", Risk: "medium",
|
Effect: "write", Risk: "medium",
|
||||||
Confirmation: "user_required", Idempotency: "retryable",
|
Confirmation: "user_required", Idempotency: "conditional",
|
||||||
},
|
},
|
||||||
Contract: ContractDecl{
|
Contract: ContractDecl{
|
||||||
Title: "Create Title",
|
Title: "Create Title",
|
||||||
Description: "Create Desc",
|
Description: "Create Desc",
|
||||||
|
Parameters: []contract.ParamDecl{{Name: "mode", Property: "mode"}},
|
||||||
Positionals: []contract.RuntimeSchemaPositional{{Name: "id", Required: true, Index: 0}},
|
Positionals: []contract.RuntimeSchemaPositional{{Name: "id", Required: true, Index: 0}},
|
||||||
DryRun: &contract.DryRunSpec{PreviewKind: "invocation", RemoteReads: true},
|
DryRun: &contract.DryRunSpec{PreviewKind: "invocation", RemoteReads: true},
|
||||||
Result: &contract.ResultSpec{
|
Result: &contract.ResultSpec{
|
||||||
@@ -47,6 +86,11 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
|||||||
DataSchema: []byte(`{"type":"object"}`),
|
DataSchema: []byte(`{"type":"object"}`),
|
||||||
},
|
},
|
||||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
|
||||||
|
RetryPolicy: &contract.RetryPolicySpec{
|
||||||
|
Mode: contract.RetryModeDeduplicationKey,
|
||||||
|
KeyParameter: "mode",
|
||||||
|
SamePayloadRequired: true,
|
||||||
|
},
|
||||||
Interface: &contract.InterfaceSpec{
|
Interface: &contract.InterfaceSpec{
|
||||||
Mode: "mcp",
|
Mode: "mcp",
|
||||||
Availability: "available",
|
Availability: "available",
|
||||||
@@ -78,7 +122,7 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
|
|||||||
if final.Title != "Create Title" || final.Description != "Create Desc" {
|
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)
|
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)
|
t.Fatalf("safety = %#v", final.Safety)
|
||||||
}
|
}
|
||||||
if final.DryRun == nil || final.DryRun.PreviewKind != "invocation" || !final.DryRun.RemoteReads {
|
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 {
|
if final.Pagination == nil || final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath {
|
||||||
t.Fatalf("pagination = %#v", final.Pagination)
|
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" {
|
if final.Interface == nil || final.Interface.Mode != "mcp" || final.Interface.Ref == nil || final.Interface.Ref.RPCName != "create_thing" {
|
||||||
t.Fatalf("interface = %#v", final.Interface)
|
t.Fatalf("interface = %#v", final.Interface)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ func cloneContractFinalPayload(in contract.ContractFinalPayload) contract.Contra
|
|||||||
value := *in.Pagination
|
value := *in.Pagination
|
||||||
out.Pagination = &value
|
out.Pagination = &value
|
||||||
}
|
}
|
||||||
|
if in.RetryPolicy != nil {
|
||||||
|
value := *in.RetryPolicy
|
||||||
|
out.RetryPolicy = &value
|
||||||
|
}
|
||||||
if in.Interface != nil {
|
if in.Interface != nil {
|
||||||
value := *in.Interface
|
value := *in.Interface
|
||||||
if in.Interface.Ref != nil {
|
if in.Interface.Ref != nil {
|
||||||
|
|||||||
@@ -1364,7 +1364,7 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
|
|||||||
_, _ = short, long
|
_, _ = short, long
|
||||||
// Reuse NewCommand's completeness rules so bind-time attaches cannot ship
|
// Reuse NewCommand's completeness rules so bind-time attaches cannot ship
|
||||||
// a partial declaration that would only fail in generated artifacts.
|
// 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})
|
validateSafetySpec(Spec{Use: cmd.Name(), Safety: safety})
|
||||||
|
|
||||||
payload := contract.ContractFinalPayload{
|
payload := contract.ContractFinalPayload{
|
||||||
@@ -1394,6 +1394,9 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
|
|||||||
}
|
}
|
||||||
payload.Pagination = pagination
|
payload.Pagination = pagination
|
||||||
}
|
}
|
||||||
|
if retryPolicy != nil {
|
||||||
|
payload.RetryPolicy = retryPolicy
|
||||||
|
}
|
||||||
if decl.Interface != nil {
|
if decl.Interface != nil {
|
||||||
iface := &contract.InterfaceSpec{
|
iface := &contract.InterfaceSpec{
|
||||||
Mode: strings.TrimSpace(decl.Interface.Mode),
|
Mode: strings.TrimSpace(decl.Interface.Mode),
|
||||||
|
|||||||
@@ -19,19 +19,21 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Invocation struct {
|
type Invocation struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Stage string `json:"stage"`
|
Stage string `json:"stage"`
|
||||||
Implemented bool `json:"implemented"`
|
Implemented bool `json:"implemented"`
|
||||||
DryRun bool `json:"dry_run,omitempty"`
|
DryRun bool `json:"dry_run,omitempty"`
|
||||||
CanonicalProduct string `json:"canonical_product"`
|
CanonicalProduct string `json:"canonical_product"`
|
||||||
Tool string `json:"tool"`
|
Tool string `json:"tool"`
|
||||||
CanonicalPath string `json:"canonical_path"`
|
CanonicalPath string `json:"canonical_path"`
|
||||||
LegacyPath string `json:"legacy_path,omitempty"`
|
LegacyPath string `json:"legacy_path,omitempty"`
|
||||||
Params map[string]any `json:"params"`
|
Params map[string]any `json:"params"`
|
||||||
|
Retry *contract.RetryDecision `json:"retry,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@@ -47,13 +49,17 @@ type EchoRunner struct{}
|
|||||||
|
|
||||||
func (EchoRunner) Run(_ context.Context, invocation Invocation) (Result, error) {
|
func (EchoRunner) Run(_ context.Context, invocation Invocation) (Result, error) {
|
||||||
if invocation.DryRun {
|
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{
|
return Result{
|
||||||
Invocation: invocation,
|
Invocation: invocation,
|
||||||
Response: map[string]any{
|
Response: response,
|
||||||
"dry_run": true,
|
|
||||||
"request": ToolCallRequest(invocation.Tool, invocation.Params),
|
|
||||||
"note": "execution skipped by --dry-run",
|
|
||||||
},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
return Result{Invocation: invocation}, nil
|
return Result{Invocation: invocation}, nil
|
||||||
|
|||||||
@@ -4,19 +4,21 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCrossPlatformCoverageInvocationBuildersAndEchoRunner(t *testing.T) {
|
func TestCrossPlatformCoverageInvocationBuildersAndEchoRunner(t *testing.T) {
|
||||||
params := map[string]any{"name": "value"}
|
params := map[string]any{"name": "value"}
|
||||||
compat := NewCompatibilityInvocation("old path", "doc", "read", params)
|
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)
|
t.Fatalf("unexpected compatibility invocation: %#v", compat)
|
||||||
}
|
}
|
||||||
if got := NewCompatibilityInvocation("old", "doc", "read", nil).Params; got == nil {
|
if got := NewCompatibilityInvocation("old", "doc", "read", nil).Params; got == nil {
|
||||||
t.Fatal("nil compatibility params were not normalized")
|
t.Fatal("nil compatibility params were not normalized")
|
||||||
}
|
}
|
||||||
help := NewHelperInvocation("old path", "chat", "send", params)
|
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)
|
t.Fatalf("unexpected helper invocation: %#v", help)
|
||||||
}
|
}
|
||||||
if got := NewHelperInvocation("old", "chat", "send", nil).Params; got == nil {
|
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)
|
t.Fatalf("EchoRunner normal result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
compat.DryRun = true
|
compat.DryRun = true
|
||||||
|
retry := contract.RetryDecision{EffectiveIdempotency: "idempotent", SafeToRetry: true, Reason: "static_idempotent"}
|
||||||
|
compat.Retry = &retry
|
||||||
result, err = runner.Run(context.Background(), compat)
|
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)
|
t.Fatalf("EchoRunner dry-run result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,11 +324,23 @@ func (m FlagMigration) validate() error {
|
|||||||
if m.Canonical.After.Hidden {
|
if m.Canonical.After.Hidden {
|
||||||
return fmt.Errorf("canonical flag must remain visible")
|
return fmt.Errorf("canonical flag must remain visible")
|
||||||
}
|
}
|
||||||
if !m.Canonical.After.Required {
|
// Requiredness belongs to the one logical parameter. The hidden legacy
|
||||||
return fmt.Errorf("canonical flag must be required after migration")
|
// spelling must not remain independently required, while the canonical
|
||||||
|
// spelling inherits the exact before-state contract. An already-visible
|
||||||
|
// canonical flag cannot change requiredness; an existing hidden canonical
|
||||||
|
// placeholder may inherit it when promoted to the public spelling.
|
||||||
|
if m.Legacy.After.Required {
|
||||||
|
return fmt.Errorf("legacy compatibility alias must not remain independently required after migration")
|
||||||
}
|
}
|
||||||
if m.Canonical.Before.Present && m.Canonical.Before.Required {
|
if m.Legacy.Before.Required != m.Canonical.After.Required {
|
||||||
return fmt.Errorf("canonical flag must be absent or optional before migration")
|
return fmt.Errorf(
|
||||||
|
"flag requiredness must be preserved from legacy before to canonical after",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if m.Canonical.Before.Present &&
|
||||||
|
!m.Canonical.Before.Hidden &&
|
||||||
|
m.Canonical.Before.Required != m.Canonical.After.Required {
|
||||||
|
return fmt.Errorf("canonical flag requiredness must remain unchanged when already present")
|
||||||
}
|
}
|
||||||
if m.Legacy.After.AliasOf != m.Canonical.Name {
|
if m.Legacy.After.AliasOf != m.Canonical.Name {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
@@ -682,6 +694,7 @@ func flagMigrationAuthorizesChange(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if migration.Canonical.Before.Present &&
|
if migration.Canonical.Before.Present &&
|
||||||
|
migration.Canonical.Before.Hidden &&
|
||||||
!migration.Canonical.Before.Required &&
|
!migration.Canonical.Before.Required &&
|
||||||
migration.Canonical.After.Required &&
|
migration.Canonical.After.Required &&
|
||||||
change.Kind == "flag_became_required" {
|
change.Kind == "flag_became_required" {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ func TestCrossPlatformCoverageFlagMigrationManifestParserEdges(t *testing.T) {
|
|||||||
|
|
||||||
func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *testing.T) {
|
func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *testing.T) {
|
||||||
optionalCanonical := func() FlagMigrationManifest {
|
optionalCanonical := func() FlagMigrationManifest {
|
||||||
manifest := coverageManifest(FlagMigrationPending)
|
manifest := coverageOptionalManifest(FlagMigrationPending)
|
||||||
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
|
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||||
Present: true,
|
Present: true,
|
||||||
Type: "string",
|
Type: "string",
|
||||||
@@ -269,6 +269,36 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *
|
|||||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After = FlagMigrationState{} },
|
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After = FlagMigrationState{} },
|
||||||
wantErr: "canonical flag must be present after migration",
|
wantErr: "canonical flag must be present after migration",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "required legacy cannot become optional canonical",
|
||||||
|
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||||
|
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Required = false },
|
||||||
|
wantErr: "requiredness must be preserved from legacy before to canonical after",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optional legacy cannot become required canonical",
|
||||||
|
make: func() FlagMigrationManifest { return coverageOptionalManifest(FlagMigrationPending) },
|
||||||
|
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Required = true },
|
||||||
|
wantErr: "requiredness must be preserved from legacy before to canonical after",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing canonical cannot change requiredness",
|
||||||
|
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||||
|
mutate: func(manifest *FlagMigrationManifest) {
|
||||||
|
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||||
|
Present: true,
|
||||||
|
Type: "string",
|
||||||
|
Scope: "local",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantErr: "canonical flag requiredness must remain unchanged when already present",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hidden legacy alias is not independently required",
|
||||||
|
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||||
|
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Required = true },
|
||||||
|
wantErr: "legacy compatibility alias must not remain independently required",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "legacy after declares alias target",
|
name: "legacy after declares alias target",
|
||||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||||
@@ -711,8 +741,32 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCa
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("existing optional canonical becomes required", func(t *testing.T) {
|
t.Run("optional canonical is introduced without becoming required", func(t *testing.T) {
|
||||||
pending := coverageManifest(FlagMigrationPending)
|
pending := coverageOptionalManifest(FlagMigrationPending)
|
||||||
|
consumed := coverageOptionalManifest(FlagMigrationConsumed)
|
||||||
|
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
|
||||||
|
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
|
||||||
|
|
||||||
|
ordinary := Compare(after, before, "merge-base")
|
||||||
|
if !hasFlagChange(ordinary.Blocking, "flag_became_hidden", pending.Migrations[0].Command, pending.Migrations[0].Legacy.Name) {
|
||||||
|
t.Fatalf("fixture did not create flag_became_hidden: %#v", ordinary.Blocking)
|
||||||
|
}
|
||||||
|
if hasFlagChange(ordinary.Blocking, "required_flag_added", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
|
||||||
|
t.Fatalf("optional canonical was treated as required: %#v", ordinary.Blocking)
|
||||||
|
}
|
||||||
|
report, err := CompareAllWithFlagMigrations(
|
||||||
|
after,
|
||||||
|
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||||
|
pending,
|
||||||
|
consumed,
|
||||||
|
)
|
||||||
|
if err != nil || !report.Compatible {
|
||||||
|
t.Fatalf("optional rename = (%#v, %v), want compatible", report, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("existing optional canonical remains optional", func(t *testing.T) {
|
||||||
|
pending := coverageOptionalManifest(FlagMigrationPending)
|
||||||
pending.Migrations[0].Canonical.Before = FlagMigrationState{
|
pending.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||||
Present: true,
|
Present: true,
|
||||||
Type: "string",
|
Type: "string",
|
||||||
@@ -725,8 +779,8 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCa
|
|||||||
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
|
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
|
||||||
|
|
||||||
ordinary := Compare(after, before, "merge-base")
|
ordinary := Compare(after, before, "merge-base")
|
||||||
if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
|
if hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
|
||||||
t.Fatalf("fixture did not create flag_became_required: %#v", ordinary.Blocking)
|
t.Fatalf("fixture changed canonical requiredness: %#v", ordinary.Blocking)
|
||||||
}
|
}
|
||||||
report, err := CompareAllWithFlagMigrations(
|
report, err := CompareAllWithFlagMigrations(
|
||||||
after,
|
after,
|
||||||
@@ -735,7 +789,83 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCa
|
|||||||
consumed,
|
consumed,
|
||||||
)
|
)
|
||||||
if err != nil || !report.Compatible {
|
if err != nil || !report.Compatible {
|
||||||
t.Fatalf("optional-to-required migration = (%#v, %v), want compatible", report, err)
|
t.Fatalf("existing optional canonical migration = (%#v, %v), want compatible", report, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hidden canonical inherits requiredness when promoted", func(t *testing.T) {
|
||||||
|
pending := coverageManifest(FlagMigrationPending)
|
||||||
|
pending.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||||
|
Present: true,
|
||||||
|
Type: "string",
|
||||||
|
Hidden: true,
|
||||||
|
Scope: "local",
|
||||||
|
}
|
||||||
|
consumed := pending
|
||||||
|
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
|
||||||
|
consumed.Migrations[0].State = FlagMigrationConsumed
|
||||||
|
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
|
||||||
|
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
|
||||||
|
|
||||||
|
ordinary := Compare(after, before, "merge-base")
|
||||||
|
if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
|
||||||
|
t.Fatalf("fixture did not change canonical requiredness: %#v", ordinary.Blocking)
|
||||||
|
}
|
||||||
|
report, err := CompareAllWithFlagMigrations(
|
||||||
|
after,
|
||||||
|
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||||
|
pending,
|
||||||
|
consumed,
|
||||||
|
)
|
||||||
|
if err != nil || !report.Compatible {
|
||||||
|
t.Fatalf("hidden canonical promotion = (%#v, %v), want compatible", report, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *testing.T) {
|
||||||
|
pending := coverageOptionalManifest(FlagMigrationPending)
|
||||||
|
consumed := coverageOptionalManifest(FlagMigrationConsumed)
|
||||||
|
empty := coverageEmptyManifest()
|
||||||
|
migration := pending.Migrations[0]
|
||||||
|
before := coverageMigrationSnapshot(migration, false, false)
|
||||||
|
after := coverageMigrationSnapshot(migration, true, false)
|
||||||
|
|
||||||
|
t.Run("candidate cannot self authorize", func(t *testing.T) {
|
||||||
|
_, err := CompareAllWithFlagMigrations(
|
||||||
|
after,
|
||||||
|
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||||
|
empty,
|
||||||
|
pending,
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
|
||||||
|
t.Fatalf("candidate self-authorization error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("partial application remains rejected", func(t *testing.T) {
|
||||||
|
partial := coverageMigrationSnapshot(migration, false, false)
|
||||||
|
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Hidden = true
|
||||||
|
_, err := CompareAllWithFlagMigrations(
|
||||||
|
partial,
|
||||||
|
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||||
|
pending,
|
||||||
|
consumed,
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "partially applied flag migration") {
|
||||||
|
t.Fatalf("partial optional migration error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("consumed receipt remains stale after every reference converges", func(t *testing.T) {
|
||||||
|
_, err := CompareAllWithFlagMigrations(
|
||||||
|
after,
|
||||||
|
map[string]Snapshot{"merge-base": after, "stable": after},
|
||||||
|
consumed,
|
||||||
|
consumed,
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
|
||||||
|
t.Fatalf("stale optional migration error = %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -886,6 +1016,13 @@ func coverageManifest(state string) FlagMigrationManifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func coverageOptionalManifest(state string) FlagMigrationManifest {
|
||||||
|
manifest := coverageManifest(state)
|
||||||
|
manifest.Migrations[0].Legacy.Before.Required = false
|
||||||
|
manifest.Migrations[0].Canonical.After.Required = false
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
|
||||||
func coverageEmptyManifest() FlagMigrationManifest {
|
func coverageEmptyManifest() FlagMigrationManifest {
|
||||||
return FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
|
return FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package interfacesnapshot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -37,6 +38,42 @@ const validFlagMigrationManifestJSON = `{
|
|||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
func optionalFlagMigrationManifestJSON() string {
|
||||||
|
manifest := strings.Replace(
|
||||||
|
validFlagMigrationManifestJSON,
|
||||||
|
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||||
|
`"before": {"present": true, "type": "string", "scope": "local"}`,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
return strings.Replace(
|
||||||
|
manifest,
|
||||||
|
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||||
|
`"after": {"present": true, "type": "string", "scope": "local"}`,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hiddenCanonicalFlagMigrationManifestJSON() string {
|
||||||
|
return strings.Replace(
|
||||||
|
validFlagMigrationManifestJSON,
|
||||||
|
`"before": {"present": false}`,
|
||||||
|
`"before": {"present": true, "type": "string", "hidden": true, "scope": "local"}`,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApprovedFlagMigrationManifestRemainsValid(t *testing.T) {
|
||||||
|
manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-flag-migrations-v1.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open approved flag migration manifest: %v", err)
|
||||||
|
}
|
||||||
|
defer manifest.Close()
|
||||||
|
|
||||||
|
if _, err := ReadFlagMigrationManifest(manifest); err != nil {
|
||||||
|
t.Fatalf("approved flag migration manifest is invalid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) {
|
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) {
|
||||||
_, err := ReadFlagMigrationManifest(strings.NewReader(`{
|
_, err := ReadFlagMigrationManifest(strings.NewReader(`{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -110,6 +147,12 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
|
|||||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)); err != nil {
|
if _, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)); err != nil {
|
||||||
t.Fatalf("ReadFlagMigrationManifest(valid) error = %v", err)
|
t.Fatalf("ReadFlagMigrationManifest(valid) error = %v", err)
|
||||||
}
|
}
|
||||||
|
if _, err := ReadFlagMigrationManifest(strings.NewReader(optionalFlagMigrationManifestJSON())); err != nil {
|
||||||
|
t.Fatalf("ReadFlagMigrationManifest(optional rename) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil {
|
||||||
|
t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -162,24 +205,34 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
|
|||||||
wantErr: "canonical flag must remain visible",
|
wantErr: "canonical flag must remain visible",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "canonical remains optional",
|
name: "required legacy becomes optional canonical",
|
||||||
input: strings.Replace(
|
input: strings.Replace(
|
||||||
validFlagMigrationManifestJSON,
|
validFlagMigrationManifestJSON,
|
||||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||||
`"after": {"present": true, "type": "string", "scope": "local"}`,
|
`"after": {"present": true, "type": "string", "scope": "local"}`,
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
wantErr: "canonical flag must be required after migration",
|
wantErr: "requiredness must be preserved from legacy before to canonical after",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "canonical was already required",
|
name: "optional legacy becomes required canonical",
|
||||||
|
input: strings.Replace(
|
||||||
|
optionalFlagMigrationManifestJSON(),
|
||||||
|
`"after": {"present": true, "type": "string", "scope": "local"}`,
|
||||||
|
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
wantErr: "requiredness must be preserved from legacy before to canonical after",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing canonical changes requiredness",
|
||||||
input: strings.Replace(
|
input: strings.Replace(
|
||||||
validFlagMigrationManifestJSON,
|
validFlagMigrationManifestJSON,
|
||||||
`"before": {"present": false}`,
|
`"before": {"present": false}`,
|
||||||
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
`"before": {"present": true, "type": "string", "scope": "local"}`,
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
wantErr: "canonical flag must be absent or optional before migration",
|
wantErr: "canonical flag requiredness must remain unchanged when already present",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,9 +268,10 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon
|
|||||||
canonicalDrift := manifest
|
canonicalDrift := manifest
|
||||||
canonicalDrift.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
|
canonicalDrift.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
|
||||||
canonicalDrift.Migrations[0].Canonical.Before = FlagMigrationState{
|
canonicalDrift.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||||
Present: true,
|
Present: true,
|
||||||
Type: "string",
|
Type: "string",
|
||||||
Scope: "local",
|
Required: true,
|
||||||
|
Scope: "local",
|
||||||
}
|
}
|
||||||
canonicalDrift.Migrations[0].Canonical.After.Type = "stringSlice"
|
canonicalDrift.Migrations[0].Canonical.After.Type = "stringSlice"
|
||||||
if err := canonicalDrift.Validate(); err == nil || !strings.Contains(err.Error(), "canonical flag type") {
|
if err := canonicalDrift.Validate(); err == nil || !strings.Contains(err.Error(), "canonical flag type") {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import (
|
|||||||
var sensitiveKeys = map[string]bool{
|
var sensitiveKeys = map[string]bool{
|
||||||
"authorization": true,
|
"authorization": true,
|
||||||
"x-user-access-token": true,
|
"x-user-access-token": true,
|
||||||
|
"dws_agent_ext": true,
|
||||||
|
"x-dws-agent-ext": true,
|
||||||
"client_secret": true,
|
"client_secret": true,
|
||||||
"client-secret": true,
|
"client-secret": true,
|
||||||
"token": true,
|
"token": true,
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ func TestIsSensitiveKey(t *testing.T) {
|
|||||||
{"authorization", true},
|
{"authorization", true},
|
||||||
{"x-user-access-token", true},
|
{"x-user-access-token", 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},
|
||||||
{"client-secret", true},
|
{"client-secret", true},
|
||||||
{"token", true},
|
{"token", true},
|
||||||
@@ -128,17 +134,24 @@ func TestSanitizeArguments_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestRedactHeaders(t *testing.T) {
|
func TestRedactHeaders(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
headers := http.Header{
|
headers := make(http.Header)
|
||||||
"Authorization": {"Bearer token123456"},
|
headers.Set("Authorization", "Bearer test-credential-value")
|
||||||
"Content-Type": {"application/json"},
|
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)
|
attrs := RedactHeaders(headers)
|
||||||
if len(attrs) != 2 {
|
if len(attrs) != 4 {
|
||||||
t.Fatalf("expected 2 attrs, got %d", len(attrs))
|
t.Fatalf("expected 4 attrs, got %d", len(attrs))
|
||||||
}
|
}
|
||||||
for _, attr := range attrs {
|
for _, attr := range attrs {
|
||||||
if attr.Key == "header.authorization" && !strings.Contains(attr.Value.String(), "***") {
|
switch attr.Key {
|
||||||
t.Fatalf("authorization should be redacted: %s", attr.Value.String())
|
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" {
|
if attr.Key == "header.content-type" && attr.Value.String() != "application/json" {
|
||||||
t.Fatalf("content-type should not be redacted: %s", attr.Value.String())
|
t.Fatalf("content-type should not be redacted: %s", attr.Value.String())
|
||||||
|
|||||||
@@ -313,17 +313,17 @@ var ConversationList = shortcut.Shortcut{
|
|||||||
Examples: []string{"dws chat +conversation-list --limit 50"},
|
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: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量;--limit 必须在 1-100"},
|
||||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"},
|
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"},
|
||||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
|
{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"},
|
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||||
},
|
}, shortcut.AutoPageControlFlags()...),
|
||||||
Constraints: []shortcut.Constraint{
|
Constraints: append([]shortcut.Constraint{
|
||||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
|
{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"},
|
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||||
},
|
}, shortcut.AutoPageControlConstraints()...),
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
`dws chat +conversation-list --limit 50`,
|
`dws chat +conversation-list --limit 50`,
|
||||||
`dws chat +conversation-list --page-all --limit 100`,
|
`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 {
|
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 500 {
|
||||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||||
}
|
}
|
||||||
|
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||||
|
return apperrors.NewValidation(err.Error())
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||||
@@ -353,9 +356,20 @@ var ConversationList = shortcut.Shortcut{
|
|||||||
complete := false
|
complete := false
|
||||||
hasMore := false
|
hasMore := false
|
||||||
nextCursor := int64(0)
|
nextCursor := int64(0)
|
||||||
|
stopReason := "source_complete"
|
||||||
|
truncatedByPageLimit := false
|
||||||
|
truncatedByResultLimit := false
|
||||||
|
unsafeResultContinuation := false
|
||||||
failures := make([]map[string]any, 0)
|
failures := make([]map[string]any, 0)
|
||||||
for pagesFetched < pageLimit {
|
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 {
|
if cursor > 0 {
|
||||||
params["cursor"] = cursor
|
params["cursor"] = cursor
|
||||||
}
|
}
|
||||||
@@ -371,6 +385,7 @@ var ConversationList = shortcut.Shortcut{
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
pagesFetched++
|
pagesFetched++
|
||||||
|
overflowOnPage := false
|
||||||
for _, conversation := range conversationListProject(data) {
|
for _, conversation := range conversationListProject(data) {
|
||||||
id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"]))
|
id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"]))
|
||||||
if id != "" && id != "<nil>" {
|
if id != "" && id != "<nil>" {
|
||||||
@@ -379,6 +394,11 @@ var ConversationList = shortcut.Shortcut{
|
|||||||
}
|
}
|
||||||
seenConversations[id] = true
|
seenConversations[id] = true
|
||||||
}
|
}
|
||||||
|
if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems {
|
||||||
|
truncatedByResultLimit = true
|
||||||
|
overflowOnPage = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
convs = append(convs, conversation)
|
convs = append(convs, conversation)
|
||||||
}
|
}
|
||||||
page := chatmsg.Pagination(data)
|
page := chatmsg.Pagination(data)
|
||||||
@@ -388,6 +408,14 @@ var ConversationList = shortcut.Shortcut{
|
|||||||
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"})
|
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"})
|
||||||
break
|
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 {
|
if !hasMore {
|
||||||
complete = true
|
complete = true
|
||||||
break
|
break
|
||||||
@@ -398,27 +426,56 @@ var ConversationList = shortcut.Shortcut{
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if !rt.Bool("page-all") {
|
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
|
break
|
||||||
}
|
}
|
||||||
seenCursors[nextCursor] = true
|
seenCursors[nextCursor] = true
|
||||||
cursor = nextCursor
|
cursor = nextCursor
|
||||||
}
|
}
|
||||||
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit {
|
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit && !truncatedByResultLimit {
|
||||||
failures = append(failures, map[string]any{"stage": "conversation-page-limit", "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多会话", pageLimit)})
|
truncatedByPageLimit = true
|
||||||
|
stopReason = "page_limit"
|
||||||
}
|
}
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"count": len(convs),
|
"count": len(convs),
|
||||||
"conversations": convs,
|
"conversations": convs,
|
||||||
"pagesFetched": pagesFetched,
|
"pagesFetched": pagesFetched,
|
||||||
"complete": complete,
|
"complete": complete,
|
||||||
"hasMore": hasMore,
|
"hasMore": hasMore,
|
||||||
"nextCursor": nextCursor,
|
"nextCursor": nextCursor,
|
||||||
"paginationKnown": len(failures) == 0 || hasMore,
|
"paginationKnown": len(failures) == 0 || hasMore,
|
||||||
"failedCount": len(failures),
|
"stopReason": stopReason,
|
||||||
"failures": failures,
|
"truncatedByPageLimit": truncatedByPageLimit,
|
||||||
"partial": len(failures) > 0,
|
"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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"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) {
|
func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) {
|
||||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||||
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`,
|
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`,
|
||||||
|
|||||||
@@ -878,16 +878,16 @@ var ChatListAll = shortcut.Shortcut{
|
|||||||
Examples: []string{"dws chat +chat-list-all --limit 50"},
|
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: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量;--limit 必须在 1-200 之间"},
|
||||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
|
{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"},
|
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||||
},
|
}, shortcut.AutoPageControlFlags()...),
|
||||||
Constraints: []shortcut.Constraint{
|
Constraints: append([]shortcut.Constraint{
|
||||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
|
{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"},
|
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||||
},
|
}, shortcut.AutoPageControlConstraints()...),
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
`dws chat +chat-list-all --limit 50`,
|
`dws chat +chat-list-all --limit 50`,
|
||||||
`dws chat +chat-list-all --limit 200 --page-all --page-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 之间")
|
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||||
|
return apperrors.NewValidation(err.Error())
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,10 +961,21 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
|||||||
hasMore := false
|
hasMore := false
|
||||||
stopReason := "source_complete"
|
stopReason := "source_complete"
|
||||||
truncatedByPageLimit := false
|
truncatedByPageLimit := false
|
||||||
|
truncatedByResultLimit := false
|
||||||
var nextCursor any
|
var nextCursor any
|
||||||
|
|
||||||
for pagesFetched < pageLimit {
|
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" {
|
if cursorKey != "0" {
|
||||||
params["cursor"] = cursorValue
|
params["cursor"] = cursorValue
|
||||||
}
|
}
|
||||||
@@ -978,6 +992,7 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
|||||||
}
|
}
|
||||||
pagesFetched++
|
pagesFetched++
|
||||||
pageGroups := chatListAllProject(data)
|
pageGroups := chatListAllProject(data)
|
||||||
|
overflowOnPage := false
|
||||||
for _, group := range pageGroups {
|
for _, group := range pageGroups {
|
||||||
id := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
|
id := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
|
||||||
if id == "<nil>" {
|
if id == "<nil>" {
|
||||||
@@ -989,6 +1004,11 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
|||||||
if id != "" {
|
if id != "" {
|
||||||
seenGroups[id] = true
|
seenGroups[id] = true
|
||||||
}
|
}
|
||||||
|
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems {
|
||||||
|
truncatedByResultLimit = true
|
||||||
|
overflowOnPage = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
allGroups = append(allGroups, group)
|
allGroups = append(allGroups, group)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,6 +1023,16 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
hasMore = pageHasMore
|
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 {
|
if !hasMore {
|
||||||
complete = true
|
complete = true
|
||||||
nextCursor = nil
|
nextCursor = nil
|
||||||
@@ -1022,8 +1052,13 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
|||||||
seenCursors[nextKey] = true
|
seenCursors[nextKey] = true
|
||||||
cursorKey = nextKey
|
cursorKey = nextKey
|
||||||
cursorValue = nextCursor
|
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
|
truncatedByPageLimit = true
|
||||||
stopReason = "page_limit"
|
stopReason = "page_limit"
|
||||||
}
|
}
|
||||||
@@ -1033,9 +1068,11 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
|||||||
"pagesFetched": pagesFetched, "paginationKnown": true,
|
"pagesFetched": pagesFetched, "paginationKnown": true,
|
||||||
"complete": complete && len(failures) == 0, "hasMore": hasMore,
|
"complete": complete && len(failures) == 0, "hasMore": hasMore,
|
||||||
"stopReason": stopReason, "truncatedByPageLimit": truncatedByPageLimit,
|
"stopReason": stopReason, "truncatedByPageLimit": truncatedByPageLimit,
|
||||||
"failedCount": len(failures), "failures": failures,
|
"truncatedByResultLimit": truncatedByResultLimit,
|
||||||
|
"failedCount": len(failures), "failures": failures,
|
||||||
"partial": len(failures) > 0 && len(allGroups) > 0,
|
"partial": len(failures) > 0 && len(allGroups) > 0,
|
||||||
}
|
}
|
||||||
|
chatmsg.ApplyTruncation(payload)
|
||||||
if hasMore && nextCursor != nil {
|
if hasMore && nextCursor != nil {
|
||||||
payload["nextCursor"] = nextCursor
|
payload["nextCursor"] = nextCursor
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -634,6 +634,7 @@ func readAllDirectMessages(rt *shortcut.RuntimeContext, params map[string]any) (
|
|||||||
payload["hasMore"] = hasMore
|
payload["hasMore"] = hasMore
|
||||||
payload["stopReason"] = stopReason
|
payload["stopReason"] = stopReason
|
||||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||||
|
chatmsg.ApplyTruncation(payload)
|
||||||
payload["failedCount"] = len(failures)
|
payload["failedCount"] = len(failures)
|
||||||
payload["failures"] = failures
|
payload["failures"] = failures
|
||||||
payload["partial"] = len(failures) > 0 && len(messages) > 0
|
payload["partial"] = len(failures) > 0 && len(messages) > 0
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package chat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -73,11 +74,115 @@ func TestCrossPlatformCoverageFlagListPageTokenAndPageLimit(t *testing.T) {
|
|||||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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) {
|
func TestCrossPlatformCoverageFlagListFailureModes(t *testing.T) {
|
||||||
t.Run("later read failure keeps partial result", func(t *testing.T) {
|
t.Run("later read failure keeps partial result", func(t *testing.T) {
|
||||||
fake := &larkAlignmentCaller{
|
fake := &larkAlignmentCaller{
|
||||||
@@ -137,7 +242,11 @@ func TestCrossPlatformCoverageFlagListPaginationValidation(t *testing.T) {
|
|||||||
{"--size", "31"},
|
{"--size", "31"},
|
||||||
{"--page-token", "not-a-number"},
|
{"--page-token", "not-a-number"},
|
||||||
{"--page-limit", "2"},
|
{"--page-limit", "2"},
|
||||||
|
{"--max-items", "1"},
|
||||||
|
{"--page-delay", "1"},
|
||||||
{"--page-all", "--page-limit", "0"},
|
{"--page-all", "--page-limit", "0"},
|
||||||
|
{"--page-all", "--max-items", "-1"},
|
||||||
|
{"--page-all", "--page-delay", "-1"},
|
||||||
{"--page-all", "--page-limit", "501"},
|
{"--page-all", "--page-limit", "501"},
|
||||||
{"--cursor", "1", "--page-token", "2"},
|
{"--cursor", "1", "--page-token", "2"},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -510,21 +510,21 @@ var FlagList = shortcut.Shortcut{
|
|||||||
Description: "分页查询当前用户收藏的消息,支持有界自动翻页",
|
Description: "分页查询当前用户收藏的消息,支持有界自动翻页",
|
||||||
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;默认读取一页,明确要求全部收藏时加 --page-all,并用 --page-limit 保持有界。底层实际使用数字 cursor,结果按 openMessageId 去重并公开 complete、hasMore、nextCursor、stopReason 和 failures;它不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
|
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,
|
Risk: shortcut.RiskRead,
|
||||||
Flags: []shortcut.Flag{
|
Flags: append([]shortcut.Flag{
|
||||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;下游真实上限为 30,显式页大小必须在 1-30 之间"},
|
{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: "size", Type: shortcut.FlagInt, Default: "20", Desc: "--page-size 的兼容别名;下游真实上限为 30,显式页大小必须在 1-30 之间"},
|
||||||
{Name: "page-token", Type: shortcut.FlagString, Desc: "Lark 对齐的起始分页参数;起始 cursor 必须是非负整数"},
|
{Name: "page-token", Type: shortcut.FlagString, Desc: "Lark 对齐的起始分页参数;起始 cursor 必须是非负整数"},
|
||||||
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "钉钉数字分页游标;起始 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"},
|
{Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||||
},
|
}, shortcut.AutoPageControlFlags()...),
|
||||||
Constraints: []shortcut.Constraint{
|
Constraints: append([]shortcut.Constraint{
|
||||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-size", "size"}},
|
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-size", "size"}},
|
||||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-token", "cursor"}},
|
{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-size", "size"}, Description: "显式页大小必须在 1-30 之间"},
|
||||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-token", "cursor"}, Description: "起始 cursor 必须是非负整数"},
|
{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"},
|
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||||
},
|
}, shortcut.AutoPageControlConstraints()...),
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
`dws chat +flag-list --cursor 0 --page-size 20`,
|
`dws chat +flag-list --cursor 0 --page-size 20`,
|
||||||
`dws chat +flag-list --page-size 30 --page-all --page-limit 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 之间")
|
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||||
|
return apperrors.NewValidation(err.Error())
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,9 +601,20 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
|||||||
var cursorErr error
|
var cursorErr error
|
||||||
stopReason := "source_complete"
|
stopReason := "source_complete"
|
||||||
truncatedByPageLimit := false
|
truncatedByPageLimit := false
|
||||||
|
truncatedByResultLimit := false
|
||||||
|
|
||||||
for pagesFetched < pageLimit {
|
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 callErr != nil {
|
||||||
if pagesFetched == 0 {
|
if pagesFetched == 0 {
|
||||||
return callErr
|
return callErr
|
||||||
@@ -613,6 +627,7 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
|||||||
}
|
}
|
||||||
pagesFetched++
|
pagesFetched++
|
||||||
pageItems := flagListItems(data)
|
pageItems := flagListItems(data)
|
||||||
|
overflowOnPage := false
|
||||||
for _, item := range pageItems {
|
for _, item := range pageItems {
|
||||||
messageID := firstNonEmptyMapString(item, "openMessageId", "messageId", "itemId", "id")
|
messageID := firstNonEmptyMapString(item, "openMessageId", "messageId", "itemId", "id")
|
||||||
if messageID != "" && seenMessages[messageID] {
|
if messageID != "" && seenMessages[messageID] {
|
||||||
@@ -621,6 +636,11 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
|||||||
if messageID != "" {
|
if messageID != "" {
|
||||||
seenMessages[messageID] = true
|
seenMessages[messageID] = true
|
||||||
}
|
}
|
||||||
|
if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems {
|
||||||
|
truncatedByResultLimit = true
|
||||||
|
overflowOnPage = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,7 +651,7 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
|||||||
switch {
|
switch {
|
||||||
case nextCursor > 0:
|
case nextCursor > 0:
|
||||||
pageHasMore = true
|
pageHasMore = true
|
||||||
case len(pageItems) < pageSize:
|
case len(pageItems) < requestPageSize:
|
||||||
paginationKnown = false
|
paginationKnown = false
|
||||||
complete = true
|
complete = true
|
||||||
hasMore = false
|
hasMore = false
|
||||||
@@ -649,6 +669,16 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasMore = pageHasMore
|
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 {
|
if !hasMore {
|
||||||
complete = true
|
complete = true
|
||||||
nextCursor = 0
|
nextCursor = 0
|
||||||
@@ -669,28 +699,35 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
|||||||
}
|
}
|
||||||
seenCursors[nextCursor] = true
|
seenCursors[nextCursor] = true
|
||||||
cursor = nextCursor
|
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")
|
truncatedByPageLimit = rt.Bool("page-all")
|
||||||
if truncatedByPageLimit {
|
if truncatedByPageLimit {
|
||||||
stopReason = "page_limit"
|
stopReason = "page_limit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"count": len(items),
|
"count": len(items),
|
||||||
"items": items,
|
"items": items,
|
||||||
"pagesFetched": pagesFetched,
|
"pagesFetched": pagesFetched,
|
||||||
"paginationKnown": paginationKnown,
|
"paginationKnown": paginationKnown,
|
||||||
"complete": complete && len(failures) == 0,
|
"complete": complete && len(failures) == 0,
|
||||||
"hasMore": hasMore,
|
"hasMore": hasMore,
|
||||||
"nextCursor": nextCursor,
|
"nextCursor": nextCursor,
|
||||||
"stopReason": stopReason,
|
"stopReason": stopReason,
|
||||||
"truncatedByPageLimit": truncatedByPageLimit,
|
"truncatedByPageLimit": truncatedByPageLimit,
|
||||||
"failedCount": len(failures),
|
"truncatedByResultLimit": truncatedByResultLimit,
|
||||||
"failures": failures,
|
"failedCount": len(failures),
|
||||||
"partial": len(failures) > 0 && len(items) > 0,
|
"failures": failures,
|
||||||
|
"partial": len(failures) > 0 && len(items) > 0,
|
||||||
}
|
}
|
||||||
|
chatmsg.ApplyTruncation(payload)
|
||||||
if outputErr := rt.Output(payload); outputErr != nil {
|
if outputErr := rt.Output(payload); outputErr != nil {
|
||||||
return outputErr
|
return outputErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ package chat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
"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) {
|
func TestCrossPlatformCoverageChatListAllPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
|
||||||
@@ -95,11 +97,86 @@ func TestCrossPlatformCoverageDirectMessagesPageLimitPublishesExecutableContinua
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if payload["complete"] != false || payload["hasMore"] != true ||
|
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)
|
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) {
|
func TestCrossPlatformCoverageDirectMessagesPageAllFailsClosedWithoutMillisecondCursor(t *testing.T) {
|
||||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||||
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true}}`,
|
"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)
|
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) {
|
func TestCrossPlatformCoverageChatListAllAdditionalEdges(t *testing.T) {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ var messageResultContractV1 = MessageResultContract{
|
|||||||
"hasMore",
|
"hasMore",
|
||||||
"nextPage",
|
"nextPage",
|
||||||
"stopReason",
|
"stopReason",
|
||||||
|
"truncated",
|
||||||
"truncatedByPageLimit",
|
"truncatedByPageLimit",
|
||||||
"truncatedByResultLimit",
|
"truncatedByResultLimit",
|
||||||
"failedCount",
|
"failedCount",
|
||||||
@@ -122,9 +123,21 @@ func NewMessageListPayload(messages []map[string]any) map[string]any {
|
|||||||
"failedCount": 0,
|
"failedCount": 0,
|
||||||
"failures": []map[string]any{},
|
"failures": []map[string]any{},
|
||||||
"partial": false,
|
"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.
|
// ListMessageItems returns message rows from the common list response envelopes.
|
||||||
func ListMessageItems(data map[string]any) []map[string]any {
|
func ListMessageItems(data map[string]any) []map[string]any {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ func TestCrossPlatformCoverageReactionsNormalizesEmotionReplyList(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
|
func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
|
||||||
|
ApplyTruncation(nil)
|
||||||
payload := map[string]any{"count": 98}
|
payload := map[string]any{"count": 98}
|
||||||
ApplyPagination(payload, map[string]any{
|
ApplyPagination(payload, map[string]any{
|
||||||
"result": map[string]any{
|
"result": map[string]any{
|
||||||
|
|||||||
@@ -486,9 +486,23 @@ var Upload = shortcut.Shortcut{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if remoteName := firstString(verified, "name", "fileName"); remoteName == "" || !strings.HasPrefix(remoteName, strings.TrimSuffix(name, filepath.Ext(name))) {
|
remoteID := firstString(verified, "fileId", "dentryUuid", "nodeId", "id")
|
||||||
|
if remoteID == "" {
|
||||||
|
return driveResponseError("drive/commit_upload", "readback_missing_id", "上传后读回缺少文件 ID;无法证明读回的是已提交文件")
|
||||||
|
}
|
||||||
|
if remoteID != nodeID {
|
||||||
|
return driveResponseError("drive/commit_upload", "readback_id_mismatch", fmt.Sprintf("上传后读回文件 ID %q 与提交 ID %q 不一致", remoteID, nodeID))
|
||||||
|
}
|
||||||
|
if remoteName := firstString(verified, "name", "fileName"); !driveReadbackNameMatches(verified, name) {
|
||||||
return driveResponseError("drive/commit_upload", "readback_mismatch", fmt.Sprintf("上传后读回名称 %q 与请求 %q 不一致", remoteName, name))
|
return driveResponseError("drive/commit_upload", "readback_mismatch", fmt.Sprintf("上传后读回名称 %q 与请求 %q 不一致", remoteName, name))
|
||||||
}
|
}
|
||||||
|
remoteSize, ok := firstInt64(verified, "fileSize", "size", "byteSize", "length")
|
||||||
|
if !ok {
|
||||||
|
return driveResponseError("drive/commit_upload", "readback_missing_size", "上传后读回缺少有效文件大小;无法证明远端文件完整")
|
||||||
|
}
|
||||||
|
if remoteSize != info.Size() {
|
||||||
|
return driveResponseError("drive/commit_upload", "readback_size_mismatch", fmt.Sprintf("上传后读回大小 %d 与本地文件大小 %d 不一致", remoteSize, info.Size()))
|
||||||
|
}
|
||||||
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "sizeBytes": info.Size(), "file": verified})
|
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "sizeBytes": info.Size(), "file": verified})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ package drive
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||||
@@ -216,6 +218,47 @@ func nestedString(data map[string]any, keys ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func driveReadbackNameMatches(data map[string]any, requested string) bool {
|
||||||
|
remoteName := firstString(data, "name", "fileName")
|
||||||
|
if remoteName == requested {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
extension := strings.TrimLeft(firstString(data, "extension", "fileExtension", "ext"), ".")
|
||||||
|
return extension != "" && remoteName+"."+extension == requested
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstInt64(data map[string]any, keys ...string) (int64, bool) {
|
||||||
|
for _, key := range keys {
|
||||||
|
value, present := data[key]
|
||||||
|
if !present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case int:
|
||||||
|
return int64(typed), true
|
||||||
|
case int32:
|
||||||
|
return int64(typed), true
|
||||||
|
case int64:
|
||||||
|
return typed, true
|
||||||
|
case float64:
|
||||||
|
if !math.IsNaN(typed) && !math.IsInf(typed, 0) && typed == math.Trunc(typed) && typed >= math.MinInt64 && typed < math.MaxInt64 {
|
||||||
|
return int64(typed), true
|
||||||
|
}
|
||||||
|
case json.Number:
|
||||||
|
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
func driveResponseError(operation, reason, message string) error {
|
func driveResponseError(operation, reason, message string) error {
|
||||||
return apperrors.NewAPI(message,
|
return apperrors.NewAPI(message,
|
||||||
apperrors.WithOperation(operation),
|
apperrors.WithOperation(operation),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
package drive
|
package drive
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||||
@@ -546,6 +547,31 @@ var Move = shortcut.Shortcut{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
remoteID := firstString(verified, "nodeId", "fileId", "dentryUuid", "id")
|
||||||
|
if remoteID == "" {
|
||||||
|
return driveResponseError("doc/move_document", "readback_missing_id", "移动后读回缺少节点 ID;无法证明读回的是已移动节点")
|
||||||
|
}
|
||||||
|
if remoteID != rt.Str("node") {
|
||||||
|
return driveResponseError("doc/move_document", "readback_id_mismatch", fmt.Sprintf("移动后读回节点 %q 与请求节点 %q 不一致", remoteID, rt.Str("node")))
|
||||||
|
}
|
||||||
|
if rt.Changed("folder") {
|
||||||
|
remoteFolder := firstString(verified, "folderId", "targetFolderId", "parentId")
|
||||||
|
if remoteFolder == "" {
|
||||||
|
return driveResponseError("doc/move_document", "readback_missing_folder", "移动后读回缺少目标文件夹 ID;无法证明移动已到达请求位置")
|
||||||
|
}
|
||||||
|
if remoteFolder != rt.Str("folder") {
|
||||||
|
return driveResponseError("doc/move_document", "readback_folder_mismatch", fmt.Sprintf("移动后读回文件夹 %q 与请求 %q 不一致", remoteFolder, rt.Str("folder")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rt.Changed("workspace") {
|
||||||
|
remoteWorkspace := firstString(verified, "workspaceId", "spaceId")
|
||||||
|
if remoteWorkspace == "" {
|
||||||
|
return driveResponseError("doc/move_document", "readback_missing_workspace", "移动后读回缺少目标知识库 ID;无法证明移动已到达请求位置")
|
||||||
|
}
|
||||||
|
if remoteWorkspace != rt.Str("workspace") {
|
||||||
|
return driveResponseError("doc/move_document", "readback_workspace_mismatch", fmt.Sprintf("移动后读回知识库 %q 与请求 %q 不一致", remoteWorkspace, rt.Str("workspace")))
|
||||||
|
}
|
||||||
|
}
|
||||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified})
|
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -176,6 +177,40 @@ func TestCrossPlatformCoverageDriveDownloadAndUploadRequireArtifactsAndReadback(
|
|||||||
if _, _, err := resolveDriveUploadInput("../escape.bin"); err == nil {
|
if _, _, err := resolveDriveUploadInput("../escape.bin"); err == nil {
|
||||||
t.Fatal("upload path escape was accepted")
|
t.Fatal("upload path escape was accepted")
|
||||||
}
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
committedID string
|
||||||
|
readback string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"missing remote id", "uploaded-2", `{"success":true,"result":{"name":"input.bin","fileSize":18}}`, "缺少文件 ID"},
|
||||||
|
{"mismatched remote id", "uploaded-3", `{"success":true,"result":{"fileId":"other","name":"input.bin","fileSize":18}}`, "与提交 ID"},
|
||||||
|
{"prefix-only remote name", "uploaded-4", `{"success":true,"result":{"fileId":"uploaded-4","name":"input.bin-old","fileSize":18}}`, "读回名称"},
|
||||||
|
{"missing remote size", "uploaded-5", `{"success":true,"result":{"fileId":"uploaded-5","name":"input.bin"}}`, "缺少有效文件大小"},
|
||||||
|
{"mismatched remote size", "uploaded-6", `{"success":true,"result":{"fileId":"uploaded-6","name":"input.bin","fileSize":17}}`, "与本地文件大小 18 不一致"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
testseam.Swap(t, &uploadDriveFile, func(context.Context, helpers.DriveUploadRequest) (map[string]any, error) {
|
||||||
|
return map[string]any{"success": true, "result": map[string]any{"fileId": tc.committedID}}, nil
|
||||||
|
})
|
||||||
|
caller := &driveCoverageCaller{responses: map[string][]string{"get_file_info": {tc.readback}}}
|
||||||
|
err := runDriveCoverage(t, Upload, caller, "--file", "input.bin", "--yes")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatalf("error = %v, want %q", err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("split remote extension", func(t *testing.T) {
|
||||||
|
testseam.Swap(t, &uploadDriveFile, func(context.Context, helpers.DriveUploadRequest) (map[string]any, error) {
|
||||||
|
return map[string]any{"success": true, "result": map[string]any{"fileId": "uploaded-7"}}, nil
|
||||||
|
})
|
||||||
|
caller := &driveCoverageCaller{responses: map[string][]string{
|
||||||
|
"get_file_info": {`{"success":true,"result":{"fileId":"uploaded-7","name":"input","extension":"bin","fileSize":18}}`},
|
||||||
|
}}
|
||||||
|
if err := runDriveCoverage(t, Upload, caller, "--file", "input.bin", "--yes"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
testseam.Swap(t, &driveDownload, func(_ context.Context, _ string, options localio.DownloadOptions) (localio.DownloadResult, error) {
|
testseam.Swap(t, &driveDownload, func(_ context.Context, _ string, options localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||||
if options.Output != "downloads/file.bin" || options.Headers["x-token"] != "secret" {
|
if options.Output != "downloads/file.bin" || options.Headers["x-token"] != "secret" {
|
||||||
@@ -218,6 +253,42 @@ func TestCrossPlatformCoverageDriveCopyPreservesSchemaProperties(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageDriveFirstInt64(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
want int64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"int", int(1), 1, true},
|
||||||
|
{"int32", int32(2), 2, true},
|
||||||
|
{"int64", int64(3), 3, true},
|
||||||
|
{"float", float64(4), 4, true},
|
||||||
|
{"json number", json.Number("5"), 5, true},
|
||||||
|
{"string", " 6 ", 6, true},
|
||||||
|
{"fraction", 1.5, 0, false},
|
||||||
|
{"nan", math.NaN(), 0, false},
|
||||||
|
{"infinity", math.Inf(1), 0, false},
|
||||||
|
{"overflow", float64(math.MaxInt64), 0, false},
|
||||||
|
{"bad json number", json.Number("bad"), 0, false},
|
||||||
|
{"bad string", "bad", 0, false},
|
||||||
|
{"unsupported", true, 0, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, ok := firstInt64(map[string]any{"size": tc.value}, "missing", "size")
|
||||||
|
if ok != tc.ok || got != tc.want {
|
||||||
|
t.Fatalf("firstInt64(%#v) = (%d, %t), want (%d, %t)", tc.value, got, ok, tc.want, tc.ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if got, ok := firstInt64(map[string]any{}, "size"); ok || got != 0 {
|
||||||
|
t.Fatalf("missing firstInt64 = (%d, %t), want (0, false)", got, ok)
|
||||||
|
}
|
||||||
|
if got, ok := firstInt64(map[string]any{"fileSize": nil, "size": "7"}, "fileSize", "size"); !ok || got != 7 {
|
||||||
|
t.Fatalf("fallback firstInt64 = (%d, %t), want (7, true)", got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageDriveVersionAndPublishContracts(t *testing.T) {
|
func TestCrossPlatformCoverageDriveVersionAndPublishContracts(t *testing.T) {
|
||||||
versionPayload := `{"success":true,"versions":[{"version":1,"fileSize":3},{"versionNumber":"2","fileSize":4}],"hasMore":false}`
|
versionPayload := `{"success":true,"versions":[{"version":1,"fileSize":3},{"versionNumber":"2","fileSize":4}],"hasMore":false}`
|
||||||
caller := &driveCoverageCaller{responses: map[string][]string{"list_file_versions": {versionPayload}}}
|
caller := &driveCoverageCaller{responses: map[string][]string{"list_file_versions": {versionPayload}}}
|
||||||
@@ -449,11 +520,34 @@ func TestCrossPlatformCoverageDriveCreateRestoreCopyMoveRename(t *testing.T) {
|
|||||||
|
|
||||||
move := &driveCoverageCaller{responses: map[string][]string{
|
move := &driveCoverageCaller{responses: map[string][]string{
|
||||||
"move_document": {`{"success":true}`},
|
"move_document": {`{"success":true}`},
|
||||||
"get_document_info": {`{"success":true,"result":{"nodeId":"n1"}}`},
|
"get_document_info": {`{"success":true,"result":{"nodeId":"n1","folderId":"target","workspaceId":"space"}}`},
|
||||||
}}
|
}}
|
||||||
if err := runDriveCoverage(t, Move, move, "--node", "n1", "--folder", "target", "--workspace", "space", "--yes"); err != nil {
|
if err := runDriveCoverage(t, Move, move, "--node", "n1", "--folder", "target", "--workspace", "space", "--yes"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
readback string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"missing node id", `{"success":true,"result":{"folderId":"target","workspaceId":"space"}}`, "缺少节点 ID"},
|
||||||
|
{"wrong node id", `{"success":true,"result":{"nodeId":"other","folderId":"target","workspaceId":"space"}}`, "与请求节点 \"n1\" 不一致"},
|
||||||
|
{"missing folder", `{"success":true,"result":{"nodeId":"n1","workspaceId":"space"}}`, "缺少目标文件夹 ID"},
|
||||||
|
{"wrong folder", `{"success":true,"result":{"nodeId":"n1","folderId":"other","workspaceId":"space"}}`, "与请求 \"target\" 不一致"},
|
||||||
|
{"missing workspace", `{"success":true,"result":{"nodeId":"n1","folderId":"target"}}`, "缺少目标知识库 ID"},
|
||||||
|
{"wrong workspace", `{"success":true,"result":{"nodeId":"n1","folderId":"target","workspaceId":"other"}}`, "与请求 \"space\" 不一致"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
caller := &driveCoverageCaller{responses: map[string][]string{
|
||||||
|
"move_document": {`{"success":true}`},
|
||||||
|
"get_document_info": {tc.readback},
|
||||||
|
}}
|
||||||
|
err := runDriveCoverage(t, Move, caller, "--node", "n1", "--folder", "target", "--workspace", "space", "--yes")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatalf("error = %v, want %q", err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
rename := &driveCoverageCaller{responses: map[string][]string{
|
rename := &driveCoverageCaller{responses: map[string][]string{
|
||||||
"get_file_info": {`{"success":true,"result":{"fileId":"n1","type":"FILE","extension":"md","name":"old.md"}}`, `{"success":true,"result":{"fileId":"n1","name":"new.md"}}`},
|
"get_file_info": {`{"success":true,"result":{"fileId":"n1","type":"FILE","extension":"md","name":"old.md"}}`, `{"success":true,"result":{"fileId":"n1","name":"new.md"}}`},
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxAutoPageDelayMS = int64(math.MaxInt64) / int64(time.Millisecond)
|
||||||
|
|
||||||
|
// AutoPageControlFlags returns the shared item and pacing controls used by
|
||||||
|
// cursor-based shortcuts. Callers retain ownership of --page-all and their
|
||||||
|
// product-specific --page-limit defaults.
|
||||||
|
func AutoPageControlFlags() []Flag {
|
||||||
|
const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"
|
||||||
|
return []Flag{
|
||||||
|
{Name: "max-items", Type: FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制)。" + evidence},
|
||||||
|
{Name: "page-delay", Type: FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待)。" + evidence},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoPageControlConstraints publishes the runtime-only relationship between
|
||||||
|
// the pagination switch and its shared controls into Help and Schema.
|
||||||
|
func AutoPageControlConstraints() []Constraint {
|
||||||
|
return []Constraint{{
|
||||||
|
Kind: ConstraintCustom,
|
||||||
|
Flags: []string{"page-all", "max-items", "page-delay"},
|
||||||
|
Description: "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAutoPageControls enforces the shared contract while preserving the
|
||||||
|
// historical behavior that defaulted controls do not activate pagination.
|
||||||
|
func ValidateAutoPageControls(rt *RuntimeContext) error {
|
||||||
|
if !rt.Bool("page-all") {
|
||||||
|
if rt.Changed("max-items") || rt.Changed("page-delay") {
|
||||||
|
return fmt.Errorf("--max-items/--page-delay 仅与 --page-all 一起使用")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rt.Int("max-items") < 0 {
|
||||||
|
return fmt.Errorf("--max-items 必须大于等于 0")
|
||||||
|
}
|
||||||
|
delayMS := rt.Int("page-delay")
|
||||||
|
if delayMS < 0 {
|
||||||
|
return fmt.Errorf("--page-delay 必须大于等于 0")
|
||||||
|
}
|
||||||
|
if int64(delayMS) > maxAutoPageDelayMS {
|
||||||
|
return fmt.Errorf("--page-delay 不能大于 %d 毫秒", maxAutoPageDelayMS)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoPageRequestSize caps the next lower-page request to the remaining item
|
||||||
|
// budget. A cursor returned for that request is therefore safe to resume from:
|
||||||
|
// the CLI did not intentionally discard a suffix of the lower page.
|
||||||
|
func AutoPageRequestSize(rt *RuntimeContext, pageSize, itemCount int) int {
|
||||||
|
maxItems := rt.Int("max-items")
|
||||||
|
if maxItems <= 0 {
|
||||||
|
return pageSize
|
||||||
|
}
|
||||||
|
remaining := maxItems - itemCount
|
||||||
|
if remaining > 0 && remaining < pageSize {
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
return pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitAutoPageDelay waits between successful pages and remains cancellable so
|
||||||
|
// a throttled pagination run cannot ignore command cancellation.
|
||||||
|
func WaitAutoPageDelay(rt *RuntimeContext) error {
|
||||||
|
delayMS := rt.Int("page-delay")
|
||||||
|
if delayMS <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(time.Duration(int64(delayMS)) * time.Millisecond)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-rt.Command().Context().Done():
|
||||||
|
return rt.Command().Context().Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func autoPageRuntimeForTest(t *testing.T, pageAll bool, maxItems, pageDelay string) *RuntimeContext {
|
||||||
|
t.Helper()
|
||||||
|
cmd := &cobra.Command{Use: "page"}
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
cmd.Flags().Bool("page-all", false, "")
|
||||||
|
cmd.Flags().Int("max-items", 0, "")
|
||||||
|
cmd.Flags().Int("page-delay", 0, "")
|
||||||
|
if pageAll {
|
||||||
|
if err := cmd.Flags().Set("page-all", "true"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, value := range map[string]string{"max-items": maxItems, "page-delay": pageDelay} {
|
||||||
|
if value != "" {
|
||||||
|
if err := cmd.Flags().Set(name, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RuntimeContextForTest(cmd, Shortcut{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageAutoPageControlsBoundDelayAndRequestSize(t *testing.T) {
|
||||||
|
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, false, "1", "")); err == nil {
|
||||||
|
t.Fatal("max-items without page-all unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "-1", "")); err == nil {
|
||||||
|
t.Fatal("negative max-items unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", "-1")); err == nil {
|
||||||
|
t.Fatal("negative page-delay unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", strconv.FormatInt(maxAutoPageDelayMS, 10))); err != nil {
|
||||||
|
t.Fatalf("maximum safe page-delay failed: %v", err)
|
||||||
|
}
|
||||||
|
if strconv.IntSize == 64 {
|
||||||
|
tooLarge := strconv.FormatInt(maxAutoPageDelayMS+1, 10)
|
||||||
|
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", tooLarge)); err == nil {
|
||||||
|
t.Fatal("overflowing page-delay unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "0", ""), 100, 40); got != 100 {
|
||||||
|
t.Fatalf("unlimited request size = %d", got)
|
||||||
|
}
|
||||||
|
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "50", ""), 100, 40); got != 10 {
|
||||||
|
t.Fatalf("remaining request size = %d", got)
|
||||||
|
}
|
||||||
|
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "50", ""), 100, 50); got != 100 {
|
||||||
|
t.Fatalf("exhausted request size = %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageWaitAutoPageDelayNoopAndCancellation(t *testing.T) {
|
||||||
|
if err := WaitAutoPageDelay(autoPageRuntimeForTest(t, true, "", "0")); err != nil {
|
||||||
|
t.Fatalf("zero delay = %v", err)
|
||||||
|
}
|
||||||
|
if err := WaitAutoPageDelay(autoPageRuntimeForTest(t, true, "", "1")); err != nil {
|
||||||
|
t.Fatalf("elapsed delay = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
rt := autoPageRuntimeForTest(t, true, "", "1")
|
||||||
|
rt.Command().SetContext(ctx)
|
||||||
|
if err := WaitAutoPageDelay(rt); err != context.Canceled {
|
||||||
|
t.Fatalf("canceled delay = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,23 +93,23 @@ var AtMe = shortcut.Shortcut{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Flags: append([]shortcut.Flag{
|
Flags: append(append([]shortcut.Flag{
|
||||||
{Name: "group", Type: shortcut.FlagString, Desc: "仅查看指定群;可传 openConversationId 或群名"},
|
{Name: "group", Type: shortcut.FlagString, Desc: "仅查看指定群;可传 openConversationId 或群名"},
|
||||||
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
|
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
|
||||||
{Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", 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: "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: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50);--limit 必须大于 0", Default: "50"},
|
||||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
|
{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: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||||||
}, chatshortcut.MessageResourceDownloadFlags()...),
|
}, shortcut.AutoPageControlFlags()...), chatshortcut.MessageResourceDownloadFlags()...),
|
||||||
Constraints: append([]shortcut.Constraint{
|
Constraints: append(append([]shortcut.Constraint{
|
||||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
|
{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{"days"}, Description: "--days 必须在 1-3650 之间"},
|
||||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"},
|
{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"},
|
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
}, shortcut.AutoPageControlConstraints()...), chatshortcut.MessageResourceDownloadConstraints()...),
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
`dws chat +at-me`,
|
`dws chat +at-me`,
|
||||||
`dws chat +at-me --days 3`,
|
`dws chat +at-me --days 3`,
|
||||||
@@ -211,6 +211,9 @@ func validateAtMe(rt *shortcut.RuntimeContext) error {
|
|||||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||||
|
return apperrors.NewValidation(err.Error())
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,12 +243,24 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
|||||||
nextCursor := ""
|
nextCursor := ""
|
||||||
stopReason := "source_complete"
|
stopReason := "source_complete"
|
||||||
truncatedByPageLimit := false
|
truncatedByPageLimit := false
|
||||||
|
truncatedByResultLimit := false
|
||||||
|
|
||||||
for pagesFetched < pageLimit {
|
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))
|
params := make(map[string]any, len(baseParams))
|
||||||
for key, value := range baseParams {
|
for key, value := range baseParams {
|
||||||
params[key] = value
|
params[key] = value
|
||||||
}
|
}
|
||||||
|
pageSize, _ := baseParams["limit"].(int)
|
||||||
|
params["limit"] = shortcut.AutoPageRequestSize(rt, pageSize, len(allItems))
|
||||||
params["cursor"] = cursor
|
params["cursor"] = cursor
|
||||||
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
|
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -260,6 +275,7 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
|||||||
}
|
}
|
||||||
pagesFetched++
|
pagesFetched++
|
||||||
pageItems := atMeMessageItems(data)
|
pageItems := atMeMessageItems(data)
|
||||||
|
overflowOnPage := false
|
||||||
for _, item := range pageItems {
|
for _, item := range pageItems {
|
||||||
id := chatmsg.StableMessageID(item)
|
id := chatmsg.StableMessageID(item)
|
||||||
if id != "" && seenMessages[id] {
|
if id != "" && seenMessages[id] {
|
||||||
@@ -268,6 +284,11 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
|||||||
if id != "" {
|
if id != "" {
|
||||||
seenMessages[id] = true
|
seenMessages[id] = true
|
||||||
}
|
}
|
||||||
|
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allItems) >= maxItems {
|
||||||
|
truncatedByResultLimit = true
|
||||||
|
overflowOnPage = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
allItems = append(allItems, item)
|
allItems = append(allItems, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +303,16 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
hasMore = pageHasMore
|
hasMore = pageHasMore
|
||||||
|
if overflowOnPage {
|
||||||
|
hasMore = true
|
||||||
|
nextCursor = ""
|
||||||
|
failures = append(failures, map[string]any{
|
||||||
|
"page": pagesFetched, "stage": "pagination",
|
||||||
|
"error": "@我消息下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
|
||||||
|
})
|
||||||
|
stopReason = "pagination_error"
|
||||||
|
break
|
||||||
|
}
|
||||||
if !hasMore {
|
if !hasMore {
|
||||||
complete = true
|
complete = true
|
||||||
nextCursor = ""
|
nextCursor = ""
|
||||||
@@ -299,8 +330,13 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
|||||||
}
|
}
|
||||||
seenCursors[nextCursor] = true
|
seenCursors[nextCursor] = true
|
||||||
cursor = nextCursor
|
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
|
truncatedByPageLimit = true
|
||||||
stopReason = "page_limit"
|
stopReason = "page_limit"
|
||||||
}
|
}
|
||||||
@@ -312,9 +348,11 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
|
|||||||
payload["hasMore"] = hasMore
|
payload["hasMore"] = hasMore
|
||||||
payload["stopReason"] = stopReason
|
payload["stopReason"] = stopReason
|
||||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||||
|
payload["truncatedByResultLimit"] = truncatedByResultLimit
|
||||||
payload["failedCount"] = len(failures)
|
payload["failedCount"] = len(failures)
|
||||||
payload["failures"] = failures
|
payload["failures"] = failures
|
||||||
payload["partial"] = len(failures) > 0 && len(allItems) > 0
|
payload["partial"] = len(failures) > 0 && len(allItems) > 0
|
||||||
|
chatmsg.ApplyTruncation(payload)
|
||||||
if hasMore && nextCursor != "" {
|
if hasMore && nextCursor != "" {
|
||||||
payload["nextCursor"] = nextCursor
|
payload["nextCursor"] = nextCursor
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ var ChatMessages = shortcut.Shortcut{
|
|||||||
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
|
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
|
||||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --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 使用工作目录内安全路径、默认不覆盖和原子落盘。",
|
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。",
|
||||||
Risk: shortcut.RiskRead,
|
Risk: shortcut.RiskRead,
|
||||||
Safety: contract.SafetySpec{
|
Safety: contract.SafetySpec{
|
||||||
@@ -86,7 +86,7 @@ var ChatMessages = shortcut.Shortcut{
|
|||||||
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
|
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
|
||||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --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 使用工作目录内安全路径、默认不覆盖和原子落盘。"},
|
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。"},
|
||||||
AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"},
|
AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"},
|
||||||
Examples: []string{
|
Examples: []string{
|
||||||
@@ -117,9 +117,11 @@ var ChatMessages = shortcut.Shortcut{
|
|||||||
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true},
|
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true},
|
||||||
{Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"},
|
{Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"},
|
||||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
{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: "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 文件"},
|
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "把完整结构化 ledger 原子写入工作目录内的相对 JSON 文件"},
|
||||||
}, chatshortcut.MessageResourceDownloadFlags()...),
|
}, chatshortcut.MessageResourceDownloadFlags()...),
|
||||||
Constraints: append([]shortcut.Constraint{
|
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{"order", "sort"}, Description: "asc 必须指定 --start/--start-time"},
|
||||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式页大小必须大于 0"},
|
{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", "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.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,
|
Kind: shortcut.ConstraintCustom,
|
||||||
Flags: []string{"output", "overwrite"},
|
Flags: []string{"output", "overwrite"},
|
||||||
@@ -179,16 +184,25 @@ func validateChatMessages(rt *shortcut.RuntimeContext) error {
|
|||||||
return localChatOptionError("invalid_page_size", "+chat-messages 的 --"+name+" 必须大于 0", "--"+name)
|
return localChatOptionError("invalid_page_size", "+chat-messages 的 --"+name+" 必须大于 0", "--"+name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !rt.Bool("page-all") && (rt.Changed("page-limit") || rt.Changed("max-results")) {
|
if !rt.Bool("page-all") && rt.Changed("page-limit") {
|
||||||
return apperrors.NewValidation("--page-limit/--max-results 仅与 --page-all 一起使用")
|
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 rt.Bool("page-all") {
|
||||||
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > chatMessagesHardPageLimit {
|
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > chatMessagesHardPageLimit {
|
||||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
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 rt.Changed("output") {
|
||||||
if err := chatshortcut.ValidateMessageExportOutput(rt.Str("output")); err != nil {
|
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) {
|
func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) {
|
||||||
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), chatMessagesDefaultPageLimit)
|
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), chatMessagesDefaultPageLimit)
|
||||||
maxResults := rt.Int("max-results")
|
maxResults := rt.IntFirst("max-items", "max-results")
|
||||||
basePageSize, _ := request.params["limit"].(int)
|
basePageSize, _ := request.params["limit"].(int)
|
||||||
if basePageSize <= 0 {
|
if basePageSize <= 0 {
|
||||||
basePageSize = chatMessagesAllPageSize
|
basePageSize = chatMessagesAllPageSize
|
||||||
@@ -496,6 +510,15 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
|||||||
var nextPage map[string]any
|
var nextPage map[string]any
|
||||||
|
|
||||||
for pagesFetched < pageLimit {
|
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
|
request.params["limit"] = basePageSize
|
||||||
if maxResults > 0 {
|
if maxResults > 0 {
|
||||||
remaining := maxResults - len(allItems)
|
remaining := maxResults - len(allItems)
|
||||||
@@ -656,6 +679,7 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
|||||||
payload["stopReason"] = stopReason
|
payload["stopReason"] = stopReason
|
||||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||||
payload["truncatedByResultLimit"] = truncatedByResultLimit
|
payload["truncatedByResultLimit"] = truncatedByResultLimit
|
||||||
|
chatmsg.ApplyTruncation(payload)
|
||||||
payload["failedCount"] = len(failures)
|
payload["failedCount"] = len(failures)
|
||||||
payload["failures"] = failures
|
payload["failures"] = failures
|
||||||
payload["partial"] = len(failures) > 0 && len(results) > 0
|
payload["partial"] = len(failures) > 0 && len(results) > 0
|
||||||
|
|||||||
@@ -503,7 +503,29 @@ func chatMessagesRuntimeForTest(t *testing.T, values map[string]string) *shortcu
|
|||||||
return shortcut.RuntimeContextForTest(cmd, ChatMessages)
|
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) {
|
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{
|
for _, values := range []map[string]string{
|
||||||
{"time": "2026-01-01", "start": "2026-01-01"},
|
{"time": "2026-01-01", "start": "2026-01-01"},
|
||||||
{"direction": "older", "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) {
|
t.Run("terminal result limit and unsafe continuation", func(t *testing.T) {
|
||||||
caller := &chatMessagesPagingCaller{responses: []string{
|
for _, flag := range []string{"max-items", "max-results"} {
|
||||||
`{"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"}]}}`,
|
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"}),
|
payload, _, err := collectAllChatMessages(
|
||||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
|
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["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
)
|
||||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
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"}]}}`,
|
`{"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"}),
|
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
|
||||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
|
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) {
|
t.Run("first failure ledger output error", func(t *testing.T) {
|
||||||
helpers.InitDeps(&chatMessagesPagingCaller{failAt: 1})
|
helpers.InitDeps(&chatMessagesPagingCaller{failAt: 1})
|
||||||
root := newPlatformCoverageRoot()
|
root := newPlatformCoverageRoot()
|
||||||
|
|||||||
@@ -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: "type", Type: shortcut.FlagString, Desc: "按群类型过滤(可选,如返回中的 groupType/conversationType,大小写不敏感)", Required: false},
|
||||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200);--limit 必须在 1-200 之间", Default: "200"},
|
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200);--limit 必须在 1-200 之间", Default: "200"},
|
||||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor"},
|
{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"},
|
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||||
},
|
}, shortcut.AutoPageControlFlags()...),
|
||||||
Constraints: []shortcut.Constraint{
|
Constraints: append([]shortcut.Constraint{
|
||||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
|
{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"},
|
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||||
},
|
}, shortcut.AutoPageControlConstraints()...),
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
`dws chat +my-groups`,
|
`dws chat +my-groups`,
|
||||||
`dws chat +my-groups --type group`,
|
`dws chat +my-groups --type group`,
|
||||||
@@ -116,6 +116,9 @@ func validateMyGroups(rt *shortcut.RuntimeContext) error {
|
|||||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||||
|
return apperrors.NewValidation(err.Error())
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,10 +183,22 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
|||||||
hasMore := false
|
hasMore := false
|
||||||
stopReason := "source_complete"
|
stopReason := "source_complete"
|
||||||
truncatedByPageLimit := false
|
truncatedByPageLimit := false
|
||||||
|
truncatedByResultLimit := false
|
||||||
|
eligibleCount := 0
|
||||||
var nextCursor any
|
var nextCursor any
|
||||||
|
|
||||||
for pagesFetched < pageLimit {
|
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" {
|
if cursorKey != "0" {
|
||||||
params["cursor"] = cursorValue
|
params["cursor"] = cursorValue
|
||||||
}
|
}
|
||||||
@@ -200,6 +215,7 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
|||||||
}
|
}
|
||||||
pagesFetched++
|
pagesFetched++
|
||||||
pageGroups := myGroupsExtract(data)
|
pageGroups := myGroupsExtract(data)
|
||||||
|
overflowOnPage := false
|
||||||
for _, group := range pageGroups {
|
for _, group := range pageGroups {
|
||||||
id := myGroupsStr(group, "openConversationId", "openConversationID", "conversationId", "openCid", "cid", "id")
|
id := myGroupsStr(group, "openConversationId", "openConversationID", "conversationId", "openCid", "cid", "id")
|
||||||
if id != "" && seenGroups[id] {
|
if id != "" && seenGroups[id] {
|
||||||
@@ -208,7 +224,15 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
|||||||
if id != "" {
|
if id != "" {
|
||||||
seenGroups[id] = true
|
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)
|
allGroups = append(allGroups, group)
|
||||||
|
if myGroupsMatchesFilter(rt, group) {
|
||||||
|
eligibleCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
page := chatmsg.Pagination(data)
|
page := chatmsg.Pagination(data)
|
||||||
@@ -222,6 +246,16 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
hasMore = pageHasMore
|
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 {
|
if !hasMore {
|
||||||
complete = true
|
complete = true
|
||||||
nextCursor = nil
|
nextCursor = nil
|
||||||
@@ -241,8 +275,13 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
|||||||
seenCursors[nextKey] = true
|
seenCursors[nextKey] = true
|
||||||
cursorKey = nextKey
|
cursorKey = nextKey
|
||||||
cursorValue = nextCursor
|
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
|
truncatedByPageLimit = true
|
||||||
stopReason = "page_limit"
|
stopReason = "page_limit"
|
||||||
}
|
}
|
||||||
@@ -254,9 +293,11 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
|
|||||||
payload["hasMore"] = hasMore
|
payload["hasMore"] = hasMore
|
||||||
payload["stopReason"] = stopReason
|
payload["stopReason"] = stopReason
|
||||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||||
|
payload["truncatedByResultLimit"] = truncatedByResultLimit
|
||||||
payload["failedCount"] = len(failures)
|
payload["failedCount"] = len(failures)
|
||||||
payload["failures"] = failures
|
payload["failures"] = failures
|
||||||
payload["partial"] = len(failures) > 0 && len(allGroups) > 0
|
payload["partial"] = len(failures) > 0 && len(allGroups) > 0
|
||||||
|
chatmsg.ApplyTruncation(payload)
|
||||||
if hasMore && nextCursor != nil {
|
if hasMore && nextCursor != nil {
|
||||||
payload["nextCursor"] = nextCursor
|
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 {
|
func myGroupsCursorString(value any) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package smart
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"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) {
|
func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMerge(t *testing.T) {
|
||||||
caller := &chatMessagesPagingCaller{responses: []string{
|
caller := &chatMessagesPagingCaller{responses: []string{
|
||||||
`{"result":{"groups":[{"openConversationId":"g1","title":"群一","groupType":"group"}],"hasMore":true,"nextCursor":88}}`,
|
`{"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) {
|
func TestCrossPlatformCoverageRemainingReadPaginationValidation(t *testing.T) {
|
||||||
for _, args := range [][]string{
|
for _, args := range [][]string{
|
||||||
{"chat", "+at-me", "--page-limit", "2"},
|
{"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", "--page-limit", "501"},
|
||||||
|
{"chat", "+at-me", "--page-all", "--max-items", "-1"},
|
||||||
{"chat", "+my-groups", "--limit", "201"},
|
{"chat", "+my-groups", "--limit", "201"},
|
||||||
{"chat", "+my-groups", "--page-limit", "2"},
|
{"chat", "+my-groups", "--page-limit", "2"},
|
||||||
|
{"chat", "+my-groups", "--max-items", "1"},
|
||||||
{"chat", "+my-groups", "--page-all", "--page-limit", "501"},
|
{"chat", "+my-groups", "--page-all", "--page-limit", "501"},
|
||||||
} {
|
} {
|
||||||
helpers.InitDeps(&chatMessagesPagingCaller{})
|
helpers.InitDeps(&chatMessagesPagingCaller{})
|
||||||
|
|||||||
@@ -463,6 +463,7 @@ func collectAllThreadReplies(rt *shortcut.RuntimeContext, params map[string]any)
|
|||||||
payload["hasMore"] = hasMore
|
payload["hasMore"] = hasMore
|
||||||
payload["stopReason"] = stopReason
|
payload["stopReason"] = stopReason
|
||||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||||
|
chatmsg.ApplyTruncation(payload)
|
||||||
payload["failedCount"] = len(failures)
|
payload["failedCount"] = len(failures)
|
||||||
payload["failures"] = failures
|
payload["failures"] = failures
|
||||||
payload["partial"] = len(failures) > 0 && len(allItems) > 0
|
payload["partial"] = len(failures) > 0 && len(allItems) > 0
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageAgentMetadataReachesEveryMCPMethod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
agentVersion = "1.2.3-test+7"
|
||||||
|
agentExt = `{"umt":"test-umt","nested":{"enabled":true}}`
|
||||||
|
cliVersion = "9.8.7-cli"
|
||||||
|
userAgent = "dws-user-agent-test/1.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
seen := make(map[string]int)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request requestEnvelope
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[request.Method]++
|
||||||
|
if got := r.Header.Get(HeaderAgentVersion); got != agentVersion {
|
||||||
|
t.Errorf("%s %s = %q, want %q", request.Method, HeaderAgentVersion, got, agentVersion)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get(HeaderAgentExt); got != agentExt {
|
||||||
|
t.Errorf("%s %s = %q, want %q", request.Method, HeaderAgentExt, got, agentExt)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get(HeaderVersion); got != cliVersion {
|
||||||
|
t.Errorf("%s %s = %q, want %q", request.Method, HeaderVersion, got, cliVersion)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("User-Agent"); got != userAgent {
|
||||||
|
t.Errorf("%s User-Agent = %q, want preset value %q", request.Method, got, userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch request.Method {
|
||||||
|
case "initialize":
|
||||||
|
_, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"%s","capabilities":{}}}`, supportedProtocolVersions[0])
|
||||||
|
case "tools/list":
|
||||||
|
_, _ = fmt.Fprint(w, `{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}`)
|
||||||
|
case "tools/call":
|
||||||
|
_, _ = fmt.Fprint(w, `{"jsonrpc":"2.0","id":3,"result":{"content":{"success":true}}}`)
|
||||||
|
default:
|
||||||
|
http.Error(w, "unexpected method", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.Client())
|
||||||
|
client.ExtraHeaders = map[string]string{
|
||||||
|
HeaderAgentVersion: agentVersion,
|
||||||
|
HeaderAgentExt: agentExt,
|
||||||
|
HeaderVersion: cliVersion,
|
||||||
|
"User-Agent": userAgent,
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := client.Initialize(ctx, server.URL); err != nil {
|
||||||
|
t.Fatalf("Initialize() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := client.ListTools(ctx, server.URL); err != nil {
|
||||||
|
t.Fatalf("ListTools() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := client.CallTool(ctx, server.URL, "test_tool", map[string]any{"value": "safe"}); err != nil {
|
||||||
|
t.Fatalf("CallTool() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, method := range []string{"initialize", "tools/list", "tools/call"} {
|
||||||
|
if got := seen[method]; got != 1 {
|
||||||
|
t.Errorf("%s request count = %d, want 1", method, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,10 +68,12 @@ const (
|
|||||||
defaultRetryMaxDelay = 5 * time.Second
|
defaultRetryMaxDelay = 5 * time.Second
|
||||||
|
|
||||||
// Security headers
|
// Security headers
|
||||||
HeaderSource = "X-Cli-Source"
|
HeaderSource = "X-Cli-Source"
|
||||||
HeaderVersion = "X-Cli-Version"
|
HeaderVersion = "X-Cli-Version"
|
||||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||||
SourceValue = "dws-cli"
|
HeaderAgentVersion = "x-dws-agent-ver"
|
||||||
|
HeaderAgentExt = "x-dws-agent-ext"
|
||||||
|
SourceValue = "dws-cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Supported MCP protocol versions, ordered from newest to oldest.
|
// 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
|
// safeRedirectPolicy prevents credential headers from being forwarded
|
||||||
// when a response redirects to a different host (e.g. API 302 → CDN).
|
// when a response redirects to a different host (e.g. API 302 → CDN).
|
||||||
// Strips Authorization, x-user-access-token on cross-host redirects;
|
// Credentials and Agent extension context are bound to the initial origin:
|
||||||
// other headers like X-Cli-* pass through.
|
// 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 {
|
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||||
if len(via) >= 10 {
|
if len(via) >= 10 {
|
||||||
return fmt.Errorf("too many redirects")
|
return fmt.Errorf("too many redirects")
|
||||||
}
|
}
|
||||||
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
|
if redirectChainLeftInitialOrigin(req, via) {
|
||||||
// Cross-host redirect: strip sensitive headers to prevent credential leakage
|
req.Header.Del(HeaderAgentExt)
|
||||||
req.Header.Del("Authorization")
|
req.Header.Del("Authorization")
|
||||||
req.Header.Del("x-user-access-token")
|
req.Header.Del("x-user-access-token")
|
||||||
}
|
}
|
||||||
return nil
|
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
|
// 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
|
// headers. The returned client shares the underlying HTTP client but is safe
|
||||||
// to use concurrently with the original.
|
// to use concurrently with the original.
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageSafeRedirectPolicyAgentMetadataHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
newRequest := func(t *testing.T, rawURL string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set(HeaderAgentVersion, "1.2.3-test")
|
||||||
|
req.Header.Set(HeaderAgentExt, `{"ua":"test-agent-value"}`)
|
||||||
|
req.Header.Set("Authorization", "Bearer test-token")
|
||||||
|
req.Header.Set("x-user-access-token", "test-token")
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
assertHeaders := func(t *testing.T, request *http.Request, wantExt bool) {
|
||||||
|
t.Helper()
|
||||||
|
if got := request.Header.Get(HeaderAgentVersion); got != "1.2.3-test" {
|
||||||
|
t.Fatalf("agent version = %q, want retained", got)
|
||||||
|
}
|
||||||
|
gotExt := request.Header.Get(HeaderAgentExt)
|
||||||
|
if wantExt && gotExt == "" {
|
||||||
|
t.Fatal("agent extension was removed")
|
||||||
|
}
|
||||||
|
if !wantExt && gotExt != "" {
|
||||||
|
t.Fatalf("agent extension leaked across origins: %q", gotExt)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"Authorization", "x-user-access-token"} {
|
||||||
|
got := request.Header.Get(key)
|
||||||
|
if wantExt && got == "" {
|
||||||
|
t.Fatalf("same-origin redirect removed %s", key)
|
||||||
|
}
|
||||||
|
if !wantExt && got != "" {
|
||||||
|
t.Fatalf("credential %s leaked across origins", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("same origin retains version and extension", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
previous := newRequest(t, "https://api.example.test/start")
|
||||||
|
redirected := newRequest(t, "https://api.example.test/next")
|
||||||
|
|
||||||
|
if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertHeaders(t, redirected, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cross host strips extension and retains version", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
previous := newRequest(t, "https://api.example.test/start")
|
||||||
|
redirected := newRequest(t, "https://cdn.example.test/asset")
|
||||||
|
|
||||||
|
if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertHeaders(t, redirected, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scheme downgrade on same host strips extension", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
previous := newRequest(t, "https://api.example.test/start")
|
||||||
|
redirected := newRequest(t, "http://api.example.test/next")
|
||||||
|
|
||||||
|
if err := safeRedirectPolicy(redirected, []*http.Request{previous}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertHeaders(t, redirected, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("extension remains stripped after returning to initial origin", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
initial := newRequest(t, "https://api.example.test/start")
|
||||||
|
crossOrigin := newRequest(t, "https://cdn.example.test/asset")
|
||||||
|
redirectedBack := newRequest(t, "https://api.example.test/final")
|
||||||
|
|
||||||
|
if err := safeRedirectPolicy(redirectedBack, []*http.Request{initial, crossOrigin}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertHeaders(t, redirectedBack, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("extension remains stripped on later cross-origin hop", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
initial := newRequest(t, "https://api.example.test/start")
|
||||||
|
crossOrigin := newRequest(t, "https://cdn.example.test/asset")
|
||||||
|
redirected := newRequest(t, "https://cdn.example.test/final")
|
||||||
|
|
||||||
|
if err := safeRedirectPolicy(redirected, []*http.Request{initial, crossOrigin}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertHeaders(t, redirected, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("initial request is unchanged", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
request := newRequest(t, "https://api.example.test/start")
|
||||||
|
|
||||||
|
if err := safeRedirectPolicy(request, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertHeaders(t, request, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("redirect limit is enforced", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
request := newRequest(t, "https://api.example.test/final")
|
||||||
|
via := make([]*http.Request, 10)
|
||||||
|
for index := range via {
|
||||||
|
via[index] = newRequest(t, "https://api.example.test/"+strings.Repeat("x", index+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := safeRedirectPolicy(request, via); err == nil {
|
||||||
|
t.Fatal("safeRedirectPolicy() error = nil, want redirect limit error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+3
-2
@@ -15,8 +15,9 @@ package cli
|
|||||||
|
|
||||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||||
|
|
||||||
// MCPIdentityHeaders returns HTTP headers aligned with MCP tool calls
|
// MCPIdentityHeaders returns the shared identity and edition headers that
|
||||||
// (identity + edition merge). Overlays may pass this to auxiliary clients.
|
// 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 {
|
func MCPIdentityHeaders() map[string]string {
|
||||||
return app.MCPIdentityHeaders()
|
return app.MCPIdentityHeaders()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageMCPIdentityHeadersExcludeMCPOnlyMetadata(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("DWS_AGENT_VER", "1.2.3")
|
||||||
|
t.Setenv("DWS_AGENT_EXT", `{"ua":"test-agent/1.2.3"}`)
|
||||||
|
|
||||||
|
headers := MCPIdentityHeaders()
|
||||||
|
if headers == nil {
|
||||||
|
t.Fatal("MCPIdentityHeaders() returned nil")
|
||||||
|
}
|
||||||
|
for key := range headers {
|
||||||
|
if strings.EqualFold(key, "x-dws-agent-ver") || strings.EqualFold(key, "x-dws-agent-ext") {
|
||||||
|
t.Fatalf("shared identity export leaked MCP-only Header %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,7 +90,26 @@ if ! jq -e --arg registry_count "$registry_count" '
|
|||||||
(.effect == "read" or .effect == "write" or .effect == "destructive") and
|
(.effect == "read" or .effect == "write" or .effect == "destructive") and
|
||||||
(.risk == "low" or .risk == "medium" or .risk == "high") and
|
(.risk == "low" or .risk == "medium" or .risk == "high") and
|
||||||
(.confirmation == "not_required" or .confirmation == "user_required") 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("use_when") and (.use_when | type) == "array") and
|
||||||
(has("avoid_when") and (.avoid_when | type) == "array") and
|
(has("avoid_when") and (.avoid_when | type) == "array") and
|
||||||
(has("examples") and (.examples | type) == "array") and
|
(has("examples") and (.examples | type) == "array") and
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type toolSchema struct {
|
|||||||
Risk string `json:"risk"`
|
Risk string `json:"risk"`
|
||||||
Confirmation string `json:"confirmation"`
|
Confirmation string `json:"confirmation"`
|
||||||
Idempotency string `json:"idempotency"`
|
Idempotency string `json:"idempotency"`
|
||||||
|
RetryPolicy string `json:"retry_policy,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type positionalSchema struct {
|
type positionalSchema struct {
|
||||||
@@ -355,6 +356,7 @@ func normalizeTool(raw json.RawMessage) (string, toolSchema, error) {
|
|||||||
Risk string `json:"risk"`
|
Risk string `json:"risk"`
|
||||||
Confirmation string `json:"confirmation"`
|
Confirmation string `json:"confirmation"`
|
||||||
Idempotency string `json:"idempotency"`
|
Idempotency string `json:"idempotency"`
|
||||||
|
RetryPolicy json.RawMessage `json:"retry_policy"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &tool); err != nil {
|
if err := json.Unmarshal(raw, &tool); err != nil {
|
||||||
return "", toolSchema{}, err
|
return "", toolSchema{}, err
|
||||||
@@ -403,6 +405,9 @@ func normalizeTool(raw json.RawMessage) (string, toolSchema, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", toolSchema{}, fmt.Errorf("dry_run: %w", err)
|
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{
|
return id, toolSchema{
|
||||||
PrimaryCLIPath: strings.TrimSpace(tool.PrimaryCLIPath),
|
PrimaryCLIPath: strings.TrimSpace(tool.PrimaryCLIPath),
|
||||||
@@ -417,6 +422,7 @@ func normalizeTool(raw json.RawMessage) (string, toolSchema, error) {
|
|||||||
Risk: strings.TrimSpace(tool.Risk),
|
Risk: strings.TrimSpace(tool.Risk),
|
||||||
Confirmation: strings.TrimSpace(tool.Confirmation),
|
Confirmation: strings.TrimSpace(tool.Confirmation),
|
||||||
Idempotency: strings.TrimSpace(tool.Idempotency),
|
Idempotency: strings.TrimSpace(tool.Idempotency),
|
||||||
|
RetryPolicy: retryPolicy,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +574,9 @@ func readContract(path string) (schemaContract, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkCompatibility(baseline, current schemaContract) []string {
|
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
|
var failures []string
|
||||||
for productID, oldProduct := range baseline.Products {
|
for productID, oldProduct := range baseline.Products {
|
||||||
newProduct, ok := current.Products[productID]
|
newProduct, ok := current.Products[productID]
|
||||||
@@ -591,6 +600,7 @@ func checkCompatibility(baseline, current schemaContract) []string {
|
|||||||
|
|
||||||
func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []string {
|
func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []string {
|
||||||
var failures []string
|
var failures []string
|
||||||
|
reviewedIdempotencyChange := compatibleReviewedIdempotencyTransition(toolPath, oldTool, newTool)
|
||||||
for _, field := range []struct {
|
for _, field := range []struct {
|
||||||
name string
|
name string
|
||||||
old string
|
old string
|
||||||
@@ -602,12 +612,17 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
|
|||||||
{name: "effect", old: oldTool.Effect, new: newTool.Effect},
|
{name: "effect", old: oldTool.Effect, new: newTool.Effect},
|
||||||
{name: "risk", old: oldTool.Risk, new: newTool.Risk},
|
{name: "risk", old: oldTool.Risk, new: newTool.Risk},
|
||||||
{name: "confirmation", old: oldTool.Confirmation, new: newTool.Confirmation},
|
{name: "confirmation", old: oldTool.Confirmation, new: newTool.Confirmation},
|
||||||
{name: "idempotency", old: oldTool.Idempotency, new: newTool.Idempotency},
|
|
||||||
} {
|
} {
|
||||||
if field.old != field.new {
|
if field.old != field.new {
|
||||||
failures = append(failures, fmt.Sprintf("schema tool %q changed %s", toolPath, field.name))
|
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 &&
|
if oldTool.Constraints != newTool.Constraints &&
|
||||||
!compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) &&
|
!compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) &&
|
||||||
!compatibleAdditiveConstraintEvolution(oldTool, newTool) &&
|
!compatibleAdditiveConstraintEvolution(oldTool, newTool) &&
|
||||||
@@ -640,6 +655,147 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
|
|||||||
return failures
|
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
|
// reviewedInterfaceRefRedirect enumerates the exact, individually reviewed
|
||||||
// backend RPC migrations this gate accepts. Schema shape alone cannot prove two
|
// backend RPC migrations this gate accepts. Schema shape alone cannot prove two
|
||||||
// RPCs share business semantics, permissions, error behaviour, or side effects,
|
// RPCs share business semantics, permissions, error behaviour, or side effects,
|
||||||
@@ -1313,6 +1469,8 @@ func validateRenamedSchemaParameter(
|
|||||||
oldParameter parameterSchema,
|
oldParameter parameterSchema,
|
||||||
newParameter parameterSchema,
|
newParameter parameterSchema,
|
||||||
) error {
|
) error {
|
||||||
|
// The migration authorizes only the CLI spelling change. Requiredness is
|
||||||
|
// part of the parameter contract in both projections and must remain exact.
|
||||||
if oldParameter.Type != newParameter.Type ||
|
if oldParameter.Type != newParameter.Type ||
|
||||||
oldParameter.Property != newParameter.Property ||
|
oldParameter.Property != newParameter.Property ||
|
||||||
oldParameter.InterfaceType != newParameter.InterfaceType ||
|
oldParameter.InterfaceType != newParameter.InterfaceType ||
|
||||||
@@ -1328,17 +1486,17 @@ func validateRenamedSchemaParameter(
|
|||||||
migration.Canonical.Name,
|
migration.Canonical.Name,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if oldParameter.Required && !newParameter.Required {
|
if oldParameter.Required != newParameter.Required {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"approved flag migration %q Schema parameter %q -> %q became optional",
|
"approved flag migration %q Schema parameter %q -> %q changed requiredness",
|
||||||
migration.Command,
|
migration.Command,
|
||||||
migration.Legacy.Name,
|
migration.Legacy.Name,
|
||||||
migration.Canonical.Name,
|
migration.Canonical.Name,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if oldParameter.CLIRequired && !newParameter.CLIRequired {
|
if oldParameter.CLIRequired != newParameter.CLIRequired {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"approved flag migration %q Schema parameter %q -> %q stopped being cli_required",
|
"approved flag migration %q Schema parameter %q -> %q changed cli_required",
|
||||||
migration.Command,
|
migration.Command,
|
||||||
migration.Legacy.Name,
|
migration.Legacy.Name,
|
||||||
migration.Canonical.Name,
|
migration.Canonical.Name,
|
||||||
|
|||||||
@@ -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) {
|
func TestSchemaCompatibilityIgnoresPositionalDescription(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
baselinePath := filepath.Join(directory, "baseline.json")
|
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) {
|
func TestMergeContracts(t *testing.T) {
|
||||||
historical := baselineContract()
|
historical := baselineContract()
|
||||||
current := cloneContract(historical)
|
current := cloneContract(historical)
|
||||||
@@ -1218,8 +1439,11 @@ func TestCrossPlatformCoverageSchemaFlagMigrationNormalizesExactRename(t *testin
|
|||||||
t.Fatalf("normalized baseline retained legacy parameter %q", legacy)
|
t.Fatalf("normalized baseline retained legacy parameter %q", legacy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if canonical := tool.Parameters["conversation-id"]; !canonical.Required || !canonical.CLIRequired {
|
if canonical := tool.Parameters["conversation-id"]; canonical.Required || canonical.CLIRequired {
|
||||||
t.Fatalf("canonical required transition was not normalized: %#v", canonical)
|
t.Fatalf("optional canonical rename changed requiredness: %#v", canonical)
|
||||||
|
}
|
||||||
|
if canonical := tool.Parameters["message-id"]; !canonical.Required || !canonical.CLIRequired {
|
||||||
|
t.Fatalf("required canonical rename changed requiredness: %#v", canonical)
|
||||||
}
|
}
|
||||||
if tool.Constraints != current.Products["chat"].Tools["chat.edit_message"].Constraints {
|
if tool.Constraints != current.Products["chat"].Tools["chat.edit_message"].Constraints {
|
||||||
t.Fatalf("constraints were not normalized: %s", tool.Constraints)
|
t.Fatalf("constraints were not normalized: %s", tool.Constraints)
|
||||||
@@ -1269,6 +1493,39 @@ func TestCrossPlatformCoverageSchemaFlagMigrationRejectsSemanticDrift(t *testing
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
want string
|
||||||
|
mutate func(*parameterSchema)
|
||||||
|
}{
|
||||||
|
{name: "optional required promotion", want: "changed requiredness", mutate: func(parameter *parameterSchema) {
|
||||||
|
parameter.Required = true
|
||||||
|
}},
|
||||||
|
{name: "optional cli_required promotion", want: "changed cli_required", mutate: func(parameter *parameterSchema) {
|
||||||
|
parameter.CLIRequired = true
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
current := schemaFlagMigrationContract(true)
|
||||||
|
product := current.Products["chat"]
|
||||||
|
tool := product.Tools["chat.edit_message"]
|
||||||
|
canonical := tool.Parameters["conversation-id"]
|
||||||
|
test.mutate(&canonical)
|
||||||
|
tool.Parameters["conversation-id"] = canonical
|
||||||
|
product.Tools["chat.edit_message"] = tool
|
||||||
|
current.Products["chat"] = product
|
||||||
|
|
||||||
|
_, err := normalizeSchemaFlagMigrations(
|
||||||
|
schemaFlagMigrationContract(false),
|
||||||
|
current,
|
||||||
|
schemaFlagMigrationAuthorizations(),
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("normalizeSchemaFlagMigrations() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
||||||
@@ -1321,9 +1578,10 @@ func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
|||||||
t.Fatal("missing candidate product was normalized away")
|
t.Fatal("missing candidate product was normalized away")
|
||||||
}
|
}
|
||||||
|
|
||||||
canonicalOnly := schemaFlagMigrationAuthorizations()[0]
|
canonicalOnlyMigration := schemaFlagMigrationAuthorizations()[0]
|
||||||
canonicalOnly.Legacy.Name = "legacy-not-published-in-schema"
|
canonicalOnlyMigration.Legacy.Name = "legacy-not-published-in-schema"
|
||||||
driftedCanonical := cloneContract(current)
|
canonicalOnlyBaseline := schemaFlagMigrationContract(true)
|
||||||
|
driftedCanonical := cloneContract(canonicalOnlyBaseline)
|
||||||
product = driftedCanonical.Products["chat"]
|
product = driftedCanonical.Products["chat"]
|
||||||
tool = product.Tools["chat.edit_message"]
|
tool = product.Tools["chat.edit_message"]
|
||||||
canonical := tool.Parameters["conversation-id"]
|
canonical := tool.Parameters["conversation-id"]
|
||||||
@@ -1331,7 +1589,7 @@ func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
|||||||
tool.Parameters["conversation-id"] = canonical
|
tool.Parameters["conversation-id"] = canonical
|
||||||
product.Tools["chat.edit_message"] = tool
|
product.Tools["chat.edit_message"] = tool
|
||||||
driftedCanonical.Products["chat"] = product
|
driftedCanonical.Products["chat"] = product
|
||||||
normalized, err = normalizeSchemaFlagMigrations(baseline, driftedCanonical, []interfacesnapshot.FlagMigration{canonicalOnly})
|
normalized, err = normalizeSchemaFlagMigrations(canonicalOnlyBaseline, driftedCanonical, []interfacesnapshot.FlagMigration{canonicalOnlyMigration})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1339,24 +1597,24 @@ func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
|||||||
t.Fatalf("canonical-only Schema drift was hidden: %s", failures)
|
t.Fatalf("canonical-only Schema drift was hidden: %s", failures)
|
||||||
}
|
}
|
||||||
|
|
||||||
canonicalOptional := schemaFlagMigrationContract(true)
|
promotedCanonical := cloneContract(canonicalOnlyBaseline)
|
||||||
product = canonicalOptional.Products["chat"]
|
product = promotedCanonical.Products["chat"]
|
||||||
tool = product.Tools["chat.edit_message"]
|
tool = product.Tools["chat.edit_message"]
|
||||||
canonical = tool.Parameters["conversation-id"]
|
canonical = tool.Parameters["conversation-id"]
|
||||||
canonical.Required = false
|
canonical.Required = true
|
||||||
canonical.CLIRequired = false
|
canonical.CLIRequired = true
|
||||||
tool.Parameters["conversation-id"] = canonical
|
tool.Parameters["conversation-id"] = canonical
|
||||||
product.Tools["chat.edit_message"] = tool
|
product.Tools["chat.edit_message"] = tool
|
||||||
canonicalOptional.Products["chat"] = product
|
promotedCanonical.Products["chat"] = product
|
||||||
normalized, err = normalizeSchemaFlagMigrations(
|
normalized, err = normalizeSchemaFlagMigrations(
|
||||||
canonicalOptional,
|
canonicalOnlyBaseline,
|
||||||
schemaFlagMigrationContract(true),
|
promotedCanonical,
|
||||||
[]interfacesnapshot.FlagMigration{canonicalOnly},
|
[]interfacesnapshot.FlagMigration{canonicalOnlyMigration},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if failures := strings.Join(checkCompatibility(normalized, schemaFlagMigrationContract(true)), "\n"); !strings.Contains(failures, "newly required") || !strings.Contains(failures, "newly cli_required") {
|
if failures := strings.Join(checkCompatibility(normalized, promotedCanonical), "\n"); !strings.Contains(failures, "newly required") || !strings.Contains(failures, "newly cli_required") {
|
||||||
t.Fatalf("canonical-only required promotion was hidden: %s", failures)
|
t.Fatalf("canonical-only required promotion was hidden: %s", failures)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1392,12 +1650,19 @@ func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
old := parameterSchema{Required: true, CLIRequired: true}
|
old := parameterSchema{Required: true, CLIRequired: true}
|
||||||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{CLIRequired: true}); err == nil || !strings.Contains(err.Error(), "became optional") {
|
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{CLIRequired: true}); err == nil || !strings.Contains(err.Error(), "changed requiredness") {
|
||||||
t.Fatalf("direct required decline error = %v", err)
|
t.Fatalf("direct required decline error = %v", err)
|
||||||
}
|
}
|
||||||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{Required: true}); err == nil || !strings.Contains(err.Error(), "stopped being cli_required") {
|
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{Required: true}); err == nil || !strings.Contains(err.Error(), "changed cli_required") {
|
||||||
t.Fatalf("direct cli_required decline error = %v", err)
|
t.Fatalf("direct cli_required decline error = %v", err)
|
||||||
}
|
}
|
||||||
|
optional := parameterSchema{}
|
||||||
|
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], optional, parameterSchema{Required: true}); err == nil || !strings.Contains(err.Error(), "changed requiredness") {
|
||||||
|
t.Fatalf("direct required promotion error = %v", err)
|
||||||
|
}
|
||||||
|
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], optional, parameterSchema{CLIRequired: true}); err == nil || !strings.Contains(err.Error(), "changed cli_required") {
|
||||||
|
t.Fatalf("direct cli_required promotion error = %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsPartialAndUnrelatedChanges(t *testing.T) {
|
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsPartialAndUnrelatedChanges(t *testing.T) {
|
||||||
@@ -1471,9 +1736,8 @@ func TestCrossPlatformCoverageSchemaFlagMigrationRejectsPartialAndUnrelatedChang
|
|||||||
t.Fatalf("constraint rewrite without Schema parameter evidence was hidden: %s", failures)
|
t.Fatalf("constraint rewrite without Schema parameter evidence was hidden: %s", failures)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A baseline that already contains only the canonical parameter may receive
|
// A baseline that already contains only the canonical parameter is not
|
||||||
// a required promotion, but that is not evidence that a stray legacy name in
|
// evidence that a stray legacy name in constraints belongs to the migration.
|
||||||
// constraints belongs to the migration.
|
|
||||||
canonicalOnly := schemaFlagMigrationContract(true)
|
canonicalOnly := schemaFlagMigrationContract(true)
|
||||||
product = canonicalOnly.Products["chat"]
|
product = canonicalOnly.Products["chat"]
|
||||||
tool = product.Tools["chat.edit_message"]
|
tool = product.Tools["chat.edit_message"]
|
||||||
@@ -1749,13 +2013,10 @@ func schemaFlagMigrationContract(after bool) schemaContract {
|
|||||||
InterfaceType: "string",
|
InterfaceType: "string",
|
||||||
}
|
}
|
||||||
parameters := map[string]parameterSchema{
|
parameters := map[string]parameterSchema{
|
||||||
"conversation-id": conversation,
|
"unrelated": {Type: `"string"`, Property: "unrelated"},
|
||||||
"unrelated": {Type: `"string"`, Property: "unrelated"},
|
|
||||||
}
|
}
|
||||||
constraints := `{"require_one_of":[["conversation-id","group","id"]]}`
|
constraints := `{"require_one_of":[["group","id"]]}`
|
||||||
if after {
|
if after {
|
||||||
conversation.Required = true
|
|
||||||
conversation.CLIRequired = true
|
|
||||||
parameters["conversation-id"] = conversation
|
parameters["conversation-id"] = conversation
|
||||||
parameters["message-id"] = legacyMessage
|
parameters["message-id"] = legacyMessage
|
||||||
constraints = `{"require_one_of":[["conversation-id"]]}`
|
constraints = `{"require_one_of":[["conversation-id"]]}`
|
||||||
@@ -1788,7 +2049,6 @@ func schemaFlagMigrationAuthorizations() []interfacesnapshot.FlagMigration {
|
|||||||
Scope: "local",
|
Scope: "local",
|
||||||
}
|
}
|
||||||
conversationAfter := conversationBefore
|
conversationAfter := conversationBefore
|
||||||
conversationAfter.Required = true
|
|
||||||
messageBefore := interfacesnapshot.FlagMigrationState{
|
messageBefore := interfacesnapshot.FlagMigrationState{
|
||||||
Present: true,
|
Present: true,
|
||||||
Type: "string",
|
Type: "string",
|
||||||
@@ -1808,7 +2068,7 @@ func schemaFlagMigrationAuthorizations() []interfacesnapshot.FlagMigration {
|
|||||||
},
|
},
|
||||||
Canonical: interfacesnapshot.FlagMigrationSide{
|
Canonical: interfacesnapshot.FlagMigrationSide{
|
||||||
Name: "conversation-id",
|
Name: "conversation-id",
|
||||||
Before: conversationBefore,
|
Before: interfacesnapshot.FlagMigrationState{},
|
||||||
After: conversationAfter,
|
After: conversationAfter,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1823,7 +2083,7 @@ func schemaFlagMigrationAuthorizations() []interfacesnapshot.FlagMigration {
|
|||||||
},
|
},
|
||||||
Canonical: interfacesnapshot.FlagMigrationSide{
|
Canonical: interfacesnapshot.FlagMigrationSide{
|
||||||
Name: "conversation-id",
|
Name: "conversation-id",
|
||||||
Before: conversationBefore,
|
Before: interfacesnapshot.FlagMigrationState{},
|
||||||
After: conversationAfter,
|
After: conversationAfter,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1987,6 +2247,9 @@ func writeRawSchemaContractFile(t *testing.T, path string, contract schemaContra
|
|||||||
if tool.DryRun != "" {
|
if tool.DryRun != "" {
|
||||||
rawTool["dry_run"] = json.RawMessage(tool.DryRun)
|
rawTool["dry_run"] = json.RawMessage(tool.DryRun)
|
||||||
}
|
}
|
||||||
|
if tool.RetryPolicy != "" {
|
||||||
|
rawTool["retry_policy"] = json.RawMessage(tool.RetryPolicy)
|
||||||
|
}
|
||||||
tools = append(tools, rawTool)
|
tools = append(tools, rawTool)
|
||||||
}
|
}
|
||||||
products = append(products, map[string]any{"id": productID, "tools": tools})
|
products = append(products, map[string]any{"id": productID, "tools": tools})
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
<!-- DWS_MESSAGE_RESULT_CONTRACT_START -->
|
<!-- DWS_MESSAGE_RESULT_CONTRACT_START -->
|
||||||
- `version`: `im.message-list.v1`
|
- `version`: `im.message-list.v1`
|
||||||
- `message_fields`: `messageId`, `conversationId`, `threadId`, `sender`, `senderId`, `senderType`, `messageType`, `text`, `createTime`, `updateTime`, `reactions`, `quotedMessage`, `forwarded`, `resourceRefs`
|
- `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 -->
|
<!-- DWS_MESSAGE_RESULT_CONTRACT_END -->
|
||||||
|
|
||||||
当 `complete=false` 时不能称为全量成功。`nextPage` 只能来自真实 lower boundary;
|
当 `complete=false` 时不能称为全量成功。`nextPage` 只能来自真实 lower boundary;
|
||||||
`failedCount/failures`、`partial` 和两个 truncated 字段必须原样保留。
|
`failedCount/failures`、`partial`、总 `truncated` 和两个原因字段必须原样保留。
|
||||||
当 Runtime 解析并应用自然发送者条件时,`resolvedFilters.senders[]` 保留原查询及选中的
|
当 Runtime 解析并应用自然发送者条件时,`resolvedFilters.senders[]` 保留原查询及选中的
|
||||||
`userId/openDingTalkId`。消息展示名可以与通讯录姓名不同;只能用稳定 `senderId` 与解析结果关联,
|
`userId/openDingTalkId`。消息展示名可以与通讯录姓名不同;只能用稳定 `senderId` 与解析结果关联,
|
||||||
不得重新做姓名字符串比较。
|
不得重新做姓名字符串比较。
|
||||||
|
|||||||
@@ -841,15 +841,27 @@ func TestInterfaceIntegrityWorkflowContract(t *testing.T) {
|
|||||||
}
|
}
|
||||||
schemaStep := schemaRemainder[:schemaEnd]
|
schemaStep := schemaRemainder[:schemaEnd]
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"make schema-compatibility",
|
`authority_worktree="$RUNNER_TEMP/dws-schema-authority-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"`,
|
||||||
`BASE_REF="$COMPATIBILITY_BASE_REF"`,
|
`git worktree add --detach "$authority_worktree" "$COMPATIBILITY_BASE_REF"`,
|
||||||
`STABLE_REF="$COMPATIBILITY_STABLE_REF"`,
|
`"$authority_worktree/scripts/policy/check-authoritative-schema-compatibility.sh"`,
|
||||||
`CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"`,
|
`--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) {
|
if !strings.Contains(schemaStep, want) {
|
||||||
t.Errorf("Schema compatibility step missing authoritative contract %q", 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) {
|
func TestLocalInterfaceIntegrityUsesAuthoritativeSeam(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user