Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb461155f | ||
|
|
7e38f9fbba | ||
|
|
067b3e6e51 | ||
|
|
bcac84987f | ||
|
|
8dd87108f1 | ||
|
|
412e77f215 | ||
|
|
2a0bf1ebea | ||
|
|
9ce13da6ed | ||
|
|
0e5731166b | ||
|
|
92edd8ea53 | ||
|
|
931d75beaf | ||
|
|
7667cb30a3 | ||
|
|
015daae064 | ||
|
|
e7510ea5f0 | ||
|
|
582b73cb40 | ||
|
|
04ea184ff6 | ||
|
|
0908b2ca6e | ||
|
|
070febd7bf | ||
|
|
e3782231be | ||
|
|
167a547a65 | ||
|
|
8f62c19104 | ||
|
|
82798dc7fc | ||
|
|
3319cf62d5 | ||
|
|
1626818a98 | ||
|
|
a03d6ebacc | ||
|
|
4ee4a44e16 | ||
|
|
40181f8c0c | ||
|
|
14ff02ebe1 | ||
|
|
55574fe12e | ||
|
|
222ee16d51 | ||
|
|
27ced3ee18 | ||
|
|
129e8a10ef | ||
|
|
02fba09c1e | ||
|
|
94b64f74ac | ||
|
|
cefcf5b409 | ||
|
|
441289cdfe | ||
|
|
d03823d772 | ||
|
|
c16a377863 | ||
|
|
31c3acc94b | ||
|
|
f7e702df8d | ||
|
|
bee246e62c | ||
|
|
089caa92a8 | ||
|
|
21cd3f8bc4 | ||
|
|
6c0cf3438b | ||
|
|
e3f30420fb |
@@ -1735,7 +1735,7 @@ jobs:
|
||||
if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}
|
||||
needs: [release-contract, release, publish-channels]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 360
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
@@ -1752,12 +1752,14 @@ jobs:
|
||||
|
||||
- name: Check out trusted release tooling
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 5
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: tmp/trusted-release-tooling
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fetch and verify sealed release tag
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
@@ -1776,7 +1778,7 @@ jobs:
|
||||
path: dist
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
timeout-minutes: 100
|
||||
timeout-minutes: 318
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ needs.release-contract.outputs.release_version }}
|
||||
@@ -2180,13 +2182,14 @@ jobs:
|
||||
needs: dispatch-contract
|
||||
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'repair_gitee' || needs.dispatch-contract.outputs.mode == 'repair_oss') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 360
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out trusted release tooling
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 5
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: tooling
|
||||
@@ -2194,6 +2197,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate repair version
|
||||
timeout-minutes: 2
|
||||
working-directory: tooling
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
|
||||
@@ -2208,6 +2212,7 @@ jobs:
|
||||
- name: Verify immutable release authority
|
||||
id: authority
|
||||
uses: actions/github-script@v7
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
|
||||
with:
|
||||
@@ -2330,12 +2335,14 @@ jobs:
|
||||
|
||||
- name: Check out sealed release source
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 5
|
||||
with:
|
||||
ref: ${{ steps.authority.outputs.commit_sha }}
|
||||
path: release-source
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fetch and verify sealed release tag
|
||||
timeout-minutes: 5
|
||||
working-directory: tooling
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
|
||||
@@ -2352,6 +2359,7 @@ jobs:
|
||||
"$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
- name: Require successful Release workflow delivery
|
||||
timeout-minutes: 5
|
||||
working-directory: tooling
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
|
||||
@@ -2368,6 +2376,7 @@ jobs:
|
||||
--channel-repair "$target" "$VERSION" "$RELEASE_COMMIT"
|
||||
|
||||
- name: Download and verify immutable GitHub Release assets
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
|
||||
@@ -2385,7 +2394,7 @@ jobs:
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
if: ${{ needs.dispatch-contract.outputs.mode == 'repair_gitee' }}
|
||||
timeout-minutes: 100
|
||||
timeout-minutes: 318
|
||||
working-directory: tooling
|
||||
run: |
|
||||
"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"
|
||||
|
||||
@@ -62,3 +62,7 @@ dwsbin
|
||||
/docs/shortcut-comparison.html
|
||||
/docs/shortcut-gsb-eval.*
|
||||
/scripts/run_shortcut_real_read_matrix.py
|
||||
|
||||
# Local coverage artifacts
|
||||
coverage-shortcut.txt
|
||||
coverage-*.txt
|
||||
|
||||
@@ -6,6 +6,44 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
|
||||
|
||||
## [1.0.54] - 2026-07-21
|
||||
|
||||
This release promotes the validated `v1.0.54-beta.2` baseline to stable. It restores the default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Personal event output compatibility** (#743) — `event consume` once again preserves the transport envelope by default for `ndjson`/`json`/`pretty`, while retaining the existing `compact` processor. New Agent workflows opt into the event-specific top-level DTO with `--flatten`, which is mutually exclusive with `-f raw` and `--debug-raw-events`; `event schema --flatten` describes that DTO, while the default schema describes `type/event_type/data/headers` and points to `.data | fromjson`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Schema CLI path compatibility** (#738) — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution.
|
||||
- **Plugin CLI overlays** (#701) — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict.
|
||||
|
||||
## [1.0.54-beta.2] - 2026-07-21
|
||||
|
||||
This beta revalidates the same `v1.0.54-beta.1` source through the cloud release path with a sealed `OSS-Mirror: deferred` policy, because the manually tagged `v1.0.54-beta.1` push run failed on the unavailable OSS mirror channel after GitHub and npm delivery.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release delivery only** — no source changes since `v1.0.54-beta.1`; see that section for the user-visible changes under validation (#743, #738, #701).
|
||||
|
||||
## [1.0.54-beta.1] - 2026-07-21
|
||||
|
||||
This beta validates the restored default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes, on top of the validated `v1.0.53-beta.7` baseline.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Personal event output compatibility** (#743) — `event consume` once again preserves the transport envelope by default for `ndjson`/`json`/`pretty`, while retaining the existing `compact` processor. New Agent workflows opt into the event-specific top-level DTO with `--flatten`, which is mutually exclusive with `-f raw` and `--debug-raw-events`; `event schema --flatten` describes that DTO, while the default schema describes `type/event_type/data/headers` and points to `.data | fromjson`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Schema CLI path compatibility** (#738) — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution.
|
||||
- **Plugin CLI overlays** (#701) — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict.
|
||||
|
||||
## [1.0.53] - 2026-07-21
|
||||
|
||||
This release promotes the validated `v1.0.53-beta.7` baseline to stable. It adds enterprise onboarding, declarative shortcuts, Sheet/Aitable writes, multi-account profiles, and broader personal IM events, while hardening authentication and the guarded release path.
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.53-beta.4"
|
||||
version "1.0.54-beta.2"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "32a442d5b42dfed8512a695a7cef513722db6912f3c3168954cfaefb68e0b075"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "46b57bed1f6e9f7ba007d8a86a6f5eb280fdeb557fc9bb5946f14f9b1f8f0c9f"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "00c694677b9ce2e1a711535740681defe0a5f83d6b72140f305483501628faf8"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "1b7fd08e64b1c86bbcee217604ffe07e0e8f1b3b5c4de518534386972bcf0f9b"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "98516620e861e516cf846cf418f1a0ad5c5eb9a8681bd066b1fd29f4847aca6a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "108d3861ef606519f9934530d29654eab55a73607d1ee6775461f98ef5a6acd4"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "266df80e8a989971789a157dd134685a7c9eda01dd1d082719ec9e09d5a07bf0"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "6cb96ee09419bbbcc1eb336218ac2aa1d9ca0ed5cbd5a80c79bc20e1e1f03ff7"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-skills.zip"
|
||||
sha256 "6770511ab9b04b4d97da1858069ff694830ba91d47c1e8564fd85856f95b016e"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-skills.zip"
|
||||
sha256 "572b93f04a10268d185ad1f8e70e0d412949ae056be8494a9949387076fd14bc"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.52"
|
||||
version "1.0.54"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-arm64.tar.gz"
|
||||
sha256 "4f6b4d064a76bcefac42feb5f356253fe43f9499b8cec9d2cdf202e7d3b9b60c"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8ae0e52cf973f6fb3df61c67a41fd11e2df417a0c815762b6060cbcb5e600c08"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-amd64.tar.gz"
|
||||
sha256 "abc87128f4b98d0a01ea99235449031971db8fa4ce94167403e3b736c4b81e9a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-amd64.tar.gz"
|
||||
sha256 "11b711b9d70dea62304bf5f8206c56b4e7ea91148dafe97fb7c0f844a2a61da3"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-arm64.tar.gz"
|
||||
sha256 "0d357ef0535f99f2f63b5ecbfdee9c32448be2a2c24f3096c03126b3b7570bc5"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-arm64.tar.gz"
|
||||
sha256 "9c7ecb4c8cd55644b2faa73f6ce7843c0279b23793e23deb5061692ea71a0cf1"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-amd64.tar.gz"
|
||||
sha256 "b7dfd9a4b3489211359261747ed0cb9c8c261434bb762ad3f76df33bdbabd5cb"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-amd64.tar.gz"
|
||||
sha256 "8a0bc245747fc3facf98c8103c06da46852a30bff31ac93b0aa874e8c7e46db7"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-skills.zip"
|
||||
sha256 "0fa3c8dec500c1659e6480d6772ae901b2d12d24322dd5d7283f016024290c21"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-skills.zip"
|
||||
sha256 "7450fd0115c75bfe6820c7099f348973d9353cca9d8d647c9cddcd70978a7ec0"
|
||||
end
|
||||
|
||||
def install
|
||||
@@ -52,6 +53,7 @@ class DingtalkWorkspaceCli < Formula
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
|
||||
EOS
|
||||
end
|
||||
|
||||
|
||||
@@ -476,6 +476,8 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
|
||||
|
||||
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
|
||||
|
||||
> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags.
|
||||
|
||||
For an event-focused installation, use the official convenience installer:
|
||||
@@ -487,19 +489,19 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
```bash
|
||||
# Inspect the public personal event catalog and schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
|
||||
# Listen for messages that mention the current user
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
dws event consume user_im_message_receive_at --flatten -f ndjson
|
||||
|
||||
# Listen for one-to-one messages with a specified user
|
||||
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
|
||||
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
|
||||
|
||||
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
|
||||
|
||||
# Listen for messages in a specified group
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# Inspect local consumers and cancel a subscription
|
||||
dws event status
|
||||
|
||||
+7
-5
@@ -470,6 +470,8 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。
|
||||
|
||||
只需要 event 能力时,可以使用官方便捷安装脚本:
|
||||
@@ -481,19 +483,19 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
```bash
|
||||
# 查看公开个人事件目录和 schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
|
||||
# 监听当前用户被 @ 的消息
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
dws event consume user_im_message_receive_at --flatten -f ndjson
|
||||
|
||||
# 监听与指定用户的单聊消息
|
||||
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
|
||||
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
|
||||
|
||||
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
|
||||
|
||||
# 监听指定群的消息
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# 查看本地 consume,并取消指定订阅
|
||||
dws event status
|
||||
|
||||
@@ -49,6 +49,15 @@ func RegisterPluginAuth(productID string, auth *PluginAuth) {
|
||||
pluginAuthRegistry[productID] = auth
|
||||
}
|
||||
|
||||
// ClearPluginAuth removes credentials for a plugin product. Registration uses
|
||||
// this before applying an accepted descriptor so a descriptor without custom
|
||||
// auth cannot inherit stale credentials from an earlier root construction.
|
||||
func ClearPluginAuth(productID string) {
|
||||
pluginAuthMu.Lock()
|
||||
defer pluginAuthMu.Unlock()
|
||||
delete(pluginAuthRegistry, productID)
|
||||
}
|
||||
|
||||
// LookupPluginAuth returns the authentication credentials registered
|
||||
// for the given product ID, or nil if none exists.
|
||||
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
|
||||
|
||||
@@ -1469,10 +1469,10 @@ func TestCrossPlatformCoveragePersonalEventPureCoverage(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("mention definition missing")
|
||||
}
|
||||
if err := renderPersonalSchema(io.Discard, def, ""); err != nil {
|
||||
if err := renderPersonalSchema(io.Discard, def, "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := renderPersonalSchema(io.Discard, def, "yaml"); err == nil {
|
||||
if err := renderPersonalSchema(io.Discard, def, "yaml", true); err == nil {
|
||||
t.Fatal("unsupported schema format succeeded")
|
||||
}
|
||||
for _, key := range []string{"", "unknown", personal.EventMention, personal.EventFromUser} {
|
||||
|
||||
@@ -112,6 +112,7 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
dryRun bool
|
||||
foreground bool
|
||||
asIdentity string
|
||||
flatten bool
|
||||
personalOpts personalConsumeOptions
|
||||
streamOpts eventStreamTicketOptions
|
||||
)
|
||||
@@ -127,7 +128,11 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
json 每事件多行美化 JSON(必须配 --max-events 或 --duration)
|
||||
pretty 同 json,未来加颜色
|
||||
raw 仅 SDK 原始 payload,无外层封装
|
||||
compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)
|
||||
compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor
|
||||
|
||||
数据结构:
|
||||
ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)
|
||||
--flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费
|
||||
|
||||
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加
|
||||
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
|
||||
@@ -144,6 +149,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
}
|
||||
if as == "user" {
|
||||
personalOpts.EventKey = firstArg(args)
|
||||
personalOpts.Flatten = flatten
|
||||
personalOpts.Common = commonConsumeOptions{
|
||||
EventTypes: eventTypes,
|
||||
Filter: filter,
|
||||
@@ -167,6 +173,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user")
|
||||
}
|
||||
if err := rejectChangedFlags(c, "user",
|
||||
"flatten",
|
||||
"subscribe-id",
|
||||
"rule",
|
||||
"name",
|
||||
@@ -280,6 +287,8 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
"提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)")
|
||||
f.StringVarP(&formatRaw, "format", "f", "ndjson",
|
||||
"输出格式 (ndjson/json/pretty/raw/compact);事件流默认 ndjson")
|
||||
f.BoolVar(&flatten, "flatten", false,
|
||||
"将个人事件 transport envelope 投影为稳定的顶层业务字段")
|
||||
f.StringVar(&outputDir, "output-dir", "",
|
||||
"每事件写一个文件到该目录 ({type}_{id}_{ts}.json);与 stdout 互斥")
|
||||
f.StringArrayVar(&routesRaw, "route", nil,
|
||||
|
||||
@@ -61,6 +61,7 @@ type commonConsumeOptions struct {
|
||||
type personalConsumeOptions struct {
|
||||
Common commonConsumeOptions
|
||||
EventKey string
|
||||
Flatten bool
|
||||
DebugRawEvents bool
|
||||
SubscribeID string
|
||||
Rule string
|
||||
@@ -140,6 +141,7 @@ var (
|
||||
func newEventSchemaCommand() *cobra.Command {
|
||||
var asIdentity string
|
||||
var formatRaw string
|
||||
var flatten bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema <event_key>",
|
||||
Short: "显示事件 schema",
|
||||
@@ -157,11 +159,12 @@ func newEventSchemaCommand() *cobra.Command {
|
||||
if !def.Public {
|
||||
return personal.PublicAvailabilityError(args[0])
|
||||
}
|
||||
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw)
|
||||
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw, flatten)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
|
||||
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
|
||||
cmd.Flags().BoolVar(&flatten, "flatten", false, "显示 --flatten 消费模式对应的顶层业务字段 schema")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
Name: "event_key",
|
||||
@@ -189,7 +192,7 @@ func runPersonalEventList(c *cobra.Command, opts personalListOptions) error {
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error {
|
||||
func renderPersonalSchema(w io.Writer, def personal.Definition, format string, flatten bool) error {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" {
|
||||
format = "json"
|
||||
@@ -199,7 +202,7 @@ func renderPersonalSchema(w io.Writer, def personal.Definition, format string) e
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(personal.BuildSchemaDocument(def))
|
||||
return enc.Encode(personal.BuildSchemaDocumentForMode(def, flatten))
|
||||
}
|
||||
|
||||
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
@@ -207,6 +210,19 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return err
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
rawFormat = opts.Common.FormatRaw
|
||||
}
|
||||
normalised, fellback := consume.NormalizeFormat(rawFormat)
|
||||
if fellback && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
@@ -221,16 +237,6 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
rawFormat = opts.Common.FormatRaw
|
||||
}
|
||||
normalised, fellback := consume.NormalizeFormat(rawFormat)
|
||||
if fellback && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
projector := personalEventProjector(opts.DebugRawEvents)
|
||||
|
||||
if opts.Common.DryRun {
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
if err := validatePersonalSubscriptionOptions(opts); err != nil {
|
||||
@@ -247,6 +253,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: opts.EventKey,
|
||||
Format: normalised,
|
||||
Flatten: opts.Flatten,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
@@ -303,6 +310,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
Flatten: opts.Flatten,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
@@ -366,11 +374,27 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
return err
|
||||
}
|
||||
|
||||
func personalEventProjector(debugRawEvents bool) consume.Projector {
|
||||
func personalEventProjector(debugRawEvents, flatten bool) consume.Projector {
|
||||
if debugRawEvents {
|
||||
return func(ev transport.Event) (any, error) { return ev, nil }
|
||||
}
|
||||
return personal.ProjectOutput
|
||||
if flatten {
|
||||
return personal.ProjectOutput
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePersonalEventOutputMode(flatten, debugRawEvents bool, format consume.Format) error {
|
||||
if !flatten {
|
||||
return nil
|
||||
}
|
||||
if debugRawEvents {
|
||||
return fmt.Errorf("--flatten and --debug-raw-events are mutually exclusive")
|
||||
}
|
||||
if format == consume.FormatRaw {
|
||||
return fmt.Errorf("--flatten and --format raw are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
|
||||
@@ -52,11 +53,14 @@ func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) {
|
||||
if personalEventProjector(false) == nil {
|
||||
t.Fatal("normal personal consume projector = nil")
|
||||
func TestPersonalEventProjectorSelectsExplicitModes(t *testing.T) {
|
||||
if personalEventProjector(false, false) != nil {
|
||||
t.Fatal("default personal consume should preserve transport envelope")
|
||||
}
|
||||
projector := personalEventProjector(true)
|
||||
if personalEventProjector(false, true) == nil {
|
||||
t.Fatal("flatten personal consume projector = nil")
|
||||
}
|
||||
projector := personalEventProjector(true, false)
|
||||
if projector == nil {
|
||||
t.Fatal("debug raw personal consume projector = nil")
|
||||
}
|
||||
@@ -74,6 +78,69 @@ func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeFlattenRejectsRawModesBeforeIdentityResolution(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "raw format",
|
||||
args: []string{personal.EventMention, "--flatten", "--format", "raw"},
|
||||
want: "--flatten and --format raw are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "raw debug",
|
||||
args: []string{personal.EventMention, "--flatten", "--debug-raw-events"},
|
||||
want: "--flatten and --debug-raw-events are mutually exclusive",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs(tc.args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Execute() error = %v, want %q", err, tc.want)
|
||||
}
|
||||
if strings.Contains(err.Error(), "login") || strings.Contains(err.Error(), "token") {
|
||||
t.Fatalf("output-mode validation ran after identity resolution: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePersonalEventOutputModeAllowsFlattenStructuredFormats(t *testing.T) {
|
||||
for _, format := range []consume.Format{consume.FormatNDJSON, consume.FormatJSON, consume.FormatPretty, consume.FormatCompact} {
|
||||
if err := validatePersonalEventOutputMode(true, false, format); err != nil {
|
||||
t.Fatalf("validatePersonalEventOutputMode(true, false, %q) error = %v", format, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeFlattenFlagIsForwarded(t *testing.T) {
|
||||
oldRun := eventRunPersonalConsume
|
||||
t.Cleanup(func() { eventRunPersonalConsume = oldRun })
|
||||
|
||||
var got personalConsumeOptions
|
||||
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
|
||||
got = opts
|
||||
return nil
|
||||
}
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{personal.EventMention, "--flatten", "--format", "compact"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !got.Flatten || got.Common.FormatRaw != "compact" {
|
||||
t.Fatalf("forwarded options = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
@@ -188,7 +188,44 @@ func TestPersonalEventSchemaHidesSchemaIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
func TestPersonalEventSchemaDefaultsToTransportEnvelope(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{personal.EventSingleChat})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if doc["jq_root_path"] != ".data | fromjson" {
|
||||
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
|
||||
}
|
||||
schema, ok := doc["schema"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema = %#v, want object", doc["schema"])
|
||||
}
|
||||
props, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema.properties = %#v, want object", schema["properties"])
|
||||
}
|
||||
for _, field := range []string{"type", "seq", "event_type", "data", "headers", "subscribe_id"} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Fatalf("default envelope schema missing %q: %#v", field, props)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"content", "sender", "conversation_id", "timestamp"} {
|
||||
if _, ok := props[field]; ok {
|
||||
t.Fatalf("default envelope schema unexpectedly contains flat field %q", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
for _, eventKey := range []string{
|
||||
personal.EventMention,
|
||||
personal.EventSingleChat,
|
||||
@@ -200,7 +237,7 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{eventKey})
|
||||
cmd.SetArgs([]string{eventKey, "--flatten"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
@@ -316,7 +353,7 @@ func TestPersonalActionEventSchemaMatchesFlatOutput(t *testing.T) {
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{eventKey})
|
||||
cmd.SetArgs([]string{eventKey, "--flatten"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -367,6 +404,9 @@ func TestEventSchemaDefaultsToUser(t *testing.T) {
|
||||
if doc["event_key"] != personal.EventSingleChat {
|
||||
t.Fatalf("event_key = %#v, want %s", doc["event_key"], personal.EventSingleChat)
|
||||
}
|
||||
if doc["jq_root_path"] != ".data | fromjson" {
|
||||
t.Fatalf("jq_root_path = %#v, want default envelope path", doc["jq_root_path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
|
||||
@@ -469,6 +509,9 @@ func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
|
||||
if _, ok := params["odid"]; ok {
|
||||
t.Fatalf("schema parameters unexpectedly include odid alias: %#v", params)
|
||||
}
|
||||
if _, ok := params["flatten"]; !ok {
|
||||
t.Fatalf("schema parameters missing flatten: %#v", params)
|
||||
}
|
||||
for _, name := range []string{"user", "open-dingtalk-id", "group"} {
|
||||
param, ok := params[name].(map[string]any)
|
||||
if !ok {
|
||||
|
||||
+10
-4
@@ -27,21 +27,27 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller) []*cobra.Command {
|
||||
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
|
||||
injectStaticServers()
|
||||
helpers.InitDeps(caller)
|
||||
commands := helpers.NewPublicCommands(runner)
|
||||
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
|
||||
// command tree, so distilled high-frequency operations mount alongside the
|
||||
// built-ins. Conflicts with built-ins are skipped inside Load.
|
||||
if _, err := userdef.Load(); err != nil {
|
||||
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
|
||||
if loadUserShortcuts {
|
||||
if _, err := userdef.Load(); err != nil {
|
||||
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
|
||||
}
|
||||
}
|
||||
// Built-in + user shortcuts (`dws <service> +<command>`) share the same
|
||||
// command tree; mergeTopLevelCommands folds each shortcut's service parent
|
||||
// into the matching helper command so the `+leaf` sits alongside existing
|
||||
// subcommands.
|
||||
commands = append(commands, builtin.Commands()...)
|
||||
if loadUserShortcuts {
|
||||
commands = append(commands, builtin.Commands()...)
|
||||
} else {
|
||||
commands = append(commands, builtin.BaseCommands()...)
|
||||
}
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,675 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type pluginFailRunner struct{}
|
||||
|
||||
func (pluginFailRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
return executor.Result{}, errors.New("runner failed")
|
||||
}
|
||||
|
||||
type pluginWrongFlagValue struct{}
|
||||
|
||||
func (pluginWrongFlagValue) String() string { return "" }
|
||||
func (pluginWrongFlagValue) Set(string) error { return nil }
|
||||
func (pluginWrongFlagValue) Type() string { return "wrong" }
|
||||
|
||||
func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) {
|
||||
invalidRoot := conferencePluginDescriptor()
|
||||
invalidRoot.CLI.Command = "Invalid Root"
|
||||
if commands := buildPluginCommands([]mcptypes.ServerDescriptor{invalidRoot}, executor.EchoRunner{}, nil); len(commands) != 0 {
|
||||
t.Fatalf("invalid root produced commands %#v", commands)
|
||||
}
|
||||
|
||||
descriptor := conferencePluginDescriptor()
|
||||
descriptor.CLI.Groups = map[string]mcptypes.CLIGroupDef{
|
||||
"empty": {Description: "removed when no leaf survives"},
|
||||
}
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"": {CLIName: "blank-tool"},
|
||||
"hidden": {CLIName: "hidden", Hidden: true},
|
||||
"invalid": {CLIName: "Invalid Leaf"},
|
||||
"first": {CLIName: "same"},
|
||||
"second": {CLIName: "same"},
|
||||
}
|
||||
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("commands = %#v", commands)
|
||||
}
|
||||
if requireOptionalPluginChild(commands[0], "same") == nil {
|
||||
t.Fatal("valid leaf was not retained")
|
||||
}
|
||||
if requireOptionalPluginChild(commands[0], "empty") != nil {
|
||||
t.Fatal("empty group was not pruned")
|
||||
}
|
||||
|
||||
empty := conferencePluginDescriptor()
|
||||
empty.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"hidden": {CLIName: "hidden", Hidden: true},
|
||||
}
|
||||
if commands := buildPluginCommands([]mcptypes.ServerDescriptor{empty}, executor.EchoRunner{}, nil); len(commands) != 0 {
|
||||
t.Fatalf("empty overlay produced commands %#v", commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginLeafExecutionErrorsAndBodyWrapper(t *testing.T) {
|
||||
base := conferencePluginDescriptor()
|
||||
base.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"wrapped": {
|
||||
CLIName: "wrapped",
|
||||
BodyWrapper: "body",
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"value": {Required: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
runner := &pluginCaptureRunner{}
|
||||
root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, runner, nil)...)
|
||||
root.SetArgs([]string{"conference", "wrapped", "--value", "ok", "--params", `{"body":{"old":1},"_meta":"kept"}`})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("wrapped command: %v", err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"_meta": "kept",
|
||||
"body": map[string]any{"old": float64(1), "value": "ok"},
|
||||
}
|
||||
if !reflect.DeepEqual(runner.invocations[0].Params, want) {
|
||||
t.Fatalf("wrapped params = %#v, want %#v", runner.invocations[0].Params, want)
|
||||
}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
runner executor.Runner
|
||||
args []string
|
||||
}{
|
||||
{name: "invalid json", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped", "--json", "["}},
|
||||
{name: "missing required", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped"}},
|
||||
{name: "missing runner", runner: nil, args: []string{"conference", "wrapped", "--value", "ok"}},
|
||||
{name: "runner error", runner: pluginFailRunner{}, args: []string{"conference", "wrapped", "--value", "ok"}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
commandRoot := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, testCase.runner, nil)...)
|
||||
commandRoot.SetArgs(testCase.args)
|
||||
if err := commandRoot.Execute(); err == nil {
|
||||
t.Fatal("expected command error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, flagName := range []string{"json", "params"} {
|
||||
t.Run("unreadable "+flagName, func(t *testing.T) {
|
||||
commands := buildPluginCommands([]mcptypes.ServerDescriptor{base}, executor.EchoRunner{}, nil)
|
||||
leaf := requirePluginChild(t, commands[0], "wrapped")
|
||||
leaf.Flags().Lookup(flagName).Value = pluginWrongFlagValue{}
|
||||
commandRoot := pluginTestRoot(commands...)
|
||||
commandRoot.SetArgs([]string{"conference", "wrapped", "--value", "ok"})
|
||||
if err := commandRoot.Execute(); err == nil {
|
||||
t.Fatal("expected unreadable flag error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginBindingCompilerCoversAliasesAndPositionalValidators(t *testing.T) {
|
||||
reservations := pluginFlagReservations{
|
||||
names: map[string]bool{"reserved": true},
|
||||
shorthands: map[string]bool{},
|
||||
}
|
||||
bindings, _, _, ok := registerPluginBindings("alias", mcptypes.CLIToolOverride{
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"value": {Alias: "value", Aliases: []string{"", "Bad", "value", "other"}},
|
||||
},
|
||||
}, reservations)
|
||||
if !ok || !reflect.DeepEqual(bindings[0].names, []string{"value", "other"}) {
|
||||
t.Fatalf("alias bindings = (%#v, %v)", bindings, ok)
|
||||
}
|
||||
if _, _, _, ok := registerPluginBindings("conflict", mcptypes.CLIToolOverride{
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "reserved"}},
|
||||
}, reservations); ok {
|
||||
t.Fatal("reserved flag was accepted")
|
||||
}
|
||||
if _, _, _, ok := registerPluginBindings("negative", mcptypes.CLIToolOverride{
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Positional: true, PositionalIndex: -1}},
|
||||
}, reservations); ok {
|
||||
t.Fatal("negative positional index was accepted")
|
||||
}
|
||||
if _, _, _, ok := registerPluginBindings("duplicate", mcptypes.CLIToolOverride{
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"first": {Positional: true, PositionalIndex: 0},
|
||||
"second": {Positional: true, PositionalIndex: 0},
|
||||
},
|
||||
}, reservations); ok {
|
||||
t.Fatal("duplicate positional index was accepted")
|
||||
}
|
||||
if _, _, _, ok := registerPluginBindings("gap", mcptypes.CLIToolOverride{
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"second": {Positional: true, PositionalIndex: 1},
|
||||
},
|
||||
}, reservations); ok {
|
||||
t.Fatal("non-contiguous positional indexes were accepted")
|
||||
}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
flags map[string]mcptypes.CLIFlagOverride
|
||||
wantUse string
|
||||
valid []string
|
||||
invalid []string
|
||||
}{
|
||||
{
|
||||
name: "exact",
|
||||
flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"second": {Positional: true, PositionalIndex: 1, Required: true},
|
||||
"first": {Positional: true, PositionalIndex: 0, Required: true},
|
||||
},
|
||||
wantUse: "exact [first] [second]", valid: []string{"a", "b"}, invalid: []string{"a"},
|
||||
},
|
||||
{
|
||||
name: "range",
|
||||
flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"first": {Positional: true, PositionalIndex: 0, Required: true},
|
||||
"second": {Positional: true, PositionalIndex: 1},
|
||||
},
|
||||
wantUse: "range [first] [second]", valid: []string{"a"}, invalid: []string{},
|
||||
},
|
||||
{
|
||||
name: "maximum",
|
||||
flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"first": {Positional: true, PositionalIndex: 0},
|
||||
"second": {Positional: true, PositionalIndex: 1},
|
||||
},
|
||||
wantUse: "maximum [first] [second]", valid: []string{}, invalid: []string{"a", "b", "c"},
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, use, validator, ok := registerPluginBindings(testCase.name, mcptypes.CLIToolOverride{Flags: testCase.flags}, reservations)
|
||||
if !ok || use != testCase.wantUse {
|
||||
t.Fatalf("binding contract = (%q, %v)", use, ok)
|
||||
}
|
||||
cmd := &cobra.Command{Use: testCase.name}
|
||||
if err := validator(cmd, testCase.valid); err != nil {
|
||||
t.Fatalf("valid args: %v", err)
|
||||
}
|
||||
if err := validator(cmd, testCase.invalid); err == nil {
|
||||
t.Fatal("invalid args were accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginFlagRegistrationAndReadingCoversAllKinds(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "leaf"}
|
||||
override := mcptypes.CLIToolOverride{Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"integer": {Default: "2", Shorthand: "i", Hidden: true},
|
||||
"float": {Default: "1.5"},
|
||||
"boolean": {Default: "true"},
|
||||
"slice": {Default: "one, ,two"},
|
||||
"json": {Default: `{"old":true}`},
|
||||
"string": {Default: "text"},
|
||||
}}
|
||||
bindings := []pluginFlagBinding{
|
||||
{property: "integer", names: []string{"integer", "integer-alias"}, kind: pluginFlagInt},
|
||||
{property: "float", names: []string{"float"}, kind: pluginFlagFloat},
|
||||
{property: "boolean", names: []string{"boolean"}, kind: pluginFlagBool},
|
||||
{property: "slice", names: []string{"slice"}, kind: pluginFlagStringSlice},
|
||||
{property: "json", names: []string{"json-value"}, kind: pluginFlagJSON},
|
||||
{property: "string", names: []string{"string"}, kind: pluginFlagString},
|
||||
}
|
||||
registerPluginFlags(cmd, bindings, override, pluginFlagReservations{shorthands: map[string]bool{}})
|
||||
for name, raw := range map[string]string{
|
||||
"integer": "3", "float": "2.5", "boolean": "false",
|
||||
"slice": "three,four", "json-value": `{"ok":true}`, "string": "changed",
|
||||
} {
|
||||
if err := cmd.Flags().Set(name, raw); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
wants := map[string]any{
|
||||
"integer": 3,
|
||||
"float": 2.5,
|
||||
"boolean": false,
|
||||
"slice": []string{"three", "four"},
|
||||
"json": map[string]any{"ok": true},
|
||||
"string": "changed",
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
value, err := readPluginFlag(cmd.Flags(), binding.names[0], binding.kind)
|
||||
if err != nil || !reflect.DeepEqual(value, wants[binding.property]) {
|
||||
t.Fatalf("read %s = (%#v, %v), want %#v", binding.property, value, err, wants[binding.property])
|
||||
}
|
||||
}
|
||||
if !cmd.Flags().Lookup("integer").Hidden || !cmd.Flags().Lookup("integer-alias").Hidden {
|
||||
t.Fatal("hidden primary or alias flag was exposed")
|
||||
}
|
||||
if err := cmd.Flags().Set("json-value", "{"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil {
|
||||
t.Fatal("invalid JSON flag was accepted")
|
||||
}
|
||||
cmd.Flags().Lookup("json-value").Value = pluginWrongFlagValue{}
|
||||
if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil {
|
||||
t.Fatal("wrong JSON flag type was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectPluginBindingsCoversEveryValueSourceAndFailure(t *testing.T) {
|
||||
t.Run("sources", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "leaf"}
|
||||
registerPluginFlag(cmd.Flags(), "flag", "", "", pluginFlagString, "")
|
||||
if err := cmd.Flags().Set("flag", "from-flag"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PLUGIN_COVERAGE_ENV", "7")
|
||||
params := map[string]any{"existing": "from-json"}
|
||||
bindings := []pluginFlagBinding{
|
||||
{property: "flag", names: []string{"flag"}, kind: pluginFlagString},
|
||||
{property: "existing", kind: pluginFlagString},
|
||||
{property: "positional", kind: pluginFlagBool, positional: true, positionalIndex: 0},
|
||||
{property: "default", kind: pluginFlagFloat, defaultProvided: true, defaultValue: "1.5"},
|
||||
{property: "env", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_ENV"},
|
||||
{property: "optional", kind: pluginFlagString},
|
||||
}
|
||||
if err := collectPluginBindings(cmd, []string{"true"}, bindings, params); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"flag": "from-flag", "existing": "from-json", "positional": true,
|
||||
"default": 1.5, "env": 7,
|
||||
}
|
||||
if !reflect.DeepEqual(params, want) {
|
||||
t.Fatalf("params = %#v, want %#v", params, want)
|
||||
}
|
||||
})
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
prepare func(t *testing.T, cmd *cobra.Command)
|
||||
args []string
|
||||
binding pluginFlagBinding
|
||||
params map[string]any
|
||||
}{
|
||||
{
|
||||
name: "wrong flag type",
|
||||
prepare: func(t *testing.T, cmd *cobra.Command) {
|
||||
cmd.Flags().String("value", "", "")
|
||||
if err := cmd.Flags().Set("value", "x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
binding: pluginFlagBinding{property: "value", names: []string{"value"}, kind: pluginFlagInt},
|
||||
},
|
||||
{name: "invalid positional", args: []string{"maybe"}, binding: pluginFlagBinding{property: "value", kind: pluginFlagBool, positional: true, positionalIndex: 0}},
|
||||
{name: "invalid default", binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, defaultProvided: true, defaultValue: "bad"}},
|
||||
{
|
||||
name: "invalid env",
|
||||
prepare: func(t *testing.T, _ *cobra.Command) { t.Setenv("PLUGIN_COVERAGE_BAD_ENV", "bad") },
|
||||
binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_BAD_ENV"},
|
||||
},
|
||||
{name: "missing named required", binding: pluginFlagBinding{property: "value", names: []string{"value"}, required: true}},
|
||||
{name: "missing positional required", binding: pluginFlagBinding{property: "value", required: true, positional: true, positionalIndex: 0}},
|
||||
{name: "required omitted", binding: pluginFlagBinding{property: "value", required: true, defaultProvided: true, defaultValue: "", omitWhen: "empty"}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "leaf"}
|
||||
if testCase.prepare != nil {
|
||||
testCase.prepare(t, cmd)
|
||||
}
|
||||
if err := collectPluginBindings(cmd, testCase.args, []pluginFlagBinding{testCase.binding}, testCase.params); err == nil {
|
||||
t.Fatal("expected binding error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
params := map[string]any{"value": ""}
|
||||
if err := collectPluginBindings(&cobra.Command{Use: "leaf"}, nil, []pluginFlagBinding{{
|
||||
property: "value", kind: pluginFlagString, omitWhen: "empty",
|
||||
}}, params); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := params["value"]; exists {
|
||||
t.Fatal("optional empty value was not omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginValueAndNamingHelpers(t *testing.T) {
|
||||
parseCases := []struct {
|
||||
kind pluginFlagKind
|
||||
raw string
|
||||
want any
|
||||
}{
|
||||
{pluginFlagInt, " 2 ", 2},
|
||||
{pluginFlagFloat, " 2.5 ", 2.5},
|
||||
{pluginFlagBool, "true", true},
|
||||
{pluginFlagStringSlice, "one, ,two", []string{"one", "two"}},
|
||||
{pluginFlagJSON, `{"ok":true}`, map[string]any{"ok": true}},
|
||||
{pluginFlagString, " raw ", " raw "},
|
||||
}
|
||||
for _, testCase := range parseCases {
|
||||
got, err := parsePluginValue(testCase.raw, testCase.kind)
|
||||
if err != nil || !reflect.DeepEqual(got, testCase.want) {
|
||||
t.Fatalf("parse %q = (%#v, %v), want %#v", testCase.raw, got, err, testCase.want)
|
||||
}
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
kind pluginFlagKind
|
||||
raw string
|
||||
}{
|
||||
{pluginFlagInt, "bad"}, {pluginFlagFloat, "bad"}, {pluginFlagBool, "bad"}, {pluginFlagJSON, "{"},
|
||||
} {
|
||||
if _, err := parsePluginValue(testCase.raw, testCase.kind); err == nil {
|
||||
t.Fatalf("invalid %q was accepted", testCase.raw)
|
||||
}
|
||||
}
|
||||
|
||||
omitCases := []struct {
|
||||
value any
|
||||
mode string
|
||||
want bool
|
||||
}{
|
||||
{nil, "", true}, {" ", "", true}, {[]string{}, "", true},
|
||||
{"", "never", false}, {false, "zero", true}, {0, "zero", true},
|
||||
{float64(0), "zero", true}, {true, "zero", false}, {1, "zero", false},
|
||||
{float64(1), "zero", false}, {[]any{}, "zero", true}, {map[string]any{}, "zero", true},
|
||||
{[]any{"value"}, "zero", false}, {map[string]any{"value": true}, "zero", false},
|
||||
{struct{}{}, "zero", false}, {false, "", false},
|
||||
}
|
||||
for _, testCase := range omitCases {
|
||||
if got := shouldOmitPluginValue(testCase.value, testCase.mode); got != testCase.want {
|
||||
t.Fatalf("omit (%#v, %q) = %v, want %v", testCase.value, testCase.mode, got, testCase.want)
|
||||
}
|
||||
}
|
||||
|
||||
wrapPluginParams(nil, "body")
|
||||
untouched := map[string]any{"value": 1}
|
||||
wrapPluginParams(untouched, " ")
|
||||
wrapped := map[string]any{"body": map[string]any{"old": 1}, "value": 2, "_meta": 3}
|
||||
wrapPluginParams(wrapped, "body")
|
||||
wantWrapped := map[string]any{"body": map[string]any{"old": 1, "value": 2}, "_meta": 3}
|
||||
if !reflect.DeepEqual(wrapped, wantWrapped) {
|
||||
t.Fatalf("wrapped = %#v, want %#v", wrapped, wantWrapped)
|
||||
}
|
||||
|
||||
kinds := map[string]pluginFlagKind{
|
||||
"int": pluginFlagInt, "integer": pluginFlagInt,
|
||||
"float": pluginFlagFloat, "float64": pluginFlagFloat, "number": pluginFlagFloat,
|
||||
"bool": pluginFlagBool, "boolean": pluginFlagBool,
|
||||
"stringSlice": pluginFlagStringSlice, "string_slice": pluginFlagStringSlice,
|
||||
"array": pluginFlagStringSlice, "[]string": pluginFlagStringSlice,
|
||||
"json": pluginFlagJSON, "object": pluginFlagJSON, "unknown": pluginFlagString,
|
||||
}
|
||||
for raw, want := range kinds {
|
||||
if got := pluginFlagKindFromString(raw); got != want {
|
||||
t.Fatalf("kind %q = %v, want %v", raw, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
used := map[string]bool{}
|
||||
reserved := map[string]bool{"r": true}
|
||||
if got := safePluginShorthand(" x ", used, reserved); got != "x" || !used["x"] {
|
||||
t.Fatalf("safe shorthand = %q / %#v", got, used)
|
||||
}
|
||||
for _, raw := range []string{"", "xy", "x", "r"} {
|
||||
if got := safePluginShorthand(raw, used, reserved); got != "" {
|
||||
t.Fatalf("unsafe shorthand %q = %q", raw, got)
|
||||
}
|
||||
}
|
||||
|
||||
baseReservations := pluginReservedFlags(nil)
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().StringP("custom", "c", "", "")
|
||||
rootReservations := pluginReservedFlags(root)
|
||||
if !baseReservations.names["yes"] || !rootReservations.names["custom"] || !rootReservations.shorthands["c"] {
|
||||
t.Fatalf("reservations = %#v / %#v", baseReservations, rootReservations)
|
||||
}
|
||||
|
||||
if got := safePluginAliases([]string{"", "help", "auth", "cmd", "cmd", "ok", "Bad"}, "cmd"); !reflect.DeepEqual(got, []string{"ok"}) {
|
||||
t.Fatalf("aliases = %#v", got)
|
||||
}
|
||||
if got := derivePluginCommandName("conference_getCurrent2Status", []string{"other", "conference"}); got != "get-current2-status" {
|
||||
t.Fatalf("derived name = %q", got)
|
||||
}
|
||||
if got := pluginKebabName(" HTTP2.Foo_bar baz@ "); got != "http2-foo-bar-baz@" {
|
||||
t.Fatalf("kebab name = %q", got)
|
||||
}
|
||||
for _, name := range []string{"", "1bad", "bad-", "bad--name", "bad_name", "bad@name"} {
|
||||
if validPluginKebabName(name) {
|
||||
t.Fatalf("invalid kebab name %q was accepted", name)
|
||||
}
|
||||
}
|
||||
if !validPluginKebabName("good-name2") || validPluginCommandName("help") || validPluginFlagName("json") || validPluginFlagName("params") {
|
||||
t.Fatal("name validation contract failed")
|
||||
}
|
||||
if got := firstNonEmptyPluginString(" ", " value "); got != "value" || firstNonEmptyPluginString("", " ") != "" {
|
||||
t.Fatal("first non-empty string contract failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginConstraintGroupAndRootHelpers(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "leaf"}
|
||||
for _, name := range []string{"a", "b", "c"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
applyPluginFlagConstraints(cmd, mcptypes.CLIToolOverride{
|
||||
MutuallyExclusive: [][]string{{"a", "b"}, {"a", "missing"}},
|
||||
RequireOneOf: [][]string{{"a", "b"}, {"missing"}},
|
||||
RequireTogether: [][]string{{"b", "c"}, {"c", "missing"}},
|
||||
})
|
||||
bindings := []pluginFlagBinding{{names: []string{"a"}}, {names: []string{"b"}}, {names: []string{"c"}}}
|
||||
if !validPluginFlagConstraints(bindings, mcptypes.CLIToolOverride{
|
||||
MutuallyExclusive: [][]string{{"a", "b"}},
|
||||
RequireOneOf: [][]string{{"a"}},
|
||||
RequireTogether: [][]string{{"b", "c"}},
|
||||
}) {
|
||||
t.Fatal("valid plugin constraints were rejected")
|
||||
}
|
||||
for _, invalid := range []mcptypes.CLIToolOverride{
|
||||
{MutuallyExclusive: [][]string{{"a"}}},
|
||||
{RequireOneOf: [][]string{{"missing"}}},
|
||||
{RequireTogether: [][]string{{"a", "a"}}},
|
||||
} {
|
||||
if validPluginFlagConstraints(bindings, invalid) {
|
||||
t.Fatalf("invalid plugin constraints were accepted: %#v", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
groups := map[string]*cobra.Command{}
|
||||
root := &cobra.Command{Use: "root"}
|
||||
group := ensurePluginGroup(root, "parent.child", "child description", groups)
|
||||
if group.Name() != "child" || group.Short != "child description" || !cmdutil.IsPluginSourced(group) {
|
||||
t.Fatalf("group = %#v", group)
|
||||
}
|
||||
if again := ensurePluginGroup(root, "parent.child", "ignored", groups); again != group {
|
||||
t.Fatal("existing group was not reused")
|
||||
}
|
||||
for _, invalid := range []string{"safe.bad_name", "_bad", ".parent", "parent."} {
|
||||
if got := ensurePluginGroup(root, invalid, "invalid", groups); got != nil {
|
||||
t.Fatalf("invalid group path %q produced %#v", invalid, got)
|
||||
}
|
||||
}
|
||||
|
||||
mergePluginRoot(nil, root)
|
||||
mergePluginRoot(root, nil)
|
||||
destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}}
|
||||
source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}}
|
||||
source.AddCommand(&cobra.Command{Use: "leaf"})
|
||||
mergePluginRoot(destination, source)
|
||||
if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil {
|
||||
t.Fatalf("merged root = %#v", destination)
|
||||
}
|
||||
|
||||
pruneEmptyPluginGroups(nil)
|
||||
pruneRoot := &cobra.Command{Use: "root"}
|
||||
empty := cobracmd.NewGroupCommand("empty", "empty")
|
||||
nonEmpty := cobracmd.NewGroupCommand("non-empty", "non-empty")
|
||||
nonEmpty.AddCommand(&cobra.Command{Use: "leaf"})
|
||||
pruneRoot.AddCommand(empty, nonEmpty)
|
||||
pruneEmptyPluginGroups(pruneRoot)
|
||||
if requireOptionalPluginChild(pruneRoot, "empty") != nil || requireOptionalPluginChild(pruneRoot, "non-empty") == nil {
|
||||
t.Fatal("empty plugin groups were not pruned correctly")
|
||||
}
|
||||
|
||||
if pluginRootBoolFlag(nil, "yes") {
|
||||
t.Fatal("nil command reported a root flag")
|
||||
}
|
||||
noFlag := &cobra.Command{Use: "root"}
|
||||
if pluginRootBoolFlag(noFlag, "yes") {
|
||||
t.Fatal("missing flag reported true")
|
||||
}
|
||||
wrongType := &cobra.Command{Use: "root"}
|
||||
wrongType.PersistentFlags().String("yes", "true", "")
|
||||
if pluginRootBoolFlag(wrongType, "yes") {
|
||||
t.Fatal("wrong flag type reported true")
|
||||
}
|
||||
boolRoot := &cobra.Command{Use: "root"}
|
||||
boolRoot.PersistentFlags().Bool("yes", false, "")
|
||||
if err := boolRoot.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !pluginRootBoolFlag(boolRoot, "yes") {
|
||||
t.Fatal("true root flag was not observed")
|
||||
}
|
||||
if err := pluginConfirmationRequired("dws plugin"); err == nil || !strings.Contains(err.Error(), "sensitive") {
|
||||
t.Fatalf("confirmation error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedPluginSemanticsReportEveryField(t *testing.T) {
|
||||
overlays := []struct {
|
||||
value mcptypes.CLIOverlay
|
||||
want string
|
||||
}{
|
||||
{mcptypes.CLIOverlay{Parent: "root"}, "parent"},
|
||||
{mcptypes.CLIOverlay{Group: "group"}, "group"},
|
||||
{mcptypes.CLIOverlay{ServerDeps: []string{"other"}}, "serverDeps"},
|
||||
{mcptypes.CLIOverlay{Hints: map[string]json.RawMessage{"x": json.RawMessage(`{}`)}}, "hintCommands"},
|
||||
{mcptypes.CLIOverlay{RedirectTo: "other"}, "redirectTo"},
|
||||
{mcptypes.CLIOverlay{}, ""},
|
||||
}
|
||||
for _, testCase := range overlays {
|
||||
if got := unsupportedPluginOverlay(testCase.value); got != testCase.want {
|
||||
t.Fatalf("unsupported overlay = %q, want %q", got, testCase.want)
|
||||
}
|
||||
}
|
||||
|
||||
tools := []struct {
|
||||
value mcptypes.CLIToolOverride
|
||||
want string
|
||||
}{
|
||||
{mcptypes.CLIToolOverride{CLIAliases: []string{"x"}}, "cliAliases"},
|
||||
{mcptypes.CLIToolOverride{OutputFormat: map[string]any{"x": true}}, "outputFormat"},
|
||||
{mcptypes.CLIToolOverride{ServerOverride: "other"}, "serverOverride"},
|
||||
{mcptypes.CLIToolOverride{RedirectTo: "x"}, "redirectTo"},
|
||||
{mcptypes.CLIToolOverride{Pipeline: []json.RawMessage{json.RawMessage(`{}`)}}, "pipeline"},
|
||||
{mcptypes.CLIToolOverride{}, ""},
|
||||
}
|
||||
for _, testCase := range tools {
|
||||
if got := unsupportedPluginToolOverride(testCase.value); got != testCase.want {
|
||||
t.Fatalf("unsupported tool = %q, want %q", got, testCase.want)
|
||||
}
|
||||
}
|
||||
|
||||
flags := []struct {
|
||||
value mcptypes.CLIFlagOverride
|
||||
want string
|
||||
}{
|
||||
{mcptypes.CLIFlagOverride{MapsTo: "x"}, "mapsTo"},
|
||||
{mcptypes.CLIFlagOverride{Transform: "x"}, "transform"},
|
||||
{mcptypes.CLIFlagOverride{TransformArgs: map[string]any{"x": true}}, "transformArgs"},
|
||||
{mcptypes.CLIFlagOverride{RuntimeDefault: "x"}, "runtimeDefault"},
|
||||
{mcptypes.CLIFlagOverride{PipelineLocal: true}, "pipelineLocal"},
|
||||
{mcptypes.CLIFlagOverride{Type: "mystery"}, "type"},
|
||||
{mcptypes.CLIFlagOverride{OmitWhen: "sometimes"}, "omitWhen"},
|
||||
{mcptypes.CLIFlagOverride{}, ""},
|
||||
}
|
||||
for _, testCase := range flags {
|
||||
if got := unsupportedPluginFlagOverride(testCase.value); got != testCase.want {
|
||||
t.Fatalf("unsupported flag = %q, want %q", got, testCase.want)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "string", "integer", "float64", "boolean", "stringSlice", "array", "json", "object"} {
|
||||
if !supportedPluginFlagType(value) {
|
||||
t.Fatalf("supported plugin flag type %q was rejected", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "empty", "zero", "never"} {
|
||||
if !supportedPluginOmitMode(value) {
|
||||
t.Fatalf("supported plugin omit mode %q was rejected", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedPluginDescriptorRejectsEveryInvalidLayer(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
mutate func(*mcptypes.ServerDescriptor)
|
||||
want string
|
||||
}{
|
||||
{name: "overlay", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Parent = "root" }, want: "parent"},
|
||||
{name: "no tools", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.ToolOverrides = nil }, want: ""},
|
||||
{name: "root", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Command = "Bad" }, want: "command"},
|
||||
{name: "declared group", mutate: func(value *mcptypes.ServerDescriptor) {
|
||||
value.CLI.Groups = map[string]mcptypes.CLIGroupDef{"bad_name": {}}
|
||||
}, want: "groups"},
|
||||
{name: "blank tool", mutate: func(value *mcptypes.ServerDescriptor) {
|
||||
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"": {}}
|
||||
}, want: "tool"},
|
||||
{name: "tool semantics", mutate: func(value *mcptypes.ServerDescriptor) {
|
||||
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {ServerOverride: "drive"}}
|
||||
}, want: "serverOverride"},
|
||||
{name: "hidden tool", mutate: func(value *mcptypes.ServerDescriptor) {
|
||||
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {Hidden: true, ServerOverride: "drive"}}
|
||||
}, want: ""},
|
||||
{name: "derived leaf", mutate: func(value *mcptypes.ServerDescriptor) {
|
||||
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"conference_derived_tool": {}}
|
||||
}, want: ""},
|
||||
{name: "leaf", mutate: func(value *mcptypes.ServerDescriptor) {
|
||||
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "Bad"}}
|
||||
}, want: "cliName"},
|
||||
{name: "leaf group", mutate: func(value *mcptypes.ServerDescriptor) {
|
||||
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "leaf", Group: "bad_name"}}
|
||||
}, want: "group"},
|
||||
{name: "flags", mutate: func(value *mcptypes.ServerDescriptor) {
|
||||
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {
|
||||
CLIName: "leaf",
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "yes"}},
|
||||
}}
|
||||
}, want: "flags"},
|
||||
{name: "constraints", mutate: func(value *mcptypes.ServerDescriptor) {
|
||||
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {
|
||||
CLIName: "leaf",
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{"value": {}},
|
||||
RequireTogether: [][]string{{"value", "missing"}},
|
||||
}}
|
||||
}, want: "constraints"},
|
||||
{name: "valid", want: ""},
|
||||
}
|
||||
root := pluginTestRoot()
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
descriptor := conferencePluginDescriptor()
|
||||
if testCase.mutate != nil {
|
||||
testCase.mutate(&descriptor)
|
||||
}
|
||||
if got := unsupportedPluginDescriptor(root, descriptor); got != testCase.want {
|
||||
t.Fatalf("unsupported descriptor = %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
// 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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
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/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type pluginCaptureRunner struct {
|
||||
invocations []executor.Invocation
|
||||
}
|
||||
|
||||
func (r *pluginCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.invocations = append(r.invocations, invocation)
|
||||
return executor.Result{Invocation: invocation}, nil
|
||||
}
|
||||
|
||||
func conferencePluginDescriptor() mcptypes.ServerDescriptor {
|
||||
return mcptypes.ServerDescriptor{
|
||||
Key: "conference-local",
|
||||
DisplayName: "conference/conference-local",
|
||||
Description: "conference plugin",
|
||||
Endpoint: "stdio://conference/conference-local",
|
||||
Source: "plugin",
|
||||
HasCLIMeta: true,
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: "conference-local",
|
||||
Command: "conference",
|
||||
Description: "视频会议:发起/邀请入会/会中控制",
|
||||
Prefixes: []string{"conference"},
|
||||
Groups: map[string]mcptypes.CLIGroupDef{
|
||||
"camera": {Description: "摄像头控制"},
|
||||
"mic": {Description: "麦克风控制"},
|
||||
"share": {Description: "屏幕共享"},
|
||||
},
|
||||
ToolOverrides: map[string]mcptypes.CLIToolOverride{
|
||||
"create_conference": {
|
||||
CLIName: "start",
|
||||
Description: "发起即时会议",
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"title": {Description: "会议标题"},
|
||||
},
|
||||
},
|
||||
"get_conference_status": {
|
||||
CLIName: "status",
|
||||
Description: "查询当前会议状态",
|
||||
},
|
||||
"ai_end_meeting_for_all": {
|
||||
CLIName: "end",
|
||||
Description: "结束会议(所有人)",
|
||||
IsSensitive: true,
|
||||
},
|
||||
"ai_open_camera": {
|
||||
CLIName: "open",
|
||||
Group: "camera",
|
||||
Description: "打开摄像头",
|
||||
},
|
||||
"ai_mute_mic": {
|
||||
CLIName: "mute",
|
||||
Group: "mic",
|
||||
Description: "静音自己",
|
||||
},
|
||||
"ai_share_desktop": {
|
||||
CLIName: "start",
|
||||
Group: "share",
|
||||
Description: "开始共享桌面",
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"capture_speaker": {Description: "是否共享电脑音频"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pluginTestRoot(commands ...*cobra.Command) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().StringP("format", "f", "json", "")
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.AddCommand(commands...)
|
||||
return root
|
||||
}
|
||||
|
||||
func requirePluginChild(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
current := parent
|
||||
for _, name := range names {
|
||||
var next *cobra.Command
|
||||
for _, child := range current.Commands() {
|
||||
if child.Name() == name {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
t.Fatalf("missing plugin command %q below %q", name, current.CommandPath())
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func TestPluginOverlayBuildsConferenceTreeAndDispatchesOriginalProperties(t *testing.T) {
|
||||
runner := &pluginCaptureRunner{}
|
||||
commands := buildPluginCommands([]mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil)
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("plugin roots = %d, want 1", len(commands))
|
||||
}
|
||||
conference := commands[0]
|
||||
if conference.Name() != "conference" || conference.Short != "视频会议:发起/邀请入会/会中控制" {
|
||||
t.Fatalf("conference root = %q / %q", conference.Name(), conference.Short)
|
||||
}
|
||||
if !cmdutil.IsPluginSourced(conference) {
|
||||
t.Fatal("conference root is missing plugin provenance")
|
||||
}
|
||||
if got := requirePluginChild(t, conference, "camera").Short; got != "摄像头控制" {
|
||||
t.Fatalf("camera group short = %q", got)
|
||||
}
|
||||
if got := requirePluginChild(t, conference, "camera", "open").Short; got != "打开摄像头" {
|
||||
t.Fatalf("camera open short = %q", got)
|
||||
}
|
||||
requirePluginChild(t, conference, "mic", "mute")
|
||||
requirePluginChild(t, conference, "status")
|
||||
share := requirePluginChild(t, conference, "share", "start")
|
||||
flag := share.Flags().Lookup("capture-speaker")
|
||||
if flag == nil || flag.Usage != "是否共享电脑音频" {
|
||||
t.Fatalf("capture-speaker flag = %#v", flag)
|
||||
}
|
||||
|
||||
root := pluginTestRoot(commands...)
|
||||
root.SetArgs([]string{
|
||||
"conference", "start",
|
||||
"--json", `{"from_json":"kept","title":"json"}`,
|
||||
"--params", `{"from_params":2,"title":"params"}`,
|
||||
"--title", "验证会议",
|
||||
"--dry-run",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("conference start: %v", err)
|
||||
}
|
||||
if len(runner.invocations) != 1 {
|
||||
t.Fatalf("runner calls = %d, want 1", len(runner.invocations))
|
||||
}
|
||||
invocation := runner.invocations[0]
|
||||
if invocation.Kind != "compat_invocation" ||
|
||||
invocation.CanonicalProduct != "conference-local" ||
|
||||
invocation.Tool != "create_conference" ||
|
||||
!invocation.DryRun {
|
||||
t.Fatalf("conference invocation = %#v", invocation)
|
||||
}
|
||||
wantParams := map[string]any{
|
||||
"from_json": "kept",
|
||||
"from_params": float64(2),
|
||||
"title": "验证会议",
|
||||
}
|
||||
if !reflect.DeepEqual(invocation.Params, wantParams) {
|
||||
t.Fatalf("conference params = %#v, want %#v", invocation.Params, wantParams)
|
||||
}
|
||||
|
||||
precedenceRunner := &pluginCaptureRunner{}
|
||||
precedenceRoot := pluginTestRoot(buildPluginCommands(
|
||||
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
|
||||
precedenceRunner,
|
||||
nil,
|
||||
)...)
|
||||
precedenceRoot.SetArgs([]string{
|
||||
"conference", "start",
|
||||
"--json", `{"title":"json"}`,
|
||||
"--params", `{"title":"params"}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err := precedenceRoot.Execute(); err != nil {
|
||||
t.Fatalf("conference payload precedence: %v", err)
|
||||
}
|
||||
if got := precedenceRunner.invocations[0].Params["title"]; got != "params" {
|
||||
t.Fatalf("conference payload title = %#v, want --params value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginOverlayTypedFlags(t *testing.T) {
|
||||
descriptor := conferencePluginDescriptor()
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"typed_tool": {
|
||||
CLIName: "typed",
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"conversationId": {Required: true, Description: "conversation"},
|
||||
"enabled": {Type: "bool"},
|
||||
"limit": {Type: "int"},
|
||||
"tags": {Type: "stringSlice"},
|
||||
},
|
||||
},
|
||||
}
|
||||
runner := &pluginCaptureRunner{}
|
||||
root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, nil)...)
|
||||
root.SetArgs([]string{
|
||||
"conference", "typed",
|
||||
"--conversation-id", "cid",
|
||||
"--enabled=false",
|
||||
"--limit", "3",
|
||||
"--tags", "one,two",
|
||||
"--dry-run",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("typed plugin command: %v", err)
|
||||
}
|
||||
if len(runner.invocations) != 1 {
|
||||
t.Fatalf("runner calls = %d", len(runner.invocations))
|
||||
}
|
||||
invocation := runner.invocations[0]
|
||||
if invocation.CanonicalProduct != "conference-local" {
|
||||
t.Fatalf("canonical product = %q", invocation.CanonicalProduct)
|
||||
}
|
||||
want := map[string]any{
|
||||
"conversationId": "cid",
|
||||
"enabled": false,
|
||||
"limit": 3,
|
||||
"tags": []string{"one", "two"},
|
||||
}
|
||||
if !reflect.DeepEqual(invocation.Params, want) {
|
||||
t.Fatalf("typed params = %#v, want %#v", invocation.Params, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSensitiveCommandRequiresConfirmation(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCalls int
|
||||
wantDry bool
|
||||
wantError bool
|
||||
}{
|
||||
{name: "blocked", args: []string{"conference", "end"}, wantError: true},
|
||||
{name: "preview", args: []string{"conference", "end", "--dry-run"}, wantCalls: 1, wantDry: true},
|
||||
{name: "confirmed", args: []string{"conference", "end", "--yes"}, wantCalls: 1},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
runner := &pluginCaptureRunner{}
|
||||
root := pluginTestRoot(buildPluginCommands(
|
||||
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil)...)
|
||||
root.SetArgs(testCase.args)
|
||||
err := root.Execute()
|
||||
if testCase.wantError {
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) ||
|
||||
appErr.Category != apperrors.CategoryValidation ||
|
||||
appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("sensitive error = %#v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("sensitive command: %v", err)
|
||||
}
|
||||
if len(runner.invocations) != testCase.wantCalls {
|
||||
t.Fatalf("runner calls = %d, want %d", len(runner.invocations), testCase.wantCalls)
|
||||
}
|
||||
if testCase.wantCalls == 1 && runner.invocations[0].DryRun != testCase.wantDry {
|
||||
t.Fatalf("dry-run = %v, want %v", runner.invocations[0].DryRun, testCase.wantDry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginOverlayMergesServersWithoutProbingHTTP(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
first := conferencePluginDescriptor()
|
||||
first.Endpoint = server.URL
|
||||
first.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"one": {CLIName: "one"},
|
||||
}
|
||||
second := first
|
||||
second.Key = "conference-extra"
|
||||
second.DisplayName = "conference/conference-extra"
|
||||
second.Endpoint = server.URL + "/extra"
|
||||
second.CLI.ID = "conference-extra"
|
||||
second.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"two": {CLIName: "two"},
|
||||
}
|
||||
registerPluginHTTPServer(first)
|
||||
registerPluginHTTPServer(second)
|
||||
runner := &pluginCaptureRunner{}
|
||||
commands := buildPluginCommands([]mcptypes.ServerDescriptor{second, first}, runner, nil)
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("merged roots = %d, want 1", len(commands))
|
||||
}
|
||||
requirePluginChild(t, commands[0], "one")
|
||||
requirePluginChild(t, commands[0], "two")
|
||||
root := pluginTestRoot(commands...)
|
||||
root.SetArgs([]string{"conference", "--help"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("conference help: %v", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("HTTP calls while building help = %d, want 0", got)
|
||||
}
|
||||
for _, command := range []string{"one", "two"} {
|
||||
root.SetArgs([]string{"conference", command, "--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("conference %s: %v", command, err)
|
||||
}
|
||||
}
|
||||
if len(runner.invocations) != 2 ||
|
||||
runner.invocations[0].CanonicalProduct != "conference-local" ||
|
||||
runner.invocations[1].CanonicalProduct != "conference-extra" {
|
||||
t.Fatalf("merged routes = %#v", runner.invocations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCanReplaceHiddenFallbackButNotVisibleDistributionCommand(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
fallback := &cobra.Command{Use: "conference", Hidden: true}
|
||||
fallback.AddCommand(&cobra.Command{Use: "meeting"})
|
||||
distribution := &cobra.Command{Use: "drive"}
|
||||
root.AddCommand(fallback, distribution)
|
||||
|
||||
conference := buildPluginCommands(
|
||||
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
|
||||
executor.EchoRunner{},
|
||||
nil,
|
||||
)[0]
|
||||
drive := &cobra.Command{Use: "drive"}
|
||||
cmdutil.MarkPluginSource(drive)
|
||||
addPluginCommandsSafe(root, []*cobra.Command{conference, drive})
|
||||
|
||||
gotConference := requirePluginChild(t, root, "conference")
|
||||
if gotConference == fallback || gotConference.Hidden {
|
||||
t.Fatalf("conference fallback was not replaced: %#v", gotConference)
|
||||
}
|
||||
requirePluginChild(t, gotConference, "status")
|
||||
if gotDrive := requirePluginChild(t, root, "drive"); gotDrive != distribution {
|
||||
t.Fatal("visible distribution command was replaced by a plugin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConflictingPluginDescriptorCannotReplaceDistributionEndpoint(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
pluginDir := filepath.Join(configDir, "plugins", "user", "drive-hijack")
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{
|
||||
"name":"drive-hijack",
|
||||
"version":"1.0.0",
|
||||
"mcpServers":{
|
||||
"drive":{
|
||||
"type":"streamable-http",
|
||||
"endpoint":"https://plugin.invalid/mcp",
|
||||
"cli":{
|
||||
"id":"drive-service",
|
||||
"command":"drive-hijack",
|
||||
"toolOverrides":{"plugin_tool":{"cliName":"plugin-tool"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
AppendDynamicServer(mcptypes.ServerDescriptor{
|
||||
Key: "drive",
|
||||
Endpoint: "https://distribution.invalid/mcp",
|
||||
CLI: mcptypes.CLIOverlay{ID: "drive-service", Command: "drive"},
|
||||
})
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(&cobra.Command{Use: "drive"})
|
||||
if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 {
|
||||
t.Fatalf("conflicting plugin commands = %#v", commands)
|
||||
}
|
||||
if endpoint, ok := directRuntimeEndpoint("drive-service", "plugin_tool"); !ok ||
|
||||
endpoint != "https://distribution.invalid/mcp" {
|
||||
t.Fatalf("drive endpoint after rejected plugin = (%q, %v)", endpoint, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaSourceRootDoesNotLoadRuntimePlugins(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
previous := rootLoadPlugins
|
||||
t.Cleanup(func() { rootLoadPlugins = previous })
|
||||
var calls atomic.Int32
|
||||
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
calls.Add(1)
|
||||
AppendDynamicServer(conferencePluginDescriptor())
|
||||
return buildPluginCommands(
|
||||
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
|
||||
executor.EchoRunner{},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
base := NewSchemaSourceRootCommand()
|
||||
if calls.Load() != 0 {
|
||||
t.Fatalf("Schema source root loaded plugins %d times", calls.Load())
|
||||
}
|
||||
baseConference := requirePluginChild(t, base, "conference")
|
||||
if !baseConference.Hidden || requireOptionalPluginChild(baseConference, "status") != nil {
|
||||
t.Fatal("Schema source root contains installed conference plugin commands")
|
||||
}
|
||||
|
||||
runtime := NewRootCommand()
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("runtime root plugin loads = %d, want 1", calls.Load())
|
||||
}
|
||||
runtimeConference := requirePluginChild(t, runtime, "conference")
|
||||
if runtimeConference.Hidden {
|
||||
t.Fatal("runtime conference plugin is hidden")
|
||||
}
|
||||
requirePluginChild(t, runtimeConference, "status")
|
||||
}
|
||||
|
||||
func requireOptionalPluginChild(parent *cobra.Command, name string) *cobra.Command {
|
||||
for _, child := range parent.Commands() {
|
||||
if child.Name() == name {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPluginDerivedNamesAndReservedAliases(t *testing.T) {
|
||||
descriptor := conferencePluginDescriptor()
|
||||
descriptor.CLI.Aliases = []string{"auth", "conf", "conf"}
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"conference_getCurrentStatus": {},
|
||||
}
|
||||
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
|
||||
if len(commands) != 1 || !reflect.DeepEqual(commands[0].Aliases, []string{"conf"}) {
|
||||
t.Fatalf("plugin aliases = %#v", commands)
|
||||
}
|
||||
if requireOptionalPluginChild(commands[0], "get-current-status") == nil {
|
||||
var names []string
|
||||
for _, command := range commands[0].Commands() {
|
||||
names = append(names, command.Name())
|
||||
}
|
||||
t.Fatalf("derived command missing, got %s", strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginFlagsCannotShadowHostControls(t *testing.T) {
|
||||
host := pluginTestRoot()
|
||||
host.PersistentFlags().StringP("host-extra", "x", "", "")
|
||||
reservations := pluginReservedFlags(host)
|
||||
for name := range reservations.names {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
descriptor := conferencePluginDescriptor()
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"unsafe": {
|
||||
CLIName: "unsafe",
|
||||
IsSensitive: true,
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"value": {Alias: name},
|
||||
},
|
||||
},
|
||||
}
|
||||
if commands := buildPluginCommands(
|
||||
[]mcptypes.ServerDescriptor{descriptor},
|
||||
executor.EchoRunner{},
|
||||
host,
|
||||
); len(commands) != 0 {
|
||||
t.Fatalf("reserved host flag %q produced commands %#v", name, commands)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginShorthandsCannotShadowHostOrHelp(t *testing.T) {
|
||||
host := pluginTestRoot()
|
||||
host.PersistentFlags().StringP("host-extra", "x", "", "")
|
||||
descriptor := conferencePluginDescriptor()
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"safe": {
|
||||
CLIName: "safe",
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"alpha": {Shorthand: "f"},
|
||||
"bravo": {Shorthand: "h"},
|
||||
"charlie": {Shorthand: "o"},
|
||||
"delta": {Shorthand: "v"},
|
||||
"echo": {Shorthand: "x"},
|
||||
"foxtrot": {Shorthand: "y"},
|
||||
},
|
||||
},
|
||||
}
|
||||
runner := &pluginCaptureRunner{}
|
||||
commands := buildPluginCommands(
|
||||
[]mcptypes.ServerDescriptor{descriptor},
|
||||
runner,
|
||||
host,
|
||||
)
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("plugin commands = %#v", commands)
|
||||
}
|
||||
host.AddCommand(commands...)
|
||||
leaf := requirePluginChild(t, commands[0], "safe")
|
||||
for _, name := range []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot"} {
|
||||
if shorthand := leaf.Flags().Lookup(name).Shorthand; shorthand != "" {
|
||||
t.Fatalf("--%s shorthand = %q, want empty", name, shorthand)
|
||||
}
|
||||
}
|
||||
host.SetArgs([]string{"conference", "safe", "-h"})
|
||||
if err := host.Execute(); err != nil {
|
||||
t.Fatalf("plugin help: %v", err)
|
||||
}
|
||||
if len(runner.invocations) != 0 {
|
||||
t.Fatalf("help executed plugin: %#v", runner.invocations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginPayloadPrecedenceRequiredAndTypedPositionals(t *testing.T) {
|
||||
descriptor := conferencePluginDescriptor()
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"payload": {
|
||||
CLIName: "payload",
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"title": {Required: true},
|
||||
"mode": {Default: "fallback"},
|
||||
"enabled": {Positional: true, PositionalIndex: 0, Alias: "enabled-value", Required: true, Type: "bool"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantEnabled bool
|
||||
}{
|
||||
{
|
||||
name: "flag satisfies dual positional",
|
||||
args: []string{
|
||||
"conference", "payload",
|
||||
"--params", `{"title":"from-json","mode":"from-json"}`,
|
||||
"--enabled-value=true",
|
||||
"--dry-run",
|
||||
},
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "json beats positional",
|
||||
args: []string{
|
||||
"conference", "payload", "true",
|
||||
"--params", `{"title":"from-json","mode":"from-json","enabled":false}`,
|
||||
"--dry-run",
|
||||
},
|
||||
wantEnabled: false,
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
runner := &pluginCaptureRunner{}
|
||||
root := pluginTestRoot(buildPluginCommands(
|
||||
[]mcptypes.ServerDescriptor{descriptor},
|
||||
runner,
|
||||
nil,
|
||||
)...)
|
||||
root.SetArgs(testCase.args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("payload command: %v", err)
|
||||
}
|
||||
if len(runner.invocations) != 1 {
|
||||
t.Fatalf("runner calls = %d", len(runner.invocations))
|
||||
}
|
||||
params := runner.invocations[0].Params
|
||||
if params["title"] != "from-json" ||
|
||||
params["mode"] != "from-json" ||
|
||||
params["enabled"] != testCase.wantEnabled {
|
||||
t.Fatalf("payload params = %#v", params)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginDescriptorWinnerKeepsRouteAuthAndClientAtomic(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
writeManifest := func(name, manifest string) {
|
||||
t.Helper()
|
||||
directory := filepath.Join(configDir, "plugins", "user", name)
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeManifest("alpha-plugin", `{
|
||||
"name":"alpha-plugin",
|
||||
"version":"1.0.0",
|
||||
"mcpServers":{
|
||||
"alpha":{
|
||||
"type":"streamable-http",
|
||||
"endpoint":"https://alpha.invalid/mcp",
|
||||
"headers":{"Authorization":"Bearer alpha-secret"},
|
||||
"cli":{
|
||||
"id":"shared-plugin-id",
|
||||
"command":"alpha-command",
|
||||
"toolOverrides":{"alpha_tool":{"cliName":"alpha"}}
|
||||
}
|
||||
},
|
||||
"alpha-extra":{
|
||||
"type":"streamable-http",
|
||||
"endpoint":"https://alpha-extra.invalid/mcp",
|
||||
"cli":{
|
||||
"id":"alpha-extra-id",
|
||||
"command":"alpha-command",
|
||||
"toolOverrides":{"extra_tool":{"cliName":"extra"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
writeManifest("beta-plugin", `{
|
||||
"name":"beta-plugin",
|
||||
"version":"1.0.0",
|
||||
"mcpServers":{
|
||||
"beta":{
|
||||
"type":"stdio",
|
||||
"command":"bin/beta",
|
||||
"cli":{
|
||||
"id":"shared-plugin-id",
|
||||
"command":"beta-command",
|
||||
"toolOverrides":{"beta_tool":{"cliName":"beta"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
root := pluginTestRoot()
|
||||
commands := loadPlugins(root, nil, executor.EchoRunner{})
|
||||
if len(commands) != 1 || commands[0].Name() != "alpha-command" {
|
||||
t.Fatalf("plugin winner commands = %#v", commands)
|
||||
}
|
||||
requirePluginChild(t, commands[0], "alpha")
|
||||
requirePluginChild(t, commands[0], "extra")
|
||||
endpoint, ok := directRuntimeEndpoint("shared-plugin-id", "alpha_tool")
|
||||
if !ok || endpoint != "https://alpha.invalid/mcp" {
|
||||
t.Fatalf("winner endpoint = (%q, %v)", endpoint, ok)
|
||||
}
|
||||
extraEndpoint, ok := directRuntimeEndpoint("alpha-extra-id", "extra_tool")
|
||||
if !ok || extraEndpoint != "https://alpha-extra.invalid/mcp" {
|
||||
t.Fatalf("merged server endpoint = (%q, %v)", extraEndpoint, ok)
|
||||
}
|
||||
auth, ok := LookupPluginAuth("shared-plugin-id")
|
||||
if !ok || auth.Token != "alpha-secret" {
|
||||
t.Fatalf("winner auth = (%#v, %v)", auth, ok)
|
||||
}
|
||||
if _, ok := LookupStdioClient("beta-plugin/beta"); ok {
|
||||
t.Fatal("losing stdio client was registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedPluginOverlaySemanticsFailClosed(t *testing.T) {
|
||||
for _, mutate := range []func(*mcptypes.ServerDescriptor){
|
||||
func(descriptor *mcptypes.ServerDescriptor) {
|
||||
descriptor.CLI.RedirectTo = "drive"
|
||||
},
|
||||
func(descriptor *mcptypes.ServerDescriptor) {
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"unsafe": {
|
||||
CLIName: "unsafe",
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"source": {MapsTo: "target"},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
func(descriptor *mcptypes.ServerDescriptor) {
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"unsafe": {
|
||||
CLIName: "unsafe",
|
||||
Pipeline: []json.RawMessage{json.RawMessage(`{"tool":"one"}`)},
|
||||
},
|
||||
}
|
||||
},
|
||||
func(descriptor *mcptypes.ServerDescriptor) {
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"unsafe": {CLIName: "unsafe", ServerOverride: "drive"},
|
||||
}
|
||||
},
|
||||
func(descriptor *mcptypes.ServerDescriptor) {
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"unsafe": {
|
||||
CLIName: "unsafe",
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{
|
||||
"Body.query": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
func(descriptor *mcptypes.ServerDescriptor) {
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"unsafe": {CLIName: "unsafe", Group: "safe.bad_name"},
|
||||
}
|
||||
},
|
||||
func(descriptor *mcptypes.ServerDescriptor) {
|
||||
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
|
||||
"unsafe": {
|
||||
CLIName: "unsafe",
|
||||
Flags: map[string]mcptypes.CLIFlagOverride{"value": {}},
|
||||
RequireTogether: [][]string{{"value", "missing"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
} {
|
||||
descriptor := conferencePluginDescriptor()
|
||||
mutate(&descriptor)
|
||||
if commands := buildPluginCommands(
|
||||
[]mcptypes.ServerDescriptor{descriptor},
|
||||
executor.EchoRunner{},
|
||||
nil,
|
||||
); len(commands) != 0 {
|
||||
t.Fatalf("unsupported overlay produced commands %#v", commands)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedPluginDescriptorsDoNotRegisterRuntimeState(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
writeManifest := func(name, manifest string) {
|
||||
t.Helper()
|
||||
directory := filepath.Join(configDir, "plugins", "user", name)
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeManifest("unsafe-http", `{
|
||||
"name":"unsafe-http",
|
||||
"version":"1.0.0",
|
||||
"mcpServers":{"unsafe":{
|
||||
"type":"streamable-http",
|
||||
"endpoint":"https://unsafe.invalid/mcp",
|
||||
"headers":{"Authorization":"Bearer unsafe-secret"},
|
||||
"cli":{"id":"unsafe-http-id","command":"unsafe-http","toolOverrides":{
|
||||
"unsafe_tool":{"cliName":"run","serverOverride":"drive"}
|
||||
}}
|
||||
}}
|
||||
}`)
|
||||
writeManifest("unsafe-stdio", `{
|
||||
"name":"unsafe-stdio",
|
||||
"version":"1.0.0",
|
||||
"mcpServers":{"unsafe":{
|
||||
"type":"stdio",
|
||||
"command":"bin/unsafe",
|
||||
"cli":{"id":"unsafe-stdio-id","command":"unsafe-stdio","toolOverrides":{
|
||||
"unsafe_tool":{"cliName":"run","flags":{"value":{"mapsTo":"target"}}}
|
||||
}}
|
||||
}}
|
||||
}`)
|
||||
|
||||
root := pluginTestRoot()
|
||||
if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 {
|
||||
t.Fatalf("unsupported plugin descriptors produced commands %#v", commands)
|
||||
}
|
||||
if endpoint, ok := directRuntimeEndpoint("unsafe-http-id", "unsafe_tool"); ok {
|
||||
t.Fatalf("unsupported HTTP descriptor registered endpoint %q", endpoint)
|
||||
}
|
||||
if _, ok := LookupPluginAuth("unsafe-http-id"); ok {
|
||||
t.Fatal("unsupported HTTP descriptor registered plugin auth")
|
||||
}
|
||||
if _, ok := LookupStdioClient("unsafe-stdio/unsafe"); ok {
|
||||
t.Fatal("unsupported stdio descriptor registered a client")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func pluginToolInputSchema(
|
||||
tools transport.ToolsListResult,
|
||||
toolName string,
|
||||
) (map[string]any, bool) {
|
||||
for _, tool := range tools.Tools {
|
||||
if strings.TrimSpace(tool.Name) == strings.TrimSpace(toolName) {
|
||||
return tool.InputSchema, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func normalizePluginInputParams(
|
||||
params map[string]any,
|
||||
schema map[string]any,
|
||||
) (map[string]any, error) {
|
||||
schema = canonicalPluginInputSchema(schema)
|
||||
normalized := make(map[string]any, len(params))
|
||||
for key, value := range params {
|
||||
normalized[key] = value
|
||||
}
|
||||
if _, err := coercePluginSchemaValue(normalized, schema); err != nil {
|
||||
return nil, cliInputValidationError(err)
|
||||
}
|
||||
if err := cli.ValidateInputSchema(normalized, schema); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func canonicalPluginInputSchema(schema map[string]any) map[string]any {
|
||||
if len(schema) == 0 {
|
||||
return schema
|
||||
}
|
||||
cloned := make(map[string]any, len(schema))
|
||||
for key, value := range schema {
|
||||
cloned[key] = clonePluginSchemaValue(key, value)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func clonePluginSchemaValue(key string, value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
cloned := make(map[string]any, len(typed))
|
||||
for childKey, childValue := range typed {
|
||||
cloned[childKey] = clonePluginSchemaValue(childKey, childValue)
|
||||
}
|
||||
return cloned
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = clonePluginSchemaValue(key, item)
|
||||
}
|
||||
return cloned
|
||||
case []string:
|
||||
cloned := make([]string, len(typed))
|
||||
for index, item := range typed {
|
||||
if key == "type" {
|
||||
item = canonicalPluginSchemaType(item)
|
||||
}
|
||||
cloned[index] = item
|
||||
}
|
||||
return cloned
|
||||
case string:
|
||||
if key == "type" {
|
||||
return canonicalPluginSchemaType(typed)
|
||||
}
|
||||
return typed
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalPluginSchemaType(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "bool":
|
||||
return "boolean"
|
||||
case "int":
|
||||
return "integer"
|
||||
case "float":
|
||||
return "number"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func cliInputValidationError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("input schema normalization failed: %v", err),
|
||||
apperrors.WithReason("plugin_input_schema_invalid"),
|
||||
)
|
||||
}
|
||||
|
||||
func coercePluginSchemaValue(value any, schema map[string]any) (any, error) {
|
||||
target := singlePluginSchemaType(schema)
|
||||
if raw, ok := value.(string); ok {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
switch target {
|
||||
case "bool", "boolean":
|
||||
parsed, err := strconv.ParseBool(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot convert %q to boolean: %w", raw, err)
|
||||
}
|
||||
value = parsed
|
||||
case "int", "integer":
|
||||
parsed, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot convert %q to integer: %w", raw, err)
|
||||
}
|
||||
value = parsed
|
||||
case "float", "number":
|
||||
parsed, err := strconv.ParseFloat(trimmed, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot convert %q to number: %w", raw, err)
|
||||
}
|
||||
value = parsed
|
||||
case "object":
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("cannot convert plugin parameter to object: %w", err)
|
||||
}
|
||||
if parsed == nil {
|
||||
return nil, fmt.Errorf("cannot convert plugin parameter to object: expected a JSON object")
|
||||
}
|
||||
value = parsed
|
||||
case "array":
|
||||
var parsed []any
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("cannot convert plugin parameter to array: %w", err)
|
||||
}
|
||||
} else if trimmed != "" {
|
||||
for _, item := range strings.Split(trimmed, ",") {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
parsed = append(parsed, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
value = parsed
|
||||
}
|
||||
}
|
||||
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
properties, _ := schema["properties"].(map[string]any)
|
||||
for key, propertyValue := range typed {
|
||||
propertySchema, _ := properties[key].(map[string]any)
|
||||
if len(propertySchema) == 0 {
|
||||
continue
|
||||
}
|
||||
coerced, err := coercePluginSchemaValue(propertyValue, propertySchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
typed[key] = coerced
|
||||
}
|
||||
return typed, nil
|
||||
case []string:
|
||||
items := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
items[index] = item
|
||||
}
|
||||
value = items
|
||||
}
|
||||
|
||||
if items, ok := value.([]any); ok {
|
||||
itemSchema, _ := schema["items"].(map[string]any)
|
||||
if len(itemSchema) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
for index, item := range items {
|
||||
coerced, err := coercePluginSchemaValue(item, itemSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("item %d: %w", index, err)
|
||||
}
|
||||
items[index] = coerced
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func singlePluginSchemaType(schema map[string]any) string {
|
||||
var types []string
|
||||
switch typed := schema["type"].(type) {
|
||||
case string:
|
||||
types = []string{typed}
|
||||
case []string:
|
||||
types = typed
|
||||
case []any:
|
||||
for _, value := range typed {
|
||||
if text, ok := value.(string); ok {
|
||||
types = append(types, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
var target string
|
||||
for _, candidate := range types {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" || candidate == "null" {
|
||||
continue
|
||||
}
|
||||
if target != "" && target != candidate {
|
||||
return ""
|
||||
}
|
||||
target = candidate
|
||||
}
|
||||
return target
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestPluginToolInputSchemaMatchesTrimmedName(t *testing.T) {
|
||||
want := map[string]any{"type": "object"}
|
||||
tools := transport.ToolsListResult{Tools: []transport.ToolDescriptor{
|
||||
{Name: "other", InputSchema: map[string]any{"type": "string"}},
|
||||
{Name: " create_conference ", InputSchema: want},
|
||||
}}
|
||||
|
||||
got, ok := pluginToolInputSchema(tools, " create_conference ")
|
||||
if !ok || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("pluginToolInputSchema() = (%#v, %v), want (%#v, true)", got, ok, want)
|
||||
}
|
||||
if got, ok := pluginToolInputSchema(tools, "missing"); ok || got != nil {
|
||||
t.Fatalf("missing pluginToolInputSchema() = (%#v, %v), want (nil, false)", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePluginInputParamsCoercesNestedValues(t *testing.T) {
|
||||
schema := map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"enabled"},
|
||||
"properties": map[string]any{
|
||||
"enabled": map[string]any{"type": []any{"null", "bool"}},
|
||||
"count": map[string]any{"type": "int"},
|
||||
"ratio": map[string]any{"type": "float"},
|
||||
"settings": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"active": map[string]any{"type": "bool"},
|
||||
},
|
||||
},
|
||||
"ids": map[string]any{
|
||||
"type": []string{"array", "null"},
|
||||
"items": map[string]any{"type": "int"},
|
||||
},
|
||||
"labels": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "string"},
|
||||
},
|
||||
"booleans": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "bool"},
|
||||
},
|
||||
"ambiguous": map[string]any{"type": []string{"string", "int"}},
|
||||
},
|
||||
}
|
||||
params := map[string]any{
|
||||
"enabled": " true ",
|
||||
"count": " 7 ",
|
||||
"ratio": " 2.5 ",
|
||||
"settings": `{"active":"false"}`,
|
||||
"ids": `["1", "2"]`,
|
||||
"labels": "alpha, , beta",
|
||||
"booleans": []string{"true", "false"},
|
||||
"ambiguous": "9",
|
||||
}
|
||||
|
||||
got, err := normalizePluginInputParams(params, schema)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizePluginInputParams() error = %v", err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"enabled": true,
|
||||
"count": 7,
|
||||
"ratio": 2.5,
|
||||
"settings": map[string]any{"active": false},
|
||||
"ids": []any{1, 2},
|
||||
"labels": []any{"alpha", "beta"},
|
||||
"booleans": []any{true, false},
|
||||
"ambiguous": "9",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("normalizePluginInputParams() = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
properties := schema["properties"].(map[string]any)
|
||||
if gotType := properties["enabled"].(map[string]any)["type"].([]any)[1]; gotType != "bool" {
|
||||
t.Fatalf("normalization mutated source schema type to %#v", gotType)
|
||||
}
|
||||
if gotValue := params["enabled"]; gotValue != " true " {
|
||||
t.Fatalf("normalization mutated source params to %#v", gotValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePluginInputParamsReportsConversionPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
fieldSchema map[string]any
|
||||
wantText string
|
||||
}{
|
||||
{name: "boolean", value: "sometimes", fieldSchema: map[string]any{"type": "bool"}, wantText: "cannot convert"},
|
||||
{name: "integer", value: "1.5", fieldSchema: map[string]any{"type": "int"}, wantText: "integer"},
|
||||
{name: "number", value: "many", fieldSchema: map[string]any{"type": "float"}, wantText: "number"},
|
||||
{name: "object", value: "{", fieldSchema: map[string]any{"type": "object"}, wantText: "object"},
|
||||
{name: "null object", value: "null", fieldSchema: map[string]any{"type": "object"}, wantText: "expected a JSON object"},
|
||||
{name: "array", value: "[", fieldSchema: map[string]any{"type": "array"}, wantText: "array"},
|
||||
{
|
||||
name: "nested property",
|
||||
value: `{"active":"sometimes"}`,
|
||||
fieldSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"active": map[string]any{"type": "bool"},
|
||||
},
|
||||
},
|
||||
wantText: "field: active:",
|
||||
},
|
||||
{
|
||||
name: "array item",
|
||||
value: "1,not-an-int",
|
||||
fieldSchema: map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "int"},
|
||||
},
|
||||
wantText: "item 1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
schema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"field": tt.fieldSchema},
|
||||
}
|
||||
_, err := normalizePluginInputParams(map[string]any{"field": tt.value}, schema)
|
||||
if err == nil {
|
||||
t.Fatal("normalizePluginInputParams() error = nil, want conversion error")
|
||||
}
|
||||
var appError *apperrors.Error
|
||||
if !errors.As(err, &appError) ||
|
||||
appError.Category != apperrors.CategoryValidation ||
|
||||
appError.Reason != "plugin_input_schema_invalid" {
|
||||
t.Fatalf("conversion error = %#v, want categorized plugin schema validation error", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("conversion error = %q, want text %q", err, tt.wantText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePluginInputParamsRunsSchemaValidation(t *testing.T) {
|
||||
schema := map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"name"},
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string"},
|
||||
},
|
||||
}
|
||||
if _, err := normalizePluginInputParams(map[string]any{}, schema); err == nil ||
|
||||
!strings.Contains(err.Error(), "$.name is required") {
|
||||
t.Fatalf("required-field validation error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginInputSchemaHelperEdges(t *testing.T) {
|
||||
if got := canonicalPluginInputSchema(nil); got != nil {
|
||||
t.Fatalf("canonicalPluginInputSchema(nil) = %#v, want nil", got)
|
||||
}
|
||||
if got := clonePluginSchemaValue("minimum", 1); got != 1 {
|
||||
t.Fatalf("clonePluginSchemaValue(scalar) = %#v, want 1", got)
|
||||
}
|
||||
if got := cliInputValidationError(nil); got != nil {
|
||||
t.Fatalf("cliInputValidationError(nil) = %v, want nil", got)
|
||||
}
|
||||
|
||||
if got, err := coercePluginSchemaValue("", map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, []any(nil)) {
|
||||
t.Fatalf("empty array coercion = (%#v, %v), want nil slice", got, err)
|
||||
}
|
||||
items := []any{"unchanged"}
|
||||
if got, err := coercePluginSchemaValue(items, map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, items) {
|
||||
t.Fatalf("array without item schema = (%#v, %v)", got, err)
|
||||
}
|
||||
if got, err := coercePluginSchemaValue(12, map[string]any{"type": "integer"}); err != nil || got != 12 {
|
||||
t.Fatalf("non-string scalar coercion = (%#v, %v), want (12, nil)", got, err)
|
||||
}
|
||||
unknown := map[string]any{"unknown": "unchanged"}
|
||||
if got, err := coercePluginSchemaValue(unknown, map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}); err != nil || !reflect.DeepEqual(got, unknown) {
|
||||
t.Fatalf("unknown property coercion = (%#v, %v), want unchanged map", got, err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
schema map[string]any
|
||||
want string
|
||||
}{
|
||||
{name: "missing", schema: map[string]any{}, want: ""},
|
||||
{name: "single string", schema: map[string]any{"type": "integer"}, want: "integer"},
|
||||
{name: "single string slice", schema: map[string]any{"type": []string{"null", "number"}}, want: "number"},
|
||||
{name: "any slice", schema: map[string]any{"type": []any{nil, 3, "", "null", "boolean"}}, want: "boolean"},
|
||||
{name: "ambiguous", schema: map[string]any{"type": []any{"string", "integer"}}, want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := singlePluginSchemaType(tt.schema); got != tt.want {
|
||||
t.Fatalf("singlePluginSchemaType(%#v) = %q, want %q", tt.schema, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for raw, want := range map[string]string{
|
||||
" BOOL ": "boolean",
|
||||
"Int": "integer",
|
||||
"FLOAT": "number",
|
||||
"custom": "custom",
|
||||
} {
|
||||
if got := canonicalPluginSchemaType(raw); got != want {
|
||||
t.Errorf("canonicalPluginSchemaType(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestPluginStdioExecutionNormalizesAndValidatesLiveSchema(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
previousInit := runnerStdioEnsureInitialized
|
||||
previousList := runnerStdioListTools
|
||||
previousCall := runnerStdioCallTool
|
||||
t.Cleanup(func() {
|
||||
runnerStdioEnsureInitialized = previousInit
|
||||
runnerStdioListTools = previousList
|
||||
runnerStdioCallTool = previousCall
|
||||
})
|
||||
|
||||
client := transport.NewStdioClient("unused", nil, nil)
|
||||
RegisterStdioClient("conference/local", client)
|
||||
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error {
|
||||
return nil
|
||||
}
|
||||
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
|
||||
return transport.ToolsListResult{
|
||||
Tools: []transport.ToolDescriptor{{
|
||||
Name: "create_conference",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"title"},
|
||||
"properties": map[string]any{
|
||||
"title": map[string]any{"type": "string"},
|
||||
"capture_speaker": map[string]any{"type": "bool"},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
var calledParams map[string]any
|
||||
runnerStdioCallTool = func(
|
||||
_ *transport.StdioClient,
|
||||
_ context.Context,
|
||||
_ string,
|
||||
params map[string]any,
|
||||
) (transport.ToolCallResult, error) {
|
||||
calledParams = params
|
||||
return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{}
|
||||
invocation := executor.Invocation{
|
||||
CanonicalProduct: "conference-local",
|
||||
Tool: "create_conference",
|
||||
Params: map[string]any{
|
||||
"title": "schema validation",
|
||||
"capture_speaker": "true",
|
||||
},
|
||||
}
|
||||
result, err := runner.executeInvocation(
|
||||
context.Background(),
|
||||
"stdio://conference/local",
|
||||
invocation,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("stdio plugin execution: %v", err)
|
||||
}
|
||||
wantParams := map[string]any{
|
||||
"title": "schema validation",
|
||||
"capture_speaker": true,
|
||||
}
|
||||
if !reflect.DeepEqual(calledParams, wantParams) ||
|
||||
!reflect.DeepEqual(result.Invocation.Params, wantParams) {
|
||||
t.Fatalf("normalized wire params = %#v, result = %#v", calledParams, result.Invocation.Params)
|
||||
}
|
||||
|
||||
calledParams = nil
|
||||
invocation.Params = map[string]any{"capture_speaker": "true"}
|
||||
_, err = runner.executeInvocation(
|
||||
context.Background(),
|
||||
"stdio://conference/local",
|
||||
invocation,
|
||||
)
|
||||
var appError *apperrors.Error
|
||||
if !errors.As(err, &appError) ||
|
||||
appError.Category != apperrors.CategoryValidation ||
|
||||
calledParams != nil {
|
||||
t.Fatalf("missing required schema validation = %#v, call params = %#v", err, calledParams)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
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/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type schemaSourceContextKey struct{}
|
||||
|
||||
func TestSchemaSourceRootPropagatesContextWithoutLoadingPlugins(t *testing.T) {
|
||||
previous := rootLoadPlugins
|
||||
t.Cleanup(func() { rootLoadPlugins = previous })
|
||||
|
||||
pluginLoads := 0
|
||||
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
pluginLoads++
|
||||
return nil
|
||||
}
|
||||
wantContext := context.WithValue(context.Background(), schemaSourceContextKey{}, "schema")
|
||||
root := NewSchemaSourceRootCommand(wantContext)
|
||||
if root.Context() != wantContext {
|
||||
t.Fatal("Schema source root did not retain the caller context")
|
||||
}
|
||||
if pluginLoads != 0 {
|
||||
t.Fatalf("Schema source root loaded runtime plugins %d times", pluginLoads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectPluginServerCandidatesSortsAndSkipsInvalidStdio(t *testing.T) {
|
||||
previousDescriptors := rootPluginDescriptors
|
||||
previousClients := rootPluginStdioClients
|
||||
previousDescriptor := rootPluginStdioDescriptor
|
||||
t.Cleanup(func() {
|
||||
rootPluginDescriptors = previousDescriptors
|
||||
rootPluginStdioClients = previousClients
|
||||
rootPluginStdioDescriptor = previousDescriptor
|
||||
})
|
||||
|
||||
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "first"}}
|
||||
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "second"}}
|
||||
wantContext := &plugin.UserContext{UserID: "user", CorpID: "corp"}
|
||||
client := transport.NewStdioClient("unused", nil, nil)
|
||||
|
||||
rootPluginDescriptors = func(owner *plugin.Plugin) []mcptypes.ServerDescriptor {
|
||||
if owner == first {
|
||||
return []mcptypes.ServerDescriptor{{Key: "same"}, {Key: " beta "}}
|
||||
}
|
||||
return []mcptypes.ServerDescriptor{{Key: "aardvark"}}
|
||||
}
|
||||
rootPluginStdioClients = func(owner *plugin.Plugin, gotContext *plugin.UserContext) []plugin.StdioServerClient {
|
||||
if gotContext != wantContext {
|
||||
t.Fatalf("stdio user context = %#v, want %#v", gotContext, wantContext)
|
||||
}
|
||||
if owner != first {
|
||||
return nil
|
||||
}
|
||||
return []plugin.StdioServerClient{
|
||||
{Key: "same", Client: client},
|
||||
{Key: " alpha ", Client: client},
|
||||
{Key: "invalid", Client: client},
|
||||
}
|
||||
}
|
||||
rootPluginStdioDescriptor = func(_ *plugin.Plugin, stdio plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) {
|
||||
if stdio.Key == "invalid" {
|
||||
return mcptypes.ServerDescriptor{}, false
|
||||
}
|
||||
return mcptypes.ServerDescriptor{Key: stdio.Key}, true
|
||||
}
|
||||
|
||||
candidates := collectPluginServerCandidates([]*plugin.Plugin{first, second}, wantContext)
|
||||
if len(candidates) != 5 {
|
||||
t.Fatalf("candidate count = %d, want 5", len(candidates))
|
||||
}
|
||||
gotKeys := make([]string, 0, len(candidates))
|
||||
gotKinds := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
gotKeys = append(gotKeys, candidate.descriptor.Key)
|
||||
if candidate.stdioClient == nil {
|
||||
gotKinds = append(gotKinds, "http")
|
||||
} else {
|
||||
gotKinds = append(gotKinds, "stdio")
|
||||
if candidate.stdioClient.Client != client {
|
||||
t.Fatal("stdio candidate did not retain its client")
|
||||
}
|
||||
}
|
||||
}
|
||||
if want := []string{" alpha ", " beta ", "same", "same", "aardvark"}; !reflect.DeepEqual(gotKeys, want) {
|
||||
t.Fatalf("candidate keys = %#v, want %#v", gotKeys, want)
|
||||
}
|
||||
if want := []string{"stdio", "http", "http", "stdio", "http"}; !reflect.DeepEqual(gotKinds, want) {
|
||||
t.Fatalf("candidate transports = %#v, want %#v", gotKinds, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginDescriptorBlankIdentityAndDistributionOwnership(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
|
||||
blank := mcptypes.ServerDescriptor{
|
||||
Key: " ",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: " ",
|
||||
Command: " ",
|
||||
Aliases: []string{"", " "},
|
||||
},
|
||||
}
|
||||
if claims := pluginDescriptorIdentityClaims(blank); len(claims) != 0 {
|
||||
t.Fatalf("blank descriptor claims = %#v, want none", claims)
|
||||
}
|
||||
if rootName := pluginDescriptorRootName(blank); rootName != "" {
|
||||
t.Fatalf("blank descriptor root = %q", rootName)
|
||||
}
|
||||
owner := &plugin.Plugin{Manifest: plugin.Manifest{Name: "blank"}}
|
||||
accepted := selectPluginServerCandidates(
|
||||
&cobra.Command{Use: "dws"},
|
||||
[]pluginServerCandidate{
|
||||
{owner: owner, descriptor: mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}},
|
||||
{owner: owner, descriptor: blank},
|
||||
},
|
||||
)
|
||||
if len(accepted) != 1 {
|
||||
t.Fatalf("blank descriptor candidates = %#v, want one accepted candidate", accepted)
|
||||
}
|
||||
|
||||
if distributionRootOwns(nil, "visible") {
|
||||
t.Fatal("nil root claimed a command")
|
||||
}
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
visible := &cobra.Command{Use: "visible", Aliases: []string{" visible-alias "}}
|
||||
hiddenFallback := &cobra.Command{Use: "conference", Hidden: true}
|
||||
hiddenOwned := &cobra.Command{Use: "hidden-owned", Hidden: true}
|
||||
pluginOwned := &cobra.Command{Use: "plugin-owned", Aliases: []string{"plugin-alias"}}
|
||||
cmdutil.MarkPluginSource(pluginOwned)
|
||||
root.AddCommand(visible, hiddenFallback, hiddenOwned, pluginOwned)
|
||||
|
||||
for _, name := range []string{"visible", "visible-alias", "hidden-owned"} {
|
||||
if !distributionRootOwns(root, name) {
|
||||
t.Errorf("distribution root did not claim %q", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"conference", "plugin-owned", "plugin-alias", "missing"} {
|
||||
if distributionRootOwns(root, name) {
|
||||
t.Errorf("distribution root unexpectedly claimed %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceableFallbackIdentitySurvivesDistributionConflictChecks(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{
|
||||
{
|
||||
Key: "conference",
|
||||
Endpoint: "https://example.com/conference/mcp",
|
||||
CLI: mcptypes.CLIOverlay{ID: "conference"},
|
||||
},
|
||||
{
|
||||
Key: "chat",
|
||||
Endpoint: "https://example.com/chat/mcp",
|
||||
CLI: mcptypes.CLIOverlay{ID: "chat"},
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(&cobra.Command{Use: "conference", Hidden: true})
|
||||
distributionProducts := DirectRuntimeProductIDs()
|
||||
|
||||
conferenceDescriptor := mcptypes.ServerDescriptor{
|
||||
Key: "conference-local",
|
||||
DisplayName: "conference/conference-local",
|
||||
CLI: mcptypes.CLIOverlay{ID: "conference-local", Command: "conference"},
|
||||
}
|
||||
if pluginDescriptorConflictsWithDistribution(root, conferenceDescriptor, distributionProducts) {
|
||||
t.Fatal("replaceable fallback identity blocked plugin server selection")
|
||||
}
|
||||
|
||||
chatDescriptor := mcptypes.ServerDescriptor{
|
||||
Key: "chat-local",
|
||||
DisplayName: "chat/chat-local",
|
||||
CLI: mcptypes.CLIOverlay{ID: "chat-local", Command: "chat"},
|
||||
}
|
||||
if !pluginDescriptorConflictsWithDistribution(root, chatDescriptor, distributionProducts) {
|
||||
t.Fatal("non-replaceable distribution product no longer conflicts")
|
||||
}
|
||||
|
||||
reservedDescriptor := mcptypes.ServerDescriptor{
|
||||
Key: "auth-local",
|
||||
DisplayName: "auth/auth-local",
|
||||
CLI: mcptypes.CLIOverlay{ID: "auth-local", Command: "auth"},
|
||||
}
|
||||
if !pluginDescriptorConflictsWithDistribution(root, reservedDescriptor, distributionProducts) {
|
||||
t.Fatal("reserved command name no longer conflicts")
|
||||
}
|
||||
|
||||
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "conference"}}
|
||||
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "other"}}
|
||||
accepted := selectPluginServerCandidates(root, []pluginServerCandidate{
|
||||
{owner: first, descriptor: conferenceDescriptor},
|
||||
{
|
||||
owner: second,
|
||||
descriptor: mcptypes.ServerDescriptor{
|
||||
Key: "conference-other",
|
||||
DisplayName: "other/conference-other",
|
||||
CLI: mcptypes.CLIOverlay{ID: "conference-other", Command: "conference"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if len(accepted) != 1 {
|
||||
t.Fatalf("accepted candidates = %d, want the first conference plugin only", len(accepted))
|
||||
}
|
||||
if accepted[0].owner != first {
|
||||
t.Fatalf("accepted owner = %q, want the first conference plugin", accepted[0].owner.Manifest.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPluginCommandsSafeFiltersConflictingAliases(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(&cobra.Command{Use: "taken"})
|
||||
command := &cobra.Command{
|
||||
Use: "extension",
|
||||
Aliases: []string{"", "extension", "auth", "taken", "shared", " shared ", " okay "},
|
||||
}
|
||||
addPluginCommandsSafe(root, []*cobra.Command{
|
||||
command,
|
||||
{Use: "shared"},
|
||||
{Use: "other", Aliases: []string{"extension"}},
|
||||
})
|
||||
|
||||
if want := []string{"shared", "okay"}; !reflect.DeepEqual(command.Aliases, want) {
|
||||
t.Fatalf("filtered aliases = %#v, want %#v", command.Aliases, want)
|
||||
}
|
||||
if child := findDirectChild(root, "shared"); child != nil {
|
||||
t.Fatal("an accepted alias was also registered as a plugin primary command")
|
||||
}
|
||||
other := findDirectChild(root, "other")
|
||||
if other == nil || len(other.Aliases) != 0 {
|
||||
t.Fatalf("later plugin aliases = %#v", other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioRunnerReportsToolsListFailureAndMissingTool(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
previousInit := runnerStdioEnsureInitialized
|
||||
previousList := runnerStdioListTools
|
||||
previousCall := runnerStdioCallTool
|
||||
t.Cleanup(func() {
|
||||
runnerStdioEnsureInitialized = previousInit
|
||||
runnerStdioListTools = previousList
|
||||
runnerStdioCallTool = previousCall
|
||||
})
|
||||
|
||||
client := transport.NewStdioClient("unused", nil, nil)
|
||||
RegisterStdioClient("plugin/server", client)
|
||||
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
|
||||
toolCalls := 0
|
||||
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
toolCalls++
|
||||
return transport.ToolCallResult{}, nil
|
||||
}
|
||||
runner := &runtimeRunner{}
|
||||
invocation := executor.Invocation{CanonicalProduct: "overlay-id", Tool: "wanted"}
|
||||
|
||||
listFailure := errors.New("list failed")
|
||||
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
|
||||
return transport.ToolsListResult{}, listFailure
|
||||
}
|
||||
_, err := runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
|
||||
assertPluginRuntimeError(t, err, apperrors.CategoryAPI, "tools/list", "stdio_tools_list_error")
|
||||
|
||||
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
|
||||
return transport.ToolsListResult{Tools: []transport.ToolDescriptor{{Name: "other"}}}, nil
|
||||
}
|
||||
_, err = runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
|
||||
assertPluginRuntimeError(t, err, apperrors.CategoryValidation, "", "plugin_tool_not_found")
|
||||
if toolCalls != 0 {
|
||||
t.Fatalf("tools/call attempts after tools/list failures = %d", toolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioManifestDescriptorAndRegistrationFailClosed(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
p := &plugin.Plugin{
|
||||
Manifest: plugin.Manifest{
|
||||
Name: "broken-plugin",
|
||||
MCPServers: map[string]*plugin.MCPServer{
|
||||
"local": {CLI: json.RawMessage(`{`)},
|
||||
},
|
||||
},
|
||||
}
|
||||
server := plugin.StdioServerClient{
|
||||
Key: "local",
|
||||
Client: transport.NewStdioClient("unused", nil, nil),
|
||||
}
|
||||
if descriptor, ok := stdioServerDescriptorFromManifest(p, server); ok || !reflect.ValueOf(descriptor).IsZero() {
|
||||
t.Fatalf("invalid descriptor = (%#v, %v), want zero, false", descriptor, ok)
|
||||
}
|
||||
if descriptor := registerStdioServerFromManifest(p, server); !reflect.ValueOf(descriptor).IsZero() {
|
||||
t.Fatalf("invalid registered descriptor = %#v, want zero", descriptor)
|
||||
}
|
||||
if _, ok := LookupStdioClient("broken-plugin/local"); ok {
|
||||
t.Fatal("invalid stdio manifest registered a client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyCommandsContinueWhenUserShortcutLoadFails(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
shortcutDir := filepath.Join(configDir, "shortcuts")
|
||||
if err := os.MkdirAll(shortcutDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(shortcutDir, "broken.yaml"), []byte("version: ["), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, loadErrors := userdef.Load(); len(loadErrors) == 0 {
|
||||
t.Fatal("malformed shortcut fixture did not fail to load")
|
||||
}
|
||||
|
||||
runner := executor.EchoRunner{}
|
||||
caller := newToolCallerAdapter(runner, &GlobalFlags{})
|
||||
if commands := newLegacyPublicCommands(runner, caller, true); len(commands) == 0 {
|
||||
t.Fatal("legacy commands were dropped after a user shortcut load error")
|
||||
}
|
||||
}
|
||||
|
||||
func findDirectChild(root *cobra.Command, name string) *cobra.Command {
|
||||
for _, command := range root.Commands() {
|
||||
if command.Name() == name {
|
||||
return command
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertPluginRuntimeError(
|
||||
t *testing.T,
|
||||
err error,
|
||||
wantCategory apperrors.Category,
|
||||
wantOperation string,
|
||||
wantReason string,
|
||||
) {
|
||||
t.Helper()
|
||||
var appError *apperrors.Error
|
||||
if !errors.As(err, &appError) {
|
||||
t.Fatalf("runtime error = %#v, want structured app error", err)
|
||||
}
|
||||
if appError.Category != wantCategory ||
|
||||
appError.Operation != wantOperation ||
|
||||
appError.Reason != wantReason {
|
||||
t.Fatalf("runtime error = %#v, want category=%q operation=%q reason=%q", appError, wantCategory, wantOperation, wantReason)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -35,6 +37,11 @@ func isolatePluginRuntime(t *testing.T) {
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stdioMu.Unlock()
|
||||
|
||||
pluginAuthMu.Lock()
|
||||
previousPluginAuth := pluginAuthRegistry
|
||||
pluginAuthRegistry = make(map[string]*PluginAuth)
|
||||
pluginAuthMu.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
StopAllStdioClients()
|
||||
dynamicMu.Lock()
|
||||
@@ -46,6 +53,9 @@ func isolatePluginRuntime(t *testing.T) {
|
||||
stdioMu.Lock()
|
||||
stdioClients = previousStdio
|
||||
stdioMu.Unlock()
|
||||
pluginAuthMu.Lock()
|
||||
pluginAuthRegistry = previousPluginAuth
|
||||
pluginAuthMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -77,14 +87,40 @@ func TestRegisterPluginHTTPServerDoesNotProbeEndpoint(t *testing.T) {
|
||||
func TestRegisterStdioServerFromManifestDoesNotStartProcess(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
marker := t.TempDir() + "/started"
|
||||
pluginRoot := t.TempDir()
|
||||
if err := os.WriteFile(pluginRoot+"/overlay.json", []byte(`{
|
||||
"id":"local",
|
||||
"command":"lazy-stdio",
|
||||
"groups":{"health":{"description":"health checks"}},
|
||||
"toolOverrides":{"ping":{"cliName":"ping","group":"health"}}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := transport.NewStdioClient("/bin/sh", []string{
|
||||
"-c", fmt.Sprintf("printf started > %q", marker),
|
||||
}, nil)
|
||||
p := &plugin.Plugin{
|
||||
Manifest: plugin.Manifest{Name: "lazy-stdio", Description: "lazy stdio test"},
|
||||
Root: t.TempDir(),
|
||||
Manifest: plugin.Manifest{
|
||||
Name: "lazy-stdio",
|
||||
Description: "lazy stdio test",
|
||||
MCPServers: map[string]*plugin.MCPServer{
|
||||
"local": {
|
||||
Type: "stdio",
|
||||
Command: "unused",
|
||||
CLI: json.RawMessage(`"overlay.json"`),
|
||||
},
|
||||
},
|
||||
},
|
||||
Root: pluginRoot,
|
||||
}
|
||||
descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "local", Client: client})
|
||||
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
|
||||
root := pluginTestRoot(commands...)
|
||||
root.SetArgs([]string{"lazy-stdio", "--help"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("lazy stdio help: %v", err)
|
||||
}
|
||||
requirePluginChild(t, commands[0], "health", "ping")
|
||||
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("stdio process started during registration: stat error = %v", err)
|
||||
|
||||
@@ -14,10 +14,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -33,50 +30,26 @@ import (
|
||||
// When no CLI metadata is present, a minimal overlay keyed by the server
|
||||
// name is returned so callers can still build an identity descriptor.
|
||||
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.CLIOverlay {
|
||||
serverID := sc.Key
|
||||
overlay := mcptypes.CLIOverlay{
|
||||
ID: serverID,
|
||||
Command: serverID,
|
||||
}
|
||||
srv, ok := p.Manifest.MCPServers[sc.Key]
|
||||
if !ok || len(srv.CLI) == 0 {
|
||||
return overlay
|
||||
}
|
||||
|
||||
cliData := srv.CLI
|
||||
// A JSON string is interpreted as a relative path to an external
|
||||
// overlay file (e.g. "overlay.json") anchored at the plugin root.
|
||||
if len(cliData) > 0 && cliData[0] == '"' {
|
||||
var cliPath string
|
||||
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
|
||||
absPath := filepath.Join(p.Root, cliPath)
|
||||
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
|
||||
cliData = fileData
|
||||
} else {
|
||||
slog.Warn("plugin: failed to read CLI overlay file",
|
||||
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
|
||||
}
|
||||
overlay, ok := p.ResolveCLIOverlay(sc.Key)
|
||||
if !ok {
|
||||
return mcptypes.CLIOverlay{
|
||||
ID: sc.Key,
|
||||
Command: sc.Key,
|
||||
Skip: true,
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(cliData, &overlay); err != nil {
|
||||
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
}
|
||||
if overlay.ID == "" {
|
||||
overlay.ID = serverID
|
||||
}
|
||||
if overlay.Command == "" {
|
||||
overlay.Command = serverID
|
||||
}
|
||||
return overlay
|
||||
}
|
||||
|
||||
// registerStdioServerFromManifest registers an endpoint descriptor and an
|
||||
// unstarted client from versioned plugin metadata. Tool discovery is not part
|
||||
// of command-tree construction; execution starts and initializes the client.
|
||||
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
descriptor := mcptypes.ServerDescriptor{
|
||||
func stdioServerDescriptorFromManifest(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
) (mcptypes.ServerDescriptor, bool) {
|
||||
overlay, ok := p.ResolveCLIOverlay(sc.Key)
|
||||
if !ok {
|
||||
return mcptypes.ServerDescriptor{}, false
|
||||
}
|
||||
return mcptypes.ServerDescriptor{
|
||||
Key: sc.Key,
|
||||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||||
Description: p.Manifest.Description,
|
||||
@@ -84,13 +57,30 @@ func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClie
|
||||
Source: "plugin",
|
||||
CLI: overlay,
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
}, true
|
||||
}
|
||||
|
||||
func registerResolvedStdioServer(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
descriptor mcptypes.ServerDescriptor,
|
||||
) {
|
||||
AppendDynamicServer(descriptor)
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
slog.Debug("plugin: stdio server registered from manifest",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
"toolOverrides", len(overlay.ToolOverrides))
|
||||
"toolOverrides", len(descriptor.CLI.ToolOverrides))
|
||||
}
|
||||
|
||||
// registerStdioServerFromManifest registers an endpoint descriptor and an
|
||||
// unstarted client from versioned plugin metadata. Tool discovery is not part
|
||||
// of command-tree construction; execution starts and initializes the client.
|
||||
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
|
||||
descriptor, ok := stdioServerDescriptorFromManifest(p, sc)
|
||||
if !ok {
|
||||
return mcptypes.ServerDescriptor{}
|
||||
}
|
||||
registerResolvedStdioServer(p, sc, descriptor)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
+324
-33
@@ -23,6 +23,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -69,7 +70,8 @@ var (
|
||||
rootPluginDescriptors = (*plugin.Plugin).ToServerDescriptors
|
||||
rootPluginStdioClients = (*plugin.Plugin).StdioClients
|
||||
rootRegisterPluginHTTPServer = registerPluginHTTPServer
|
||||
rootRegisterStdioManifest = registerStdioServerFromManifest
|
||||
rootPluginStdioDescriptor = stdioServerDescriptorFromManifest
|
||||
rootRegisterResolvedStdioServer = registerResolvedStdioServer
|
||||
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
|
||||
rootPluginSyncSkills = plugin.SyncSkills
|
||||
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||
@@ -306,13 +308,28 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
rootCtx = ctx[0]
|
||||
}
|
||||
return NewRootCommandWithEngine(rootCtx, nil)
|
||||
return newRootCommandWithEngine(rootCtx, nil, true)
|
||||
}
|
||||
|
||||
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
|
||||
// used by Schema generation and command-surface policy. Installed plugins and
|
||||
// user-defined shortcuts must not change the reviewed embedded Schema.
|
||||
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
var rootCtx context.Context
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
rootCtx = ctx[0]
|
||||
}
|
||||
return newRootCommandWithEngine(rootCtx, nil, false)
|
||||
}
|
||||
|
||||
// NewRootCommandWithEngine constructs the root CLI command with an
|
||||
// optional pipeline engine for input correction. When engine is nil,
|
||||
// no pipeline processing is applied.
|
||||
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
return newRootCommandWithEngine(rootCtx, engine, true)
|
||||
}
|
||||
|
||||
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command {
|
||||
if rootCtx == nil {
|
||||
rootCtx = context.Background()
|
||||
}
|
||||
@@ -396,16 +413,9 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
}
|
||||
root.AddCommand(utilityCommands...)
|
||||
|
||||
root.AddCommand(newLegacyPublicCommands(runner, patCaller)...)
|
||||
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
|
||||
root.AddCommand(newLegacyHiddenCommands(runner)...)
|
||||
|
||||
// --- Plugin loading: runs AFTER legacy commands so plugin endpoints can
|
||||
// be appended on top of the static endpoint registry.
|
||||
pluginCmds := rootLoadPlugins(engine, runner)
|
||||
if len(pluginCmds) > 0 {
|
||||
addPluginCommandsSafe(root, pluginCmds)
|
||||
}
|
||||
|
||||
// PAT authorization commands (open-source core)
|
||||
pat.RegisterCommands(root, patCaller)
|
||||
|
||||
@@ -414,6 +424,15 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
fn(root, caller)
|
||||
deduplicateCommands(root)
|
||||
}
|
||||
if loadRuntimeExtensions {
|
||||
// Resolve plugins only after the complete distribution command tree is
|
||||
// present, so endpoint and Cobra conflict checks see PAT and edition
|
||||
// commands as well as the open-source base.
|
||||
pluginCmds := rootLoadPlugins(root, engine, runner)
|
||||
if len(pluginCmds) > 0 {
|
||||
addPluginCommandsSafe(root, pluginCmds)
|
||||
}
|
||||
}
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
configureRootHelp(root)
|
||||
// Set custom flag error handler for better UX
|
||||
@@ -631,12 +650,17 @@ var reservedCommands = map[string]bool{
|
||||
"schema": true, "mcp": true, "help": true,
|
||||
}
|
||||
|
||||
var replaceablePluginFallbacks = map[string]bool{
|
||||
"conference": true,
|
||||
}
|
||||
|
||||
// addPluginCommandsSafe registers plugin commands with conflict detection.
|
||||
//
|
||||
// Rules:
|
||||
// - Plugin vs reserved (auth/plugin/cache/...) → reject, warn
|
||||
// - Plugin vs plugin (same name) → reject later one, warn
|
||||
// - Plugin vs Market dynamic command → allow, plugin wins
|
||||
// - Plugin vs hidden compatibility fallback → allow, plugin wins
|
||||
// - Plugin vs visible distribution command → reject, warn
|
||||
func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) {
|
||||
// Build index of existing commands before plugin registration.
|
||||
existing := make(map[string]bool)
|
||||
@@ -664,17 +688,47 @@ func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) {
|
||||
}
|
||||
pluginSeen[name] = true
|
||||
|
||||
// Rule 3: plugin vs Market — plugin wins, remove the old one.
|
||||
// An alias must not bypass the same protections applied to primary
|
||||
// plugin command names or shadow another root command.
|
||||
filteredAliases := make([]string, 0, len(cmd.Aliases))
|
||||
for _, rawAlias := range cmd.Aliases {
|
||||
alias := strings.TrimSpace(rawAlias)
|
||||
if alias == "" || alias == name || reservedCommands[alias] ||
|
||||
existing[alias] || pluginSeen[alias] {
|
||||
if alias != "" {
|
||||
slog.Warn("plugin: command alias conflicts with an existing command, skipping",
|
||||
"command", name, "alias", alias)
|
||||
}
|
||||
continue
|
||||
}
|
||||
pluginSeen[alias] = true
|
||||
filteredAliases = append(filteredAliases, alias)
|
||||
}
|
||||
cmd.Aliases = filteredAliases
|
||||
|
||||
// Rule 3: an installed plugin may replace a hidden compatibility
|
||||
// fallback (for example conference), but never a visible distribution
|
||||
// command that participates in the reviewed base interface.
|
||||
if existing[name] {
|
||||
for _, old := range root.Commands() {
|
||||
if old.Name() == name {
|
||||
if !old.Hidden || !replaceablePluginFallbacks[name] ||
|
||||
cmdutil.IsPluginSourced(old) {
|
||||
slog.Warn("plugin: command conflicts with a visible distribution command, skipping",
|
||||
"command", name)
|
||||
cmd = nil
|
||||
break
|
||||
}
|
||||
root.RemoveCommand(old)
|
||||
slog.Debug("plugin: overriding Market command",
|
||||
slog.Debug("plugin: overriding hidden compatibility command",
|
||||
"command", name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if cmd == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
root.AddCommand(cmd)
|
||||
}
|
||||
@@ -811,7 +865,21 @@ func CloseFileLogger() {
|
||||
// loadPlugins registers versioned plugin manifests, stdio clients, hooks, and
|
||||
// skills. It deliberately does not initialize MCP transports or call
|
||||
// tools/list while constructing the command tree.
|
||||
func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
||||
type pluginServerCandidate struct {
|
||||
owner *plugin.Plugin
|
||||
order int
|
||||
descriptor mcptypes.ServerDescriptor
|
||||
stdioClient *plugin.StdioServerClient
|
||||
}
|
||||
|
||||
type pluginIdentityOwner struct {
|
||||
plugin *plugin.Plugin
|
||||
serverKey string
|
||||
rootName string
|
||||
shareable bool
|
||||
}
|
||||
|
||||
func loadPlugins(root *cobra.Command, engine *pipeline.Engine, runner executor.Runner) []*cobra.Command {
|
||||
pluginLoader := plugin.NewLoader(RawVersion())
|
||||
|
||||
// 0a. Inject plugin config values from settings.json as environment
|
||||
@@ -838,25 +906,34 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
||||
|
||||
// 2. Load dev plugins (registered via `dws plugin dev`)
|
||||
devPlugins := rootPluginLoadDev(pluginLoader)
|
||||
sortPluginsForRegistration(userPlugins)
|
||||
sortPluginsForRegistration(devPlugins)
|
||||
|
||||
allPlugins := append(userPlugins, devPlugins...)
|
||||
descriptorsByPlugin := make(map[*plugin.Plugin][]mcptypes.ServerDescriptor, len(allPlugins))
|
||||
|
||||
// 3. Register HTTP descriptors and authentication from the manifest.
|
||||
for _, p := range allPlugins {
|
||||
for _, srv := range rootPluginDescriptors(p) {
|
||||
rootRegisterPluginHTTPServer(srv)
|
||||
// 3. Resolve every descriptor once, then choose identity winners before
|
||||
// mutating endpoint, auth, or stdio-client registries. This keeps the
|
||||
// visible command and its transport owned by the same plugin.
|
||||
candidates := collectPluginServerCandidates(allPlugins, userCtx)
|
||||
accepted := selectPluginServerCandidates(root, candidates)
|
||||
for _, candidate := range accepted {
|
||||
if candidate.stdioClient != nil {
|
||||
rootRegisterResolvedStdioServer(
|
||||
candidate.owner,
|
||||
*candidate.stdioClient,
|
||||
candidate.descriptor,
|
||||
)
|
||||
} else {
|
||||
rootRegisterPluginHTTPServer(candidate.descriptor)
|
||||
}
|
||||
descriptorsByPlugin[candidate.owner] = append(
|
||||
descriptorsByPlugin[candidate.owner],
|
||||
candidate.descriptor,
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Register stdio descriptors and unstarted clients. The subprocess is
|
||||
// started and initialized only when a command is actually executed.
|
||||
for _, p := range allPlugins {
|
||||
for _, sc := range rootPluginStdioClients(p, userCtx) {
|
||||
rootRegisterStdioManifest(p, sc)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Register plugin hooks into pipeline engine
|
||||
// 4. Register plugin hooks into pipeline engine
|
||||
if engine != nil {
|
||||
for _, p := range allPlugins {
|
||||
hooksCfg, err := rootPluginLoadHooks(p)
|
||||
@@ -874,7 +951,7 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Sync plugin skills to agent directories
|
||||
// 5. Sync plugin skills to agent directories
|
||||
rootPluginSyncSkills(allPlugins)
|
||||
|
||||
if len(allPlugins) > 0 {
|
||||
@@ -884,11 +961,228 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
var pluginCommands []*cobra.Command
|
||||
for _, p := range allPlugins {
|
||||
// Build each plugin independently. addPluginCommandsSafe deliberately
|
||||
// resolves cross-plugin root conflicts with first-plugin-wins semantics.
|
||||
pluginCommands = append(pluginCommands, buildPluginCommands(descriptorsByPlugin[p], runner, root)...)
|
||||
}
|
||||
return pluginCommands
|
||||
}
|
||||
|
||||
func sortPluginsForRegistration(plugins []*plugin.Plugin) {
|
||||
sort.SliceStable(plugins, func(i, j int) bool {
|
||||
left := strings.TrimSpace(plugins[i].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[i].Root)
|
||||
right := strings.TrimSpace(plugins[j].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[j].Root)
|
||||
return left < right
|
||||
})
|
||||
}
|
||||
|
||||
func collectPluginServerCandidates(
|
||||
plugins []*plugin.Plugin,
|
||||
userCtx *plugin.UserContext,
|
||||
) []pluginServerCandidate {
|
||||
var candidates []pluginServerCandidate
|
||||
for order, owner := range plugins {
|
||||
for _, descriptor := range rootPluginDescriptors(owner) {
|
||||
candidates = append(candidates, pluginServerCandidate{
|
||||
owner: owner,
|
||||
order: order,
|
||||
descriptor: descriptor,
|
||||
})
|
||||
}
|
||||
for _, stdioClient := range rootPluginStdioClients(owner, userCtx) {
|
||||
descriptor, ok := rootPluginStdioDescriptor(owner, stdioClient)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
clientCopy := stdioClient
|
||||
candidates = append(candidates, pluginServerCandidate{
|
||||
owner: owner,
|
||||
order: order,
|
||||
descriptor: descriptor,
|
||||
stdioClient: &clientCopy,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
if candidates[i].order != candidates[j].order {
|
||||
return candidates[i].order < candidates[j].order
|
||||
}
|
||||
left := strings.TrimSpace(candidates[i].descriptor.Key)
|
||||
right := strings.TrimSpace(candidates[j].descriptor.Key)
|
||||
if left != right {
|
||||
return left < right
|
||||
}
|
||||
return candidates[i].stdioClient == nil && candidates[j].stdioClient != nil
|
||||
})
|
||||
return candidates
|
||||
}
|
||||
|
||||
func selectPluginServerCandidates(
|
||||
root *cobra.Command,
|
||||
candidates []pluginServerCandidate,
|
||||
) []pluginServerCandidate {
|
||||
distributionProducts := DirectRuntimeProductIDs()
|
||||
owners := make(map[string]pluginIdentityOwner)
|
||||
for identity := range distributionProducts {
|
||||
if replaceablePluginFallbacks[identity] {
|
||||
continue
|
||||
}
|
||||
owners[identity] = pluginIdentityOwner{serverKey: "distribution"}
|
||||
}
|
||||
|
||||
accepted := make([]pluginServerCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
descriptor := candidate.descriptor
|
||||
if descriptor.CLI.Skip {
|
||||
continue
|
||||
}
|
||||
if reason := unsupportedPluginDescriptor(root, descriptor); reason != "" {
|
||||
slog.Warn("plugin: descriptor CLI semantics are unsupported, skipping",
|
||||
"plugin", candidate.owner.Manifest.Name,
|
||||
"server", descriptor.Key,
|
||||
"field", reason)
|
||||
continue
|
||||
}
|
||||
if pluginDescriptorConflictsWithDistribution(root, descriptor, distributionProducts) {
|
||||
continue
|
||||
}
|
||||
claims := pluginDescriptorIdentityClaims(descriptor)
|
||||
conflict := ""
|
||||
for identity, shareable := range claims {
|
||||
existing, exists := owners[identity]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
rootName := pluginDescriptorRootName(descriptor)
|
||||
if shareable && existing.shareable &&
|
||||
existing.plugin == candidate.owner &&
|
||||
existing.rootName == rootName {
|
||||
continue
|
||||
}
|
||||
conflict = identity
|
||||
break
|
||||
}
|
||||
if conflict != "" {
|
||||
slog.Warn("plugin: descriptor identity already owned, skipping",
|
||||
"plugin", candidate.owner.Manifest.Name,
|
||||
"server", descriptor.Key,
|
||||
"identity", conflict)
|
||||
continue
|
||||
}
|
||||
rootName := pluginDescriptorRootName(descriptor)
|
||||
for identity, shareable := range claims {
|
||||
if existing, exists := owners[identity]; exists &&
|
||||
shareable && existing.shareable &&
|
||||
existing.plugin == candidate.owner &&
|
||||
existing.rootName == rootName {
|
||||
continue
|
||||
}
|
||||
owners[identity] = pluginIdentityOwner{
|
||||
plugin: candidate.owner,
|
||||
serverKey: descriptor.Key,
|
||||
rootName: rootName,
|
||||
shareable: shareable,
|
||||
}
|
||||
}
|
||||
accepted = append(accepted, candidate)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
func pluginDescriptorIdentityClaims(descriptor mcptypes.ServerDescriptor) map[string]bool {
|
||||
claims := make(map[string]bool)
|
||||
canonicalID := firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key)
|
||||
if canonicalID != "" {
|
||||
claims[canonicalID] = false
|
||||
}
|
||||
for _, identity := range append(
|
||||
[]string{pluginDescriptorRootName(descriptor)},
|
||||
descriptor.CLI.Aliases...,
|
||||
) {
|
||||
identity = strings.TrimSpace(identity)
|
||||
if identity == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := claims[identity]; !exists {
|
||||
claims[identity] = true
|
||||
}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func pluginDescriptorRootName(descriptor mcptypes.ServerDescriptor) string {
|
||||
return firstNonEmptyPluginString(
|
||||
descriptor.CLI.Command,
|
||||
descriptor.CLI.ID,
|
||||
descriptor.Key,
|
||||
)
|
||||
}
|
||||
|
||||
func pluginDescriptorConflictsWithDistribution(
|
||||
root *cobra.Command,
|
||||
descriptor mcptypes.ServerDescriptor,
|
||||
distributionProducts map[string]bool,
|
||||
) bool {
|
||||
candidates := append(
|
||||
[]string{
|
||||
firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key),
|
||||
pluginDescriptorRootName(descriptor),
|
||||
},
|
||||
descriptor.CLI.Aliases...,
|
||||
)
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if !reservedCommands[candidate] && replaceablePluginFallbacks[candidate] {
|
||||
// The distribution ships only a hidden compatibility fallback for
|
||||
// this name; plugins may claim it and the later command merge in
|
||||
// addPluginCommandsSafe still rejects visible non-fallback owners.
|
||||
continue
|
||||
}
|
||||
if reservedCommands[candidate] ||
|
||||
distributionProducts[candidate] ||
|
||||
distributionRootOwns(root, candidate) {
|
||||
slog.Warn("plugin: descriptor conflicts with a distribution command, skipping",
|
||||
"plugin", descriptor.DisplayName,
|
||||
"server", descriptor.Key,
|
||||
"identity", candidate)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func distributionRootOwns(root *cobra.Command, name string) bool {
|
||||
if root == nil {
|
||||
return false
|
||||
}
|
||||
for _, command := range root.Commands() {
|
||||
if cmdutil.IsPluginSourced(command) {
|
||||
continue
|
||||
}
|
||||
if command.Name() == name {
|
||||
if command.Hidden && replaceablePluginFallbacks[name] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, alias := range command.Aliases {
|
||||
if strings.TrimSpace(alias) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
|
||||
AppendDynamicServer(srv)
|
||||
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
|
||||
ClearPluginAuth(productID)
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
}
|
||||
@@ -917,10 +1211,7 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
||||
host := parsed.Hostname()
|
||||
trustedDomains = []string{host, "*." + host}
|
||||
}
|
||||
productID := strings.TrimSpace(srv.CLI.ID)
|
||||
if productID == "" {
|
||||
productID = srv.Key
|
||||
}
|
||||
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
|
||||
RegisterPluginAuth(productID, &PluginAuth{
|
||||
Token: authToken,
|
||||
ExtraHeaders: extraHeaders,
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestCrossPlatformCoverageRootConstructionHooksAndVersionCoverage(t *testing
|
||||
version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit
|
||||
})
|
||||
|
||||
rootLoadPlugins = func(*pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
return []*cobra.Command{{Use: "plugin-added", Run: func(*cobra.Command, []string) {}}}
|
||||
}
|
||||
preRunCalled := false
|
||||
@@ -236,7 +236,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
|
||||
oldDescriptors := rootPluginDescriptors
|
||||
oldStdioClients := rootPluginStdioClients
|
||||
oldHTTP := rootRegisterPluginHTTPServer
|
||||
oldStdio := rootRegisterStdioManifest
|
||||
oldStdioDescriptor := rootPluginStdioDescriptor
|
||||
oldStdioRegister := rootRegisterResolvedStdioServer
|
||||
oldHooks := rootPluginLoadHooks
|
||||
oldSync := rootPluginSyncSkills
|
||||
oldToken := rootAuthLoadTokenData
|
||||
@@ -247,7 +248,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
|
||||
rootPluginDescriptors = oldDescriptors
|
||||
rootPluginStdioClients = oldStdioClients
|
||||
rootRegisterPluginHTTPServer = oldHTTP
|
||||
rootRegisterStdioManifest = oldStdio
|
||||
rootPluginStdioDescriptor = oldStdioDescriptor
|
||||
rootRegisterResolvedStdioServer = oldStdioRegister
|
||||
rootPluginLoadHooks = oldHooks
|
||||
rootPluginSyncSkills = oldSync
|
||||
rootAuthLoadTokenData = oldToken
|
||||
@@ -264,9 +266,17 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
|
||||
}
|
||||
rootPluginDescriptors = func(p *plugin.Plugin) []mcptypes.ServerDescriptor {
|
||||
if p == p1 {
|
||||
return []mcptypes.ServerDescriptor{{Key: "http", Endpoint: "https://example.test"}}
|
||||
return []mcptypes.ServerDescriptor{{
|
||||
Key: "http", Endpoint: "https://example.test",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: "http", Command: "one-http",
|
||||
ToolOverrides: map[string]mcptypes.CLIToolOverride{
|
||||
"ping": {CLIName: "ping"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
return []mcptypes.ServerDescriptor{{Key: "no-cli", Endpoint: "https://example.test"}}
|
||||
return []mcptypes.ServerDescriptor{{Key: p.Manifest.Name + "-no-cli", Endpoint: "https://example.test"}}
|
||||
}
|
||||
client := transport.NewStdioClient("ignored", nil, nil)
|
||||
rootPluginStdioClients = func(p *plugin.Plugin, uc *plugin.UserContext) []plugin.StdioServerClient {
|
||||
@@ -278,9 +288,23 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
|
||||
httpCount := 0
|
||||
stdioCount := 0
|
||||
rootRegisterPluginHTTPServer = func(mcptypes.ServerDescriptor) { httpCount++ }
|
||||
rootRegisterStdioManifest = func(*plugin.Plugin, plugin.StdioServerClient) mcptypes.ServerDescriptor {
|
||||
rootPluginStdioDescriptor = func(*plugin.Plugin, plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) {
|
||||
return mcptypes.ServerDescriptor{
|
||||
Key: "local",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: "local", Command: "one-stdio",
|
||||
ToolOverrides: map[string]mcptypes.CLIToolOverride{
|
||||
"pong": {CLIName: "pong"},
|
||||
},
|
||||
},
|
||||
}, true
|
||||
}
|
||||
rootRegisterResolvedStdioServer = func(
|
||||
*plugin.Plugin,
|
||||
plugin.StdioServerClient,
|
||||
mcptypes.ServerDescriptor,
|
||||
) {
|
||||
stdioCount++
|
||||
return mcptypes.ServerDescriptor{}
|
||||
}
|
||||
rootPluginLoadHooks = func(p *plugin.Plugin) (*plugin.HooksConfig, error) {
|
||||
switch p {
|
||||
@@ -294,7 +318,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
|
||||
}
|
||||
synced := false
|
||||
rootPluginSyncSkills = func([]*plugin.Plugin) { synced = true }
|
||||
if got := loadPlugins(pipeline.NewEngine(), runnerCoverageFallback{}); got != nil {
|
||||
got := loadPlugins(nil, pipeline.NewEngine(), runnerCoverageFallback{})
|
||||
if len(got) != 2 || got[0].Name() != "one-http" || got[1].Name() != "one-stdio" {
|
||||
t.Fatalf("loaded plugin commands = %#v", got)
|
||||
}
|
||||
if httpCount != 3 || stdioCount != 1 || !synced {
|
||||
|
||||
+37
-3
@@ -168,6 +168,7 @@ var (
|
||||
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
|
||||
runnerCallTool = (*transport.Client).CallTool
|
||||
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
|
||||
runnerStdioListTools = (*transport.StdioClient).ListTools
|
||||
runnerStdioCallTool = (*transport.StdioClient).CallTool
|
||||
runnerHandlePatAuthCheck func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error)
|
||||
runnerRetryWithPatAuthRetry func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error)
|
||||
@@ -485,7 +486,7 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut
|
||||
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
|
||||
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
|
||||
if IsStdioEndpoint(endpoint) {
|
||||
return r.executeStdioInvocation(ctx, invocation)
|
||||
return r.executeStdioInvocationAtEndpoint(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
// Constructing the Cobra tree is also used for help, schema, and command
|
||||
@@ -766,6 +767,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
// subprocess instead of the HTTP transport. This is used for plugin stdio
|
||||
// servers whose endpoints use the stdio:// scheme.
|
||||
func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
return r.executeStdioInvocationAtEndpoint(ctx, "", invocation)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
invocation executor.Invocation,
|
||||
) (executor.Result, error) {
|
||||
if invocation.DryRun {
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
@@ -778,10 +787,14 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
|
||||
}, nil
|
||||
}
|
||||
|
||||
client, ok := LookupStdioClient(invocation.CanonicalProduct)
|
||||
lookupKey := strings.Trim(strings.TrimPrefix(strings.TrimSpace(endpoint), stdioEndpointScheme), "/")
|
||||
if lookupKey == "" {
|
||||
lookupKey = invocation.CanonicalProduct
|
||||
}
|
||||
client, ok := LookupStdioClient(lookupKey)
|
||||
if !ok {
|
||||
return executor.Result{}, apperrors.NewInternal(
|
||||
fmt.Sprintf("stdio client not found for %q", invocation.CanonicalProduct))
|
||||
fmt.Sprintf("stdio client not found for %q", lookupKey))
|
||||
}
|
||||
|
||||
callCtx := ctx
|
||||
@@ -798,6 +811,27 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
|
||||
)
|
||||
}
|
||||
|
||||
tools, err := runnerStdioListTools(client, callCtx)
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("stdio tools/list failed: %v", err),
|
||||
apperrors.WithOperation("tools/list"),
|
||||
apperrors.WithReason("stdio_tools_list_error"),
|
||||
)
|
||||
}
|
||||
schema, ok := pluginToolInputSchema(tools, invocation.Tool)
|
||||
if !ok {
|
||||
return executor.Result{}, apperrors.NewValidation(
|
||||
fmt.Sprintf("plugin tool %q is not declared by tools/list", invocation.Tool),
|
||||
apperrors.WithReason("plugin_tool_not_found"),
|
||||
)
|
||||
}
|
||||
normalizedParams, err := normalizePluginInputParams(invocation.Params, schema)
|
||||
if err != nil {
|
||||
return executor.Result{}, err
|
||||
}
|
||||
invocation.Params = normalizedParams
|
||||
|
||||
callResult, err := runnerStdioCallTool(client, callCtx, invocation.Tool, invocation.Params)
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
|
||||
@@ -290,10 +290,12 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *testing.T) {
|
||||
oldStdioInit := runnerStdioEnsureInitialized
|
||||
oldStdioList := runnerStdioListTools
|
||||
oldStdioCall := runnerStdioCallTool
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
runnerStdioEnsureInitialized = oldStdioInit
|
||||
runnerStdioListTools = oldStdioList
|
||||
runnerStdioCallTool = oldStdioCall
|
||||
edition.Override(oldEdition)
|
||||
StopAllStdioClients()
|
||||
@@ -309,6 +311,14 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
||||
t.Fatalf("stdio initialize error = %v", err)
|
||||
}
|
||||
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
|
||||
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
|
||||
return transport.ToolsListResult{
|
||||
Tools: []transport.ToolDescriptor{{
|
||||
Name: "tool",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{}, wantErr
|
||||
}
|
||||
@@ -327,6 +337,16 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
||||
if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented {
|
||||
t.Fatalf("stdio success = %#v, %v", got, err)
|
||||
}
|
||||
RegisterStdioClient("plugin/server-key", client)
|
||||
overlayIDInvocation := inv
|
||||
overlayIDInvocation.CanonicalProduct = "overlay-id"
|
||||
if got, err := r.executeInvocation(
|
||||
context.Background(),
|
||||
"stdio://plugin/server-key",
|
||||
overlayIDInvocation,
|
||||
); err != nil || !got.Invocation.Implemented {
|
||||
t.Fatalf("stdio endpoint-key lookup = %#v, %v", got, err)
|
||||
}
|
||||
|
||||
r.globalFlags.Token = " explicit "
|
||||
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "explicit" {
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := NewRootCommand()
|
||||
root := NewSchemaSourceRootCommand()
|
||||
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -304,3 +304,14 @@ func splitSchemaPathTokens(raw string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeSchemaQueryCLIPath accepts the historical query spellings while
|
||||
// keeping authored Registry CLI paths strict and space-separated. Canonical
|
||||
// identity lookup still runs before this compatibility normalization.
|
||||
func normalizeSchemaQueryCLIPath(path string) string {
|
||||
parts := splitSchemaPathTokens(strings.TrimSpace(path))
|
||||
if len(parts) > 0 && parts[0] == "dws" {
|
||||
parts = parts[1:]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"product_id": "event",
|
||||
"tools": {
|
||||
"event consume": {
|
||||
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
|
||||
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"agent_summary_source": "dws-agent-selection/event",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
@@ -13,19 +13,19 @@
|
||||
"effect": "write",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
|
||||
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
|
||||
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
@@ -105,8 +105,8 @@
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -115,8 +115,8 @@
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -538,7 +538,7 @@
|
||||
]
|
||||
},
|
||||
"event schema": {
|
||||
"agent_summary": "查询指定个人事件码的 payload 字段结构",
|
||||
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
"agent_summary_source": "dws-agent-selection/event",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
@@ -549,18 +549,18 @@
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws event schema user_im_message_receive_at --format json"
|
||||
"dws event schema user_im_message_receive_at --flatten --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "查询指定个人事件码的 payload 字段结构",
|
||||
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "查询指定个人事件码的 payload 字段结构",
|
||||
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
@@ -640,7 +640,7 @@
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws event schema user_im_message_receive_at --format json"
|
||||
"dws event schema user_im_message_receive_at --flatten --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -649,7 +649,7 @@
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws event schema user_im_message_receive_at --format json"
|
||||
"dws event schema user_im_message_receive_at --flatten --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
|
||||
"source_hash": "sha256:5df496973c41b3b4f7ae8c856ff0e9e99bcabd4455419b7d41bb23c44df6f1af",
|
||||
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
|
||||
"coverage": {
|
||||
"surface_products": 22,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
|
||||
"source_hash": "sha256:5df496973c41b3b4f7ae8c856ff0e9e99bcabd4455419b7d41bb23c44df6f1af",
|
||||
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
|
||||
"source_files": 150,
|
||||
"hint_files": 46,
|
||||
|
||||
@@ -290,7 +290,7 @@ func schemaPayloadFromLoadedCatalog(loaded loadedSchemaCatalog, args []string) (
|
||||
return payload, nil
|
||||
}
|
||||
raw := strings.TrimSpace(args[0])
|
||||
if tool, ok := loaded.Index.Resolve(raw); ok {
|
||||
if tool, ok := loaded.Index.ResolveQuery(raw); ok {
|
||||
return schemaToolForResolvedPath(tool, raw).ToPayload()
|
||||
}
|
||||
tokens := splitSchemaPathTokens(raw)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:522d4b43cf13f07430a407319a772cd11e9b1f268f8d98b4d68bdb385e4e585b",
|
||||
"source_hash": "sha256:14398788381c822f208e8cae059d98cd60da6625023869c74015735650d67e6f",
|
||||
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
|
||||
"catalog": {
|
||||
"agent_metadata": {
|
||||
"products_with_metadata": 22,
|
||||
"source": "embedded-skill-metadata",
|
||||
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
|
||||
"source_hash": "sha256:5df496973c41b3b4f7ae8c856ff0e9e99bcabd4455419b7d41bb23c44df6f1af",
|
||||
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
|
||||
"surface_products": 22,
|
||||
"surface_tools": 572,
|
||||
@@ -12178,7 +12178,7 @@
|
||||
"tools": [
|
||||
{
|
||||
"agent_metadata_source": "embedded-skill-metadata",
|
||||
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
|
||||
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"agent_summary_source": "dws-agent-selection/event",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
@@ -12189,7 +12189,7 @@
|
||||
"cli_name": "consume",
|
||||
"cli_path": "event consume",
|
||||
"confirmation": "not_required",
|
||||
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
|
||||
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
|
||||
"effect": "write",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
@@ -12234,7 +12234,7 @@
|
||||
},
|
||||
{
|
||||
"agent_metadata_source": "embedded-skill-metadata",
|
||||
"agent_summary": "查询指定个人事件码的 payload 字段结构",
|
||||
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
"agent_summary_source": "dws-agent-selection/event",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
@@ -290130,7 +290130,7 @@
|
||||
"skills/mono/SKILL.md",
|
||||
"skills/mono/references/products/event.md"
|
||||
],
|
||||
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
|
||||
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"agent_summary_source": "dws-agent-selection/event",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
@@ -290149,13 +290149,13 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
|
||||
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
|
||||
"display": "事件订阅 (DingTalk Stream 长连接)",
|
||||
"effect": "write",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
@@ -290165,14 +290165,14 @@
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流"
|
||||
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流"
|
||||
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON"
|
||||
},
|
||||
"availability": {
|
||||
"candidates": [
|
||||
@@ -290250,13 +290250,13 @@
|
||||
"precedence": "cobra_help",
|
||||
"selected": true,
|
||||
"source": "cobra_help",
|
||||
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
|
||||
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_help",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_help",
|
||||
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
|
||||
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
|
||||
},
|
||||
"effect": {
|
||||
"candidates": [
|
||||
@@ -290282,8 +290282,8 @@
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -290292,8 +290292,8 @@
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
@@ -290444,7 +290444,7 @@
|
||||
"interface_reason": "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
|
||||
"is_alias": false,
|
||||
"name": "consume",
|
||||
"parameter_count": 27,
|
||||
"parameter_count": 28,
|
||||
"parameters": {
|
||||
"compact": {
|
||||
"description": "提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)",
|
||||
@@ -291124,6 +291124,90 @@
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
"flatten": {
|
||||
"description": "将个人事件 transport envelope 投影为稳定的顶层业务字段",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "将个人事件 transport envelope 投影为稳定的顶层业务字段"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "将个人事件 transport envelope 投影为稳定的顶层业务字段"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": true,
|
||||
"source": "flag_name_inference",
|
||||
"value": "flatten"
|
||||
}
|
||||
],
|
||||
"precedence": "inference",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "flag_name_inference",
|
||||
"value": "flatten"
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "fallback",
|
||||
"source": "default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
},
|
||||
"property": "flatten",
|
||||
"required": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"force": {
|
||||
"description": "仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)",
|
||||
"field_provenance": {
|
||||
@@ -293464,7 +293548,7 @@
|
||||
"skills/mono/SKILL.md",
|
||||
"skills/mono/references/products/event.md"
|
||||
],
|
||||
"agent_summary": "查询指定个人事件码的 payload 字段结构",
|
||||
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
"agent_summary_source": "dws-agent-selection/event",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
@@ -293480,7 +293564,7 @@
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws event schema user_im_message_receive_at --format json"
|
||||
"dws event schema user_im_message_receive_at --flatten --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
@@ -293490,14 +293574,14 @@
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": "查询指定个人事件码的 payload 字段结构"
|
||||
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式"
|
||||
}
|
||||
],
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": "查询指定个人事件码的 payload 字段结构"
|
||||
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式"
|
||||
},
|
||||
"availability": {
|
||||
"candidates": [
|
||||
@@ -293607,7 +293691,7 @@
|
||||
"selected": true,
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"dws event schema user_im_message_receive_at --format json"
|
||||
"dws event schema user_im_message_receive_at --flatten --format json"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -293616,7 +293700,7 @@
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"source": "internal/cli/schema_hints/selection/event.json",
|
||||
"value": [
|
||||
"dws event schema user_im_message_receive_at --format json"
|
||||
"dws event schema user_im_message_receive_at --flatten --format json"
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
@@ -293763,8 +293847,92 @@
|
||||
"interface_reason": "命令读取 CLI 内置的个人事件 payload 定义,不绑定 pinned MCP RPC",
|
||||
"is_alias": false,
|
||||
"name": "schema",
|
||||
"parameter_count": 1,
|
||||
"parameter_count": 2,
|
||||
"parameters": {
|
||||
"flatten": {
|
||||
"description": "显示 --flatten 消费模式对应的顶层业务字段 schema",
|
||||
"field_provenance": {
|
||||
"description": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_usage",
|
||||
"value": "显示 --flatten 消费模式对应的顶层业务字段 schema"
|
||||
},
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": false,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_usage",
|
||||
"value": "显示 --flatten 消费模式对应的顶层业务字段 schema"
|
||||
},
|
||||
"property": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "inference",
|
||||
"selected": true,
|
||||
"source": "flag_name_inference",
|
||||
"value": "flatten"
|
||||
}
|
||||
],
|
||||
"precedence": "inference",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "flag_name_inference",
|
||||
"value": "flatten"
|
||||
},
|
||||
"required": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": false
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "fallback",
|
||||
"source": "default",
|
||||
"value": false
|
||||
},
|
||||
"required_when": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "default",
|
||||
"selected": true,
|
||||
"source": "default",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"precedence": "default",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "default",
|
||||
"value": ""
|
||||
},
|
||||
"type": {
|
||||
"candidates": [
|
||||
{
|
||||
"precedence": "cobra_contract",
|
||||
"selected": true,
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
],
|
||||
"precedence": "cobra_contract",
|
||||
"resolution": "highest_precedence",
|
||||
"source": "cobra_flag_type",
|
||||
"value": "boolean"
|
||||
}
|
||||
},
|
||||
"property": "flatten",
|
||||
"required": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"format": {
|
||||
"default": "json",
|
||||
"description": "输出格式: json",
|
||||
|
||||
@@ -194,6 +194,23 @@ func TestCrossPlatformCoverageSchemaCatalogLookupAndConversionEdges(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedSchemaLookupAcceptsCompatibleCLIPathSeparators(t *testing.T) {
|
||||
loaded := embeddedSchemaCatalog()
|
||||
for _, path := range []string{
|
||||
"dev app list",
|
||||
"dev.app.list",
|
||||
"dev/app/list",
|
||||
} {
|
||||
payload, err := schemaPayloadFromLoadedCatalog(loaded, []string{path})
|
||||
if err != nil {
|
||||
t.Fatalf("schemaPayloadFromLoadedCatalog(%q) error = %v", path, err)
|
||||
}
|
||||
if got := schemaString(payload["canonical_path"]); got != "dev.list_dev_app" {
|
||||
t.Fatalf("schemaPayloadFromLoadedCatalog(%q) canonical_path = %q, want %q", path, got, "dev.list_dev_app")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type catalogHookSnapshot struct {
|
||||
parameterBindings func(BoundCommandRegistry, SchemaRegistry) error
|
||||
dryRun func(SchemaRegistry) error
|
||||
|
||||
@@ -617,6 +617,22 @@ func (i SchemaIndex) Resolve(path string) (ToolSpec, bool) {
|
||||
return i.registry.Products[location.product].Tools[location.tool], true
|
||||
}
|
||||
|
||||
// ResolveQuery adds compatibility for dotted and slash-separated CLI paths at
|
||||
// the user-facing query boundary. Resolve remains strict because Registry
|
||||
// validation uses it to detect missing canonical identities without falling
|
||||
// through to a similarly spelled CLI path.
|
||||
func (i SchemaIndex) ResolveQuery(path string) (ToolSpec, bool) {
|
||||
if tool, ok := i.Resolve(path); ok {
|
||||
return tool, true
|
||||
}
|
||||
canonical, ok := i.byCLIPath[normalizeSchemaQueryCLIPath(path)]
|
||||
if !ok {
|
||||
return ToolSpec{}, false
|
||||
}
|
||||
location := i.byCanonical[canonical]
|
||||
return i.registry.Products[location.product].Tools[location.tool], true
|
||||
}
|
||||
|
||||
// CanonicalPaths returns the complete tool identity set in stable order.
|
||||
func (i SchemaIndex) CanonicalPaths() []string {
|
||||
paths := make([]string, 0, len(i.byCanonical))
|
||||
|
||||
@@ -514,6 +514,19 @@ func TestSchemaRegistryIndexResolvesCanonicalCLIAndAlias(t *testing.T) {
|
||||
t.Fatalf("Resolve(%q) = %#v, %v", path, resolved.Identity, ok)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
"calendar.attendee.delete",
|
||||
"calendar/attendee/delete",
|
||||
"dws.calendar.attendee.delete",
|
||||
} {
|
||||
resolved, ok := index.ResolveQuery(path)
|
||||
if !ok || resolved.Identity.CanonicalPath != "calendar.attendee_delete" {
|
||||
t.Fatalf("ResolveQuery(%q) = %#v, %v", path, resolved.Identity, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := index.ResolveQuery("calendar.attendee.unknown"); ok {
|
||||
t.Fatal("unknown dotted CLI path unexpectedly resolved")
|
||||
}
|
||||
if got := index.CanonicalPaths(); !reflect.DeepEqual(got, []string{"calendar.attendee_delete"}) {
|
||||
t.Fatalf("CanonicalPaths() = %#v", got)
|
||||
}
|
||||
|
||||
@@ -154,6 +154,35 @@ func TestSelectionExplicitEmptyListSurvivesFinalDelivery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibleSchemaAliasSeparatorsSurviveFinalDelivery(t *testing.T) {
|
||||
snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{
|
||||
Canonical: "sample.run",
|
||||
CLIPath: "sample category run",
|
||||
Aliases: []string{"sample legacy execute"},
|
||||
})
|
||||
encoded, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := decodeSchemaCatalogSnapshot(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeSchemaCatalogSnapshot(): %v", err)
|
||||
}
|
||||
canonical, err := schemaPayloadFromLoadedCatalog(loaded, []string{"sample.run"})
|
||||
if err != nil {
|
||||
t.Fatalf("canonical query: %v", err)
|
||||
}
|
||||
for _, path := range []string{"sample legacy execute", "sample.legacy.execute", "sample/legacy/execute"} {
|
||||
alias, aliasErr := schemaPayloadFromLoadedCatalog(loaded, []string{path})
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("alias query %q: %v", path, aliasErr)
|
||||
}
|
||||
if problem := schemaAliasViewProblem(canonical, alias, "sample legacy execute"); problem != "" {
|
||||
t.Fatalf("alias projection for %q: %s", path, problem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSchemaDeliveryInvariantsAllowsOnlyEnvelopeHashes(t *testing.T) {
|
||||
snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{Canonical: "sample.run", CLIPath: "sample run"})
|
||||
snapshot.SurfaceHash = "sha256:reviewed-command-registry"
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// Go's normal per-package coverage accounting attributes the exercised Schema
|
||||
// assembly code to internal/cli.
|
||||
func TestCrossPlatformCoverageProductionSchemaSourcePipeline(t *testing.T) {
|
||||
root := app.NewRootCommand()
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
resolved, err := cli.ResolveSchemaBuild(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSchemaBuild() error = %v", err)
|
||||
@@ -33,17 +33,17 @@ func TestCrossPlatformCoverageProductionSchemaSourcePipeline(t *testing.T) {
|
||||
if len(snapshot.Tools) == 0 {
|
||||
t.Fatal("production Schema snapshot contains no tools")
|
||||
}
|
||||
registry, err := cli.AssembleSchemaRegistry(app.NewRootCommand())
|
||||
registry, err := cli.AssembleSchemaRegistry(app.NewSchemaSourceRootCommand())
|
||||
if err != nil {
|
||||
t.Fatalf("AssembleSchemaRegistry() error = %v", err)
|
||||
}
|
||||
if len(registry.Products) == 0 {
|
||||
t.Fatal("assembled production Schema registry contains no products")
|
||||
}
|
||||
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(app.NewRootCommand()); err != nil {
|
||||
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(app.NewSchemaSourceRootCommand()); err != nil {
|
||||
t.Fatalf("ValidateEmbeddedRuntimeSchemaCompleteness() error = %v", err)
|
||||
}
|
||||
root = app.NewRootCommand()
|
||||
root = app.NewSchemaSourceRootCommand()
|
||||
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -634,6 +634,11 @@
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 和输出参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_receive_at": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_receive_group": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
|
||||
@@ -585,6 +585,11 @@
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 和输出参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_receive_at": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
|
||||
},
|
||||
"event consume user_im_message_receive_group": {
|
||||
"status": "alias",
|
||||
"target": "event consume",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"tools": {
|
||||
"event.consume": {
|
||||
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
|
||||
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
"use_when": [
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
@@ -20,8 +20,8 @@
|
||||
"只看事件目录/字段时用 event list / event schema"
|
||||
],
|
||||
"examples": [
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
@@ -57,7 +57,7 @@
|
||||
]
|
||||
},
|
||||
"event.schema": {
|
||||
"agent_summary": "查询指定个人事件码的 payload 字段结构",
|
||||
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
"use_when": [
|
||||
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
|
||||
],
|
||||
@@ -66,7 +66,7 @@
|
||||
"要实际收事件时用 event consume"
|
||||
],
|
||||
"examples": [
|
||||
"dws event schema user_im_message_receive_at --format json"
|
||||
"dws event schema user_im_message_receive_at --flatten --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
|
||||
@@ -284,7 +284,7 @@ func runtimeSchemaPayloadFromRegistry(registry SchemaRegistry, args []string) (m
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(args[0])
|
||||
if tool, ok := index.Resolve(raw); ok {
|
||||
if tool, ok := index.ResolveQuery(raw); ok {
|
||||
tool = schemaToolForResolvedPath(tool, raw)
|
||||
return renderRegistryToolPayload(tool)
|
||||
}
|
||||
@@ -338,7 +338,7 @@ func runtimeSchemaAllPayloadFromRegistry(registry SchemaRegistry) (map[string]an
|
||||
}
|
||||
|
||||
func schemaToolForResolvedPath(tool ToolSpec, raw string) ToolSpec {
|
||||
normalized := normalizeSchemaCLIPath(raw)
|
||||
normalized := normalizeSchemaQueryCLIPath(raw)
|
||||
if normalized == "" || normalized == tool.Identity.CLIPath || normalized == tool.Identity.PrimaryCLIPath {
|
||||
return tool
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@ type Config struct {
|
||||
// NormalizeFormat; an empty Format here defaults to NDJSON inside
|
||||
// BuildPipeline.
|
||||
Format Format
|
||||
// Flatten records whether the caller explicitly selected a structured
|
||||
// business projection. Projector remains the executable behavior; this
|
||||
// field is surfaced in dry-run output so users can verify the final mode.
|
||||
Flatten bool
|
||||
// OutputDir, if non-empty, switches the fallback sink from stdout to
|
||||
// "file per event" under this directory.
|
||||
OutputDir string
|
||||
|
||||
@@ -132,6 +132,7 @@ func PrintDryRun(w io.Writer, cfg Config) {
|
||||
fmt.Fprintf(w, " filter : %s\n", cfg.Filter)
|
||||
}
|
||||
fmt.Fprintf(w, " format : %s\n", cfg.Format)
|
||||
fmt.Fprintf(w, " flatten : %v\n", cfg.Flatten)
|
||||
if cfg.OutputDir != "" {
|
||||
fmt.Fprintf(w, " output_dir : %s\n", cfg.OutputDir)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ func TestPrintDryRun_RendersAllSetFields(t *testing.T) {
|
||||
c.EventTypes = []string{"im.*", "approval.*"}
|
||||
c.Filter = "^im\\."
|
||||
c.Format = FormatCompact
|
||||
c.Flatten = true
|
||||
c.OutputDir = "/tmp/events"
|
||||
c.Routes, _ = ParseRoutes([]string{`^im\.=dir:/tmp/im/`})
|
||||
c.MaxEvents = 5
|
||||
@@ -151,7 +152,7 @@ func TestPrintDryRun_RendersAllSetFields(t *testing.T) {
|
||||
wants := []string{
|
||||
"client_id", "workdir", "ipc_endpoint", "im.*,approval.*",
|
||||
"^im\\.", "compact", "/tmp/events", "route[0]", "max_events : 5",
|
||||
"duration", "true",
|
||||
"duration", "flatten : true", "true",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(out, w) {
|
||||
|
||||
@@ -394,6 +394,39 @@ func outputSchema(eventKey string) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func transportEnvelopeSchema(eventKey string) map[string]any {
|
||||
eventType := reflect.TypeOf(transport.Event{})
|
||||
properties := make(map[string]any, eventType.NumField())
|
||||
for i := 0; i < eventType.NumField(); i++ {
|
||||
field := eventType.Field(i)
|
||||
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
property := map[string]any{"type": schemaType(field.Type)}
|
||||
switch name {
|
||||
case "type":
|
||||
property["description"] = "transport frame 类型"
|
||||
property["enum"] = []string{string(transport.FrameTypeEvent)}
|
||||
case "event_type":
|
||||
property["description"] = "事件类型"
|
||||
property["enum"] = []string{eventKey}
|
||||
case "data":
|
||||
property["description"] = "服务端业务 payload JSON 字符串"
|
||||
property["content_media_type"] = "application/json"
|
||||
case "headers":
|
||||
property["description"] = "Stream transport headers"
|
||||
property["additionalProperties"] = map[string]any{"type": "string"}
|
||||
case "event_id":
|
||||
property["description"] = "transport 事件 ID"
|
||||
case "subscribe_id":
|
||||
property["description"] = "个人事件订阅 ID"
|
||||
}
|
||||
properties[name] = property
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
}
|
||||
}
|
||||
|
||||
func outputTypeForEvent(eventKey string) reflect.Type {
|
||||
switch {
|
||||
case isMessageReceiveEvent(eventKey):
|
||||
|
||||
@@ -258,8 +258,18 @@ func Catalog(category string, enabledOnly, includePending bool) []Definition {
|
||||
}
|
||||
|
||||
func BuildSchemaDocument(def Definition) SchemaDocument {
|
||||
return BuildSchemaDocumentForMode(def, false)
|
||||
}
|
||||
|
||||
func BuildSchemaDocumentForMode(def Definition, flatten bool) SchemaDocument {
|
||||
requiredParams := make([]string, 0, len(def.RequiredParams))
|
||||
requiredParams = append(requiredParams, def.RequiredParams...)
|
||||
jqRootPath := ".data | fromjson"
|
||||
schema := transportEnvelopeSchema(def.EventKey)
|
||||
if flatten {
|
||||
jqRootPath = "."
|
||||
schema = outputSchema(def.EventKey)
|
||||
}
|
||||
return SchemaDocument{
|
||||
EventKey: def.EventKey,
|
||||
DisplayName: def.DisplayName,
|
||||
@@ -268,8 +278,8 @@ func BuildSchemaDocument(def Definition) SchemaDocument {
|
||||
RuleType: def.RuleType,
|
||||
RequiredParams: requiredParams,
|
||||
Constraints: cloneParameterConstraints(def.Constraints),
|
||||
JQRootPath: ".",
|
||||
Schema: outputSchema(def.EventKey),
|
||||
JQRootPath: jqRootPath,
|
||||
Schema: schema,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestDefinitionJSONHidesInternalSchemaIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
|
||||
func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
|
||||
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
def, ok := Lookup(eventKey)
|
||||
@@ -117,16 +117,16 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
|
||||
"required_params",
|
||||
"jq_root_path",
|
||||
"schema",
|
||||
"type",
|
||||
"seq",
|
||||
"event_id",
|
||||
"timestamp",
|
||||
"event_born_time",
|
||||
"event_type",
|
||||
"subscribe_id",
|
||||
"content",
|
||||
"sender",
|
||||
"sender_open_dingtalk_id",
|
||||
"conversation_id",
|
||||
"message_id",
|
||||
"create_time",
|
||||
"event_time",
|
||||
"source_id",
|
||||
"data",
|
||||
"headers",
|
||||
"received_at_unix_ms",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("schema for %s missing %q: %s", eventKey, want, out)
|
||||
@@ -144,7 +144,6 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
|
||||
"payload_schema",
|
||||
"output_schema",
|
||||
"data_json_path",
|
||||
"headers",
|
||||
"audit",
|
||||
"tenant",
|
||||
"subject",
|
||||
@@ -152,13 +151,18 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
|
||||
"msgIdMetaq",
|
||||
"at_users",
|
||||
"sender_user_id",
|
||||
"sender_open_dingtalk_id",
|
||||
"conversation_id",
|
||||
"message_id",
|
||||
"create_time",
|
||||
"event_time",
|
||||
} {
|
||||
if strings.Contains(out, leaked) {
|
||||
t.Fatalf("schema for %s leaked %q: %s", eventKey, leaked, out)
|
||||
}
|
||||
}
|
||||
if doc.JQRootPath != "." {
|
||||
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
|
||||
if doc.JQRootPath != ".data | fromjson" {
|
||||
t.Fatalf("jq_root_path = %q, want .data | fromjson", doc.JQRootPath)
|
||||
}
|
||||
if doc.RequiredParams == nil {
|
||||
t.Fatalf("required_params = nil, want empty slice")
|
||||
@@ -167,6 +171,38 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"])
|
||||
}
|
||||
wantProperties := []string{
|
||||
"type", "seq", "event_id", "event_born_time", "event_corp_id",
|
||||
"event_type", "event_unified_app_id", "event_scope", "subscribe_id",
|
||||
"source_id", "rule_type", "data", "headers", "received_at_unix_ms",
|
||||
}
|
||||
if len(props) != len(wantProperties) {
|
||||
t.Fatalf("schema.properties = %#v, want exactly %d transport fields", props, len(wantProperties))
|
||||
}
|
||||
for _, name := range wantProperties {
|
||||
if _, ok := props[name].(map[string]any); !ok {
|
||||
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenedSchemaDocumentsUseMessageDTO(t *testing.T) {
|
||||
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
def, ok := Lookup(eventKey)
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) failed", eventKey)
|
||||
}
|
||||
doc := BuildSchemaDocumentForMode(def, true)
|
||||
if doc.JQRootPath != "." {
|
||||
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
|
||||
}
|
||||
props, ok := doc.Schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"])
|
||||
}
|
||||
wantProperties := []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "message_id",
|
||||
"conversation_id", "sender", "sender_open_dingtalk_id", "content",
|
||||
@@ -180,6 +216,11 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
|
||||
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
|
||||
}
|
||||
}
|
||||
for _, transportField := range []string{"data", "headers", "seq", "event_type"} {
|
||||
if _, ok := props[transportField]; ok {
|
||||
t.Fatalf("flattened schema exposed transport field %q", transportField)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -286,7 +327,7 @@ func TestActionSchemaDocumentsMatchOutputDTOs(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) failed", eventKey)
|
||||
}
|
||||
doc := BuildSchemaDocument(def)
|
||||
doc := BuildSchemaDocumentForMode(def, true)
|
||||
if doc.JQRootPath != "." {
|
||||
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
|
||||
}
|
||||
|
||||
@@ -116,11 +116,14 @@ func NewWorkflowInvocation(legacyPath, workflowName string, steps []Invocation)
|
||||
func MergePayloads(jsonPayload, paramsPayload string, overrides map[string]any) (map[string]any, error) {
|
||||
merged := make(map[string]any)
|
||||
|
||||
for label, payload := range map[string]string{
|
||||
"--json": jsonPayload,
|
||||
"--params": paramsPayload,
|
||||
for _, payload := range []struct {
|
||||
label string
|
||||
raw string
|
||||
}{
|
||||
{label: "--json", raw: jsonPayload},
|
||||
{label: "--params", raw: paramsPayload},
|
||||
} {
|
||||
value, err := parseJSONObject(label, payload)
|
||||
value, err := parseJSONObject(payload.label, payload.raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ func TestCrossPlatformCoverageMergePayloadsAndToolCallRequest(t *testing.T) {
|
||||
if empty, err := MergePayloads(" ", "", nil); err != nil || len(empty) != 0 {
|
||||
t.Fatalf("empty MergePayloads() = %#v, %v", empty, err)
|
||||
}
|
||||
merged, err = MergePayloads(`{"same":"json"}`, `{"same":"params"}`, nil)
|
||||
if err != nil || merged["same"] != "params" {
|
||||
t.Fatalf("MergePayloads() precedence = %#v, %v; want --params to win", merged, err)
|
||||
}
|
||||
for _, input := range []string{`{`, `[]`, `null`} {
|
||||
if _, err := MergePayloads(input, "", nil); err == nil {
|
||||
t.Errorf("MergePayloads(%q) error = nil", input)
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestCrossPlatformCoverageGenerateProductionAgentMetadataPipeline(t *testing
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := app.NewRootCommand()
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ var (
|
||||
writeMetadataFileBytes = os.WriteFile
|
||||
writeMetadataJSON = writeJSON
|
||||
|
||||
newMetadataRoot = app.NewRootCommand
|
||||
newMetadataRoot = app.NewSchemaSourceRootCommand
|
||||
buildEffectiveMetadata = cli.BuildEffectiveCommandRegistry
|
||||
bindEffectiveMetadata = cli.BindEffectiveCommandRegistry
|
||||
loadSelectionMetadataHints = cli.LoadAgentHintsFromSelectionForValidation
|
||||
|
||||
@@ -48,7 +48,7 @@ func main() {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
root := app.NewRootCommand()
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
if err := generateSchemaCatalog(root, resolvedSurfacePath, outputPath); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func TestCrossPlatformCoverageGenerateSchemaCatalogFailureEdges(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGenerateSchemaCatalogResolvesBuildExactlyOnce(t *testing.T) {
|
||||
root := app.NewRootCommand()
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
resolveCalls := 0
|
||||
resolvedRegistryHash := ""
|
||||
resolver := func(candidate *cobra.Command) (cli.ResolvedSchemaBuild, error) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
newSmokeRoot = app.NewRootCommand
|
||||
newSmokeRoot = app.NewSchemaSourceRootCommand
|
||||
buildEffectiveSmokeRegistry = cli.BuildEffectiveCommandRegistry
|
||||
bindEffectiveSmokeRegistry = cli.BindEffectiveCommandRegistry
|
||||
buildSmokeRegistryData = buildSmokeRegistry
|
||||
|
||||
@@ -3143,6 +3143,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
# resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId
|
||||
# message-id: 从 dws chat message list 返回的 openMessageId
|
||||
# open-conversation-id: 从 dws chat search 获取 openConversationId`,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Cobra validates required flags after PreRunE. Copy a supplied alias
|
||||
// into the canonical flag first so --message-id can remain a hard
|
||||
// required fact in both the executable and Agent Schema contracts.
|
||||
if cmd.Flags().Changed("message-id") {
|
||||
return nil
|
||||
}
|
||||
alias := ""
|
||||
switch {
|
||||
case cmd.Flags().Changed("msg-id"):
|
||||
alias = "msg-id"
|
||||
case cmd.Flags().Changed("open-message-id"):
|
||||
alias = "open-message-id"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
value, _ := cmd.Flags().GetString(alias) // registered string flags above
|
||||
return cmd.Flags().Set("message-id", value)
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "type", "resource-id", "message-id", "open-conversation-id", "output"); err != nil {
|
||||
return err
|
||||
@@ -3226,6 +3245,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("open-conversation-id")
|
||||
chatMessageDownloadMediaCmd.Flags().String("message-id", "", "消息 openMessageId (必填)")
|
||||
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("message-id")
|
||||
// Hidden aliases: agents routinely pass --msg-id / --open-message-id since
|
||||
// the message-list output exposes the field as openMessageId/msgId. Accept
|
||||
// them transparently instead of failing with "unknown flag".
|
||||
chatMessageDownloadMediaCmd.Flags().String("msg-id", "", "--message-id 的别名")
|
||||
_ = chatMessageDownloadMediaCmd.Flags().MarkHidden("msg-id")
|
||||
chatMessageDownloadMediaCmd.Flags().String("open-message-id", "", "--message-id 的别名")
|
||||
_ = chatMessageDownloadMediaCmd.Flags().MarkHidden("open-message-id")
|
||||
chatMessageDownloadMediaCmd.Flags().String("output", "", "本地保存路径,文件或目录 (必填)")
|
||||
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("output")
|
||||
|
||||
|
||||
@@ -222,6 +222,12 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te
|
||||
tmp := t.TempDir()
|
||||
base := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", "--message-id=mid"}
|
||||
_ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, append(base, "--output="+filepath.Join(tmp, "dry"))...)
|
||||
for _, alias := range []string{"--msg-id=mid", "--open-message-id=mid"} {
|
||||
aliasArgs := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", alias, "--output=" + filepath.Join(tmp, "alias-dry")}
|
||||
_ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, aliasArgs...)
|
||||
}
|
||||
missingMessageID := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", "--output=" + filepath.Join(tmp, "missing-id")}
|
||||
_ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, missingMessageID...)
|
||||
for _, tc := range []struct {
|
||||
step scriptedToolStep
|
||||
out string
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -24,6 +26,8 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
)
|
||||
|
||||
const maxPluginCLIOverlayBytes = 4 << 20
|
||||
|
||||
// UserContext holds the minimal user identity fields injected into
|
||||
// stdio plugin subprocesses via environment variables.
|
||||
type UserContext struct {
|
||||
@@ -111,23 +115,9 @@ func (p *Plugin) ToServerDescriptors() []mcptypes.ServerDescriptor {
|
||||
continue
|
||||
}
|
||||
|
||||
overlay := mcptypes.CLIOverlay{}
|
||||
if len(srv.CLI) > 0 {
|
||||
if err := json.Unmarshal(srv.CLI, &overlay); err != nil {
|
||||
slog.Warn("plugin: failed to parse CLIOverlay",
|
||||
"plugin", p.Manifest.Name,
|
||||
"server", key,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the overlay has an ID — fall back to server key.
|
||||
if overlay.ID == "" {
|
||||
overlay.ID = key
|
||||
}
|
||||
if overlay.Command == "" {
|
||||
overlay.Command = key
|
||||
overlay, ok := p.ResolveCLIOverlay(key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
source := "plugin"
|
||||
@@ -154,3 +144,73 @@ func (p *Plugin) ToServerDescriptors() []mcptypes.ServerDescriptor {
|
||||
}
|
||||
return descriptors
|
||||
}
|
||||
|
||||
// ResolveCLIOverlay resolves inline or external manifest CLI metadata exactly
|
||||
// once. External files are opened relative to the plugin root with os.Root so
|
||||
// absolute paths, parent traversal, and escaping symlinks fail closed.
|
||||
func (p *Plugin) ResolveCLIOverlay(serverKey string) (mcptypes.CLIOverlay, bool) {
|
||||
overlay := mcptypes.CLIOverlay{
|
||||
ID: serverKey,
|
||||
Command: serverKey,
|
||||
}
|
||||
server, ok := p.Manifest.MCPServers[serverKey]
|
||||
if !ok || len(server.CLI) == 0 {
|
||||
return overlay, true
|
||||
}
|
||||
|
||||
data := []byte(strings.TrimSpace(string(server.CLI)))
|
||||
if len(data) == 0 {
|
||||
return overlay, true
|
||||
}
|
||||
if data[0] == '"' {
|
||||
var relativePath string
|
||||
if err := json.Unmarshal(data, &relativePath); err != nil ||
|
||||
strings.TrimSpace(relativePath) == "" {
|
||||
slog.Warn("plugin: invalid external CLI overlay path",
|
||||
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
|
||||
return mcptypes.CLIOverlay{}, false
|
||||
}
|
||||
root, err := os.OpenRoot(p.Root)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: failed to open plugin root",
|
||||
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
|
||||
return mcptypes.CLIOverlay{}, false
|
||||
}
|
||||
defer root.Close()
|
||||
file, err := root.Open(relativePath)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: failed to open CLI overlay file",
|
||||
"plugin", p.Manifest.Name, "server", serverKey,
|
||||
"path", relativePath, "error", err)
|
||||
return mcptypes.CLIOverlay{}, false
|
||||
}
|
||||
defer file.Close()
|
||||
data, err = io.ReadAll(io.LimitReader(file, maxPluginCLIOverlayBytes+1))
|
||||
if err != nil || len(data) > maxPluginCLIOverlayBytes {
|
||||
slog.Warn("plugin: failed to read CLI overlay file",
|
||||
"plugin", p.Manifest.Name, "server", serverKey,
|
||||
"path", relativePath, "error", err)
|
||||
return mcptypes.CLIOverlay{}, false
|
||||
}
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&overlay); err != nil {
|
||||
slog.Warn("plugin: failed to parse CLI overlay",
|
||||
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
|
||||
return mcptypes.CLIOverlay{}, false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
slog.Warn("plugin: CLI overlay contains trailing JSON",
|
||||
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
|
||||
return mcptypes.CLIOverlay{}, false
|
||||
}
|
||||
if strings.TrimSpace(overlay.ID) == "" {
|
||||
overlay.ID = serverKey
|
||||
}
|
||||
if strings.TrimSpace(overlay.Command) == "" {
|
||||
overlay.Command = serverKey
|
||||
}
|
||||
return overlay, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveCLIOverlayDefaultsAndInlineErrors(t *testing.T) {
|
||||
plugin := &Plugin{
|
||||
Root: t.TempDir(),
|
||||
Manifest: Manifest{MCPServers: map[string]*MCPServer{
|
||||
"empty": {CLI: nil},
|
||||
"whitespace": {CLI: json.RawMessage(" \n\t")},
|
||||
"fallback": {CLI: json.RawMessage(`{"id":" ","command":""}`)},
|
||||
"malformed": {CLI: json.RawMessage(`{`)},
|
||||
"unknown": {CLI: json.RawMessage(`{"id":"plugin","unknownField":true}`)},
|
||||
"unknownFlag": {CLI: json.RawMessage(`{
|
||||
"toolOverrides":{"tool":{"flags":{"value":{"unknownFlagField":true}}}}
|
||||
}`)},
|
||||
"trailing": {CLI: json.RawMessage(`{} {}`)},
|
||||
"legacyTools": {CLI: json.RawMessage(`{
|
||||
"tools":[{
|
||||
"name":"tool","cliName":"leaf","title":"Title","description":"Description",
|
||||
"isSensitive":true,"category":"read","hidden":true,
|
||||
"flags":{"value":{"alias":"value-alias","shorthand":"v"}}
|
||||
}]
|
||||
}`)},
|
||||
}},
|
||||
}
|
||||
|
||||
for _, serverKey := range []string{"missing", "empty", "whitespace", "fallback"} {
|
||||
t.Run(serverKey, func(t *testing.T) {
|
||||
overlay, ok := plugin.ResolveCLIOverlay(serverKey)
|
||||
if !ok || overlay.ID != serverKey || overlay.Command != serverKey {
|
||||
t.Fatalf("ResolveCLIOverlay(%q) = (%#v, %v), want default overlay", serverKey, overlay, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
legacy, ok := plugin.ResolveCLIOverlay("legacyTools")
|
||||
if !ok || len(legacy.Tools) != 1 || legacy.Tools[0].CLIName != "leaf" ||
|
||||
legacy.Tools[0].Flags["value"].Alias != "value-alias" {
|
||||
t.Fatalf("ResolveCLIOverlay(legacyTools) = (%#v, %v), want historical tool metadata", legacy, ok)
|
||||
}
|
||||
if overlay, ok := plugin.ResolveCLIOverlay("malformed"); ok {
|
||||
t.Fatalf("ResolveCLIOverlay(malformed) = (%#v, true), want failure", overlay)
|
||||
}
|
||||
for _, serverKey := range []string{"unknown", "unknownFlag", "trailing"} {
|
||||
if overlay, ok := plugin.ResolveCLIOverlay(serverKey); ok {
|
||||
t.Fatalf("ResolveCLIOverlay(%s) = (%#v, true), want strict failure", serverKey, overlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCLIOverlayRejectsInvalidExternalFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
plugin := &Plugin{
|
||||
Root: root,
|
||||
Manifest: Manifest{MCPServers: map[string]*MCPServer{
|
||||
"external": {},
|
||||
}},
|
||||
}
|
||||
server := plugin.Manifest.MCPServers["external"]
|
||||
|
||||
t.Run("malformed path JSON", func(t *testing.T) {
|
||||
server.CLI = json.RawMessage(`"unterminated`)
|
||||
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
|
||||
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want malformed path failure", overlay)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank path", func(t *testing.T) {
|
||||
server.CLI = json.RawMessage(`" "`)
|
||||
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
|
||||
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want blank path failure", overlay)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing root", func(t *testing.T) {
|
||||
plugin.Root = filepath.Join(root, "does-not-exist")
|
||||
server.CLI = json.RawMessage(`"overlay.json"`)
|
||||
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
|
||||
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want missing root failure", overlay)
|
||||
}
|
||||
plugin.Root = root
|
||||
})
|
||||
|
||||
t.Run("missing file", func(t *testing.T) {
|
||||
server.CLI = json.RawMessage(`"missing.json"`)
|
||||
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
|
||||
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want missing file failure", overlay)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read error", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(root, "overlay-dir"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.CLI = json.RawMessage(`"overlay-dir"`)
|
||||
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
|
||||
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want directory read failure", overlay)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized file", func(t *testing.T) {
|
||||
path := filepath.Join(root, "oversized.json")
|
||||
if err := os.WriteFile(path, bytes.Repeat([]byte(" "), maxPluginCLIOverlayBytes+1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.CLI = json.RawMessage(`"oversized.json"`)
|
||||
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
|
||||
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want oversized file failure", overlay)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveCLIOverlayExternalFileAppliesFallbackIdentity(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "overlay.json"), []byte(`{
|
||||
"id":"",
|
||||
"command":" ",
|
||||
"aliases":["conference"]
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugin := &Plugin{
|
||||
Root: root,
|
||||
Manifest: Manifest{MCPServers: map[string]*MCPServer{
|
||||
"server-key": {CLI: json.RawMessage(`"overlay.json"`)},
|
||||
}},
|
||||
}
|
||||
|
||||
overlay, ok := plugin.ResolveCLIOverlay("server-key")
|
||||
if !ok || overlay.ID != "server-key" || overlay.Command != "server-key" ||
|
||||
len(overlay.Aliases) != 1 || overlay.Aliases[0] != "conference" {
|
||||
t.Fatalf("ResolveCLIOverlay() = (%#v, %v), want fallback identity with file metadata", overlay, ok)
|
||||
}
|
||||
}
|
||||
@@ -131,22 +131,19 @@ func TestCrossPlatformCoveragePluginConverterEdges(t *testing.T) {
|
||||
t.Fatalf("StdioClients(user) length = %d", len(got))
|
||||
}
|
||||
descriptors := p.ToServerDescriptors()
|
||||
if len(descriptors) != 2 {
|
||||
if len(descriptors) != 1 {
|
||||
t.Fatalf("descriptors length = %d", len(descriptors))
|
||||
}
|
||||
seenDefault := false
|
||||
seenOverlay := false
|
||||
for _, d := range descriptors {
|
||||
switch d.Key {
|
||||
case "http-default":
|
||||
seenDefault = d.CLI.ID == "http-default" && d.CLI.Command == "http-default" && d.HasCLIMeta
|
||||
case "http-overlay":
|
||||
seenOverlay = d.CLI.ID == "custom" && d.CLI.Command == "run" &&
|
||||
d.AuthHeaders["Authorization"] == "Bearer secret"
|
||||
}
|
||||
}
|
||||
if !seenDefault || !seenOverlay {
|
||||
t.Fatalf("descriptor defaults/overlay not covered: %#v", descriptors)
|
||||
if !seenOverlay {
|
||||
t.Fatalf("valid descriptor overlay not covered: %#v", descriptors)
|
||||
}
|
||||
dataDir := filepath.Join(filepath.Dir(filepath.Dir(root)), "data")
|
||||
if got := expandPluginVars("$"+"{DWS_PLUGIN_ROOT}|$"+"{DWS_PLUGIN_DATA}|$"+"{PLUGIN_TOKEN}", root); got != root+"|"+dataDir+"|secret" {
|
||||
@@ -300,6 +297,7 @@ func TestCrossPlatformCoverageManifestEdges(t *testing.T) {
|
||||
func TestCrossPlatformCoverageLoaderDiscoveryLifecycle(t *testing.T) {
|
||||
oldHome := pluginUserHomeDir
|
||||
t.Cleanup(func() { pluginUserHomeDir = oldHome })
|
||||
t.Setenv("DWS_CONFIG_DIR", "")
|
||||
home := t.TempDir()
|
||||
pluginUserHomeDir = func() (string, error) { return home, nil }
|
||||
defaultLoader := NewLoader("1.2.3")
|
||||
@@ -310,6 +308,12 @@ func TestCrossPlatformCoverageLoaderDiscoveryLifecycle(t *testing.T) {
|
||||
if got := NewLoader("dev").PluginsDir; got != filepath.Join(".dws", "plugins") {
|
||||
t.Fatalf("NewLoader error path = %q", got)
|
||||
}
|
||||
customConfig := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", customConfig)
|
||||
if got := NewLoader("dev").PluginsDir; got != filepath.Join(customConfig, "plugins") {
|
||||
t.Fatalf("NewLoader custom config path = %q", got)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", "")
|
||||
|
||||
root := t.TempDir()
|
||||
l := &Loader{PluginsDir: root, CLIVersion: "1.0.0"}
|
||||
|
||||
@@ -56,9 +56,13 @@ var (
|
||||
|
||||
// NewLoader creates a Loader with default paths.
|
||||
func NewLoader(cliVersion string) *Loader {
|
||||
home, _ := pluginUserHomeDir()
|
||||
configDir := strings.TrimSpace(os.Getenv("DWS_CONFIG_DIR"))
|
||||
if configDir == "" {
|
||||
home, _ := pluginUserHomeDir()
|
||||
configDir = filepath.Join(home, ".dws")
|
||||
}
|
||||
return &Loader{
|
||||
PluginsDir: filepath.Join(home, ".dws", "plugins"),
|
||||
PluginsDir: filepath.Join(configDir, "plugins"),
|
||||
CLIVersion: cliVersion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,56 @@ func TestParseManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCLIOverlayExternalFileIsRootContained(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
overlayPath := filepath.Join(root, "overlay.json")
|
||||
if err := os.WriteFile(overlayPath, []byte(`{
|
||||
"id":"external-id",
|
||||
"command":"external-command",
|
||||
"toolOverrides":{"ping":{"cliName":"ping"}}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded := &Plugin{
|
||||
Root: root,
|
||||
Manifest: Manifest{
|
||||
Name: "external-plugin",
|
||||
MCPServers: map[string]*MCPServer{
|
||||
"external": {
|
||||
Type: "streamable-http",
|
||||
Endpoint: "https://example.invalid/mcp",
|
||||
CLI: json.RawMessage(`"overlay.json"`),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
overlay, ok := loaded.ResolveCLIOverlay("external")
|
||||
if !ok || overlay.ID != "external-id" || overlay.Command != "external-command" {
|
||||
t.Fatalf("external overlay = (%#v, %v)", overlay, ok)
|
||||
}
|
||||
descriptors := loaded.ToServerDescriptors()
|
||||
if len(descriptors) != 1 || descriptors[0].CLI.ID != "external-id" {
|
||||
t.Fatalf("external HTTP descriptors = %#v", descriptors)
|
||||
}
|
||||
|
||||
outside := filepath.Join(t.TempDir(), "outside-overlay.json")
|
||||
if err := os.WriteFile(outside, []byte(`{"id":"escaped"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded.Manifest.MCPServers["external"].CLI = json.RawMessage(`"../outside-overlay.json"`)
|
||||
if overlay, ok := loaded.ResolveCLIOverlay("external"); ok {
|
||||
t.Fatalf("parent traversal resolved overlay %#v", overlay)
|
||||
}
|
||||
|
||||
link := filepath.Join(root, "escaped-link.json")
|
||||
if err := os.Symlink(outside, link); err == nil {
|
||||
loaded.Manifest.MCPServers["external"].CLI = json.RawMessage(`"escaped-link.json"`)
|
||||
if overlay, ok := loaded.ResolveCLIOverlay("external"); ok {
|
||||
t.Fatalf("escaping symlink resolved overlay %#v", overlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -219,8 +269,23 @@ func TestLoadAllSuppressesOptionalPluginValidationWarnings(t *testing.T) {
|
||||
|
||||
func TestPluginToServerDescriptors(t *testing.T) {
|
||||
cliOverlay, _ := json.Marshal(map[string]any{
|
||||
"id": "conference",
|
||||
"command": "conference",
|
||||
"id": "conference",
|
||||
"command": "conference",
|
||||
"description": "video conference",
|
||||
"groups": map[string]any{
|
||||
"camera": map[string]any{"description": "camera control"},
|
||||
},
|
||||
"toolOverrides": map[string]any{
|
||||
"open_camera": map[string]any{
|
||||
"cliName": "open",
|
||||
"group": "camera",
|
||||
"description": "open camera",
|
||||
"isSensitive": true,
|
||||
"flags": map[string]any{
|
||||
"device_id": map[string]any{"description": "camera device"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p := &Plugin{
|
||||
@@ -262,6 +327,16 @@ func TestPluginToServerDescriptors(t *testing.T) {
|
||||
if d.CLI.ID != "conference" {
|
||||
t.Errorf("cli.id = %q, want conference", d.CLI.ID)
|
||||
}
|
||||
override := d.CLI.ToolOverrides["open_camera"]
|
||||
if d.CLI.Description != "video conference" ||
|
||||
d.CLI.Groups["camera"].Description != "camera control" ||
|
||||
override.CLIName != "open" ||
|
||||
override.Group != "camera" ||
|
||||
override.Description != "open camera" ||
|
||||
!override.IsSensitive ||
|
||||
override.Flags["device_id"].Description != "camera device" {
|
||||
t.Fatalf("CLI overlay command metadata was not preserved: %#v", d.CLI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginToServerDescriptorsWithHeaders(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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.
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/builtin"
|
||||
)
|
||||
|
||||
func TestBaseCommandsExposeSortedDistributionShortcuts(t *testing.T) {
|
||||
commands := builtin.BaseCommands()
|
||||
if len(commands) == 0 {
|
||||
t.Fatal("BaseCommands returned no distribution shortcuts")
|
||||
}
|
||||
for index, command := range commands {
|
||||
if index > 0 && commands[index-1].Name() > command.Name() {
|
||||
t.Fatalf("BaseCommands are not sorted: %q before %q", commands[index-1].Name(), command.Name())
|
||||
}
|
||||
children := command.Commands()
|
||||
if len(children) == 0 {
|
||||
t.Fatalf("base shortcut service %q has no commands", command.Name())
|
||||
}
|
||||
for _, child := range children {
|
||||
if !strings.HasPrefix(child.Name(), "+") {
|
||||
t.Fatalf("base shortcut %q under %q is not mounted as a +command", child.Name(), command.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,3 +50,9 @@ import (
|
||||
func Commands() []*cobra.Command {
|
||||
return shortcut.Commands()
|
||||
}
|
||||
|
||||
// BaseCommands returns only distribution-owned shortcuts for Schema and
|
||||
// interface generation.
|
||||
func BaseCommands() []*cobra.Command {
|
||||
return shortcut.BuiltInCommands()
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
// MessagesSend sends a text/markdown message as the current user
|
||||
@@ -242,6 +243,11 @@ var MessagesList = shortcut.Shortcut{
|
||||
// clean output projection. Both the list container and the per-item field names are
|
||||
// probed defensively across candidate keys, so an empty or unexpected shape
|
||||
// yields an empty list rather than a crash or fabricated data.
|
||||
//
|
||||
// Text goes through the shared chatmsg projection so card/auto-reply JSON is
|
||||
// rendered readable and encrypted ciphertext is marked, and forwarded chat
|
||||
// records ("聊天记录") expand their nested messages under "forwarded" instead of
|
||||
// collapsing to a "[卡片]" summary.
|
||||
func listMessagesProject(data map[string]any) []map[string]any {
|
||||
raw := listMessagesResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
@@ -250,29 +256,39 @@ func listMessagesProject(data map[string]any) []map[string]any {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok {
|
||||
row["messageId"] = v
|
||||
}
|
||||
if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok {
|
||||
row["senderId"] = v
|
||||
}
|
||||
if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok {
|
||||
row["msgType"] = v
|
||||
}
|
||||
if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok {
|
||||
row["createTime"] = v
|
||||
}
|
||||
if v, ok := listMessagesFirst(m, "text", "content", "plainText"); ok {
|
||||
row["text"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
if row := listMessageProjectOne(m); len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listMessageProjectOne projects a single message into the native
|
||||
// {messageId, senderId, msgType, createTime, text(, forwarded)} shape, reused
|
||||
// recursively for forwarded chat records.
|
||||
func listMessageProjectOne(m map[string]any) map[string]any {
|
||||
row := map[string]any{}
|
||||
if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok {
|
||||
row["messageId"] = v
|
||||
}
|
||||
if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok {
|
||||
row["senderId"] = v
|
||||
}
|
||||
if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok {
|
||||
row["msgType"] = v
|
||||
}
|
||||
if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok {
|
||||
row["createTime"] = v
|
||||
}
|
||||
if text := chatmsg.Text(m); text != nil {
|
||||
row["text"] = text
|
||||
}
|
||||
if forwarded := chatmsg.Forwarded(m, listMessageProjectOne); len(forwarded) > 0 {
|
||||
row["forwarded"] = forwarded
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// listMessagesResolveList locates the list payload, tolerating a bare top-level
|
||||
// array container or nesting one level deeper under a common envelope key.
|
||||
func listMessagesResolveList(data map[string]any) []any {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
|
||||
|
||||
func TestListMessageProjectOne(t *testing.T) {
|
||||
// full field mapping + forwarded expansion; an encrypted body is marked (no
|
||||
// cross-conversation recovery), not leaked as base64.
|
||||
row := listMessageProjectOne(map[string]any{
|
||||
"openMessageId": "mid",
|
||||
"senderOpenDingTalkId": "DXYZ",
|
||||
"msgType": "text",
|
||||
"createTime": "2026-07-19 13:37:03",
|
||||
"content": testCipher,
|
||||
"forwardMessages": []any{
|
||||
map[string]any{"openMessageId": "c1", "senderOpenDingTalkId": "DA", "content": "子消息", "createTime": "t"},
|
||||
},
|
||||
})
|
||||
|
||||
if row["messageId"] != "mid" || row["senderId"] != "DXYZ" || row["msgType"] != "text" {
|
||||
t.Fatalf("field mapping = %#v", row)
|
||||
}
|
||||
if row["createTime"] != "2026-07-19 13:37:03" {
|
||||
t.Errorf("createTime = %v", row["createTime"])
|
||||
}
|
||||
if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") || strings.Contains(s, "||2||1||") {
|
||||
t.Errorf("encrypted text = %v, want marker", row["text"])
|
||||
}
|
||||
fwd, ok := row["forwarded"].([]map[string]any)
|
||||
if !ok || len(fwd) != 1 || fwd[0]["messageId"] != "c1" || fwd[0]["text"] != "子消息" {
|
||||
t.Errorf("forwarded = %#v", row["forwarded"])
|
||||
}
|
||||
|
||||
// a bare message with no recognizable fields → empty row (no keys)
|
||||
row = listMessageProjectOne(map[string]any{"unrelated": 1})
|
||||
if len(row) != 0 {
|
||||
t.Errorf("empty message row = %#v, want no keys", row)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// 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 chatmsg holds the shared, read-only projection helpers for DingTalk
|
||||
// message-list responses (list_individual_chat_message,
|
||||
// list_conversation_message_v2, search_at_me_message, search_messages_by_keyword,
|
||||
// list_topic_replies, …). Several shortcuts reshape those raw responses into a
|
||||
// clean speaker/text/time list; centralising the fiddly bits here keeps them
|
||||
// consistent and fixed in one place:
|
||||
//
|
||||
// - Sender: the display name lives under the bare "sender" key, forwarded
|
||||
// entries carry the literal string "null", and some responses nest the
|
||||
// speaker in a {name:…} object — all handled here.
|
||||
// - Text: out-of-office auto-replies / cards arrive as raw rich-content JSON,
|
||||
// and card/robot messages arrive as undecryptable ciphertext; CleanText
|
||||
// renders the former to readable text and marks the latter, WITHOUT ever
|
||||
// rewriting ordinary text that merely contains a JSON fragment.
|
||||
// - Forwarded: a forwarded chat record ("聊天记录") hides its real per-message
|
||||
// bodies in forwardMessages while the top-level content is a lossy summary.
|
||||
package chatmsg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sender reads a message's speaker display name, tolerating common sender-name
|
||||
// keys. The message-list responses carry the display name under the bare
|
||||
// "sender" key (verified live), so it is probed first; the remaining aliases and
|
||||
// the *Id fallbacks keep the projection resilient to other shapes. The literal
|
||||
// string "null" (forwarded entries) and the empty string are treated as absent,
|
||||
// and a nested {name:…} sender object yields its display name rather than the
|
||||
// raw object.
|
||||
func Sender(m map[string]any) any {
|
||||
for _, key := range []string{"sender", "senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId", "senderOpenDingTalkId"} {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t == "" || t == "null" {
|
||||
continue
|
||||
}
|
||||
return t
|
||||
case map[string]any:
|
||||
// Nested sender object: extract a display-name field; never return
|
||||
// the raw map (it would surface a JSON object and block fallbacks).
|
||||
if name := senderDisplayName(t); name != "" {
|
||||
return name
|
||||
}
|
||||
continue
|
||||
default:
|
||||
// Scalar id (e.g. numeric) — usable as-is.
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// senderDisplayName extracts a human name from a nested sender object.
|
||||
func senderDisplayName(m map[string]any) string {
|
||||
for _, k := range []string{"name", "nick", "userName", "staffName", "displayName", "senderName"} {
|
||||
if s, ok := m[k].(string); ok {
|
||||
if s = strings.TrimSpace(s); s != "" && s != "null" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Text reads a message's textual content (tolerating common text keys and one
|
||||
// level of nesting) and runs it through CleanText.
|
||||
func Text(m map[string]any) any {
|
||||
for _, key := range []string{"text", "content", "msgContent", "message", "body", "plainText"} {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t != "" {
|
||||
return CleanText(t)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, inner := range []string{"text", "content", "value"} {
|
||||
if s, ok := t[inner].(string); ok && s != "" {
|
||||
return CleanText(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTime reads a message's create/send time under whichever candidate key is
|
||||
// present, returning the raw value.
|
||||
func CreateTime(m map[string]any) any {
|
||||
for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} {
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Forwarded projects the nested messages of a forwarded chat record. The caller
|
||||
// supplies its own per-message projection so each command keeps its own row
|
||||
// shape; project is applied recursively, so multi-level forwards expand too.
|
||||
func Forwarded(m map[string]any, project func(map[string]any) map[string]any) []map[string]any {
|
||||
raw, ok := m["forwardMessages"].([]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
if sub, ok := e.(map[string]any); ok {
|
||||
out = append(out, project(sub))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CleanText makes a message body human-readable WITHOUT ever rewriting ordinary
|
||||
// text. It only transforms a body that is a genuine DingTalk structured message:
|
||||
//
|
||||
// - Encrypted card/robot ciphertext (base64 + "||v||t||len" trailer) → a clear
|
||||
// "[加密消息]" marker instead of the raw base64.
|
||||
// - A rich-content card (out-of-office auto-reply, link/preview card, …) whose
|
||||
// lines include at least one recognised rich-content block → the readable
|
||||
// text extracted from those blocks, with the card's decorative JSON lines and
|
||||
// "empty" placeholders dropped.
|
||||
//
|
||||
// Crucially, if NO line is a recognised rich-content block (e.g. ordinary text
|
||||
// that merely embeds a `{"approved":false}` fragment), the original string is
|
||||
// returned verbatim — a JSON line is never silently dropped.
|
||||
func CleanText(s string) string {
|
||||
if IsEncrypted(s) {
|
||||
return "[加密消息,无法解码]"
|
||||
}
|
||||
|
||||
// Fast path: no JSON delimiters at all — the overwhelming common case.
|
||||
if !strings.ContainsAny(s, "{[") {
|
||||
return s
|
||||
}
|
||||
|
||||
lines := strings.Split(s, "\n")
|
||||
isJSON := make([]bool, len(lines))
|
||||
isDecoration := make([]bool, len(lines))
|
||||
extracted := make([][]string, len(lines))
|
||||
anyExtracted := false
|
||||
for i, line := range lines {
|
||||
t := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(t, "{") && !strings.HasPrefix(t, "[") {
|
||||
continue
|
||||
}
|
||||
var v any
|
||||
if json.Unmarshal([]byte(t), &v) != nil {
|
||||
continue
|
||||
}
|
||||
isJSON[i] = true
|
||||
isDecoration[i] = isKnownRichDecoration(v)
|
||||
if texts := richItemTexts(v); len(texts) > 0 {
|
||||
extracted[i] = texts
|
||||
anyExtracted = true
|
||||
}
|
||||
}
|
||||
|
||||
// No recognised rich-content block anywhere → treat the whole body as plain
|
||||
// text (which may merely contain a JSON fragment) and return it untouched.
|
||||
if !anyExtracted {
|
||||
return s
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(lines))
|
||||
for i, line := range lines {
|
||||
if len(extracted[i]) > 0 {
|
||||
out = append(out, extracted[i]...)
|
||||
continue
|
||||
}
|
||||
// In card mode, drop only JSON shapes known to be card decoration.
|
||||
// Unrecognised JSON may be user-authored message content and must remain
|
||||
// verbatim even when another line contains a rich-content block.
|
||||
if isJSON[i] && isDecoration[i] {
|
||||
continue
|
||||
}
|
||||
if t := strings.TrimSpace(line); t == "" || t == "empty" {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
// anyExtracted is true here, so out always holds at least one non-empty
|
||||
// extracted text — the joined result is never empty.
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
// isKnownRichDecoration recognises the two decoration records emitted alongside
|
||||
// DingTalk rich-content bodies. Keep this deliberately narrow: an arbitrary JSON
|
||||
// object in the same message is user content unless its shape is known here.
|
||||
func isKnownRichDecoration(node any) bool {
|
||||
m, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, hasPreviewURL := m["previewUrl"]
|
||||
_, hasTitle := m["title"]
|
||||
_, hasAutoLayout := m["autoLayout"]
|
||||
_, hasEnableForward := m["enableForward"]
|
||||
return (hasPreviewURL && hasTitle) || (hasAutoLayout && hasEnableForward)
|
||||
}
|
||||
|
||||
// richItemTexts walks a decoded DingTalk rich-content blob and returns the
|
||||
// readable text carried by its rich-content items (items[].data.text). It only
|
||||
// harvests item bodies, so decorative fields (card titles, preview URLs, layout
|
||||
// config) contribute nothing and are dropped. An empty result means "not a
|
||||
// recognised rich-content block".
|
||||
func richItemTexts(node any) []string {
|
||||
var texts []string
|
||||
var walk func(n any)
|
||||
walk = func(n any) {
|
||||
switch t := n.(type) {
|
||||
case []any:
|
||||
for _, e := range t {
|
||||
walk(e)
|
||||
}
|
||||
case map[string]any:
|
||||
if items, ok := t["items"].([]any); ok {
|
||||
for _, it := range items {
|
||||
mm, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
data, ok := mm["data"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if s, ok := data["text"].(string); ok {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
texts = append(texts, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, e := range t {
|
||||
walk(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
return texts
|
||||
}
|
||||
|
||||
// encryptedTrailerRE matches DingTalk's encrypted-message trailer
|
||||
// "||<version>||<type>||<len>" (e.g. "||2||1||196") anchored at the end.
|
||||
var encryptedTrailerRE = regexp.MustCompile(`\|\|\d+\|\|\d+\|\|\d+\s*$`)
|
||||
|
||||
// IsEncrypted reports whether a message body is a raw DingTalk encrypted-message
|
||||
// ciphertext: a base64 blob (DingTalk wraps it across several lines) followed by
|
||||
// the "||v||t||len" trailer. It is intentionally strict — both the trailer and a
|
||||
// pure-base64 body are required — so ordinary text (CJK, punctuation, …) never
|
||||
// trips it.
|
||||
func IsEncrypted(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if !encryptedTrailerRE.MatchString(s) {
|
||||
return false
|
||||
}
|
||||
body := strings.TrimSpace(encryptedTrailerRE.ReplaceAllString(s, ""))
|
||||
if len(body) < 32 {
|
||||
return false
|
||||
}
|
||||
for _, r := range body {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9',
|
||||
r == '+', r == '/', r == '=', r == '\n', r == '\r', r == ' ', r == '\t':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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 chatmsg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSender(t *testing.T) {
|
||||
// The display name lives under the bare "sender" key.
|
||||
if got := Sender(map[string]any{"sender": "念晨", "senderOpenDingTalkId": "D1"}); got != "念晨" {
|
||||
t.Fatalf("sender = %v, want 念晨", got)
|
||||
}
|
||||
// Falls back to the open id when no display name is present.
|
||||
if got := Sender(map[string]any{"senderOpenDingTalkId": "DXYZ"}); got != "DXYZ" {
|
||||
t.Fatalf("sender fallback = %v, want DXYZ", got)
|
||||
}
|
||||
// forwardMessages entries carry the literal string "null" — treat as absent.
|
||||
if got := Sender(map[string]any{"sender": "null"}); got != nil {
|
||||
t.Fatalf("sender \"null\" = %v, want nil", got)
|
||||
}
|
||||
if got := Sender(map[string]any{"sender": "null", "senderName": "念晨"}); got != "念晨" {
|
||||
t.Fatalf("sender \"null\" fallthrough = %v, want 念晨", got)
|
||||
}
|
||||
// A nested {name:…} sender object yields its display name, not the raw map.
|
||||
if got := Sender(map[string]any{"sender": map[string]any{"name": "Alice"}}); got != "Alice" {
|
||||
t.Fatalf("nested sender = %v, want Alice", got)
|
||||
}
|
||||
// A nested sender object with no usable name must not block the fallback.
|
||||
if got := Sender(map[string]any{"sender": map[string]any{"foo": "bar"}, "senderName": "Bob"}); got != "Bob" {
|
||||
t.Fatalf("nested-no-name fallthrough = %v, want Bob", got)
|
||||
}
|
||||
// A scalar numeric id is returned as-is.
|
||||
if got := Sender(map[string]any{"senderId": float64(42)}); got != float64(42) {
|
||||
t.Fatalf("numeric sender id = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanText(t *testing.T) {
|
||||
// Out-of-office auto-reply: readable body lives in items[].data.text; the
|
||||
// decorative preview/config JSON lines and "empty" placeholder are dropped.
|
||||
autoReply := "* 仅你和对方可见\n" +
|
||||
`[{"text":{"minSupportVersion":"1.1","translateMap":{},"version":"1.2","items":[{"fallbackKey":"","data":{"text":"你好,我在出差中,消息回复可能不及时。"},"style":{"size":15,"bold":0},"type":"text"}]},"type":"markdown"}]` + "\n" +
|
||||
`{"previewUrl":"dingtalk://x","title":{"text":"自动回复","type":"text"}}` + "\n" +
|
||||
"empty\n" +
|
||||
`{"autoLayout":false,"enableForward":false}`
|
||||
if got, want := CleanText(autoReply), "* 仅你和对方可见\n你好,我在出差中,消息回复可能不及时。"; got != want {
|
||||
t.Fatalf("auto-reply cleaned = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// P1 regression: ordinary text whose middle line is a JSON fragment (no
|
||||
// rich-content block anywhere) must be returned VERBATIM, not rewritten.
|
||||
mixed := "payload:\n{\"approved\":false}\nplease check"
|
||||
if got := CleanText(mixed); got != mixed {
|
||||
t.Fatalf("mixed text was rewritten: got %q, want %q", got, mixed)
|
||||
}
|
||||
|
||||
// An ordinary JSON line must also survive when a different line contains a
|
||||
// recognised rich-content block. Card mode is not permission to discard
|
||||
// unrelated user-authored JSON.
|
||||
richAndPlain := `[{"items":[{"data":{"text":"卡片正文"}}]}]` + "\n" +
|
||||
`{"approved":false}`
|
||||
if got, want := CleanText(richAndPlain), "卡片正文\n{\"approved\":false}"; got != want {
|
||||
t.Fatalf("mixed rich/plain JSON was rewritten: got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Malformed items (non-map item, item whose "data" isn't a map) are skipped;
|
||||
// only the well-formed item's text is extracted.
|
||||
blob := `[{"items":["notmap",{"data":"notmap"},{"data":{"text":"有效正文"}}]}]`
|
||||
if got := CleanText(blob); got != "有效正文" {
|
||||
t.Fatalf("CleanText rich edge = %q, want 有效正文", got)
|
||||
}
|
||||
|
||||
tests := map[string]string{
|
||||
"上周五 7.1 KW": "上周五 7.1 KW",
|
||||
"上周客户统计的[图片消息](mediaId=@lQ)": "上周客户统计的[图片消息](mediaId=@lQ)",
|
||||
"[文件] 简历.pdf fileId: qnY 注意:如需下载使用dws drive download命令下载": "[文件] 简历.pdf fileId: qnY 注意:如需下载使用dws drive download命令下载",
|
||||
"[讨论] 排期\n明天开会": "[讨论] 排期\n明天开会",
|
||||
// a lone JSON object that isn't a rich-content block is left untouched
|
||||
`{"autoLayout":false,"enableForward":false}`: `{"autoLayout":false,"enableForward":false}`,
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := CleanText(in); got != want {
|
||||
t.Errorf("CleanText(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEncryptedAndMarker(t *testing.T) {
|
||||
cipher := "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP\n" +
|
||||
"7oDptklFO+YzDltH+myErV6rkc8URHYykpeSDsMP6kznFa9E320NsIntfY771dx+\n" +
|
||||
"||2||1||196"
|
||||
if !IsEncrypted(cipher) {
|
||||
t.Fatalf("ciphertext not detected: %q", cipher)
|
||||
}
|
||||
if got := CleanText(cipher); !strings.Contains(got, "加密消息") || strings.Contains(got, "||2||1||") {
|
||||
t.Fatalf("encrypted cleaned = %q, want marker not ciphertext", got)
|
||||
}
|
||||
for _, s := range []string{
|
||||
"上周五 7.1 KW",
|
||||
"价格 100||2||1||3",
|
||||
"[图片消息](mediaId=@lQLPJwDw3VmNDcfMos0DhLB3OHPQeTBlzgov2Oi1ly4A)",
|
||||
"大哥,我看了一下我觉得有几个点可以关注一下",
|
||||
strings.Repeat("好", 20) + "||2||1||1", // long CJK body + trailer, not base64
|
||||
} {
|
||||
if IsEncrypted(s) {
|
||||
t.Errorf("false positive: %q flagged as encrypted", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestText(t *testing.T) {
|
||||
if got := Text(map[string]any{"content": "你好"}); got != "你好" {
|
||||
t.Errorf("Text string = %v", got)
|
||||
}
|
||||
if got := Text(map[string]any{"content": map[string]any{"text": "嵌套"}}); got != "嵌套" {
|
||||
t.Errorf("Text nested = %v", got)
|
||||
}
|
||||
if got := Text(map[string]any{"plainText": "纯文本"}); got != "纯文本" {
|
||||
t.Errorf("Text plainText = %v", got)
|
||||
}
|
||||
if got := Text(map[string]any{"foo": 1}); got != nil {
|
||||
t.Errorf("Text none = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTime(t *testing.T) {
|
||||
if got := CreateTime(map[string]any{"sendTime": "2026-07-19 13:37:03"}); got != "2026-07-19 13:37:03" {
|
||||
t.Errorf("CreateTime = %v", got)
|
||||
}
|
||||
if got := CreateTime(map[string]any{}); got != nil {
|
||||
t.Errorf("CreateTime empty = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwarded(t *testing.T) {
|
||||
var project func(m map[string]any) map[string]any
|
||||
project = func(m map[string]any) map[string]any {
|
||||
row := map[string]any{"text": Text(m)}
|
||||
if fwd := Forwarded(m, project); len(fwd) > 0 { // recurse
|
||||
row["forwarded"] = fwd
|
||||
}
|
||||
return row
|
||||
}
|
||||
if got := Forwarded(map[string]any{"content": "x"}, project); got != nil {
|
||||
t.Errorf("Forwarded none = %v", got)
|
||||
}
|
||||
fwd := Forwarded(map[string]any{
|
||||
"forwardMessages": []any{
|
||||
map[string]any{"content": "a"},
|
||||
"not-a-map",
|
||||
map[string]any{"content": "b", "forwardMessages": []any{
|
||||
map[string]any{"content": "nested"},
|
||||
}},
|
||||
},
|
||||
}, project)
|
||||
if len(fwd) != 2 || fwd[0]["text"] != "a" || fwd[1]["text"] != "b" {
|
||||
t.Fatalf("Forwarded = %#v", fwd)
|
||||
}
|
||||
nested, ok := fwd[1]["forwarded"].([]map[string]any)
|
||||
if !ok || len(nested) != 1 || nested[0]["text"] != "nested" {
|
||||
t.Errorf("nested forwarded = %#v", fwd[1]["forwarded"])
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,17 @@ func Commands() []*cobra.Command {
|
||||
return build(allShortcuts)
|
||||
}
|
||||
|
||||
// BuiltInCommands compiles only distribution-owned shortcuts.
|
||||
func BuiltInCommands() []*cobra.Command {
|
||||
builtins := make([]Shortcut, 0, len(allShortcuts))
|
||||
for _, registered := range allShortcuts {
|
||||
if !registered.UserDefined {
|
||||
builtins = append(builtins, registered)
|
||||
}
|
||||
}
|
||||
return build(builtins)
|
||||
}
|
||||
|
||||
// All returns the registered shortcuts. Primarily for coverage tests that need
|
||||
// each shortcut's declared flags (types/enums/required) to synthesize inputs.
|
||||
func All() []Shortcut {
|
||||
|
||||
@@ -266,6 +266,26 @@ func TestCrossPlatformCoverageBuildGroupsByService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInCommandsExcludeUserDefinedShortcuts(t *testing.T) {
|
||||
previous := append([]Shortcut(nil), allShortcuts...)
|
||||
t.Cleanup(func() { allShortcuts = previous })
|
||||
allShortcuts = nil
|
||||
Register(
|
||||
Shortcut{Service: "calendar", Command: "+builtin", Execute: noop},
|
||||
Shortcut{Service: "calendar", Command: "+user", UserDefined: true, Execute: noop},
|
||||
)
|
||||
|
||||
all := Commands()
|
||||
if len(all) != 1 || len(all[0].Commands()) != 2 {
|
||||
t.Fatalf("all shortcut commands = %#v", all)
|
||||
}
|
||||
builtins := BuiltInCommands()
|
||||
if len(builtins) != 1 || len(builtins[0].Commands()) != 1 ||
|
||||
builtins[0].Commands()[0].Name() != "+builtin" {
|
||||
t.Fatalf("built-in shortcut commands = %#v", builtins)
|
||||
}
|
||||
}
|
||||
|
||||
func noop(_ *RuntimeContext) error { return nil }
|
||||
|
||||
func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
// AtMe: pull the messages that recently @-mentioned ME across chats in one step.
|
||||
@@ -91,12 +92,7 @@ var AtMe = shortcut.Shortcut{
|
||||
}
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, m := range items {
|
||||
results = append(results, map[string]any{
|
||||
"sender": atMeSender(m),
|
||||
"time": atMeTime(m),
|
||||
"text": atMeText(m),
|
||||
"conversation": atMeConversation(m),
|
||||
})
|
||||
results = append(results, atMeProject(m))
|
||||
}
|
||||
return rt.Output(map[string]any{"messages": results})
|
||||
},
|
||||
@@ -194,28 +190,62 @@ func atMeToMaps(arr []any) []map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// atMeProject reshapes one @me message into {sender, time, text, conversation},
|
||||
// running text through the shared chatmsg cleaning (card/auto-reply JSON →
|
||||
// readable, ciphertext → marker) and recursively expanding any forwarded chat
|
||||
// record under "forwarded".
|
||||
func atMeProject(m map[string]any) map[string]any {
|
||||
row := map[string]any{
|
||||
"sender": atMeSender(m),
|
||||
"time": atMeTime(m),
|
||||
"text": atMeCleanText(m),
|
||||
"conversation": atMeConversation(m),
|
||||
}
|
||||
if forwarded := chatmsg.Forwarded(m, atMeProject); len(forwarded) > 0 {
|
||||
row["forwarded"] = forwarded
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// atMeCleanText runs atMeText's extraction through chatmsg.CleanText so
|
||||
// card/auto-reply JSON and ciphertext render readable instead of leaking raw.
|
||||
func atMeCleanText(m map[string]any) any {
|
||||
if s, ok := atMeText(m).(string); ok {
|
||||
return chatmsg.CleanText(s)
|
||||
}
|
||||
return atMeText(m)
|
||||
}
|
||||
|
||||
// atMeSender reads a message's sender display name/id, tolerating the common
|
||||
// sender keys the gateway may use (including a nested sender object).
|
||||
// sender keys the gateway may use (including a nested sender object). The literal
|
||||
// string "null" (carried by forwarded sub-messages) and the empty string are
|
||||
// both treated as absent so they never surface as the speaker.
|
||||
func atMeSender(m map[string]any) any {
|
||||
norm := func(v any) string {
|
||||
if s := atMeString(v); s != "" && s != "null" {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"senderName", "sender_name", "senderNick", "fromName", "senderStaffName"} {
|
||||
if v := atMeString(m[key]); v != "" {
|
||||
if v := norm(m[key]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"sender", "from", "senderUser"} {
|
||||
if nested, ok := m[key].(map[string]any); ok {
|
||||
for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} {
|
||||
if v := atMeString(nested[k2]); v != "" {
|
||||
if v := norm(nested[k2]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
if v := atMeString(m[key]); v != "" {
|
||||
if v := norm(m[key]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} {
|
||||
if v := atMeString(m[key]); v != "" {
|
||||
if v := norm(m[key]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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 smart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
|
||||
|
||||
func TestAtMeProject(t *testing.T) {
|
||||
// nested sender object + plain text
|
||||
row := atMeProject(map[string]any{
|
||||
"sender": map[string]any{"name": "念晨"},
|
||||
"createTime": "2026-07-19 13:37:03",
|
||||
"content": "普通消息",
|
||||
"conversationTitle": "群A",
|
||||
"openConversationId": "cid1",
|
||||
})
|
||||
if row["sender"] != "念晨" || row["text"] != "普通消息" || row["conversation"] != "群A" {
|
||||
t.Fatalf("atMeProject nested = %#v", row)
|
||||
}
|
||||
|
||||
// encrypted content → marked (never leaked); id-only sender fallback; a
|
||||
// forwarded sub-message whose sender is the literal "null" must be nulled.
|
||||
row = atMeProject(map[string]any{
|
||||
"senderId": "DXYZ",
|
||||
"openMessageId": "m1",
|
||||
"content": testCipher,
|
||||
"forwardMessages": []any{
|
||||
map[string]any{"sender": "null", "content": "子消息", "createTime": "t"},
|
||||
},
|
||||
})
|
||||
if row["sender"] != "DXYZ" {
|
||||
t.Errorf("atMeProject id-fallback sender = %v", row["sender"])
|
||||
}
|
||||
if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") {
|
||||
t.Errorf("atMeProject encrypted text = %v, want marker", row["text"])
|
||||
}
|
||||
fwd, ok := row["forwarded"].([]map[string]any)
|
||||
if !ok || len(fwd) != 1 {
|
||||
t.Fatalf("atMeProject forwarded = %#v", row["forwarded"])
|
||||
}
|
||||
if fwd[0]["sender"] != nil {
|
||||
t.Errorf("forwarded sub sender = %v, want nil (literal \"null\")", fwd[0]["sender"])
|
||||
}
|
||||
|
||||
// no sender / no text at all → nils, no forwarded key
|
||||
row = atMeProject(map[string]any{"createTime": "t"})
|
||||
if row["sender"] != nil || row["text"] != nil {
|
||||
t.Errorf("atMeProject empty = %#v", row)
|
||||
}
|
||||
if _, has := row["forwarded"]; has {
|
||||
t.Errorf("atMeProject plain unexpectedly has forwarded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgProject(t *testing.T) {
|
||||
// nested sender + plain text + messageId
|
||||
row := searchMsgProject(map[string]any{
|
||||
"sender": map[string]any{"nick": "千启"},
|
||||
"createTime": "2026-07-19 13:37:03",
|
||||
"content": "命中关键词的消息",
|
||||
"msgId": "mid1",
|
||||
})
|
||||
if row["sender"] != "千启" || row["text"] != "命中关键词的消息" {
|
||||
t.Fatalf("searchMsgProject = %#v", row)
|
||||
}
|
||||
|
||||
// encrypted → marker; id-only sender; forwarded "null" sender nulled.
|
||||
row = searchMsgProject(map[string]any{
|
||||
"senderId": "DAAA",
|
||||
"openMessageId": "m2",
|
||||
"content": testCipher,
|
||||
"forwardMessages": []any{
|
||||
map[string]any{"sender": "null", "content": "转发子消息", "createTime": "t"},
|
||||
},
|
||||
})
|
||||
if row["sender"] != "DAAA" {
|
||||
t.Errorf("searchMsgProject sender = %v", row["sender"])
|
||||
}
|
||||
if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") {
|
||||
t.Errorf("searchMsgProject encrypted text = %v, want marker", row["text"])
|
||||
}
|
||||
fwd, ok := row["forwarded"].([]map[string]any)
|
||||
if !ok || len(fwd) != 1 || fwd[0]["sender"] != nil {
|
||||
t.Errorf("searchMsgProject forwarded = %#v", row["forwarded"])
|
||||
}
|
||||
|
||||
// no sender / no text
|
||||
row = searchMsgProject(map[string]any{"createTime": "t"})
|
||||
if row["sender"] != nil || row["text"] != nil {
|
||||
t.Errorf("searchMsgProject empty = %#v", row)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSenderHelpers exercises the atMe/searchMsg sender key families directly:
|
||||
// a senderName-family key (first probe loop), a flat string under "sender"
|
||||
// (second loop), and the "null" sentinel normalisation.
|
||||
func TestSenderHelpers(t *testing.T) {
|
||||
cases := []struct {
|
||||
fn func(map[string]any) any
|
||||
name string
|
||||
}{
|
||||
{atMeSender, "atMeSender"},
|
||||
{searchMsgSender, "searchMsgSender"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.fn(map[string]any{"senderName": "张三"}); got != "张三" {
|
||||
t.Errorf("%s senderName = %v, want 张三", c.name, got)
|
||||
}
|
||||
if got := c.fn(map[string]any{"sender": "李四"}); got != "李四" {
|
||||
t.Errorf("%s flat sender = %v, want 李四", c.name, got)
|
||||
}
|
||||
if got := c.fn(map[string]any{"senderName": "null"}); got != nil {
|
||||
t.Errorf("%s \"null\" = %v, want nil", c.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
// ChatMessages: fetch the message list of one conversation (group OR single
|
||||
@@ -111,11 +112,7 @@ var ChatMessages = shortcut.Shortcut{
|
||||
items := chatMessageItems(data)
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, m := range items {
|
||||
results = append(results, map[string]any{
|
||||
"sender": chatMessageSender(m),
|
||||
"text": chatMessageText(m),
|
||||
"createTime": chatMessageCreateTime(m),
|
||||
})
|
||||
results = append(results, projectChatMessage(m))
|
||||
}
|
||||
|
||||
return rt.Output(map[string]any{
|
||||
@@ -156,53 +153,20 @@ func chatMessageItems(data map[string]any) []map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
// chatMessageSender reads a message's speaker display name, tolerating common
|
||||
// sender-name keys.
|
||||
func chatMessageSender(m map[string]any) any {
|
||||
for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} {
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
if s, ok := v.(string); ok && s == "" {
|
||||
continue
|
||||
}
|
||||
return v
|
||||
}
|
||||
// projectChatMessage reshapes one raw message into the clean
|
||||
// {sender, text, createTime} projection, rendering card/auto-reply JSON and
|
||||
// marking encrypted messages via chatmsg, and recursively expanding forwarded
|
||||
// chat records under "forwarded".
|
||||
func projectChatMessage(m map[string]any) map[string]any {
|
||||
row := map[string]any{
|
||||
"sender": chatmsg.Sender(m),
|
||||
"text": chatmsg.Text(m),
|
||||
"createTime": chatmsg.CreateTime(m),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// chatMessageText reads a message's textual content, tolerating common text
|
||||
// keys and one level of nesting (e.g. {"content":{"text":"..."}}).
|
||||
func chatMessageText(m map[string]any) any {
|
||||
for _, key := range []string{"text", "content", "msgContent", "message", "body"} {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t != "" {
|
||||
return t
|
||||
}
|
||||
case map[string]any:
|
||||
for _, inner := range []string{"text", "content", "value"} {
|
||||
if s, ok := t[inner].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
if forwarded := chatmsg.Forwarded(m, projectChatMessage); len(forwarded) > 0 {
|
||||
row["forwarded"] = forwarded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// chatMessageCreateTime reads a message's create/send time, returning the raw
|
||||
// value under whichever candidate key is present.
|
||||
func chatMessageCreateTime(m map[string]any) any {
|
||||
for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} {
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return row
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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 smart
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestProjectChatMessageExpandsForwarded guards that a forwarded chat record
|
||||
// ("聊天记录") exposes its nested messages under "forwarded" instead of
|
||||
// collapsing to the lossy top-level "[卡片]" summary, recursing through nested
|
||||
// forwards, and that the string-"null" sender is nulled out. The per-field
|
||||
// behaviour (sender/text/encryption) is covered in the chatmsg package tests.
|
||||
func TestProjectChatMessageExpandsForwarded(t *testing.T) {
|
||||
row := projectChatMessage(map[string]any{
|
||||
"sender": "hugozhu",
|
||||
"content": "hugozhu与opencode-agent的聊天记录\nopencode-agent:[卡片]",
|
||||
"createTime": "2026-07-20 21:41:21",
|
||||
"forwardMessages": []any{
|
||||
map[string]any{"sender": "null", "content": "读下冬翔发给我的最近两条消息", "createTime": "2026-07-20 09:30:33"},
|
||||
map[string]any{"sender": "冬翔", "content": "W29 工作总结", "createTime": "2026-07-19 23:35:40",
|
||||
// nested forward inside a forward — must expand recursively.
|
||||
"forwardMessages": []any{
|
||||
map[string]any{"sender": "念晨", "content": "收到", "createTime": "2026-07-19 23:36:00"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if row["sender"] != "hugozhu" {
|
||||
t.Fatalf("top sender = %v, want hugozhu", row["sender"])
|
||||
}
|
||||
forwarded, ok := row["forwarded"].([]map[string]any)
|
||||
if !ok || len(forwarded) != 2 {
|
||||
t.Fatalf("forwarded = %#v, want 2 entries", row["forwarded"])
|
||||
}
|
||||
if forwarded[0]["sender"] != nil {
|
||||
t.Errorf("forwarded[0].sender = %v, want nil (string \"null\")", forwarded[0]["sender"])
|
||||
}
|
||||
if forwarded[0]["text"] != "读下冬翔发给我的最近两条消息" {
|
||||
t.Errorf("forwarded[0].text = %v", forwarded[0]["text"])
|
||||
}
|
||||
nested, ok := forwarded[1]["forwarded"].([]map[string]any)
|
||||
if !ok || len(nested) != 1 || nested[0]["sender"] != "念晨" {
|
||||
t.Errorf("nested forwarded = %#v, want 1 entry from 念晨", forwarded[1]["forwarded"])
|
||||
}
|
||||
|
||||
// A plain message must not grow a "forwarded" key.
|
||||
plain := projectChatMessage(map[string]any{"sender": "念晨", "content": "hi", "createTime": "t"})
|
||||
if _, has := plain["forwarded"]; has {
|
||||
t.Errorf("plain message unexpectedly has forwarded key: %#v", plain)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
// SearchMsg: search messages inside a single group chat by keyword in one step.
|
||||
@@ -108,12 +109,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
}
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, m := range items {
|
||||
results = append(results, map[string]any{
|
||||
"sender": searchMsgSender(m),
|
||||
"time": searchMsgTime(m),
|
||||
"text": searchMsgText(m),
|
||||
"messageId": searchMsgMessageID(m),
|
||||
})
|
||||
results = append(results, searchMsgProject(m))
|
||||
}
|
||||
return rt.Output(map[string]any{"messages": results})
|
||||
},
|
||||
@@ -152,28 +148,61 @@ func searchMsgToMaps(arr []any) []map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// searchMsgProject reshapes one matched message into {sender, time, text,
|
||||
// messageId}, running text through the shared chatmsg cleaning (card/auto-reply
|
||||
// JSON → readable, ciphertext → marker) and recursively expanding any forwarded
|
||||
// chat record under "forwarded".
|
||||
func searchMsgProject(m map[string]any) map[string]any {
|
||||
row := map[string]any{
|
||||
"sender": searchMsgSender(m),
|
||||
"time": searchMsgTime(m),
|
||||
"text": searchMsgCleanText(m),
|
||||
"messageId": searchMsgMessageID(m),
|
||||
}
|
||||
if forwarded := chatmsg.Forwarded(m, searchMsgProject); len(forwarded) > 0 {
|
||||
row["forwarded"] = forwarded
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// searchMsgCleanText runs searchMsgText's extraction through chatmsg.CleanText.
|
||||
func searchMsgCleanText(m map[string]any) any {
|
||||
if s, ok := searchMsgText(m).(string); ok {
|
||||
return chatmsg.CleanText(s)
|
||||
}
|
||||
return searchMsgText(m)
|
||||
}
|
||||
|
||||
// searchMsgSender reads a message's sender display name/id, tolerating the
|
||||
// common sender keys the gateway may use (including a nested sender object).
|
||||
// common sender keys the gateway may use (including a nested sender object). The
|
||||
// literal string "null" (carried by forwarded sub-messages) and the empty string
|
||||
// are both treated as absent so they never surface as the speaker.
|
||||
func searchMsgSender(m map[string]any) any {
|
||||
norm := func(v any) string {
|
||||
if s := searchMsgString(v); s != "" && s != "null" {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"senderName", "sender_name", "senderNick", "fromName", "senderStaffName"} {
|
||||
if v := searchMsgString(m[key]); v != "" {
|
||||
if v := norm(m[key]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"sender", "from", "senderUser"} {
|
||||
if nested, ok := m[key].(map[string]any); ok {
|
||||
for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} {
|
||||
if v := searchMsgString(nested[k2]); v != "" {
|
||||
if v := norm(nested[k2]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
if v := searchMsgString(m[key]); v != "" {
|
||||
if v := norm(m[key]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} {
|
||||
if v := searchMsgString(m[key]); v != "" {
|
||||
if v := norm(m[key]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,11 +78,7 @@ var ThreadReplies = shortcut.Shortcut{
|
||||
items := threadReplyItems(data)
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, m := range items {
|
||||
results = append(results, map[string]any{
|
||||
"sender": threadReplySender(m),
|
||||
"text": threadReplyText(m),
|
||||
"createTime": threadReplyCreateTime(m),
|
||||
})
|
||||
results = append(results, projectChatMessage(m))
|
||||
}
|
||||
|
||||
return rt.Output(map[string]any{
|
||||
@@ -124,55 +120,6 @@ func threadReplyItems(data map[string]any) []map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
// threadReplySender reads a reply's speaker display name, tolerating common
|
||||
// sender-name keys.
|
||||
func threadReplySender(m map[string]any) any {
|
||||
for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} {
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
if s, ok := v.(string); ok && s == "" {
|
||||
continue
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// threadReplyText reads a reply's textual content, tolerating common text keys
|
||||
// and one level of nesting (e.g. {"text":{"content":"..."}}).
|
||||
func threadReplyText(m map[string]any) any {
|
||||
for _, key := range []string{"text", "content", "msgContent", "message", "body"} {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t != "" {
|
||||
return t
|
||||
}
|
||||
case map[string]any:
|
||||
for _, inner := range []string{"content", "text", "value"} {
|
||||
if s, ok := t[inner].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// threadReplyCreateTime reads a reply's create/send time, returning the raw
|
||||
// value under whichever candidate key is present.
|
||||
func threadReplyCreateTime(m map[string]any) any {
|
||||
for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} {
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(ThreadReplies)
|
||||
}
|
||||
|
||||
@@ -127,6 +127,10 @@ type Shortcut struct {
|
||||
Tips []string
|
||||
// Hidden hides the command from listings while keeping it invocable.
|
||||
Hidden bool
|
||||
// UserDefined identifies shortcuts loaded from the user's config
|
||||
// directory. Distribution-owned Schema and interface snapshots exclude
|
||||
// these runtime extensions even if another root loaded them earlier.
|
||||
UserDefined bool
|
||||
|
||||
// Validate optionally checks resolved flag values before execution. Return a
|
||||
// non-nil error to abort with a validation message. Runs after built-in
|
||||
|
||||
@@ -196,6 +196,7 @@ func Compile(s Spec) shortcut.Shortcut {
|
||||
Intent: intent,
|
||||
Risk: risk,
|
||||
Flags: flags,
|
||||
UserDefined: true,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
for key, tmpl := range bind {
|
||||
|
||||
@@ -69,6 +69,9 @@ func TestCrossPlatformCoverageCompileFlagsAndDefaults(t *testing.T) {
|
||||
if sc.Service != "chat" || sc.Command != "+notify-team" {
|
||||
t.Fatalf("bad identity: %+v", sc)
|
||||
}
|
||||
if !sc.UserDefined {
|
||||
t.Fatal("compiled user shortcut is missing user-defined provenance")
|
||||
}
|
||||
if sc.Risk != shortcut.RiskRead {
|
||||
t.Errorf("risk default = %q, want read", sc.Risk)
|
||||
}
|
||||
|
||||
@@ -23,15 +23,28 @@ const SourceAnnotation = "dws.source"
|
||||
// SourceEnvelope marks a command as authored by the runtime discovery envelope.
|
||||
const SourceEnvelope = "envelope"
|
||||
|
||||
// SourcePlugin marks a command as an installed plugin extension. Plugin
|
||||
// commands are part of the runtime CLI surface, not the embedded base Schema.
|
||||
const SourcePlugin = "plugin"
|
||||
|
||||
// MarkEnvelopeSource stamps cmd with runtime discovery provenance.
|
||||
func MarkEnvelopeSource(cmd *cobra.Command) {
|
||||
markSource(cmd, SourceEnvelope)
|
||||
}
|
||||
|
||||
// MarkPluginSource stamps cmd with installed-plugin provenance.
|
||||
func MarkPluginSource(cmd *cobra.Command) {
|
||||
markSource(cmd, SourcePlugin)
|
||||
}
|
||||
|
||||
func markSource(cmd *cobra.Command, source string) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[SourceAnnotation] = SourceEnvelope
|
||||
cmd.Annotations[SourceAnnotation] = source
|
||||
}
|
||||
|
||||
// IsEnvelopeSourced reports whether cmd was authored by the runtime discovery
|
||||
@@ -40,6 +53,11 @@ func IsEnvelopeSourced(cmd *cobra.Command) bool {
|
||||
return cmd != nil && cmd.Annotations[SourceAnnotation] == SourceEnvelope
|
||||
}
|
||||
|
||||
// IsPluginSourced reports whether cmd came from an installed plugin.
|
||||
func IsPluginSourced(cmd *cobra.Command) bool {
|
||||
return cmd != nil && cmd.Annotations[SourceAnnotation] == SourcePlugin
|
||||
}
|
||||
|
||||
// KindAnnotation is the annotation key for marking command kinds.
|
||||
const KindAnnotation = "dws.kind"
|
||||
|
||||
|
||||
@@ -43,3 +43,19 @@ func TestMarkEnvelopeSourceNilDoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
MarkEnvelopeSource(nil)
|
||||
}
|
||||
|
||||
func TestPluginSourceProvenance(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IsPluginSourced(nil) {
|
||||
t.Fatal("nil command should not be plugin sourced")
|
||||
}
|
||||
cmd := &cobra.Command{Use: "conference"}
|
||||
MarkPluginSource(cmd)
|
||||
if !IsPluginSourced(cmd) || IsEnvelopeSourced(cmd) {
|
||||
t.Fatalf("plugin source annotation = %#v", cmd.Annotations)
|
||||
}
|
||||
if got := cmd.Annotations[SourceAnnotation]; got != SourcePlugin {
|
||||
t.Fatalf("SourceAnnotation = %q, want %q", got, SourcePlugin)
|
||||
}
|
||||
MarkPluginSource(nil)
|
||||
}
|
||||
|
||||
+98
-3
@@ -1,6 +1,10 @@
|
||||
package mcptypes
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ServerDescriptor struct {
|
||||
Key string
|
||||
@@ -16,19 +20,110 @@ type ServerDescriptor struct {
|
||||
type CLIOverlay struct {
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Aliases []string `json:"aliases"`
|
||||
Prefixes []string `json:"prefixes"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Skip bool `json:"skip"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Tools []CLITool `json:"tools"`
|
||||
Groups map[string]CLIGroupDef `json:"groups,omitempty"`
|
||||
ToolOverrides map[string]CLIToolOverride `json:"toolOverrides,omitempty"`
|
||||
ServerDeps []string `json:"serverDeps,omitempty"`
|
||||
Hints map[string]json.RawMessage `json:"hintCommands,omitempty"`
|
||||
RedirectTo string `json:"redirectTo,omitempty"`
|
||||
}
|
||||
|
||||
type CLIGroupDef struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type CLITool struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
CLIName string `json:"cliName,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
IsSensitive bool `json:"isSensitive,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Flags map[string]CLIFlagHint `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
type CLIFlagHint struct {
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
Alias string `json:"alias,omitempty"`
|
||||
}
|
||||
|
||||
type CLIToolOverride struct {
|
||||
ServerOverride string `json:"serverOverride,omitempty"`
|
||||
CLIName string `json:"cliName,omitempty"`
|
||||
CLIAliases []string `json:"cliAliases,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Example string `json:"example,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
IsSensitive bool `json:"isSensitive,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Flags map[string]CLIFlagOverride `json:"flags,omitempty"`
|
||||
OutputFormat map[string]any `json:"outputFormat,omitempty"`
|
||||
ServerOverride string `json:"serverOverride,omitempty"`
|
||||
BodyWrapper string `json:"bodyWrapper,omitempty"`
|
||||
MutuallyExclusive [][]string `json:"mutuallyExclusive,omitempty"`
|
||||
RequireOneOf [][]string `json:"requireOneOf,omitempty"`
|
||||
RequireTogether [][]string `json:"requireTogether,omitempty"`
|
||||
RejectPositional bool `json:"rejectPositional,omitempty"`
|
||||
RedirectTo string `json:"redirectTo,omitempty"`
|
||||
Pipeline []json.RawMessage `json:"pipeline,omitempty"`
|
||||
}
|
||||
|
||||
type CLIFlagOverride struct {
|
||||
Alias string `json:"alias,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
MapsTo string `json:"mapsTo,omitempty"`
|
||||
Transform string `json:"transform,omitempty"`
|
||||
TransformArgs map[string]any `json:"transformArgs,omitempty"`
|
||||
EnvDefault string `json:"envDefault,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Positional bool `json:"positional,omitempty"`
|
||||
PositionalIndex int `json:"positionalIndex,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
OmitWhen string `json:"omitWhen,omitempty"`
|
||||
RuntimeDefault string `json:"runtimeDefault,omitempty"`
|
||||
PipelineLocal bool `json:"pipelineLocal,omitempty"`
|
||||
}
|
||||
|
||||
func (override *CLIFlagOverride) UnmarshalJSON(data []byte) error {
|
||||
type alias CLIFlagOverride
|
||||
aux := struct {
|
||||
Default json.RawMessage `json:"default,omitempty"`
|
||||
*alias
|
||||
}{alias: (*alias)(override)}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&aux); err != nil {
|
||||
return err
|
||||
}
|
||||
override.Default = coercePluginScalar(aux.Default)
|
||||
return nil
|
||||
}
|
||||
|
||||
func coercePluginScalar(raw json.RawMessage) string {
|
||||
value := strings.TrimSpace(string(raw))
|
||||
if value == "" || value == "null" ||
|
||||
strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(value, `"`) {
|
||||
var decoded string
|
||||
if json.Unmarshal(raw, &decoded) == nil {
|
||||
return decoded
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func OverlayFromJSON(data json.RawMessage) CLIOverlay {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package mcptypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLIFlagOverrideUnmarshalJSONCoercesScalarDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "missing", raw: `{}`, want: ""},
|
||||
{name: "null", raw: `{"default":null}`, want: ""},
|
||||
{name: "object", raw: `{"default":{"nested":true}}`, want: ""},
|
||||
{name: "array", raw: `{"default":[1,2]}`, want: ""},
|
||||
{name: "string", raw: `{"default":"hello"}`, want: "hello"},
|
||||
{name: "escaped string", raw: `{"default":"line\nvalue"}`, want: "line\nvalue"},
|
||||
{name: "boolean", raw: `{"default":true}`, want: "true"},
|
||||
{name: "integer", raw: `{"default":42}`, want: "42"},
|
||||
{name: "number", raw: `{"default":-1.25}`, want: "-1.25"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got CLIFlagOverride
|
||||
if err := json.Unmarshal([]byte(tt.raw), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Default != tt.want {
|
||||
t.Fatalf("Default = %q, want %q", got.Default, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var got CLIFlagOverride
|
||||
if err := json.Unmarshal([]byte(`{"alias":`), &got); err == nil {
|
||||
t.Fatal("json.Unmarshal() error = nil, want malformed JSON error")
|
||||
}
|
||||
if err := got.UnmarshalJSON([]byte(`{"alias":`)); err == nil {
|
||||
t.Fatal("CLIFlagOverride.UnmarshalJSON() error = nil, want malformed JSON error")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"unknownFlagField":true}`), &got); err == nil {
|
||||
t.Fatal("CLIFlagOverride.UnmarshalJSON() accepted an unknown field")
|
||||
}
|
||||
if got := coercePluginScalar(json.RawMessage(`"unterminated`)); got != "" {
|
||||
t.Fatalf("coercePluginScalar(invalid string) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayFromJSONHandlesEmptyValidAndMalformedInput(t *testing.T) {
|
||||
if got := OverlayFromJSON(nil); got.ID != "" || got.Command != "" {
|
||||
t.Fatalf("OverlayFromJSON(nil) = %#v, want zero overlay", got)
|
||||
}
|
||||
|
||||
valid := json.RawMessage(`{
|
||||
"id":"conference",
|
||||
"command":"meeting",
|
||||
"toolOverrides":{"create":{"flags":{"count":{"default":3}}}}
|
||||
}`)
|
||||
got := OverlayFromJSON(valid)
|
||||
if got.ID != "conference" || got.Command != "meeting" ||
|
||||
got.ToolOverrides["create"].Flags["count"].Default != "3" {
|
||||
t.Fatalf("OverlayFromJSON(valid) = %#v", got)
|
||||
}
|
||||
|
||||
if got := OverlayFromJSON(json.RawMessage(`{`)); got.ID != "" || got.Command != "" {
|
||||
t.Fatalf("OverlayFromJSON(malformed) = %#v, want zero overlay", got)
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,32 @@ GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
|
||||
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
|
||||
GITEE_LIST_MAX_TIME="${GITEE_LIST_MAX_TIME:-20}"
|
||||
# Attachment-list reads are safe to retry and are used both before an upload
|
||||
# and to detect a committed upload whose HTTP response was lost. Keep retrying
|
||||
# long enough to bridge a short Gitee TLS/API outage without ever blindly
|
||||
# replaying the upload itself.
|
||||
GITEE_LIST_RETRIES="${GITEE_LIST_RETRIES:-24}"
|
||||
GITEE_LIST_RETRY_DELAY="${GITEE_LIST_RETRY_DELAY:-20}"
|
||||
GITEE_LIST_RETRY_WINDOW_SECONDS="${GITEE_LIST_RETRY_WINDOW_SECONDS:-420}"
|
||||
GITEE_VERIFY_MAX_TIME="${GITEE_VERIFY_MAX_TIME:-60}"
|
||||
GITEE_MUTATION_MAX_TIME="${GITEE_MUTATION_MAX_TIME:-20}"
|
||||
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-120}"
|
||||
# Gitee does not return a response until an attachment upload is committed.
|
||||
# From GitHub-hosted runners, a near-10 MiB DWS binary can legitimately take
|
||||
# more than five minutes, so keep the transfer deadline above that observed
|
||||
# floor while the per-asset and overall deadlines retain hard upper bounds.
|
||||
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-1200}"
|
||||
# The per-asset deadline guarantees one complete slow upload plus one complete
|
||||
# attachment-list recovery on each side of that upload. The second upload
|
||||
# attempt is allowed only for zero-byte failures before HTTP begins; a first
|
||||
# attempt that consumes the full transfer deadline intentionally exhausts the
|
||||
# retry budget instead of holding a runner for another full slow upload.
|
||||
GITEE_UPLOAD_RETRIES="${GITEE_UPLOAD_RETRIES:-2}"
|
||||
GITEE_UPLOAD_RETRY_DELAY="${GITEE_UPLOAD_RETRY_DELAY:-5}"
|
||||
GITEE_EXISTING_VERIFY_ATTEMPTS="${GITEE_EXISTING_VERIFY_ATTEMPTS:-1}"
|
||||
GITEE_POST_UPLOAD_VERIFY_ATTEMPTS="${GITEE_POST_UPLOAD_VERIFY_ATTEMPTS:-2}"
|
||||
GITEE_VERIFY_RETRY_DELAY="${GITEE_VERIFY_RETRY_DELAY:-5}"
|
||||
GITEE_ASSET_TIMEOUT_SECONDS="${GITEE_ASSET_TIMEOUT_SECONDS:-600}"
|
||||
GITEE_OVERALL_TIMEOUT_SECONDS="${GITEE_OVERALL_TIMEOUT_SECONDS:-4920}"
|
||||
GITEE_ASSET_TIMEOUT_SECONDS="${GITEE_ASSET_TIMEOUT_SECONDS:-2220}"
|
||||
GITEE_OVERALL_TIMEOUT_SECONDS="${GITEE_OVERALL_TIMEOUT_SECONDS:-18300}"
|
||||
|
||||
err() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
@@ -43,6 +59,8 @@ for setting in \
|
||||
GITEE_CURL_CONNECT_TIMEOUT \
|
||||
GITEE_CURL_MAX_TIME \
|
||||
GITEE_LIST_MAX_TIME \
|
||||
GITEE_LIST_RETRIES \
|
||||
GITEE_LIST_RETRY_WINDOW_SECONDS \
|
||||
GITEE_VERIFY_MAX_TIME \
|
||||
GITEE_MUTATION_MAX_TIME \
|
||||
GITEE_UPLOAD_MAX_TIME \
|
||||
@@ -54,6 +72,7 @@ for setting in \
|
||||
require_positive_integer "$setting" "${!setting}"
|
||||
done
|
||||
for setting in \
|
||||
GITEE_LIST_RETRY_DELAY \
|
||||
GITEE_UPLOAD_RETRY_DELAY \
|
||||
GITEE_VERIFY_RETRY_DELAY; do
|
||||
require_nonnegative_integer "$setting" "${!setting}"
|
||||
@@ -139,21 +158,81 @@ api_get() {
|
||||
--max-time "$max_time" "$@"
|
||||
}
|
||||
|
||||
list_assets() {
|
||||
api_get "$GITEE_LIST_MAX_TIME" \
|
||||
list_assets_once() {
|
||||
local max_time="$1" payload
|
||||
payload="$(api_get "$max_time" \
|
||||
-H "Authorization: token ${GITEE_TOKEN}" \
|
||||
"${base}/releases/${release_id}/attach_files" \
|
||||
| python3 -c 'import json,sys
|
||||
"${base}/releases/${release_id}/attach_files")" || return 1
|
||||
printf '%s\n' "$payload" | python3 -c 'import json,sys
|
||||
data=json.load(sys.stdin)
|
||||
rows=data if isinstance(data,list) else data.get("attach_files",[])
|
||||
if isinstance(data,list):
|
||||
rows=data
|
||||
elif isinstance(data,dict) and "attach_files" in data:
|
||||
rows=data["attach_files"]
|
||||
else:
|
||||
raise ValueError("attachment list response must be a list or contain attach_files")
|
||||
if not isinstance(rows,list):
|
||||
raise ValueError("attach_files must be a list")
|
||||
for asset in rows:
|
||||
if not isinstance(asset,dict):
|
||||
raise ValueError("each attachment must be an object")
|
||||
name=asset.get("name","")
|
||||
asset_id=asset.get("id","")
|
||||
url=asset.get("browser_download_url","")
|
||||
if name and asset_id != "":
|
||||
print("%s\t%s\t%s" % (name, asset_id, url))'
|
||||
valid_id=(isinstance(asset_id,int) and not isinstance(asset_id,bool) and asset_id > 0) or (isinstance(asset_id,str) and asset_id.isdigit() and int(asset_id) > 0)
|
||||
if not isinstance(name,str) or not name or not valid_id or not isinstance(url,str) or not url:
|
||||
raise ValueError("attachment identity and download URL are required")
|
||||
print("%s\t%s\t%s" % (name, asset_id, url))'
|
||||
}
|
||||
|
||||
list_assets() {
|
||||
local attempt=1 candidate now retry_deadline remaining max_time delay
|
||||
now="$(now_seconds)"
|
||||
retry_deadline=$(( now + GITEE_LIST_RETRY_WINDOW_SECONDS ))
|
||||
if [ "$retry_deadline" -gt "$active_deadline" ]; then
|
||||
retry_deadline="$active_deadline"
|
||||
fi
|
||||
while [ "$attempt" -le "$GITEE_LIST_RETRIES" ]; do
|
||||
now="$(now_seconds)"
|
||||
remaining=$(( retry_deadline - now ))
|
||||
[ "$remaining" -gt 0 ] || break
|
||||
max_time="$GITEE_LIST_MAX_TIME"
|
||||
if [ "$max_time" -gt "$remaining" ]; then
|
||||
max_time="$remaining"
|
||||
fi
|
||||
# Publish one complete parse atomically. A malformed response can make the
|
||||
# parser emit valid leading rows before it fails; leaking those rows into a
|
||||
# later successful retry would manufacture duplicates and could delete a
|
||||
# correct attachment.
|
||||
if candidate="$(list_assets_once "$max_time")"; then
|
||||
now="$(now_seconds)"
|
||||
if [ "$now" -lt "$retry_deadline" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -ge "$GITEE_LIST_RETRIES" ]; then
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
now="$(now_seconds)"
|
||||
remaining=$(( retry_deadline - now ))
|
||||
[ "$remaining" -gt 0 ] || break
|
||||
delay="$GITEE_LIST_RETRY_DELAY"
|
||||
if [ "$delay" -gt 0 ]; then
|
||||
# Do not turn a configured backoff into a burst of zero-delay requests
|
||||
# during the final wall-clock second. Explicit delay=0 remains available
|
||||
# to fast unit tests and tightly controlled callers.
|
||||
[ "$remaining" -gt 1 ] || break
|
||||
if [ "$delay" -ge "$remaining" ]; then
|
||||
delay=$(( remaining - 1 ))
|
||||
fi
|
||||
fi
|
||||
echo " ⚠ Gitee attachment list attempt $((attempt - 1))/${GITEE_LIST_RETRIES} failed; retrying in ${delay}s" >&2
|
||||
sleep_within_deadline "$delay" || return 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
sha256_of() {
|
||||
@@ -234,20 +313,42 @@ delete_named_assets() {
|
||||
}
|
||||
|
||||
gitee_attach() {
|
||||
local file="$1" name attempt response status max_time
|
||||
local file="$1" name attempt response status max_time transfer_log metrics http_code size_upload safe_to_retry
|
||||
name="$(basename "$file")"
|
||||
attempt=1
|
||||
while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do
|
||||
status=0
|
||||
max_time="$(bounded_max_time "$GITEE_UPLOAD_MAX_TIME")" || return 1
|
||||
if response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \
|
||||
transfer_log="$(mktemp)" || return 1
|
||||
if metrics="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \
|
||||
--max-time "$max_time" \
|
||||
-X POST "${base}/releases/${release_id}/attach_files" \
|
||||
-H "Authorization: token ${GITEE_TOKEN}" -F "file=@${file}" 2>&1)"; then
|
||||
-H "Authorization: token ${GITEE_TOKEN}" \
|
||||
-H "Expect:" \
|
||||
-F "file=@${file}" \
|
||||
-o /dev/null \
|
||||
-w '%{http_code}\t%{size_upload}' \
|
||||
2>"$transfer_log")"; then
|
||||
status=0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
response="$(<"$transfer_log")"
|
||||
rm -f "$transfer_log"
|
||||
http_code="${metrics%%$'\t'*}"
|
||||
if [ "$metrics" = "$http_code" ]; then
|
||||
size_upload=""
|
||||
else
|
||||
size_upload="${metrics#*$'\t'}"
|
||||
fi
|
||||
safe_to_retry=0
|
||||
if [ "$status" -ne 0 ] && [ "$http_code" = "000" ] && \
|
||||
awk -v value="$size_upload" 'BEGIN { exit !((value + 0) == 0 && value ~ /^[0-9]+([.][0-9]+)?$/) }'; then
|
||||
# A request that never reached HTTP and uploaded zero bytes cannot have
|
||||
# committed an attachment. TLS/DNS/connect failures are therefore the
|
||||
# only transport failures safe to replay automatically.
|
||||
safe_to_retry=1
|
||||
fi
|
||||
|
||||
# Gitee can commit an upload but let the HTTP response time out. Probe the
|
||||
# release before retrying so a lost response does not create duplicates.
|
||||
@@ -259,6 +360,10 @@ gitee_attach() {
|
||||
fi
|
||||
|
||||
echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${name}: $(printf '%s' "$response" | head -c 240)" >&2
|
||||
if [ "$safe_to_retry" -ne 1 ]; then
|
||||
echo " ⚠ ${name} upload outcome is ambiguous (HTTP ${http_code:-unknown}, uploaded ${size_upload:-unknown} bytes); refusing to replay POST" >&2
|
||||
return 1
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; then
|
||||
# Remove any partial, stale, or duplicate attachment before the one
|
||||
|
||||
@@ -31,11 +31,13 @@ DIST_DIR="${DIST_DIR:-dist}"
|
||||
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
|
||||
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
|
||||
GITEE_SYNC_TIMEOUT_SECONDS="${GITEE_SYNC_TIMEOUT_SECONDS:-5700}"
|
||||
GITEE_SYNC_TIMEOUT_SECONDS="${GITEE_SYNC_TIMEOUT_SECONDS:-18840}"
|
||||
GITEE_TAG_TIMEOUT_SECONDS="${GITEE_TAG_TIMEOUT_SECONDS:-300}"
|
||||
GITEE_RELEASE_LOOKUP_MAX_TIME="${GITEE_RELEASE_LOOKUP_MAX_TIME:-60}"
|
||||
GITEE_RELEASE_LOOKUP_RETRIES="${GITEE_RELEASE_LOOKUP_RETRIES:-2}"
|
||||
GITEE_RELEASE_LOOKUP_RETRY_DELAY="${GITEE_RELEASE_LOOKUP_RETRY_DELAY:-2}"
|
||||
GITEE_RELEASE_CREATE_MAX_TIME="${GITEE_RELEASE_CREATE_MAX_TIME:-60}"
|
||||
GITEE_RECONCILE_TIMEOUT_SECONDS="${GITEE_RECONCILE_TIMEOUT_SECONDS:-4920}"
|
||||
GITEE_RECONCILE_TIMEOUT_SECONDS="${GITEE_RECONCILE_TIMEOUT_SECONDS:-18300}"
|
||||
GITEE_CHILD_DEADLINE_RESERVE_SECONDS="${GITEE_CHILD_DEADLINE_RESERVE_SECONDS:-5}"
|
||||
|
||||
err() {
|
||||
@@ -51,17 +53,28 @@ require_positive_integer() {
|
||||
[ "$value" -gt 0 ] || err "${name} must be greater than zero"
|
||||
}
|
||||
|
||||
require_nonnegative_integer() {
|
||||
local name="$1" value="$2"
|
||||
case "$value" in
|
||||
''|*[!0-9]*) err "${name} must be a non-negative integer: ${value}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for setting in \
|
||||
GITEE_CURL_CONNECT_TIMEOUT \
|
||||
GITEE_CURL_MAX_TIME \
|
||||
GITEE_SYNC_TIMEOUT_SECONDS \
|
||||
GITEE_TAG_TIMEOUT_SECONDS \
|
||||
GITEE_RELEASE_LOOKUP_MAX_TIME \
|
||||
GITEE_RELEASE_LOOKUP_RETRIES \
|
||||
GITEE_RELEASE_CREATE_MAX_TIME \
|
||||
GITEE_RECONCILE_TIMEOUT_SECONDS \
|
||||
GITEE_CHILD_DEADLINE_RESERVE_SECONDS; do
|
||||
require_positive_integer "$setting" "${!setting}"
|
||||
done
|
||||
require_nonnegative_integer \
|
||||
GITEE_RELEASE_LOOKUP_RETRY_DELAY \
|
||||
"$GITEE_RELEASE_LOOKUP_RETRY_DELAY"
|
||||
|
||||
missing=""
|
||||
[ -z "${GITEE_TOKEN:-}" ] && missing="$missing GITEE_TOKEN"
|
||||
@@ -117,14 +130,6 @@ GITEE_TAG_TIMEOUT_SECONDS="$GITEE_TAG_TIMEOUT_SECONDS" \
|
||||
deadline_remaining >/dev/null || err "overall Gitee sync deadline exhausted during tag synchronization"
|
||||
target_commit="$(git rev-parse --verify "${VERSION}^{commit}")"
|
||||
|
||||
api_get() {
|
||||
local configured_max_time="$1" max_time
|
||||
shift
|
||||
max_time="$(bounded_max_time "$configured_max_time")" || return 1
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \
|
||||
--max-time "$max_time" "$@"
|
||||
}
|
||||
|
||||
release_id_from_json() {
|
||||
python3 -c 'import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
@@ -136,12 +141,41 @@ elif isinstance(value, str) and value.isdigit() and int(value) > 0:
|
||||
}
|
||||
|
||||
# ── Resolve or create the Gitee release for this tag ──────────────────────────
|
||||
rel_json="$(api_get "$GITEE_RELEASE_LOOKUP_MAX_TIME" \
|
||||
# A transient lookup failure must not be mistaken for a missing release: doing
|
||||
# so can race a duplicate create and hides the actual Gitee availability issue.
|
||||
release_lookup_body="$(mktemp)"
|
||||
trap 'rm -f "$release_lookup_body"' EXIT HUP INT TERM
|
||||
lookup_max_time="$(bounded_max_time "$GITEE_RELEASE_LOOKUP_MAX_TIME")" \
|
||||
|| err "overall Gitee sync deadline exhausted before release lookup"
|
||||
if ! release_status="$(curl -sS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \
|
||||
--max-time "$lookup_max_time" \
|
||||
--retry "$GITEE_RELEASE_LOOKUP_RETRIES" \
|
||||
--retry-all-errors \
|
||||
--retry-delay "$GITEE_RELEASE_LOOKUP_RETRY_DELAY" \
|
||||
--retry-max-time "$lookup_max_time" \
|
||||
-o "$release_lookup_body" \
|
||||
-w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEE_TOKEN}" \
|
||||
"${base}/releases/tags/${VERSION}" 2>/dev/null || true)"
|
||||
release_id="$(printf '%s' "$rel_json" | release_id_from_json || true)"
|
||||
"${base}/releases/tags/${VERSION}")"; then
|
||||
deadline_remaining >/dev/null \
|
||||
|| err "overall Gitee sync deadline exhausted during release lookup"
|
||||
err "could not query Gitee release ${VERSION} after bounded retries"
|
||||
fi
|
||||
rel_json="$(<"$release_lookup_body")"
|
||||
case "$release_status" in
|
||||
200)
|
||||
release_id="$(printf '%s' "$rel_json" | release_id_from_json || true)"
|
||||
[ -n "$release_id" ] || err "Gitee release lookup returned HTTP 200 without a valid release id"
|
||||
;;
|
||||
404)
|
||||
release_id=""
|
||||
;;
|
||||
*)
|
||||
err "Gitee release lookup returned HTTP ${release_status} for ${VERSION}"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
if [ "$release_status" = "404" ]; then
|
||||
deadline_remaining >/dev/null || err "overall Gitee sync deadline exhausted during release lookup"
|
||||
echo " No Gitee release for ${VERSION} yet — creating it."
|
||||
create_max_time="$(bounded_max_time "$GITEE_RELEASE_CREATE_MAX_TIME")" \
|
||||
|
||||
@@ -22,7 +22,7 @@ cli_version: ">=1.0.15"
|
||||
- 单次批量操作不超过 30 条记录
|
||||
- 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开)
|
||||
- **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/<name>.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤
|
||||
- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume` 长连接,不要写脚本轮询消息历史
|
||||
- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume ... --flatten` 长连接,不要写脚本轮询消息历史
|
||||
|
||||
## Shortcut 与原子命令的使用原则
|
||||
|
||||
@@ -259,7 +259,7 @@ Schema 与 Help 冲突是**契约漂移**,不得静默猜测或把两边字段
|
||||
|
||||
`dev.*` 包含 helper-only 执行面,其中远端 helper 未进入 pinned metadata 时标记为 `composite`,不能伪装成 `local`。`event list` / `event schema` 读取内置目录和 payload 定义,属于 `local`;`event consume` / `event status` / `event stop` 同时编排远端个人订阅控制面与本地 bus/consume,属于 `composite`。实现来源不同,不改变统一查询边界:进入全局 `dws schema` 的命令必须先进入 reviewed CommandRegistry,并由同一 `ToolSpec` 投影到 leaf、产品/分组、`--all` 与 Catalog。不得在查询时重新调用 MCP `tools/list`,也不得把 Cobra 临时合成结果作为第二条 Schema 数据路径。
|
||||
|
||||
事件需要区分两种 Schema:`dws event schema <event_key>` 查询事件 payload 字段;`dws schema "event consume"` 查询 CLI 命令参数。前者是真实业务命令,后者只读取最终内嵌 SchemaRegistry;不能相互替代。
|
||||
事件需要区分两种 Schema:`dws event schema <event_key> --flatten` 查询 Agent 要消费的顶层业务字段;`dws schema "event consume"` 查询 CLI 命令参数。前者是真实业务命令,后者只读取最终内嵌 SchemaRegistry;不能相互替代。
|
||||
|
||||
`source` 表示最终命令 identity 的来源,不表示运行时 backing;helper/local/MCP 实现机制读取 `interface_mode`、`availability` 和 provenance,不要假定 `dev.*` 必然是 `source=mcp:<server>`,也不要假定本地命令必然是 `source=cobra`。
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dws event schema <event_key>` | 查看事件参数和输出字段 schema |
|
||||
| `dws event consume <event_key> [flags]` | 阻塞消费,事件写到 stdout,用 `-f ndjson` |
|
||||
| `dws event schema <event_key> --flatten` | 查看 Agent 使用的顶层业务字段 schema |
|
||||
| `dws event consume <event_key> --flatten [flags]` | 阻塞消费,事件写到 stdout,用 `-f ndjson` |
|
||||
| `dws event status --event <event_key>` | 查看个人订阅、bus、本地 consume |
|
||||
| `dws event stop <subscribe_id> --dry-run` / `--yes` | 先预览,再确认取消订阅并停止对应本地消费 |
|
||||
| `dws event stop --all --dry-run` / `--yes` | 先预览,再确认清理当前身份下全部个人订阅 |
|
||||
@@ -42,20 +42,20 @@
|
||||
|
||||
| 用户说 | 下一步 |
|
||||
|---|---|
|
||||
| "监听有人 @ 我的消息" | `event consume`,事件码 `user_im_message_receive_at`,参数 `-f ndjson` |
|
||||
| "监听我和 userId test-user-001 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--user test-user-001 -f ndjson` |
|
||||
| "监听我和 openDingtalkId abc 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--open-dingtalk-id abc -f ndjson` |
|
||||
| "监听有人 @ 我的消息" | `event consume`,事件码 `user_im_message_receive_at`,参数 `--flatten -f ndjson` |
|
||||
| "监听我和 userId test-user-001 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
|
||||
| "监听我和 openDingtalkId abc 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--open-dingtalk-id abc --flatten -f ndjson` |
|
||||
| "监听 XX 群消息" | 先 `dws chat search --query "XX" --format json`,确认后 consume group |
|
||||
| "监听 userId test-user-001 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--user test-user-001 -f ndjson` |
|
||||
| "监听 openDingtalkId abc 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--open-dingtalk-id abc -f ndjson` |
|
||||
| "监听我发给 userId test-user-001 的消息是否已读" | `event consume`,事件码 `user_im_message_read_o2o`,参数 `--user test-user-001 -f ndjson` |
|
||||
| "监听 userId test-user-001 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--user test-user-001 --flatten -f ndjson` |
|
||||
| "监听 openDingtalkId abc 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--open-dingtalk-id abc --flatten -f ndjson` |
|
||||
| "监听我发给 userId test-user-001 的消息是否已读" | `event consume`,事件码 `user_im_message_read_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
|
||||
| "监听 XX 群消息已读" | 先解析群 ID,再 consume `user_im_message_read_group --group <id>` |
|
||||
| "监听我和 userId test-user-001 的消息撤回" | `event consume`,事件码 `user_im_message_recall_o2o`,参数 `--user test-user-001 -f ndjson` |
|
||||
| "监听我和 userId test-user-001 的消息撤回" | `event consume`,事件码 `user_im_message_recall_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
|
||||
| "监听 XX 群消息撤回" | 先解析群 ID,再 consume `user_im_message_recall_group --group <id>` |
|
||||
| "监听我和 userId test-user-001 的消息贴表情" | `event consume`,事件码 `user_im_message_reaction_o2o`,参数 `--user test-user-001 -f ndjson` |
|
||||
| "监听我和 userId test-user-001 的消息贴表情" | `event consume`,事件码 `user_im_message_reaction_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
|
||||
| "监听 XX 群消息表情回应" | 先解析群 ID,再 consume `user_im_message_reaction_group --group <id>` |
|
||||
| "监听并自动回复某人的单聊消息" | 先解析对端 userId,再启动 o2o consume;不要写轮询脚本 |
|
||||
| "查看个人消息事件 schema" | `dws event schema <event_key>` |
|
||||
| "查看个人消息事件 schema" | `dws event schema <event_key> --flatten` |
|
||||
| "看个人事件订阅状态" | `dws event status --event <event_key>` |
|
||||
| "停止这个个人事件订阅" | `dws event stop <subscribe_id> --dry-run`,确认后改用 `--yes` |
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
## Call flow
|
||||
|
||||
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
|
||||
2. 需要了解字段时运行 `dws event schema <event_key>`,读取 `schema.properties`;`jq_root_path` 当前固定为 `.`。
|
||||
3. 启动 `dws event consume <event_key> ... -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
|
||||
2. 需要了解字段时运行 `dws event schema <event_key> --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
|
||||
3. 启动 `dws event consume <event_key> ... --flatten -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
|
||||
4. stdout 每行是一个扁平事件 JSON;直接按该事件的 `schema.properties` 读取顶层字段。
|
||||
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
|
||||
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop <subscribe_id> --dry-run`,向用户确认后再以 `--yes` 执行;自测可在 consume 加 `--max-events` 或 `--duration` 自动退出。
|
||||
@@ -75,31 +75,31 @@
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
dws event schema user_im_message_receive_at
|
||||
dws event schema user_im_message_receive_o2o
|
||||
dws event schema user_im_message_receive_group
|
||||
dws event schema user_im_message_receive_user
|
||||
dws event schema user_im_message_read_o2o
|
||||
dws event schema user_im_message_read_group
|
||||
dws event schema user_im_message_recall_o2o
|
||||
dws event schema user_im_message_recall_group
|
||||
dws event schema user_im_message_reaction_o2o
|
||||
dws event schema user_im_message_reaction_group
|
||||
dws event schema user_im_message_receive_at --flatten
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
dws event schema user_im_message_receive_group --flatten
|
||||
dws event schema user_im_message_receive_user --flatten
|
||||
dws event schema user_im_message_read_o2o --flatten
|
||||
dws event schema user_im_message_read_group --flatten
|
||||
dws event schema user_im_message_recall_o2o --flatten
|
||||
dws event schema user_im_message_recall_group --flatten
|
||||
dws event schema user_im_message_reaction_o2o --flatten
|
||||
dws event schema user_im_message_reaction_group --flatten
|
||||
```
|
||||
|
||||
```bash
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
dws event consume user_im_message_receive_o2o --user test-user-001 -f ndjson
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id abc -f ndjson
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
dws event consume user_im_message_receive_user --user test-user-001 -f ndjson
|
||||
dws event consume user_im_message_receive_user --open-dingtalk-id abc -f ndjson
|
||||
dws event consume user_im_message_read_o2o --user test-user-001 -f ndjson
|
||||
dws event consume user_im_message_read_group --group <openConversationId> -f ndjson
|
||||
dws event consume user_im_message_recall_o2o --user test-user-001 -f ndjson
|
||||
dws event consume user_im_message_recall_group --group <openConversationId> -f ndjson
|
||||
dws event consume user_im_message_reaction_o2o --user test-user-001 -f ndjson
|
||||
dws event consume user_im_message_reaction_group --group <openConversationId> -f ndjson
|
||||
dws event consume user_im_message_receive_at --flatten -f ndjson
|
||||
dws event consume user_im_message_receive_o2o --user test-user-001 --flatten -f ndjson
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id abc --flatten -f ndjson
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_message_receive_user --user test-user-001 --flatten -f ndjson
|
||||
dws event consume user_im_message_receive_user --open-dingtalk-id abc --flatten -f ndjson
|
||||
dws event consume user_im_message_read_o2o --user test-user-001 --flatten -f ndjson
|
||||
dws event consume user_im_message_read_group --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_message_recall_o2o --user test-user-001 --flatten -f ndjson
|
||||
dws event consume user_im_message_recall_group --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_message_reaction_o2o --user test-user-001 --flatten -f ndjson
|
||||
dws event consume user_im_message_reaction_group --group <openConversationId> --flatten -f ndjson
|
||||
```
|
||||
|
||||
上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user <userId>` 替换为 `--open-dingtalk-id <openDingtalkId>`,但两个参数不能同时使用。
|
||||
@@ -125,10 +125,10 @@ dws event stop --all --yes
|
||||
|
||||
## Output parsing
|
||||
|
||||
- 推荐 `-f ndjson`:一行一个事件 JSON,适合 Agent 管道读取。
|
||||
- 人工取样可用 `-f json --max-events 1`。
|
||||
- `jq_root_path` 当前为 `.`;消息正文、发送人和会话 ID 分别直接读取顶层 `content`、`sender`、`conversation_id`。
|
||||
- 不要生成 `fromjson` 或内部 payload 路径。正常处理直接持续读取 stdout,不要改写为 `--output-dir` watcher。
|
||||
- 推荐 `--flatten -f ndjson`:顶层业务字段,一行一个事件 JSON,适合 Agent 管道读取。
|
||||
- 人工取样可用 `--flatten -f json --max-events 1`。`--format` 只控制序列化,`--flatten` 控制数据结构。
|
||||
- `--flatten` 的 `jq_root_path` 为 `.`;消息正文、发送人和会话 ID 分别直接读取顶层 `content`、`sender`、`conversation_id`。
|
||||
- Agent 已显式使用 `--flatten`,不要再生成 `fromjson` 或内部 payload 路径。不传时默认保持兼容 envelope,业务 payload 在 `.data | fromjson`。正常处理直接持续读取 stdout,不要改写为 `--output-dir` watcher。
|
||||
- 群自动回复使用顶层 `conversation_id`;单聊自动回复使用顶层 `sender_open_dingtalk_id`。
|
||||
- 已读事件直接读取 `reader/reader_open_dingtalk_id/read_time`;撤回事件读取 `recaller/recaller_open_dingtalk_id/recall_time`。
|
||||
- 表情回应事件直接读取 `operator/operator_open_dingtalk_id/reaction_name/reaction_text/operation_type/operation_time`。
|
||||
@@ -136,7 +136,7 @@ dws event stop --all --yes
|
||||
- 正常动作事件输出不含内部 `payload/uid/corpid/clientId/filterSubId/bizid`;原始排查才使用 `-f raw` 或 `--debug-raw-events`。
|
||||
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤);自发验证会看到 0 事件,测试投递使用别人或机器人发消息。
|
||||
- `--jq <表达式>` 可进一步过滤或投影扁平输出。
|
||||
- `--debug-raw-events` 仅用于服务端联调,正常消费不要使用。
|
||||
- `--debug-raw-events` 仅用于服务端联调,正常消费不要使用;它和 `--flatten` 互斥,`-f raw` 也不能与 `--flatten` 同时使用。
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dws event list` | 查看当前个人事件目录;不要把它当能力菜单主动展示 |
|
||||
| `dws event schema <event_key>` | 查看事件参数和输出字段 schema,默认 JSON |
|
||||
| `dws event consume <event_key> [flags]` | 阻塞消费;事件写到 stdout,推荐 `-f ndjson` |
|
||||
| `dws event schema <event_key> --flatten` | 查看 Agent 使用的顶层业务字段 schema,默认 JSON |
|
||||
| `dws event consume <event_key> --flatten [flags]` | 阻塞消费;事件写到 stdout,推荐 `-f ndjson` |
|
||||
| `dws event status --event <event_key>` | 查看个人订阅、personal bus 和本地 consume |
|
||||
| `dws event stop <subscribe_id> --dry-run` / `--yes` | 先预览,再确认取消个人订阅并停止对应本地消费 |
|
||||
| `dws event stop --all --dry-run` / `--yes` | 先预览,再确认清理当前身份下本地记录的全部个人订阅 |
|
||||
@@ -57,17 +57,17 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
|
||||
- 用户只给群名时,先运行 `dws chat search --query "<group>" --format json` 解析 openConversationId;多候选必须让用户确认。
|
||||
- 用户要求执行“撤回消息”时使用 `dws chat`;只有“监听/订阅消息撤回”才使用 `dws event consume user_im_message_recall_*`。
|
||||
- 用户说“贴标签”且语义是给消息贴表情时,按消息表情回应事件处理,event key 使用 `reaction`。
|
||||
- 正常 Agent 消费使用 `-f ndjson`。抓一条样本可用 `--max-events 1 -f json`。
|
||||
- 正常 Agent 消费统一显式使用 `--flatten -f ndjson`。抓一条样本可用 `--flatten --max-events 1 -f json`。`--format` 只控制 JSON 序列化,`--flatten` 才控制数据结构。
|
||||
- 监听非默认组织时带 `--profile <corpId 或 profile 名>`;漏传会退回默认 profile 而失败。
|
||||
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边监听边 `dws chat message send` 回复不成环;测试投递用别人 / 机器人发(自发会看到 0 事件)。
|
||||
- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。
|
||||
- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。它和 `--flatten` 互斥,`-f raw` 也不能与 `--flatten` 同时使用。
|
||||
- 排查:consume 报 bus 启动失败 → 报错已带真实原因,先查 `dws --profile <x> auth status`(非默认组织带对 `--profile`);本地日志见 `~/.dws/events/<edition>/personal_stream/<hash>/bus.log`(`hash` 见 `dws event status` 的 Workdir);有残留先用 `dws event stop --all --dry-run` 预览,确认后加 `--yes` 清理。看着"挂住"无输出多是误加了 `--foreground`(那是跑 bus、不打印事件),去掉即可。
|
||||
|
||||
## Call flow
|
||||
|
||||
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
|
||||
2. 需要了解字段时运行 `dws event schema <event_key>`,读取 `schema.properties`;`jq_root_path` 当前固定为 `.`。
|
||||
3. 启动 `dws event consume <event_key> ... -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
|
||||
2. 需要了解字段时运行 `dws event schema <event_key> --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
|
||||
3. 启动 `dws event consume <event_key> ... --flatten -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
|
||||
4. stdout 每行是一个扁平事件 JSON;直接按该事件的 `schema.properties` 读取顶层字段。
|
||||
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
|
||||
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先用 `dws event stop <subscribe_id> --dry-run` 预览,向用户确认后再加 `--yes`;临时测试可用 `--max-events` 或 `--duration` 自动退出。
|
||||
@@ -88,57 +88,67 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
|
||||
|
||||
```bash
|
||||
# 当前用户被 @ 的消息
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
dws event consume user_im_message_receive_at --flatten -f ndjson
|
||||
|
||||
# 当前用户与指定用户的单聊消息
|
||||
dws event consume user_im_message_receive_o2o \
|
||||
--user test-user-001 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份的单聊消息
|
||||
dws event consume user_im_message_receive_o2o \
|
||||
--open-dingtalk-id open-user-1 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定群聊/会话消息
|
||||
dws event consume user_im_message_receive_group \
|
||||
--group cidxxxxxxxx \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定发送人的消息(单聊和群聊)
|
||||
dws event consume user_im_message_receive_user \
|
||||
--user test-user-001 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 使用 openDingtalkId 监听指定发送人的消息
|
||||
dws event consume user_im_message_receive_user \
|
||||
--open-dingtalk-id open-user-1 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定单聊消息已读
|
||||
dws event consume user_im_message_read_o2o \
|
||||
--user test-user-001 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定群聊消息撤回
|
||||
dws event consume user_im_message_recall_group \
|
||||
--group cidxxxxxxxx \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定单聊消息收到表情回应
|
||||
dws event consume user_im_message_reaction_o2o \
|
||||
--user test-user-001 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 有界自测
|
||||
dws event consume user_im_message_receive_at \
|
||||
--duration 10m \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 抓一条样本
|
||||
dws event consume user_im_message_receive_o2o \
|
||||
--user test-user-001 \
|
||||
--max-events 1 \
|
||||
--flatten \
|
||||
-f json
|
||||
```
|
||||
|
||||
@@ -146,10 +156,10 @@ dws event consume user_im_message_receive_o2o \
|
||||
|
||||
## 输出处理
|
||||
|
||||
- `dws event schema <event_key>` 是写解析逻辑的依据。
|
||||
- 顶层 `jq_root_path` 说明业务字段起点;当前值是 `.`。
|
||||
- `dws event schema <event_key> --flatten` 是 Agent 写解析逻辑的依据。
|
||||
- `--flatten` 模式的顶层 `jq_root_path` 为 `.`;不传时为兼容存量脚本的 transport envelope,业务 payload 在 `.data | fromjson`。
|
||||
- `schema.properties` 是业务字段列表,例如 `content`、`sender`、`conversation_id`、`message_id`、`event_time`。
|
||||
- 所有公开事件都是扁平业务对象,直接读取顶层字段;不要生成 `fromjson` 或内部 payload 路径。
|
||||
- Agent 命令已显式传 `--flatten`,直接读取顶层字段;不要对该模式再生成 `fromjson` 或内部 payload 路径。
|
||||
- 群自动回复使用事件顶层 `conversation_id`;单聊自动回复使用顶层 `sender_open_dingtalk_id`。
|
||||
- 已读事件读取顶层 `reader`、`reader_open_dingtalk_id`、`read_time`;撤回事件读取 `recaller`、`recaller_open_dingtalk_id`、`recall_time`。
|
||||
- 表情回应事件读取顶层 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`reaction_text`、`operation_type`、`operation_time`。
|
||||
|
||||
@@ -15,19 +15,19 @@ dws auth login
|
||||
查看事件 schema:
|
||||
|
||||
```bash
|
||||
dws event schema user_im_message_receive_at
|
||||
dws event schema user_im_message_receive_o2o
|
||||
dws event schema user_im_message_receive_group
|
||||
dws event schema user_im_message_receive_user
|
||||
dws event schema user_im_message_read_o2o
|
||||
dws event schema user_im_message_read_group
|
||||
dws event schema user_im_message_recall_o2o
|
||||
dws event schema user_im_message_recall_group
|
||||
dws event schema user_im_message_reaction_o2o
|
||||
dws event schema user_im_message_reaction_group
|
||||
dws event schema user_im_message_receive_at --flatten
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
dws event schema user_im_message_receive_group --flatten
|
||||
dws event schema user_im_message_receive_user --flatten
|
||||
dws event schema user_im_message_read_o2o --flatten
|
||||
dws event schema user_im_message_read_group --flatten
|
||||
dws event schema user_im_message_recall_o2o --flatten
|
||||
dws event schema user_im_message_recall_group --flatten
|
||||
dws event schema user_im_message_reaction_o2o --flatten
|
||||
dws event schema user_im_message_reaction_group --flatten
|
||||
```
|
||||
|
||||
schema 默认 JSON。业务字段说明在 `schema.properties`,`jq_root_path` 当前固定为 `.`。
|
||||
schema 默认 JSON。Agent 使用 `--flatten` schema,业务字段在 `schema.properties`,`jq_root_path` 为 `.`。不传 `--flatten` 时查看兼容 transport envelope,其 `jq_root_path` 为 `.data | fromjson`。
|
||||
|
||||
## Event catalog
|
||||
|
||||
@@ -62,61 +62,72 @@ schema 默认 JSON。业务字段说明在 `schema.properties`,`jq_root_path`
|
||||
|
||||
```bash
|
||||
# 被 @ 消息
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
dws event consume user_im_message_receive_at --flatten -f ndjson
|
||||
|
||||
# 指定单聊消息
|
||||
dws event consume user_im_message_receive_o2o \
|
||||
--user test-user-001 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 通过 openDingtalkId 指定单聊对端
|
||||
dws event consume user_im_message_receive_o2o \
|
||||
--open-dingtalk-id open-user-1 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定群消息
|
||||
dws event consume user_im_message_receive_group \
|
||||
--group cidxxxxxxxx \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定发送人的消息(单聊和群聊)
|
||||
dws event consume user_im_message_receive_user \
|
||||
--user test-user-001 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 通过 openDingtalkId 指定发送人
|
||||
dws event consume user_im_message_receive_user \
|
||||
--open-dingtalk-id open-user-1 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定单聊已读事件
|
||||
dws event consume user_im_message_read_o2o \
|
||||
--user test-user-001 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定群聊已读事件
|
||||
dws event consume user_im_message_read_group \
|
||||
--group cidxxxxxxxx \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定单聊撤回事件
|
||||
dws event consume user_im_message_recall_o2o \
|
||||
--user test-user-001 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定群聊撤回事件
|
||||
dws event consume user_im_message_recall_group \
|
||||
--group cidxxxxxxxx \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定单聊表情回应事件
|
||||
dws event consume user_im_message_reaction_o2o \
|
||||
--user test-user-001 \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 指定群聊表情回应事件
|
||||
dws event consume user_im_message_reaction_group \
|
||||
--group cidxxxxxxxx \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
```
|
||||
|
||||
@@ -124,16 +135,16 @@ dws event consume user_im_message_reaction_group \
|
||||
|
||||
| 事件码 | 自测参数 | 触发方式 |
|
||||
|---|---|---|
|
||||
| `user_im_message_receive_at` | `--duration 10m -f ndjson` | 让任意可触达用户在群里 @ 当前登录用户 |
|
||||
| `user_im_message_receive_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 |
|
||||
| `user_im_message_receive_group` | `--group <openConversationId> --duration 10m -f ndjson` | 让任意用户在该群发送消息 |
|
||||
| `user_im_message_receive_user` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 让指定用户分别在单聊或共同群聊中发送消息 |
|
||||
| `user_im_message_read_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 当前用户给对端发送单聊消息,再让对端打开并阅读 |
|
||||
| `user_im_message_read_group` | `--group <openConversationId> --duration 10m -f ndjson` | 当前用户在群内发送消息,再让群成员打开并阅读 |
|
||||
| `user_im_message_recall_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 在指定单聊中发送并撤回一条消息 |
|
||||
| `user_im_message_recall_group` | `--group <openConversationId> --duration 10m -f ndjson` | 在指定群聊中发送并撤回一条消息 |
|
||||
| `user_im_message_reaction_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 在指定单聊中给消息添加表情回应 |
|
||||
| `user_im_message_reaction_group` | `--group <openConversationId> --duration 10m -f ndjson` | 在指定群聊中给消息添加表情回应 |
|
||||
| `user_im_message_receive_at` | `--flatten --duration 10m -f ndjson` | 让任意可触达用户在群里 @ 当前登录用户 |
|
||||
| `user_im_message_receive_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 |
|
||||
| `user_im_message_receive_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 让任意用户在该群发送消息 |
|
||||
| `user_im_message_receive_user` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 让指定用户分别在单聊或共同群聊中发送消息 |
|
||||
| `user_im_message_read_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 当前用户给对端发送单聊消息,再让对端打开并阅读 |
|
||||
| `user_im_message_read_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 当前用户在群内发送消息,再让群成员打开并阅读 |
|
||||
| `user_im_message_recall_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 在指定单聊中发送并撤回一条消息 |
|
||||
| `user_im_message_recall_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 在指定群聊中发送并撤回一条消息 |
|
||||
| `user_im_message_reaction_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 在指定单聊中给消息添加表情回应 |
|
||||
| `user_im_message_reaction_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 在指定群聊中给消息添加表情回应 |
|
||||
|
||||
stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 表示本地 consume 已连接到事件 bus;父进程等这行再读 stdout。stdout 每行是一个扁平事件 JSON。
|
||||
|
||||
@@ -141,7 +152,8 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscr
|
||||
|
||||
| 参数 | 用途 |
|
||||
|---|---|
|
||||
| `-f ndjson` | 推荐输出,一行一个事件 JSON |
|
||||
| `--flatten` | 将 `ndjson/json/pretty` 的默认 transport envelope(或原 compact processor)投影为 Agent 可直接读取的顶层业务字段;不能与 `-f raw` 或 `--debug-raw-events` 同时使用 |
|
||||
| `-f ndjson` | 控制序列化为一行一个 JSON;不改变数据结构 |
|
||||
| `-f json` | 人工查看单条或少量样本;必须配合 `--max-events` 或 `--duration` |
|
||||
| `--max-events <n>` | 收到 N 条后退出 |
|
||||
| `--duration <duration>` | 到时退出,例如 `30s`、`10m` |
|
||||
@@ -153,11 +165,11 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscr
|
||||
| `--filter-json <json>` | 使用个人事件 Filter DSL 过滤 |
|
||||
| `--debug-raw-events` | 联调用:绕过本地过滤,输出当前 personal stream 实际收到的可解析事件 |
|
||||
|
||||
正常 Agent 消费不要使用 `--debug-raw-events`。它会输出当前连接收到的所有可解析事件,只用于判断服务端是否推到了本机连接。
|
||||
正常 Agent 消费不要使用 `--debug-raw-events`。它会输出当前连接收到的所有可解析事件,只用于判断服务端是否推到了本机连接,并且不能与 `--flatten` 同时使用。
|
||||
|
||||
## Output parsing
|
||||
|
||||
`-f ndjson` 的 stdout 每行就是一个扁平业务事件对象。消息接收事件常见顶层字段:
|
||||
Agent 使用 `--flatten -f ndjson`,stdout 每行是一个扁平业务事件对象。消息接收事件常见顶层字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
@@ -173,7 +185,7 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscr
|
||||
| `create_time` | 消息创建时间 |
|
||||
| `event_time` | 消息事件时间戳 |
|
||||
|
||||
直接按顶层字段解析,不要使用 `fromjson`,也不要依赖内部 transport payload 路径。图片、文件等媒体消息的 `content` 可能是可读描述;需要实际媒体文件时调用 `dws chat message download-media`。
|
||||
在 `--flatten` 模式下直接按顶层字段解析,不要再使用 `fromjson` 或内部 payload 路径。不传 `--flatten` 时保持兼容 transport envelope,字段为 `type/event_type/data/headers`,业务 payload 需从 `.data | fromjson` 读取。图片、文件等媒体消息的 `content` 可能是可读描述;需要实际媒体文件时调用 `dws chat message download-media`。
|
||||
|
||||
所有动作事件都包含顶层 `type`、`event_id`、`timestamp`、`subscribe_id`、`message_id`、`conversation_id`、`sender`、`sender_open_dingtalk_id` 和 `event_time`。各类动作的专有字段如下:
|
||||
|
||||
@@ -204,6 +216,7 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscr
|
||||
dws event consume user_im_message_receive_group \
|
||||
--group cidxxxxxxxx \
|
||||
--query "报警,故障" \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
```
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestDWSDocsCommandTreeCoverage(t *testing.T) {
|
||||
t.Skip("no command paths parsed from docs/dws (directory may not exist)")
|
||||
}
|
||||
|
||||
index := buildCommandIndex(app.NewRootCommand())
|
||||
index := buildCommandIndex(app.NewSchemaSourceRootCommand())
|
||||
missing := make([]string, 0)
|
||||
for _, path := range docPaths {
|
||||
if _, ok := index[path]; ok {
|
||||
@@ -52,7 +52,7 @@ func TestDWSDocsLocalFlagsCoverage(t *testing.T) {
|
||||
}
|
||||
docLeafSet := leafCommandSet(docPaths)
|
||||
|
||||
index := buildCommandIndex(app.NewRootCommand())
|
||||
index := buildCommandIndex(app.NewSchemaSourceRootCommand())
|
||||
missing := make([]string, 0)
|
||||
fallbackMatched := 0
|
||||
explicitMatched := 0
|
||||
|
||||
@@ -9,6 +9,12 @@ import (
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
configDir, err := os.MkdirTemp("", "dws-cli-test-config-")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
os.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
// Set an empty catalog fixture so that EnvironmentLoader does not
|
||||
// attempt live discovery (which would hang on unreachable MCP endpoints).
|
||||
// Tests that construct app root commands must remain serial because root
|
||||
@@ -17,5 +23,6 @@ func TestMain(m *testing.M) {
|
||||
os.Setenv(cli.CatalogFixtureEnv, absFixture)
|
||||
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(configDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
+323
-18
@@ -300,7 +300,12 @@ func TestSyncToGiteeRunsTagReleaseCreationAndAssetReconciliationWithinOneBudget(
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/repos/owner/repo/releases/tags/v1.2.3":
|
||||
releaseMu.Lock()
|
||||
releaseLookups++
|
||||
lookupAttempt := releaseLookups
|
||||
releaseMu.Unlock()
|
||||
if lookupAttempt == 1 {
|
||||
http.Error(w, "temporary Gitee outage", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/repos/owner/repo/releases":
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
@@ -336,6 +341,8 @@ func TestSyncToGiteeRunsTagReleaseCreationAndAssetReconciliationWithinOneBudget(
|
||||
"GITEE_TAG_TIMEOUT_SECONDS=5",
|
||||
"GITEE_GIT_TIMEOUT_SECONDS=3",
|
||||
"GITEE_RELEASE_LOOKUP_MAX_TIME=2",
|
||||
"GITEE_RELEASE_LOOKUP_RETRIES=2",
|
||||
"GITEE_RELEASE_LOOKUP_RETRY_DELAY=0",
|
||||
"GITEE_RELEASE_CREATE_MAX_TIME=2",
|
||||
"GITEE_RECONCILE_TIMEOUT_SECONDS=20",
|
||||
"GITEE_CHILD_DEADLINE_RESERVE_SECONDS=1",
|
||||
@@ -357,8 +364,8 @@ func TestSyncToGiteeRunsTagReleaseCreationAndAssetReconciliationWithinOneBudget(
|
||||
}
|
||||
|
||||
releaseMu.Lock()
|
||||
if releaseLookups != 1 || releaseCreates != 1 {
|
||||
t.Errorf("release lookup/create calls = %d/%d, want 1/1", releaseLookups, releaseCreates)
|
||||
if releaseLookups != 2 || releaseCreates != 1 {
|
||||
t.Errorf("release lookup/create calls = %d/%d, want 2/1", releaseLookups, releaseCreates)
|
||||
}
|
||||
releaseMu.Unlock()
|
||||
fake.mu.Lock()
|
||||
@@ -374,11 +381,15 @@ func TestReconcileGiteeAssetsRecoversACommittedUploadWithLostResponse(t *testing
|
||||
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
|
||||
distDir := seedGiteeDist(t)
|
||||
fake := newFakeGiteeRelease(true, false)
|
||||
fake.listErrorResponsesAfterUpload = 1
|
||||
fake.listEmptyResponsesAfterUpload = 1
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
|
||||
cmd := exec.Command("bash", scriptPath)
|
||||
cmd.Env = giteeAssetEnv(distDir, server.URL, "2")
|
||||
cmd.Env = append(giteeAssetEnv(distDir, server.URL, "2"),
|
||||
"GITEE_POST_UPLOAD_VERIFY_ATTEMPTS=2",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile-gitee-assets.sh error = %v\noutput:\n%s", err, output)
|
||||
@@ -409,6 +420,167 @@ func TestReconcileGiteeAssetsRecoversACommittedUploadWithLostResponse(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileGiteeAssetsDoesNotReplayAnAmbiguousUpload(t *testing.T) {
|
||||
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
|
||||
distDir := seedGiteeDist(t)
|
||||
fake := newFakeGiteeRelease(true, false)
|
||||
fake.listEmptyResponsesAfterUpload = 3
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
|
||||
cmd := exec.Command("bash", scriptPath)
|
||||
cmd.Env = append(giteeAssetEnv(distDir, server.URL, "2"),
|
||||
"GITEE_POST_UPLOAD_VERIFY_ATTEMPTS=2",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("ambiguous upload unexpectedly succeeded:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(string(output), "refusing to replay POST") {
|
||||
t.Fatalf("ambiguous upload did not fail closed:\n%s", output)
|
||||
}
|
||||
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
for _, name := range requiredGiteeAssets {
|
||||
if got := fake.uploadCalls[name]; got != 1 {
|
||||
t.Errorf("upload calls for %s = %d, want exactly 1", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileGiteeAssetsRetriesATransientListOutage(t *testing.T) {
|
||||
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
|
||||
distDir := seedGiteeDist(t)
|
||||
fake := newFakeGiteeRelease(false, false)
|
||||
fake.listFailuresRemaining = 2
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
|
||||
cmd := exec.Command("bash", scriptPath)
|
||||
cmd.Env = append(giteeAssetEnv(distDir, server.URL, "1"),
|
||||
"GITEE_LIST_RETRIES=3",
|
||||
"GITEE_LIST_RETRY_DELAY=1",
|
||||
"GITEE_LIST_RETRY_WINDOW_SECONDS=4",
|
||||
)
|
||||
started := time.Now()
|
||||
output, err := cmd.CombinedOutput()
|
||||
elapsed := time.Since(started)
|
||||
if err != nil {
|
||||
t.Fatalf("transient-list reconciliation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "Gitee attachment list attempt 2/3 failed; retrying in 1s") {
|
||||
t.Fatalf("transient list failures were not retried visibly:\n%s", output)
|
||||
}
|
||||
if elapsed < 1500*time.Millisecond {
|
||||
t.Fatalf("transient list retry elapsed = %s, want production-style backoff", elapsed)
|
||||
}
|
||||
if !strings.Contains(string(output), "all 8 verified") {
|
||||
t.Fatalf("transient-list reconciliation did not verify every asset:\n%s", output)
|
||||
}
|
||||
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if fake.listFailuresRemaining != 0 {
|
||||
t.Fatalf("unconsumed list failures = %d, want 0", fake.listFailuresRemaining)
|
||||
}
|
||||
for _, name := range requiredGiteeAssets {
|
||||
if got := fake.uploadCalls[name]; got != 1 {
|
||||
t.Errorf("upload calls for %s = %d, want 1", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileGiteeAssetsDoesNotBurstRetriesInTheFinalWindowSecond(t *testing.T) {
|
||||
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
|
||||
distDir := seedGiteeDist(t)
|
||||
fake := newFakeGiteeRelease(false, false)
|
||||
fake.listFailuresRemaining = 1000
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
|
||||
cmd := exec.Command("bash", scriptPath)
|
||||
cmd.Env = append(giteeAssetEnv(distDir, server.URL, "1"),
|
||||
"GITEE_LIST_RETRIES=24",
|
||||
"GITEE_LIST_RETRY_DELAY=1",
|
||||
"GITEE_LIST_RETRY_WINDOW_SECONDS=1",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("permanent list outage unexpectedly succeeded:\n%s", output)
|
||||
}
|
||||
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if fake.listCalls > len(requiredGiteeAssets)+1 {
|
||||
t.Fatalf("list calls = %d, want at most one per asset plus final verification", fake.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileGiteeAssetsDiscardsPartialOutputFromAMalformedList(t *testing.T) {
|
||||
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
|
||||
distDir := seedGiteeDist(t)
|
||||
fake := newFakeGiteeRelease(false, false)
|
||||
existingName := requiredGiteeAssets[0]
|
||||
existingData, err := os.ReadFile(filepath.Join(distDir, existingName))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", existingName, err)
|
||||
}
|
||||
fake.assets[1] = fakeGiteeAsset{id: 1, name: existingName, data: existingData}
|
||||
fake.nextID = 2
|
||||
fake.malformedListResponsesRemaining = 1
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
|
||||
cmd := exec.Command("bash", scriptPath)
|
||||
cmd.Env = append(giteeAssetEnv(distDir, server.URL, "1"),
|
||||
"GITEE_LIST_RETRIES=2",
|
||||
"GITEE_LIST_RETRY_DELAY=0",
|
||||
"GITEE_LIST_RETRY_WINDOW_SECONDS=2",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("malformed-list reconciliation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), existingName+" already correct on Gitee") {
|
||||
t.Fatalf("partial failed-list output manufactured a stale or duplicate asset:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(string(output), "all 8 verified") {
|
||||
t.Fatalf("malformed-list reconciliation did not verify every asset:\n%s", output)
|
||||
}
|
||||
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if got := fake.uploadCalls[existingName]; got != 0 {
|
||||
t.Errorf("upload calls for existing %s = %d, want 0", existingName, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileGiteeAssetsDisablesExpectContinueForLargeUploads(t *testing.T) {
|
||||
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
|
||||
distDir := seedGiteeDist(t)
|
||||
mustWriteFile(
|
||||
t,
|
||||
filepath.Join(distDir, requiredGiteeAssets[0]),
|
||||
[]byte(strings.Repeat("x", 2<<20)),
|
||||
0o644,
|
||||
)
|
||||
fake := newFakeGiteeRelease(false, false)
|
||||
fake.rejectExpectContinue = true
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
|
||||
cmd := exec.Command("bash", scriptPath)
|
||||
cmd.Env = giteeAssetEnv(distDir, server.URL, "1")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("large-asset reconciliation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "all 8 verified") {
|
||||
t.Fatalf("large-asset reconciliation did not verify every asset:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileGiteeAssetsFailsWhenAnyUploadIsMissing(t *testing.T) {
|
||||
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
|
||||
distDir := seedGiteeDist(t)
|
||||
@@ -515,7 +687,53 @@ func TestGiteeReleaseWorkflowUsesImmutableTagsAndBoundedRetryBudget(t *testing.T
|
||||
}
|
||||
perAssetSeconds := shellDefaultInt(t, reconciler, "GITEE_ASSET_TIMEOUT_SECONDS")
|
||||
overallSeconds := shellDefaultInt(t, reconciler, "GITEE_OVERALL_TIMEOUT_SECONDS")
|
||||
finalListSeconds := shellDefaultInt(t, reconciler, "GITEE_LIST_MAX_TIME")
|
||||
listSeconds := shellDefaultInt(t, reconciler, "GITEE_LIST_MAX_TIME")
|
||||
listRetries := shellDefaultInt(t, reconciler, "GITEE_LIST_RETRIES")
|
||||
listRetryDelay := shellDefaultInt(t, reconciler, "GITEE_LIST_RETRY_DELAY")
|
||||
listRetryWindow := shellDefaultInt(t, reconciler, "GITEE_LIST_RETRY_WINDOW_SECONDS")
|
||||
uploadSeconds := shellDefaultInt(t, reconciler, "GITEE_UPLOAD_MAX_TIME")
|
||||
verifySeconds := shellDefaultInt(t, reconciler, "GITEE_VERIFY_MAX_TIME")
|
||||
postUploadVerifyAttempts := shellDefaultInt(t, reconciler, "GITEE_POST_UPLOAD_VERIFY_ATTEMPTS")
|
||||
verifyRetryDelay := shellDefaultInt(t, reconciler, "GITEE_VERIFY_RETRY_DELAY")
|
||||
uploadRetryDelay := shellDefaultInt(t, reconciler, "GITEE_UPLOAD_RETRY_DELAY")
|
||||
const minimumLargeAssetUploadSeconds = 1200
|
||||
if uploadSeconds < minimumLargeAssetUploadSeconds {
|
||||
t.Fatalf(
|
||||
"Gitee upload deadline = %ds, want at least %ds for near-10 MiB release assets",
|
||||
uploadSeconds, minimumLargeAssetUploadSeconds,
|
||||
)
|
||||
}
|
||||
const minimumTransientListOutageSeconds = 300
|
||||
if listRetryWindow < minimumTransientListOutageSeconds {
|
||||
t.Fatalf(
|
||||
"Gitee list recovery budget = %ds, want at least %ds for a transient API outage",
|
||||
listRetryWindow, minimumTransientListOutageSeconds,
|
||||
)
|
||||
}
|
||||
if (listRetries-1)*listRetryDelay < listRetryWindow {
|
||||
t.Fatalf(
|
||||
"%d Gitee list attempts with %ds delay cannot span the configured %ds retry window after fast failures",
|
||||
listRetries, listRetryDelay, listRetryWindow,
|
||||
)
|
||||
}
|
||||
completeListRecoveryBudget := listRetryWindow
|
||||
oneSlowSuccessBudget := 2*completeListRecoveryBudget + uploadSeconds + verifySeconds
|
||||
if oneSlowSuccessBudget > perAssetSeconds {
|
||||
t.Fatalf(
|
||||
"one complete slow upload budget = %ds, exceeds per-asset deadline %ds",
|
||||
oneSlowSuccessBudget, perAssetSeconds,
|
||||
)
|
||||
}
|
||||
fastFailureRetryBudget := uploadSeconds + (postUploadVerifyAttempts+3)*listSeconds +
|
||||
verifySeconds + (postUploadVerifyAttempts-1)*verifyRetryDelay + uploadRetryDelay
|
||||
fastFailureRetryBudget += 2 * (completeListRecoveryBudget - listSeconds)
|
||||
if fastFailureRetryBudget > perAssetSeconds {
|
||||
t.Fatalf(
|
||||
"fast-failure retry budget = %ds, exceeds per-asset deadline %ds",
|
||||
fastFailureRetryBudget, perAssetSeconds,
|
||||
)
|
||||
}
|
||||
finalListSeconds := completeListRecoveryBudget
|
||||
completeAssetBudget := perAssetSeconds*len(requiredGiteeAssets) + finalListSeconds
|
||||
if completeAssetBudget > overallSeconds {
|
||||
t.Fatalf(
|
||||
@@ -530,15 +748,16 @@ func TestGiteeReleaseWorkflowUsesImmutableTagsAndBoundedRetryBudget(t *testing.T
|
||||
)
|
||||
}
|
||||
|
||||
completeSyncBudget := tagSeconds + lookupSeconds + createSeconds + reconcileSeconds
|
||||
childDeadlineReserveSeconds := shellDefaultInt(t, syncScript, "GITEE_CHILD_DEADLINE_RESERVE_SECONDS")
|
||||
completeSyncBudget := tagSeconds + lookupSeconds + createSeconds + reconcileSeconds + childDeadlineReserveSeconds
|
||||
if completeSyncBudget > syncSeconds {
|
||||
t.Fatalf(
|
||||
"complete sync budget = %ds (tag=%d + lookup=%d + create=%d + reconcile=%d), exceeds sync deadline %ds",
|
||||
completeSyncBudget, tagSeconds, lookupSeconds, createSeconds, reconcileSeconds, syncSeconds,
|
||||
"complete sync budget = %ds (tag=%d + lookup=%d + create=%d + reconcile=%d + child reserve=%d), exceeds sync deadline %ds",
|
||||
completeSyncBudget, tagSeconds, lookupSeconds, createSeconds, reconcileSeconds, childDeadlineReserveSeconds, syncSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
const workflowReserveMinutes = 5
|
||||
const syncStepReserveMinutes = 4
|
||||
releasePath := mustAbs(t, filepath.Join("..", "..", ".github", "workflows", "release.yml"))
|
||||
releaseData, err := os.ReadFile(releasePath)
|
||||
if err != nil {
|
||||
@@ -550,21 +769,49 @@ func TestGiteeReleaseWorkflowUsesImmutableTagsAndBoundedRetryBudget(t *testing.T
|
||||
"mirror-gitee-release:",
|
||||
[]string{
|
||||
"name: Check out sealed release source",
|
||||
"name: Check out trusted release tooling",
|
||||
"name: Fetch and verify sealed release tag",
|
||||
"name: Restore finalized distribution files",
|
||||
"name: Mirror release to Gitee (China)",
|
||||
},
|
||||
workflowReserveMinutes,
|
||||
17,
|
||||
)
|
||||
releaseSyncStepSeconds := workflowTimeoutMinutesAfter(
|
||||
t, string(releaseData), "mirror-gitee-release:", "name: Mirror release to Gitee (China)",
|
||||
) * 60
|
||||
if syncSeconds+workflowReserveMinutes*60 > releaseSyncStepSeconds {
|
||||
if syncSeconds+syncStepReserveMinutes*60 > releaseSyncStepSeconds {
|
||||
t.Fatalf(
|
||||
"sync deadline %ds plus reserve exceeds release fallback step %ds",
|
||||
syncSeconds, releaseSyncStepSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
assertWorkflowBudget(
|
||||
t,
|
||||
string(releaseData),
|
||||
"repair-channel:",
|
||||
[]string{
|
||||
"name: Check out trusted release tooling",
|
||||
"name: Validate repair version",
|
||||
"name: Verify immutable release authority",
|
||||
"name: Check out sealed release source",
|
||||
"name: Fetch and verify sealed release tag",
|
||||
"name: Require successful Release workflow delivery",
|
||||
"name: Download and verify immutable GitHub Release assets",
|
||||
"name: Mirror release to Gitee (China)",
|
||||
},
|
||||
5,
|
||||
)
|
||||
repairSyncStepSeconds := workflowTimeoutMinutesAfter(
|
||||
t, string(releaseData), "repair-channel:", "name: Mirror release to Gitee (China)",
|
||||
) * 60
|
||||
if syncSeconds+syncStepReserveMinutes*60 > repairSyncStepSeconds {
|
||||
t.Fatalf(
|
||||
"sync deadline %ds plus reserve exceeds repair step %ds",
|
||||
syncSeconds, repairSyncStepSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
localBuildPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "build-and-publish-gitee.sh"))
|
||||
localBuildData, err := os.ReadFile(localBuildPath)
|
||||
if err != nil {
|
||||
@@ -731,6 +978,9 @@ func giteeAssetEnv(distDir, apiURL, retries string) []string {
|
||||
"GITEE_RELEASE_ID=1",
|
||||
"GITEE_CURL_CONNECT_TIMEOUT=2",
|
||||
"GITEE_CURL_MAX_TIME=2",
|
||||
"GITEE_LIST_RETRIES=2",
|
||||
"GITEE_LIST_RETRY_DELAY=0",
|
||||
"GITEE_LIST_RETRY_WINDOW_SECONDS=2",
|
||||
"GITEE_UPLOAD_MAX_TIME=2",
|
||||
"GITEE_UPLOAD_RETRIES="+retries,
|
||||
"GITEE_UPLOAD_RETRY_DELAY=0",
|
||||
@@ -747,14 +997,22 @@ type fakeGiteeAsset struct {
|
||||
}
|
||||
|
||||
type fakeGiteeRelease struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
assets map[int]fakeGiteeAsset
|
||||
uploadCalls map[string]int
|
||||
dropFirstResponse bool
|
||||
droppedResponse bool
|
||||
failUploads bool
|
||||
uploadDelay time.Duration
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
assets map[int]fakeGiteeAsset
|
||||
uploadCalls map[string]int
|
||||
dropFirstResponse bool
|
||||
droppedResponse bool
|
||||
failUploads bool
|
||||
uploadDelay time.Duration
|
||||
rejectExpectContinue bool
|
||||
listFailuresRemaining int
|
||||
malformedListResponsesRemaining int
|
||||
listErrorResponsesRemaining int
|
||||
listEmptyResponsesRemaining int
|
||||
listErrorResponsesAfterUpload int
|
||||
listEmptyResponsesAfterUpload int
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func newFakeGiteeRelease(dropFirstResponse, failUploads bool) *fakeGiteeRelease {
|
||||
@@ -799,7 +1057,42 @@ func (f *fakeGiteeRelease) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
func (f *fakeGiteeRelease) list(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.listCalls++
|
||||
if f.listFailuresRemaining > 0 {
|
||||
f.listFailuresRemaining--
|
||||
http.Error(w, "temporary Gitee list outage", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if f.listErrorResponsesRemaining > 0 {
|
||||
f.listErrorResponsesRemaining--
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{}`)
|
||||
return
|
||||
}
|
||||
if f.listEmptyResponsesRemaining > 0 {
|
||||
f.listEmptyResponsesRemaining--
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode([]any{})
|
||||
return
|
||||
}
|
||||
baseURL := requestBaseURL(r)
|
||||
if f.malformedListResponsesRemaining > 0 {
|
||||
f.malformedListResponsesRemaining--
|
||||
for _, asset := range f.assets {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode([]any{
|
||||
map[string]any{
|
||||
"id": asset.id,
|
||||
"name": asset.name,
|
||||
"browser_download_url": fmt.Sprintf("%s/download/%d", baseURL, asset.id),
|
||||
},
|
||||
nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "malformed-list fixture requires one asset", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(f.assets))
|
||||
for _, asset := range f.assets {
|
||||
rows = append(rows, map[string]any{
|
||||
@@ -813,6 +1106,10 @@ func (f *fakeGiteeRelease) list(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (f *fakeGiteeRelease) upload(w http.ResponseWriter, r *http.Request) {
|
||||
if f.rejectExpectContinue && r.Header.Get("Expect") != "" {
|
||||
http.Error(w, "Expect: 100-continue is not supported", http.StatusExpectationFailed)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -847,6 +1144,14 @@ func (f *fakeGiteeRelease) upload(w http.ResponseWriter, r *http.Request) {
|
||||
id := f.nextID
|
||||
f.nextID++
|
||||
f.assets[id] = fakeGiteeAsset{id: id, name: header.Filename, data: data}
|
||||
if f.listErrorResponsesAfterUpload > 0 {
|
||||
f.listErrorResponsesRemaining += f.listErrorResponsesAfterUpload
|
||||
f.listErrorResponsesAfterUpload = 0
|
||||
}
|
||||
if f.listEmptyResponsesAfterUpload > 0 {
|
||||
f.listEmptyResponsesRemaining += f.listEmptyResponsesAfterUpload
|
||||
f.listEmptyResponsesAfterUpload = 0
|
||||
}
|
||||
drop := f.dropFirstResponse && !f.droppedResponse
|
||||
if drop {
|
||||
f.droppedResponse = true
|
||||
|
||||
+15
-15
@@ -1000,27 +1000,27 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**event_event_consume_at_001**
|
||||
- Prompt: 监听有人 @ 我的消息
|
||||
- Expected: `dws event consume user_im_message_receive_at -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_receive_at --flatten -f ndjson`
|
||||
|
||||
#### `dws event consume user_im_message_receive_o2o`
|
||||
|
||||
**event_event_consume_o2o_001**
|
||||
- Prompt: 监听我和 userId test-user-001 的单聊消息
|
||||
- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 --flatten -f ndjson`
|
||||
- Flags: `--user` = `test-user-001`
|
||||
|
||||
**event_event_consume_o2o_open_id_001**
|
||||
- Prompt: 监听我和 openDingtalkId abc 的单聊消息
|
||||
- Expected: `dws event consume user_im_message_receive_o2o --open-dingtalk-id abc -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_receive_o2o --open-dingtalk-id abc --flatten -f ndjson`
|
||||
- Flags: `--open-dingtalk-id` = `abc`
|
||||
|
||||
**event_event_consume_o2o_003** `[ASK_USER]`
|
||||
- Prompt: 监听我的个人单聊消息
|
||||
- Expected: `dws event consume user_im_message_receive_o2o -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_receive_o2o --flatten -f ndjson`
|
||||
|
||||
**event_event_consume_o2o_auto_reply_001**
|
||||
- Prompt: 监听我和 userId test-user-001 的单聊消息,并对方发什么自动回复什么
|
||||
- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 --flatten -f ndjson`
|
||||
- Flags: `--user` = `test-user-001`
|
||||
- Contract: 等待 stderr 的 `[event] ready`;从每行 NDJSON 顶层读取 `content` 和 `sender_open_dingtalk_id`,持续读取 stdout,不使用轮询或 output-dir watcher
|
||||
|
||||
@@ -1028,7 +1028,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**event_event_consume_group_001**
|
||||
- Prompt: 监听 openConversationId cid123 的群消息
|
||||
- Expected: `dws event consume user_im_message_receive_group --group cid123 -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_receive_group --group cid123 --flatten -f ndjson`
|
||||
- Flags: `--group` = `cid123`
|
||||
- Contract: 群自动回复时直接读取事件顶层 `conversation_id`
|
||||
|
||||
@@ -1036,19 +1036,19 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**event_event_consume_user_001**
|
||||
- Prompt: 监听 userId test-user-001 发给我的消息,包括单聊和群聊
|
||||
- Expected: `dws event consume user_im_message_receive_user --user test-user-001 -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_receive_user --user test-user-001 --flatten -f ndjson`
|
||||
- Flags: `--user` = `test-user-001`
|
||||
|
||||
**event_event_consume_user_open_id_001**
|
||||
- Prompt: 监听 openDingtalkId abc 发给我的消息,包括单聊和群聊
|
||||
- Expected: `dws event consume user_im_message_receive_user --open-dingtalk-id abc -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_receive_user --open-dingtalk-id abc --flatten -f ndjson`
|
||||
- Flags: `--open-dingtalk-id` = `abc`
|
||||
|
||||
#### `dws event consume user_im_message_read_o2o`
|
||||
|
||||
**event_event_consume_read_o2o_001**
|
||||
- Prompt: 监听我发给 userId test-user-001 的单聊消息是否已读
|
||||
- Expected: `dws event consume user_im_message_read_o2o --user test-user-001 -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_read_o2o --user test-user-001 --flatten -f ndjson`
|
||||
- Flags: `--user` = `test-user-001`
|
||||
- Contract: 从每行 NDJSON 顶层读取 `message_id`、`reader`、`reader_open_dingtalk_id`、`read_time`
|
||||
|
||||
@@ -1056,7 +1056,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**event_event_consume_read_group_001**
|
||||
- Prompt: 监听 openConversationId cid123 群里我发的消息是否已读
|
||||
- Expected: `dws event consume user_im_message_read_group --group cid123 -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_read_group --group cid123 --flatten -f ndjson`
|
||||
- Flags: `--group` = `cid123`
|
||||
- Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`reader`、`reader_open_dingtalk_id`、`read_time`
|
||||
|
||||
@@ -1064,7 +1064,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**event_event_consume_recall_o2o_001**
|
||||
- Prompt: 监听我和 userId test-user-001 的单聊消息撤回事件
|
||||
- Expected: `dws event consume user_im_message_recall_o2o --user test-user-001 -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_recall_o2o --user test-user-001 --flatten -f ndjson`
|
||||
- Flags: `--user` = `test-user-001`
|
||||
- Contract: 从每行 NDJSON 顶层读取 `message_id`、`recaller`、`recaller_open_dingtalk_id`、`recall_time`
|
||||
|
||||
@@ -1072,7 +1072,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**event_event_consume_recall_group_001**
|
||||
- Prompt: 监听 openConversationId cid123 的群消息撤回事件
|
||||
- Expected: `dws event consume user_im_message_recall_group --group cid123 -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_recall_group --group cid123 --flatten -f ndjson`
|
||||
- Flags: `--group` = `cid123`
|
||||
- Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`recaller`、`recaller_open_dingtalk_id`、`recall_time`
|
||||
|
||||
@@ -1080,13 +1080,13 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**event_event_consume_reaction_o2o_001**
|
||||
- Prompt: 监听我和 userId test-user-001 的单聊消息贴表情事件
|
||||
- Expected: `dws event consume user_im_message_reaction_o2o --user test-user-001 -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_reaction_o2o --user test-user-001 --flatten -f ndjson`
|
||||
- Flags: `--user` = `test-user-001`
|
||||
- Contract: 从每行 NDJSON 顶层读取 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`operation_type`
|
||||
|
||||
**event_event_consume_reaction_o2o_open_id_001**
|
||||
- Prompt: 监听我和 openDingtalkId abc 的单聊消息贴表情事件
|
||||
- Expected: `dws event consume user_im_message_reaction_o2o --open-dingtalk-id abc -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_reaction_o2o --open-dingtalk-id abc --flatten -f ndjson`
|
||||
- Flags: `--open-dingtalk-id` = `abc`
|
||||
- Contract: 从每行 NDJSON 顶层读取 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`operation_type`
|
||||
|
||||
@@ -1094,7 +1094,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**event_event_consume_reaction_group_001**
|
||||
- Prompt: 监听 openConversationId cid123 的群消息表情回应事件
|
||||
- Expected: `dws event consume user_im_message_reaction_group --group cid123 -f ndjson`
|
||||
- Expected: `dws event consume user_im_message_reaction_group --group cid123 --flatten -f ndjson`
|
||||
- Flags: `--group` = `cid123`
|
||||
- Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`operator`、`reaction_name`、`reaction_text`、`operation_type`
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ func TestEventSkillUsesFlatOutputContract(t *testing.T) {
|
||||
text := string(content)
|
||||
for _, required := range []string{
|
||||
"[event] ready",
|
||||
"--flatten",
|
||||
"conversation_id",
|
||||
"sender_open_dingtalk_id",
|
||||
"reader_open_dingtalk_id",
|
||||
@@ -109,7 +110,6 @@ func TestEventSkillUsesFlatOutputContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for _, retired := range []string{
|
||||
".data | fromjson",
|
||||
"payload.body.",
|
||||
"尚无稳定业务样本",
|
||||
"暂无稳定 payload schema",
|
||||
|
||||
Reference in New Issue
Block a user