Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd8414891 | ||
|
|
2a82d07311 | ||
|
|
60ac0b409d | ||
|
|
4bc4b60dca | ||
|
|
31e65dda51 | ||
|
|
387ae5ff59 | ||
|
|
838e5453d8 | ||
|
|
330922cdee | ||
|
|
eaa60f95b5 | ||
|
|
e7a3010b81 | ||
|
|
e7ef2c4677 | ||
|
|
8c2093a41a | ||
|
|
5fbf12fe50 | ||
|
|
dd419ca498 | ||
|
|
f826375556 | ||
|
|
cb95207d5a |
@@ -6,6 +6,41 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.37] - 2026-06-11
|
||||
|
||||
This release realigns the npm channel and hardens PAT batch grants. Background on the npm realignment: 1.0.36 was re-cut on GitHub on 2026-06-11 to fold in the canonical-tree poisoned-cache guard (#454), but the npm registry permanently forbids republishing a version number, so the npm package stayed on the original, unguarded cut. 1.0.37 is therefore the first version where **every** distribution channel — GitHub releases, `dws upgrade`, the install scripts, and npm — ships the same guarded build. If you installed 1.0.36 from npm, upgrade to this version.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PAT batch grants carry the agent identity and require explicit confirmation** (#455; `internal/pat/chmod.go`, `internal/auth/channel.go`, `internal/app/runner.go`) — an explicit `--agentCode` flag or the `DINGTALK_DWS_AGENTCODE` env var is now carried into PAT batch plan/grant arguments instead of being dropped, and a missing agentCode is forwarded as absent so the PAT core can apply the server-side default rather than failing. Batch grants now refuse to execute without an explicit `--yes` (dry-run and single-scope grants keep their existing behavior), closing the gap where a multi-scope grant could fire without a deliberate confirmation. Only the canonical env name `DINGTALK_DWS_AGENTCODE` is recognized; draft/reversed spellings from earlier iterations are ignored. Verified against prepub: dry-run, single grant, flag-priority grant, and batch grant all resolve the target agentCode, with the granted rows confirmed server-side. Tests: `internal/pat/chmod_test.go`, `internal/pat/browser_policy_test.go`, `test/unit/pat_host_owned_signal_test.go`.
|
||||
|
||||
## [1.0.36] - 2026-06-10
|
||||
|
||||
This release closes out the poisoned-discovery-cache lock-out for good, with four layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452), and (4) the same guards now also cover the canonical `dws mcp` tree, which is built even earlier and sat outside all three defenses as originally cut (#454 — this release was re-cut on 2026-06-11 to include it; verified against the preserved real poisoned cache from the 2026-05-25 incident). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
|
||||
|
||||
**Escaping a locked-out older binary**: a binary ≤1.0.35 bricked by a poisoned cache cannot run `dws upgrade`. Either bypass the cache for one invocation with `DWS_CACHE_DIR=$(mktemp -d) dws upgrade`, or delete `~/.dws/cache/<partition>/tools/` by hand, or reinstall via the install script. Once 1.0.36 is on the machine this never needs doing again.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws devdoc` — RAG-backed Open Platform doc search and error diagnosis** (#434; `internal/helpers/devdoc.go`, `internal/transport/client.go`) — `dws devdoc article search` now routes to the upstream `search_open_platform_docs_rag` tool, returning structured RAG/reference payloads (the CLI stays a thin invoker; no extra AI analysis layer). New `dws devdoc error diagnose` (alias `troubleshoot`) routes to `search_open_error_code_rag` for diagnosing DingTalk Open Platform API errors, with `--request-id` (hidden `--trace-id` kept for compatibility), `--error-code`, `--error-message`, `--api`, `--context`, `--query`, `--page`, `--size`. Transport-side: query parameters required by DingTalk MCP gateway URLs are preserved on the wire but their values are redacted from debug logs. Default MCP / skill hosts stay on production `https://mcp.dingtalk.com` (prepub remains runtime-configurable). Skill docs (mono + multi `dingtalk-devdoc`) and `docs/command-index.md` updated alongside.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CLI no longer bricks when the dynamic command build panics — degrades to built-in commands** (#447; `internal/app/legacy.go`) — `buildEnvelopeCommandsSafe` wraps the envelope-driven build in a local `recover()`. On panic the CLI logs it, prints a stderr hint, and falls back to the hardcoded helper commands, so `auth` / `cache` / `doctor` / `version` / `upgrade` and the helpers stay alive and `dws cache refresh` can rebuild the poisoned cache. Before this, the only recovery from the pre-1.0.32 lock-out class was manually deleting cache files; the duplicate-flag class itself had been fixed at the builder level, but any *future* panic class in the cache-driven build would have bricked the CLI again. Tests: `TestNewLegacyPublicCommandsPanicFallsBackToHelpers`, `TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath`.
|
||||
- **Envelope-driven flag registration no longer panics on the four known malformed-envelope shapes** (#449; `internal/compat/registry.go`) — while reproducing the lock-out byte-for-byte, four envelope shapes were found still forwarded to pflag calls that panic, each bricking every invocation: a flag named `params` / `json` colliding with the reserved payload flags (the original `flag redefined: params` — earlier dedup fixes covered the alias list and Detail-schema path but not the primary name); two bindings resolving to the same long flag name across bindings; two flags claiming the same shorthand; and a multi-character shorthand. Two small guards applied at every registration site (`ApplyBindings`, `registerPositionalAliasFlags`): `canRegisterFlag` skips duplicate/reserved long names (the value stays reachable via `--params`), and `safeShorthand` drops an invalid or already-taken shorthand while keeping the long flag. The trailing `--json` / `--params` registration is now idempotent. Defense in depth with #447: the escape hatch should never trigger for these known vectors. Test: `TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope` (5 table-driven vectors).
|
||||
- **Poisoned discovery cache now self-heals: quarantine + rebuild on panic, and `dws upgrade` clears discovery caches** (#452; `internal/app/legacy.go`, `internal/app/upgrade.go`, `internal/cache/store.go`) — #447's recovery is upgraded from "degrade and ask the user to run `dws cache refresh`" to a two-stage self-heal: on the first build panic the partition's discovery cache is moved aside to `<partition>.quarantined` (kept on disk for inspection; a previous quarantine is replaced so nothing accumulates — new `Store.QuarantinePartition`) and the build retried once against a fresh fetch. If the retry succeeds the user gets the full dynamic command tree with zero manual steps; only a second panic (remote envelope itself still poisoned, or offline) degrades to helper commands with the `cache refresh` hint. Additionally `dws upgrade` purges discovery-derived caches (`market` / `tools` / `detail` across all partitions — new `Store.PurgeDiscoveryData`) after a successful binary swap, leaving the co-located `downloads/` cache untouched, so an upgraded binary always rebuilds its command tree from fresh data instead of inheriting snapshots written by the old version. Tests: `internal/cache/store_quarantine_test.go`, rewritten `internal/app/legacy_panic_fallback_test.go` (self-heal success, double-panic degradation, no-cache no-op, happy path).
|
||||
- **Canonical `dws mcp` tree no longer escapes the poisoned-cache guards** (#454; `internal/cli/canonical.go`, `internal/app/root.go`) — the canonical tree is assembled from cached catalog data *before* the legacy command build, so a pflag panic there — a tool schema property named after the reserved `--params` flag, exactly what the 2026-05-25 incident cache contained — bypassed #447/#449/#452 entirely and still bricked every invocation, including on this release as originally cut. Two layers, mirroring the existing guards: `applyFlagSpecs` skips reserved (`--json`/`--params`), duplicate, and alias-colliding flag names and sanitizes shorthands (`canRegisterToolFlag` / `safeToolShorthand`; a skipped property stays reachable through the reserved JSON payload flags), and `newMCPCommand` wraps the build in the #452 recover → quarantine → retry-once → degrade-to-stub sequence. Verified against the preserved real poisoned cache: the original cut locks out on `--version` / `cache refresh` / `doctor`; this build self-heals on first run and `cache refresh` clears the poison. Tests: `internal/cli/canonical_flag_guard_test.go` (4 cases), `internal/app/canonical_panic_fallback_test.go` (4 cases mirroring the legacy fallback suite).
|
||||
- **`dws doc create` no longer produces a document with two identical headings** (#448; `internal/helpers/doc.go`) — the platform renders the document name as the page title, and LLM agents habitually repeat `# <title>` as the markdown body's first line despite the skill docs saying not to, so duplicate-heading documents kept appearing. The `doc create` helper (which wins the envelope merge via `preferLegacyLeaf`) now strips a leading ATX H1 whose text exactly equals `--name` (trimmed, case-insensitive) before forwarding to `create_document`, printing a stderr note so agents learn the convention. Deliberately conservative: only an exact match is removed (`# 背景` stays), ATX closing hashes are handled without over-trimming names ending in `#` (e.g. `C#`), H2+/setext headings are never touched, and a body that is nothing but the duplicate H1 omits the `markdown` param instead of sending an empty string. JSONML bodies are out of scope. Tests: `TestStripLeadingDuplicateTitleHeading` (9 cases) plus three end-to-end cobra tests asserting the exact `markdown` param sent.
|
||||
|
||||
## [1.0.35] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`chat message send` @-mentions not rendered in group / direct chat** (#433, `internal/helpers/chat.go`) — when sending a group message or an openDingTalkId direct message (`send_personal_message`) as the current user, the `content` body was packed with `json.Marshal`, whose default HTML escaping turns the `<` `>` in `<@openDingTalkId>` / `<@all>` into `<` `>`. The DingTalk client renders @-mentions by matching the **literal** `<@...>` token, so after escaping the match fails and the mention shows as plain text — while the API still returns `success`, masking the bug. Fix: add `marshalMessageContent`, which serializes `{title,text}` with `json.Encoder` + `SetEscapeHTML(false)`; both the group and openDingTalkId-direct `send_personal_message` paths now use it, preserving the literal `<@...>`. Added regression test `TestChatMessageSendContentNotHTMLEscaped` asserting the content keeps the literal token and is never HTML-escaped. Verified on a real device: `@someone` and `@all` both render as clickable blue mentions.
|
||||
- **`chat` skill docs & scripts aligned to direct-chat `list-direct`** (#424) — `chat message list` now supports group chats only (`--user` / `--open-dingtalk-id` removed); reading a direct chat moves to the dedicated `list-direct` command, but the skill docs and scripts still taught `chat message list --user`, which now errors with `unknown flag: --user`, also breaking `chat_history_with_user.py` (listed as the "preferred" way to query direct chats). This update: `skills/{mono,multi/dingtalk-chat}/references/products/chat.md` switches `message list` to group-only and documents the new `list-direct` command, syncing the intent routing / key-distinction / context-passing tables / caveats; `skills/mono/references/best_practices/01-messaging.md` changes query-private-chat from `list --user` to `list-direct` (the multi version was already updated); `chat_history_with_user.py` (mono + multi) now calls `list-direct` and fixes response parsing (unwraps `result.messages`, aligns `createTime/content/sender` fields — it previously crashed on `'str' object has no attribute 'get'`). Direct-chat sending still uses `chat message send --user` (since v1.0.34 the direct-send rpc is folded into the `send` command; there is no separate `send-direct`). Docs/scripts only; no change to CLI binary behavior.
|
||||
- **`pat chmod` batch authorization did not pass through `agentCode`** (#414, `internal/pat/chmod.go`) — the batch plan / grant paths (`buildBatchPlanArgs` / `batchArgs`) previously carried `agentCode` only in the single-grant `toolArgs`; batch calls omitted it, so a batch authorization with an explicit `agentCode` was processed under the default agent. Fix: the batch plan / grant args now also carry `agentCode`, matching the single-grant path.
|
||||
- **`pat` JSON output escaped the authorization URL into an unreadable form** (#401, `internal/pat`) — the authorization URL attached to PAT error messages, after default HTML escaping, turned `&` into `&`, breaking the link when copied / recognized on mobile. Fix: the PAT error-enrichment JSON output now uses `SetEscapeHTML(false)` (scoped to PAT JSON only), preserving the readable `&` separators.
|
||||
|
||||
## [1.0.34] - 2026-06-03
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 20 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 20 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -277,7 +277,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 19 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 18 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
@@ -492,12 +492,12 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
|
||||
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
|
||||
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
|
||||
| DevDoc | `devdoc` | 2 | `article` `error` | Search Open Platform documentation and troubleshoot API errors |
|
||||
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> **330 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
> **331 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
|
||||
+5
-5
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 19 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 18 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。19 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。18 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -274,7 +274,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 19 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
@@ -488,12 +488,12 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
|
||||
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
|
||||
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
|
||||
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档、排查开放平台调用错误 |
|
||||
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
> **18 个产品,330 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Every runtime command the `dws` CLI exposes when loaded with the **pre** environ
|
||||
|
||||
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
|
||||
- **Products**: 13
|
||||
- **Total commands**: 159
|
||||
- **Total commands**: 160
|
||||
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Edit `pre/config.json`, not this file.
|
||||
@@ -36,7 +36,7 @@ Every command inherits these flags (documented here once, not repeated per comma
|
||||
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
|
||||
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 1 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
|
||||
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
|
||||
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
|
||||
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
|
||||
@@ -182,11 +182,12 @@ _Users, departments, and directory lookups._
|
||||
|
||||
_Search the DingTalk Open Platform documentation._
|
||||
|
||||
**1 commands**
|
||||
**2 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
|
||||
| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, error code, error message, or context. | When the agent has a requestId, traceId, error code, or failure description and needs diagnostic facts plus references. |
|
||||
|
||||
## `dws ding` — DING Messages
|
||||
|
||||
@@ -320,4 +321,3 @@ _Personal todo task management._
|
||||
| `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. |
|
||||
| `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. |
|
||||
| `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. |
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// 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"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestNewMCPCommandPanicDegradesToStub verifies the canonical-tree guard:
|
||||
// the `dws mcp` build runs BEFORE the legacy build and used to sit outside
|
||||
// every poisoned-cache guard, so a panic there (e.g. a tool schema property
|
||||
// named after the reserved --params flag) aborted every invocation. With no
|
||||
// on-disk cache to quarantine it must degrade to an inert stub instead.
|
||||
func TestNewMCPCommandPanicDegradesToStub(t *testing.T) {
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
calls := 0
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
var cmd *cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
||||
})
|
||||
|
||||
if cmd == nil || cmd.Name() != "mcp" {
|
||||
t.Fatalf("newMCPCommand() = %v after build panic, want an 'mcp' stub", cmd)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "dws cache refresh") {
|
||||
t.Errorf("stub RunE error = %v, want a 'dws cache refresh' hint", err)
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("canonical build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPCommandSelfHealsPoisonedCache verifies the self-heal path: when
|
||||
// the build panics AND a discovery cache exists on disk, the partition is
|
||||
// quarantined and the build retried once, so a fixed binary escapes the
|
||||
// lock-out with zero manual cache surgery.
|
||||
func TestNewMCPCommandSelfHealsPoisonedCache(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
return &cobra.Command{Use: "mcp", Short: "rebuilt-probe"}
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
var cmd *cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
if cmd == nil || cmd.Short != "rebuilt-probe" {
|
||||
t.Errorf("newMCPCommand() did not return the rebuilt tree, got %v", cmd)
|
||||
}
|
||||
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
|
||||
if len(quarantines) != 1 {
|
||||
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
|
||||
}
|
||||
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
|
||||
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPCommandSecondPanicDegradesToStub verifies the final safety net:
|
||||
// if the rebuild after quarantine panics again, the stub is returned and the
|
||||
// `dws cache refresh` hint kept.
|
||||
func TestNewMCPCommandSecondPanicDegradesToStub(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
var cmd *cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != "mcp" {
|
||||
t.Fatalf("newMCPCommand() = %v after repeated panics, want an 'mcp' stub", cmd)
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPCommandNoPanicKeepsCanonicalPath ensures the guard is transparent
|
||||
// on the happy path.
|
||||
func TestNewMCPCommandNoPanicKeepsCanonicalPath(t *testing.T) {
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "mcp", Short: "canonical-probe"}
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
cmd := newMCPCommand(context.Background(), nil, nil, nil)
|
||||
if cmd == nil || cmd.Short != "canonical-probe" {
|
||||
t.Errorf("newMCPCommand() lost the canonical command, got %v", cmd)
|
||||
}
|
||||
}
|
||||
+71
-2
@@ -16,6 +16,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -49,7 +50,75 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
dynamicCmds := loadDynamicCommands(ctx, runner)
|
||||
return buildEnvelopeCommandsSafe(ctx, runner)
|
||||
}
|
||||
|
||||
// loadDynamicCommandsFn is a test seam for buildEnvelopeCommandsSafe so a
|
||||
// panic in the cache-driven build can be simulated without crafting a
|
||||
// poisoned on-disk cache.
|
||||
var loadDynamicCommandsFn = loadDynamicCommands
|
||||
|
||||
// buildEnvelopeCommandsSafe builds the public command set from the discovery
|
||||
// envelope, self-healing a poisoned cache when the dynamic build panics and
|
||||
// degrading to the hardcoded helper commands only if that also fails.
|
||||
//
|
||||
// Why this guard exists: the dynamic command tree is constructed from cached
|
||||
// discovery data BEFORE Cobra dispatches any command, so a panic here (e.g.
|
||||
// a duplicate pflag registration fed by a poisoned cache, as seen before
|
||||
// 1.0.32: "chat_permission_grant flag redefined: params") used to abort
|
||||
// every invocation — including `dws cache refresh`, the very command that
|
||||
// repairs the cache.
|
||||
//
|
||||
// Recovery is two-staged. First the partition's discovery cache is moved
|
||||
// aside (kept on disk for inspection) and the build retried against a fresh
|
||||
// fetch — so any path that delivers a fixed binary (`dws upgrade`, reinstall)
|
||||
// escapes the lock-out with zero manual cache surgery. Only when the rebuild
|
||||
// panics again (e.g. the remote envelope itself is still poisoned, or the
|
||||
// machine is offline with no usable cache) does the CLI degrade to utility
|
||||
// and helper commands with a `dws cache refresh` hint.
|
||||
func buildEnvelopeCommandsSafe(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
cmds, panicked := tryBuildEnvelopeCommands(ctx, runner)
|
||||
if panicked == nil {
|
||||
return cmds
|
||||
}
|
||||
slog.Error("buildEnvelopeCommandsSafe: dynamic command build panicked", "panic", panicked)
|
||||
|
||||
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
|
||||
if qErr != nil {
|
||||
slog.Error("buildEnvelopeCommandsSafe: failed to quarantine discovery cache", "error", qErr)
|
||||
}
|
||||
if quarantined != "" {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Warning: building product commands from the local discovery cache failed: %v\n"+
|
||||
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
|
||||
panicked, quarantined)
|
||||
cmds, panicked = tryBuildEnvelopeCommands(ctx, runner)
|
||||
if panicked == nil {
|
||||
fmt.Fprintln(os.Stderr, "Product commands rebuilt successfully.")
|
||||
return cmds
|
||||
}
|
||||
slog.Error("buildEnvelopeCommandsSafe: rebuild after cache quarantine panicked again, degrading to built-in commands", "panic", panicked)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Warning: building product commands from the local discovery cache failed: %v\n"+
|
||||
"Product commands are temporarily unavailable; utility commands still work.\n"+
|
||||
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
|
||||
return mergeTopLevelCommands(helpers.NewPublicCommands(runner))
|
||||
}
|
||||
|
||||
// tryBuildEnvelopeCommands runs one attempt of the envelope-driven build,
|
||||
// converting a panic into a return value so the caller can decide between
|
||||
// self-heal and degradation.
|
||||
func tryBuildEnvelopeCommands(ctx context.Context, runner executor.Runner) (cmds []*cobra.Command, panicked any) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
cmds = nil
|
||||
panicked = r
|
||||
}
|
||||
}()
|
||||
|
||||
dynamicCmds := loadDynamicCommandsFn(ctx, runner)
|
||||
helperCmds := helpers.NewPublicCommands(runner)
|
||||
merged := mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
|
||||
// Post-merge product hooks: tasks the envelope cannot express on its
|
||||
@@ -58,7 +127,7 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
|
||||
// command surface remains predictable from the envelope alone.
|
||||
helpers.AttachReportLegacyInboxAlias(merged, runner)
|
||||
helpers.AttachReportListReadableEnrichment(merged, runner)
|
||||
return merged
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// pickCommands returns the union of dynamic and helpers commands. For
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// captureStderr redirects os.Stderr for the duration of fn and returns what
|
||||
// was written to it.
|
||||
func captureStderr(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
pipeR, pipeW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe() error = %v", err)
|
||||
}
|
||||
origStderr := os.Stderr
|
||||
os.Stderr = pipeW
|
||||
defer func() { os.Stderr = origStderr }()
|
||||
|
||||
fn()
|
||||
|
||||
_ = pipeW.Close()
|
||||
os.Stderr = origStderr
|
||||
captured, _ := io.ReadAll(pipeR)
|
||||
return string(captured)
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsPanicFallsBackToHelpers verifies the escape
|
||||
// hatch for a poisoned discovery cache: when the dynamic command build
|
||||
// panics (e.g. duplicate pflag registration, the pre-1.0.32 lock-out
|
||||
// "flag redefined: params"), newLegacyPublicCommands must NOT propagate
|
||||
// the panic. With no on-disk cache to quarantine there is nothing to
|
||||
// self-heal from, so it degrades to the hardcoded helper commands and
|
||||
// prints a stderr hint pointing at `dws cache refresh`.
|
||||
func TestNewLegacyPublicCommandsPanicFallsBackToHelpers(t *testing.T) {
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if len(cmds) == 0 {
|
||||
t.Fatalf("newLegacyPublicCommands() = 0 commands after build panic, want helper fallback set")
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("dynamic build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsSelfHealsPoisonedCache verifies the self-heal
|
||||
// path: when the build panics AND a discovery cache exists on disk, the
|
||||
// partition is quarantined (moved aside, kept for inspection) and the build
|
||||
// retried once. The retry succeeding means the user gets the full dynamic
|
||||
// command tree with zero manual cache surgery.
|
||||
func TestNewLegacyPublicCommandsSelfHealsPoisonedCache(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
partition := editionPartition()
|
||||
if err := store.SaveTools(partition, "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
return []*cobra.Command{{Use: "dynamic-probe"}}
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c.Name() == "dynamic-probe" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("newLegacyPublicCommands() did not return the rebuilt dynamic command tree; got %d commands without 'dynamic-probe'", len(cmds))
|
||||
}
|
||||
|
||||
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
|
||||
if len(quarantines) != 1 {
|
||||
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(quarantines[0], "tools", "poisoned-server.json")); err != nil {
|
||||
t.Errorf("poisoned snapshot not preserved in quarantine: %v", err)
|
||||
}
|
||||
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
|
||||
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
|
||||
}
|
||||
if strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, must not tell the user to run 'dws cache refresh' when the rebuild succeeded", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers verifies the final
|
||||
// safety net: if the rebuild after quarantine panics again (remote envelope
|
||||
// still poisoned, or offline), the CLI degrades to helper commands and keeps
|
||||
// the `dws cache refresh` hint.
|
||||
func TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
t.Fatalf("newLegacyPublicCommands() = 0 commands after repeated build panics, want helper fallback set")
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath ensures the guard is
|
||||
// transparent on the happy path: commands returned by the dynamic build
|
||||
// still reach the caller unchanged.
|
||||
func TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath(t *testing.T) {
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
return []*cobra.Command{{Use: "dynamic-probe"}}
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
cmds := newLegacyPublicCommands(context.Background(), nil)
|
||||
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c.Name() == "dynamic-probe" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("newLegacyPublicCommands() lost the dynamic command; got %d commands without 'dynamic-probe'", len(cmds))
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
@@ -229,7 +230,7 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
}
|
||||
data["openBrowser"] = openBrowser
|
||||
|
||||
encoded, err := json.Marshal(payload)
|
||||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
@@ -595,7 +596,7 @@ func enrichPATErrorForHostControl(raw string) string {
|
||||
apperrors.ApplyHostMutations(payload)
|
||||
|
||||
// stderr JSON MUST be single-line.
|
||||
encoded, err := json.Marshal(payload)
|
||||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
@@ -636,6 +637,20 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := buf.Bytes()
|
||||
if len(out) > 0 && out[len(out)-1] == '\n' {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pollPatDeviceFlow polls the PAT device flow status endpoint until a terminal
|
||||
// state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled.
|
||||
// Returns the final status string and the authCode (non-empty only on APPROVED).
|
||||
|
||||
@@ -590,6 +590,29 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W"
|
||||
raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI)
|
||||
|
||||
out := enrichPATErrorWithOpenBrowser(raw, true)
|
||||
|
||||
if strings.Contains(out, `\u0026`) {
|
||||
t.Fatalf("enriched PAT JSON should keep URL ampersands readable for mobile copy/linkify, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "&userCode=QZYH-D64W") {
|
||||
t.Fatalf("enriched PAT JSON missing readable authorization URL separator, got: %s", out)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(enriched PAT payload) error = %v\nraw=%s", err, out)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
@@ -1082,6 +1105,21 @@ func TestEnrichPATErrorForHostControl_SingleLineOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPATErrorForHostControlKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
|
||||
t.Setenv("DINGTALK_AGENT", "sales-copilot")
|
||||
|
||||
raw := `{"success":false,"code":"PAT_HIGH_RISK_NO_PERMISSION","data":{"flowId":"flow-host","desc":"授权","uri":"https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-host%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-host&userCode=QZYH-D64W"}}`
|
||||
out := enrichPATErrorForHostControl(raw)
|
||||
|
||||
if strings.Contains(out, `\u0026`) {
|
||||
t.Fatalf("host PAT JSON should keep URL ampersands readable for mobile copy/linkify, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "&userCode=QZYH-D64W") {
|
||||
t.Fatalf("host PAT JSON missing readable authorization URL separator, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPATScopeHostJSON_SingleLineOutput mirrors the above regression
|
||||
// for the scope-error branch (PAT_SCOPE_AUTH_REQUIRED emission).
|
||||
func TestBuildPATScopeHostJSON_SingleLineOutput(t *testing.T) {
|
||||
|
||||
@@ -293,7 +293,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
|
||||
Status: "empty",
|
||||
Request: &recovery.ToolCallRecord{
|
||||
ServerID: "devdoc",
|
||||
ToolName: "search_open_platform_docs",
|
||||
ToolName: "search_open_platform_docs_rag",
|
||||
Arguments: cloneRecoveryArgs(requestArgs),
|
||||
},
|
||||
},
|
||||
@@ -302,7 +302,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
|
||||
retrieval.DocSearch.Status = "skipped"
|
||||
return retrieval, nil
|
||||
}
|
||||
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs", requestArgs)
|
||||
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
|
||||
if result != nil {
|
||||
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
|
||||
}
|
||||
|
||||
+69
-1
@@ -682,8 +682,76 @@ func newGenerateSkillsCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// buildMCPCommandFn is a test seam for newMCPCommand so a panic in the
|
||||
// catalog-driven canonical build can be simulated without crafting a
|
||||
// poisoned on-disk cache.
|
||||
var buildMCPCommandFn = cli.NewMCPCommand
|
||||
|
||||
// newMCPCommand builds the canonical `dws mcp` tree, self-healing a poisoned
|
||||
// cache when the build panics and degrading to an inert stub if that also
|
||||
// fails.
|
||||
//
|
||||
// Why this guard exists: the canonical tree is assembled from cached catalog
|
||||
// data BEFORE the legacy command build and before Cobra dispatches anything,
|
||||
// so a panic here (e.g. a tool schema property named after the reserved
|
||||
// --params flag, as cached during the 1.0.32 incident) used to abort every
|
||||
// invocation — including `dws cache refresh` and `dws upgrade` — and was NOT
|
||||
// covered by the legacy-path guards (#447/#452). Same two-staged recovery as
|
||||
// buildEnvelopeCommandsSafe: quarantine the partition, retry once against a
|
||||
// fresh fetch, then degrade with a `dws cache refresh` hint.
|
||||
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
|
||||
return cli.NewMCPCommand(ctx, loader, runner, engine)
|
||||
cmd, panicked := tryBuildMCPCommand(ctx, loader, runner, engine)
|
||||
if panicked == nil {
|
||||
return cmd
|
||||
}
|
||||
slog.Error("newMCPCommand: canonical command build panicked", "panic", panicked)
|
||||
|
||||
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
|
||||
if qErr != nil {
|
||||
slog.Error("newMCPCommand: failed to quarantine discovery cache", "error", qErr)
|
||||
}
|
||||
if quarantined != "" {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
|
||||
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
|
||||
panicked, quarantined)
|
||||
cmd, panicked = tryBuildMCPCommand(ctx, loader, runner, engine)
|
||||
if panicked == nil {
|
||||
fmt.Fprintln(os.Stderr, "Canonical commands rebuilt successfully.")
|
||||
return cmd
|
||||
}
|
||||
slog.Error("newMCPCommand: rebuild after cache quarantine panicked again, degrading to a stub", "panic", panicked)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
|
||||
"The 'dws mcp' surface is temporarily unavailable; other commands still work.\n"+
|
||||
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
|
||||
buildErr := apperrors.NewInternal(fmt.Sprintf("canonical command build failed: %v; run 'dws cache refresh'", panicked))
|
||||
stub := &cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: "Canonical MCP-derived CLI surface (unavailable)",
|
||||
Hidden: true,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return buildErr
|
||||
},
|
||||
}
|
||||
return stub
|
||||
}
|
||||
|
||||
// tryBuildMCPCommand runs one attempt of the canonical build, converting a
|
||||
// panic into a return value so the caller can decide between self-heal and
|
||||
// degradation.
|
||||
func tryBuildMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) (cmd *cobra.Command, panicked any) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
cmd = nil
|
||||
panicked = r
|
||||
}
|
||||
}()
|
||||
return buildMCPCommandFn(ctx, loader, runner, engine), nil
|
||||
}
|
||||
|
||||
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
|
||||
|
||||
@@ -111,7 +111,7 @@ func logHostOwnedPATDecisionOnce() {
|
||||
hostOwnedPATDecisionOnce.Do(func() {
|
||||
slog.Debug("runtime.host_owned_pat",
|
||||
"hostOwned", authpkg.HostOwnsPATFlow(),
|
||||
"agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
"agentCodeEnvPresent", authpkg.AgentCodeEnvPresent(),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -687,9 +687,10 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv(envRewindSessionID)
|
||||
}
|
||||
agentCode, _ := authpkg.AgentCodeFromEnv()
|
||||
envHeaders := map[string]string{
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
|
||||
"x-dingtalk-dws-agent-code": agentCode,
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": sessionID,
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
|
||||
@@ -328,6 +328,17 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", " compat ")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want empty because reversed env is ignored", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(envDingtalkSessionID, "ding-session")
|
||||
|
||||
@@ -518,6 +518,16 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
}
|
||||
|
||||
// Clear discovery-derived caches so the upgraded binary rebuilds its
|
||||
// command tree from a fresh fetch instead of inheriting snapshots written
|
||||
// by the old version — a poisoned snapshot used to lock out every
|
||||
// invocation before the build guards landed (#447 / #449).
|
||||
if purged, purgeErr := cacheStoreFromEnv().PurgeDiscoveryData(); purgeErr != nil {
|
||||
fmt.Printf(" %s %s\n", ugYellow("⚠"), ugDim(fmt.Sprintf("清理发现缓存失败 (可手动运行 dws cache refresh): %v", purgeErr)))
|
||||
} else if len(purged) > 0 {
|
||||
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("发现缓存已清空, 新版本首次运行时自动重建"))
|
||||
}
|
||||
|
||||
// Cleanup old backups
|
||||
rm.Cleanup(5)
|
||||
|
||||
|
||||
@@ -19,19 +19,38 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// AgentCodeEnv is the sole per-spawn environment variable the host injects
|
||||
// to declare "this process is driven by a third-party Agent host, render
|
||||
// authorization UI yourselves".
|
||||
// AgentCodeEnv is the primary per-spawn environment variable the host
|
||||
// injects to declare "this process is driven by a third-party Agent host,
|
||||
// render authorization UI yourselves".
|
||||
AgentCodeEnv = "DINGTALK_DWS_AGENTCODE"
|
||||
)
|
||||
|
||||
// AgentCodeFromEnv returns the effective host agent code and the env name that
|
||||
// supplied it.
|
||||
//
|
||||
// Keep the public env surface intentionally single-spelled. The reversed
|
||||
// DWS_DINGTALK_AGENTCODE draft name is not consumed, so host-owned PAT mode,
|
||||
// gateway identity headers, and `pat chmod --agentCode` fallback all agree on
|
||||
// the same stable signal: DINGTALK_DWS_AGENTCODE.
|
||||
func AgentCodeFromEnv() (string, string) {
|
||||
if value := strings.TrimSpace(os.Getenv(AgentCodeEnv)); value != "" {
|
||||
return value, AgentCodeEnv
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func AgentCodeEnvPresent() bool {
|
||||
value, _ := AgentCodeFromEnv()
|
||||
return value != ""
|
||||
}
|
||||
|
||||
// HostOwnsPATFlow reports whether the current process is running under a
|
||||
// third-party Agent host that will render the PAT authorization card
|
||||
// itself. The sole trigger is AgentCodeEnv (DINGTALK_DWS_AGENTCODE) being
|
||||
// non-empty. The CLI deliberately does not consult any other signal
|
||||
// (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type header) for this
|
||||
// decision so that server-side routing tags and the host-owned UI contract
|
||||
// remain independent concerns.
|
||||
// itself. The trigger is DINGTALK_DWS_AGENTCODE being non-empty. The CLI
|
||||
// deliberately does not consult any other signal (DINGTALK_AGENT /
|
||||
// DWS_CHANNEL / the wire claw-type header) for this decision so that
|
||||
// server-side routing tags and the host-owned UI contract remain independent
|
||||
// concerns.
|
||||
func HostOwnsPATFlow() bool {
|
||||
return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != ""
|
||||
return AgentCodeEnvPresent()
|
||||
}
|
||||
|
||||
Vendored
+68
@@ -238,6 +238,74 @@ func (s *Store) DeleteDetail(partition, serverKey string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuarantinePartition moves the entire on-disk cache for a partition aside,
|
||||
// renaming it to "<partition>.quarantined", so the next load starts from an
|
||||
// empty cache while the poisoned snapshot stays on disk for inspection.
|
||||
// Returns the quarantine path, or "" when the partition has no cache on disk.
|
||||
// A previous quarantine for the same partition is replaced, so repeated
|
||||
// quarantines never accumulate.
|
||||
func (s *Store) QuarantinePartition(partition string) (string, error) {
|
||||
dir := filepath.Join(s.Root, sanitize(partition))
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
quarantine := dir + ".quarantined"
|
||||
if err := os.RemoveAll(quarantine); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(dir, quarantine); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return quarantine, nil
|
||||
}
|
||||
|
||||
// discoverySubdirs are the per-partition directories holding discovery-derived
|
||||
// data: the market registry envelope plus tools / detail snapshots.
|
||||
var discoverySubdirs = []string{"market", "tools", "detail"}
|
||||
|
||||
// PurgeDiscoveryData deletes the discovery-derived cache for every partition
|
||||
// under the cache root, leaving unrelated data that shares the root (e.g. the
|
||||
// upgrade download cache in "downloads/") untouched. Returns the names of the
|
||||
// partition directories that had data removed. Removal errors are collected
|
||||
// into the returned error but do not stop the sweep.
|
||||
func (s *Store) PurgeDiscoveryData() ([]string, error) {
|
||||
entries, err := os.ReadDir(s.Root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var purged []string
|
||||
var firstErr error
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
removedAny := false
|
||||
for _, sub := range discoverySubdirs {
|
||||
dir := filepath.Join(s.Root, entry.Name(), sub)
|
||||
if _, statErr := os.Stat(dir); statErr != nil {
|
||||
continue
|
||||
}
|
||||
if rmErr := os.RemoveAll(dir); rmErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = rmErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
removedAny = true
|
||||
}
|
||||
if removedAny {
|
||||
purged = append(purged, entry.Name())
|
||||
}
|
||||
}
|
||||
return purged, firstErr
|
||||
}
|
||||
|
||||
func (s *Store) registryPath(partition string) string {
|
||||
return filepath.Join(s.Root, sanitize(partition), "market", "servers.json")
|
||||
}
|
||||
|
||||
+131
@@ -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 cache
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQuarantinePartitionNoCacheIsNoop(t *testing.T) {
|
||||
s := NewStore(t.TempDir())
|
||||
path, err := s.QuarantinePartition("default_default")
|
||||
if err != nil {
|
||||
t.Fatalf("QuarantinePartition() error = %v", err)
|
||||
}
|
||||
if path != "" {
|
||||
t.Errorf("QuarantinePartition() = %q, want empty path when nothing is cached", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantinePartitionMovesCacheAside(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
s := NewStore(tmp)
|
||||
if err := s.SaveTools("default_default", "srv", ToolsSnapshot{ServerKey: "srv"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
path, err := s.QuarantinePartition("default_default")
|
||||
if err != nil {
|
||||
t.Fatalf("QuarantinePartition() error = %v", err)
|
||||
}
|
||||
want := filepath.Join(tmp, "default_default.quarantined")
|
||||
if path != want {
|
||||
t.Errorf("QuarantinePartition() = %q, want %q", path, want)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(tmp, "default_default")); !os.IsNotExist(statErr) {
|
||||
t.Errorf("original partition dir still present after quarantine (stat err = %v)", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(path, "tools", "srv.json")); statErr != nil {
|
||||
t.Errorf("quarantined snapshot missing: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantinePartitionReplacesPreviousQuarantine(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
s := NewStore(tmp)
|
||||
if err := s.SaveTools("default_default", "first", ToolsSnapshot{ServerKey: "first"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
if _, err := s.QuarantinePartition("default_default"); err != nil {
|
||||
t.Fatalf("first QuarantinePartition() error = %v", err)
|
||||
}
|
||||
if err := s.SaveTools("default_default", "second", ToolsSnapshot{ServerKey: "second"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
path, err := s.QuarantinePartition("default_default")
|
||||
if err != nil {
|
||||
t.Fatalf("second QuarantinePartition() error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(path, "tools", "second.json")); statErr != nil {
|
||||
t.Errorf("latest quarantine missing newest snapshot: %v", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(path, "tools", "first.json")); !os.IsNotExist(statErr) {
|
||||
t.Errorf("previous quarantine was not replaced (stat err = %v)", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeDiscoveryDataRemovesDiscoveryDirsOnly(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
s := NewStore(tmp)
|
||||
|
||||
mustWrite := func(parts ...string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(parts...)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
mustWrite(tmp, "default_default", "market", "servers.json")
|
||||
mustWrite(tmp, "default_default", "tools", "srv.json")
|
||||
mustWrite(tmp, "default_default", "detail", "srv.json")
|
||||
mustWrite(tmp, "wukong_default", "tools", "srv.json")
|
||||
// Unrelated data sharing the cache root must survive the purge.
|
||||
mustWrite(tmp, "downloads", "dws-1.0.36.tar.gz")
|
||||
|
||||
purged, err := s.PurgeDiscoveryData()
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeDiscoveryData() error = %v", err)
|
||||
}
|
||||
if len(purged) != 2 {
|
||||
t.Fatalf("PurgeDiscoveryData() purged = %v, want 2 partitions", purged)
|
||||
}
|
||||
for _, sub := range []string{"market", "tools", "detail"} {
|
||||
if _, statErr := os.Stat(filepath.Join(tmp, "default_default", sub)); !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s dir survived the purge (stat err = %v)", sub, statErr)
|
||||
}
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(tmp, "wukong_default", "tools")); !os.IsNotExist(statErr) {
|
||||
t.Errorf("second partition tools dir survived the purge (stat err = %v)", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(tmp, "downloads", "dws-1.0.36.tar.gz")); statErr != nil {
|
||||
t.Errorf("unrelated downloads data was removed: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeDiscoveryDataMissingRootIsNoop(t *testing.T) {
|
||||
s := NewStore(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
purged, err := s.PurgeDiscoveryData()
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeDiscoveryData() error = %v", err)
|
||||
}
|
||||
if len(purged) != 0 {
|
||||
t.Errorf("PurgeDiscoveryData() purged = %v, want none", purged)
|
||||
}
|
||||
}
|
||||
@@ -534,6 +534,34 @@ func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner
|
||||
return cmd
|
||||
}
|
||||
|
||||
// canRegisterToolFlag reports whether a long flag named name can be
|
||||
// registered on cmd without panicking pflag ("flag redefined"). The reserved
|
||||
// payload names are excluded too: newToolCommand unconditionally registers
|
||||
// --json/--params before the spec loop. Tool schemas are remote data — a
|
||||
// property named after a reserved or already-registered flag must degrade to
|
||||
// "flag unavailable" (the value stays reachable through --json/--params),
|
||||
// never abort the process. Mirrors internal/compat's canRegisterFlag.
|
||||
func canRegisterToolFlag(cmd *cobra.Command, name string) bool {
|
||||
if name == "" || name == "json" || name == "params" {
|
||||
return false
|
||||
}
|
||||
return cmd.Flags().Lookup(name) == nil
|
||||
}
|
||||
|
||||
// safeToolShorthand returns short when it is a single-character shorthand not
|
||||
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
|
||||
// pflag panics on both multi-character and duplicate shorthands.
|
||||
func safeToolShorthand(cmd *cobra.Command, short string) string {
|
||||
short = strings.TrimSpace(short)
|
||||
if len(short) != 1 {
|
||||
return ""
|
||||
}
|
||||
if cmd.Flags().ShorthandLookup(short) != nil {
|
||||
return ""
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
|
||||
for _, spec := range specs {
|
||||
usage := spec.Description
|
||||
@@ -541,41 +569,42 @@ func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
|
||||
usage = fmt.Sprintf("Override %s", spec.PropertyName)
|
||||
}
|
||||
primary := strings.TrimSpace(spec.FlagName)
|
||||
if primary == "" {
|
||||
if !canRegisterToolFlag(cmd, primary) {
|
||||
continue
|
||||
}
|
||||
shorthand := safeToolShorthand(cmd, spec.Shorthand)
|
||||
alias := strings.TrimSpace(spec.Alias)
|
||||
if alias == primary {
|
||||
if alias == primary || !canRegisterToolFlag(cmd, alias) {
|
||||
alias = ""
|
||||
}
|
||||
|
||||
switch spec.Kind {
|
||||
case flagString, flagJSON:
|
||||
cmd.Flags().StringP(primary, spec.Shorthand, "", usage)
|
||||
cmd.Flags().StringP(primary, shorthand, "", usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().String(alias, "", usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagInteger:
|
||||
cmd.Flags().IntP(primary, spec.Shorthand, 0, usage)
|
||||
cmd.Flags().IntP(primary, shorthand, 0, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Int(alias, 0, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagNumber:
|
||||
cmd.Flags().Float64P(primary, spec.Shorthand, 0, usage)
|
||||
cmd.Flags().Float64P(primary, shorthand, 0, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Float64(alias, 0, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagBoolean:
|
||||
cmd.Flags().BoolP(primary, spec.Shorthand, false, usage)
|
||||
cmd.Flags().BoolP(primary, shorthand, false, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Bool(alias, false, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagStringArray, flagIntegerList, flagNumberList, flagBooleanList:
|
||||
cmd.Flags().StringSliceP(primary, spec.Shorthand, nil, usage)
|
||||
cmd.Flags().StringSliceP(primary, shorthand, nil, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().StringSlice(alias, nil, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newToolCommandFixture mirrors the flag environment of newToolCommand: the
|
||||
// reserved payload flags are registered before the spec loop runs.
|
||||
func newToolCommandFixture() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "probe"}
|
||||
cmd.Flags().String("json", "", "Base JSON object payload for this tool invocation")
|
||||
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSkipsReservedNames locks in the fix for the 1.0.32-class
|
||||
// lock-out: a tool schema property named after a reserved payload flag
|
||||
// ("params", as cached during the chat_permission_grant incident, or "json")
|
||||
// must be skipped instead of panicking pflag ("flag redefined") — that panic
|
||||
// fires while the canonical tree is assembled, before Cobra dispatches
|
||||
// anything, and used to kill every invocation including `dws cache refresh`
|
||||
// and `dws upgrade`.
|
||||
func TestApplyFlagSpecsSkipsReservedNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "params", FlagName: "params", Kind: flagString, Description: "命令授权参数"},
|
||||
{PropertyName: "json", FlagName: "json", Kind: flagString},
|
||||
{PropertyName: "scope", FlagName: "scope", Kind: flagString},
|
||||
})
|
||||
|
||||
if cmd.Flags().Lookup("scope") == nil {
|
||||
t.Errorf("non-colliding flag --scope was not registered")
|
||||
}
|
||||
// The reserved flags must keep their payload usage strings, proving the
|
||||
// schema-derived specs did not touch them.
|
||||
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
|
||||
t.Errorf("--params usage = %q, want the reserved payload usage", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSkipsDuplicates covers duplicate property names within a
|
||||
// single tool schema (or a spec colliding with an already-applied one).
|
||||
func TestApplyFlagSpecsSkipsDuplicates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "scope", FlagName: "scope", Kind: flagString, Description: "first"},
|
||||
{PropertyName: "scope", FlagName: "scope", Kind: flagBoolean, Description: "second"},
|
||||
})
|
||||
|
||||
flag := cmd.Flags().Lookup("scope")
|
||||
if flag == nil {
|
||||
t.Fatalf("--scope was not registered at all")
|
||||
}
|
||||
if flag.Usage != "first" {
|
||||
t.Errorf("--scope usage = %q, want the first spec to win", flag.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSkipsCollidingAlias verifies an alias colliding with a
|
||||
// reserved or existing flag is dropped while the primary still registers.
|
||||
func TestApplyFlagSpecsSkipsCollidingAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "scope", FlagName: "scope", Alias: "params", Kind: flagString},
|
||||
})
|
||||
|
||||
if cmd.Flags().Lookup("scope") == nil {
|
||||
t.Errorf("primary flag --scope was not registered when its alias collided")
|
||||
}
|
||||
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
|
||||
t.Errorf("--params usage = %q, alias overwrote the reserved payload flag", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSanitizesShorthand verifies multi-character and duplicate
|
||||
// shorthands (both pflag panics) degrade to long-flag-only registration.
|
||||
func TestApplyFlagSpecsSanitizesShorthand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "alpha", FlagName: "alpha", Shorthand: "ab", Kind: flagString},
|
||||
{PropertyName: "beta", FlagName: "beta", Shorthand: "s", Kind: flagString},
|
||||
{PropertyName: "gamma", FlagName: "gamma", Shorthand: "s", Kind: flagString},
|
||||
})
|
||||
|
||||
for _, name := range []string{"alpha", "beta", "gamma"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("--%s was not registered", name)
|
||||
}
|
||||
}
|
||||
if flag := cmd.Flags().ShorthandLookup("s"); flag == nil || flag.Name != "beta" {
|
||||
t.Errorf("shorthand -s should stay bound to the first claimant --beta, got %v", flag)
|
||||
}
|
||||
}
|
||||
@@ -381,7 +381,7 @@ func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
|
||||
"article": {Description: "文档文章"},
|
||||
},
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"search_open_platform_docs": {
|
||||
"search_open_platform_docs_rag": {
|
||||
CLIName: "search",
|
||||
Group: "article",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
|
||||
+50
-11
@@ -415,6 +415,33 @@ func parseFlagDefault(kind ValueKind, raw string) (defStr string, defInt int, de
|
||||
return
|
||||
}
|
||||
|
||||
// canRegisterFlag reports whether a long flag named name can be registered
|
||||
// on cmd without panicking pflag ("flag redefined"). The reserved payload
|
||||
// names are excluded too: ApplyBindings unconditionally registers hidden
|
||||
// --json/--params after the bindings loop. The envelope is remote data —
|
||||
// a duplicate or reserved name there must degrade to "flag unavailable",
|
||||
// never abort the process.
|
||||
func canRegisterFlag(cmd *cobra.Command, name string) bool {
|
||||
if name == "" || name == "json" || name == "params" {
|
||||
return false
|
||||
}
|
||||
return cmd.Flags().Lookup(name) == nil
|
||||
}
|
||||
|
||||
// safeShorthand returns short when it is a single-character shorthand not
|
||||
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
|
||||
// pflag panics on both multi-character and duplicate shorthands.
|
||||
func safeShorthand(cmd *cobra.Command, short string) string {
|
||||
short = strings.TrimSpace(short)
|
||||
if len(short) != 1 {
|
||||
return ""
|
||||
}
|
||||
if cmd.Flags().ShorthandLookup(short) != nil {
|
||||
return ""
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
for _, binding := range bindings {
|
||||
// Positional bindings are collected from cobra args rather than flags.
|
||||
@@ -464,7 +491,7 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
|
||||
|
||||
registerHidden := func(name string, suffix string) {
|
||||
if name == "" {
|
||||
if !canRegisterFlag(cmd, name) {
|
||||
return
|
||||
}
|
||||
switch binding.Kind {
|
||||
@@ -484,19 +511,27 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
|
||||
if !canRegisterFlag(cmd, primary) {
|
||||
// Duplicate or reserved primary name in the envelope. Skip the
|
||||
// whole binding: CollectBindings tolerates the missing flag
|
||||
// (Lookup → nil → continue) and the value can still be supplied
|
||||
// via the --params payload.
|
||||
continue
|
||||
}
|
||||
short := safeShorthand(cmd, binding.Short)
|
||||
switch binding.Kind {
|
||||
case ValueString:
|
||||
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage)
|
||||
cmd.Flags().StringP(primary, short, defStr, binding.Usage)
|
||||
case ValueInt:
|
||||
cmd.Flags().IntP(primary, binding.Short, defInt, binding.Usage)
|
||||
cmd.Flags().IntP(primary, short, defInt, binding.Usage)
|
||||
case ValueFloat:
|
||||
cmd.Flags().Float64P(primary, binding.Short, defFloat, binding.Usage)
|
||||
cmd.Flags().Float64P(primary, short, defFloat, binding.Usage)
|
||||
case ValueBool:
|
||||
cmd.Flags().BoolP(primary, binding.Short, defBool, binding.Usage)
|
||||
cmd.Flags().BoolP(primary, short, defBool, binding.Usage)
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
cmd.Flags().StringSliceP(primary, binding.Short, defSlice, binding.Usage)
|
||||
cmd.Flags().StringSliceP(primary, short, defSlice, binding.Usage)
|
||||
case ValueJSON:
|
||||
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage+" (JSON)")
|
||||
cmd.Flags().StringP(primary, short, defStr, binding.Usage+" (JSON)")
|
||||
}
|
||||
registerHidden(alias, " (alias)")
|
||||
for _, extra := range extras {
|
||||
@@ -513,8 +548,12 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
}
|
||||
}
|
||||
}
|
||||
cmd.Flags().String("json", "", "Base JSON object payload for this command")
|
||||
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
cmd.Flags().String("json", "", "Base JSON object payload for this command")
|
||||
}
|
||||
if cmd.Flags().Lookup("params") == nil {
|
||||
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
|
||||
}
|
||||
_ = cmd.Flags().MarkHidden("json")
|
||||
_ = cmd.Flags().MarkHidden("params")
|
||||
}
|
||||
@@ -553,12 +592,12 @@ func registerPositionalAliasFlags(cmd *cobra.Command, binding FlagBinding) {
|
||||
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
|
||||
|
||||
register := func(name string, withShort bool, hidden bool, usageSuffix string) {
|
||||
if name == "" {
|
||||
if !canRegisterFlag(cmd, name) {
|
||||
return
|
||||
}
|
||||
short := ""
|
||||
if withShort {
|
||||
short = binding.Short
|
||||
short = safeShorthand(cmd, binding.Short)
|
||||
}
|
||||
usage := binding.Usage + usageSuffix
|
||||
switch binding.Kind {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// The envelope is remote data; none of these malformed shapes may panic the
|
||||
// command build — pflag panics on duplicate long names, duplicate shorthands,
|
||||
// and multi-character shorthands, and a poisoned discovery cache used to take
|
||||
// down every CLI invocation this way (pre-1.0.32 lockout class).
|
||||
func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]market.CLIFlagOverride
|
||||
}{
|
||||
{
|
||||
name: "duplicate shorthand across two flags",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {Shorthand: "x"},
|
||||
"beta": {Shorthand: "x"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi-character shorthand",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {Shorthand: "xy"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "primary collides with reserved payload flag",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"params": {},
|
||||
"json": {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cross-binding duplicate primary via alias",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"user_id": {Alias: "target"},
|
||||
"member_id": {Alias: "target"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cross-binding alias collides with another primary",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {},
|
||||
"beta": {Aliases: []string{"alpha"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-guard",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "guard",
|
||||
Command: "guard",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"guard_tool": {
|
||||
CLIName: "boom",
|
||||
Flags: tc.flags,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Must not panic; the command must build and stay executable.
|
||||
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
|
||||
if len(cmds) != 1 {
|
||||
t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
|
||||
}
|
||||
cmds[0].SetArgs([]string{"boom", "--help"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
if err := cmds[0].Execute(); err != nil {
|
||||
t.Fatalf("execute --help: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDynamicCommandsKeepsFirstShorthand pins the winner: when two
|
||||
// flags claim the same shorthand, the first (sorted param order) keeps it
|
||||
// and the second still registers its long flag.
|
||||
func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-guard",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "guard",
|
||||
Command: "guard",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"guard_tool": {
|
||||
CLIName: "boom",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {Shorthand: "x"},
|
||||
"beta": {Shorthand: "x"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
|
||||
boom, _, err := cmds[0].Find([]string{"boom"})
|
||||
if err != nil {
|
||||
t.Fatalf("find boom: %v", err)
|
||||
}
|
||||
short := boom.Flags().ShorthandLookup("x")
|
||||
if short == nil || short.Name != "alpha" {
|
||||
t.Fatalf("shorthand -x bound to %v, want alpha", short)
|
||||
}
|
||||
if boom.Flags().Lookup("beta") == nil {
|
||||
t.Fatalf("long flag --beta missing; dropping the shorthand must not drop the flag")
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -14,6 +14,7 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
@@ -462,13 +463,27 @@ func cleanPATJSON(body map[string]any, code string) string {
|
||||
// stderr JSON MUST be a single-line, directly json.Unmarshal-able
|
||||
// payload — pretty-printing would break naïve host parsers that read
|
||||
// stderr line-by-line and fail on leading whitespace.
|
||||
b, err := json.Marshal(out)
|
||||
b, err := marshalSingleLineJSONNoHTMLEscape(out)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"success":false,"code":"%s"}`, code)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := buf.Bytes()
|
||||
if len(out) > 0 && out[len(out)-1] == '\n' {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- Runner adapter functions ------------------------------------------------
|
||||
// These match the function signatures referenced by runner.go's PAT check
|
||||
// framework (ClassifyPatAuthCheck / AsPatAuthCheckError).
|
||||
|
||||
@@ -730,6 +730,13 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
|
||||
|
||||
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
|
||||
|
||||
if strings.Contains(result, `\u0026`) {
|
||||
t.Fatalf("cleanPATJSON should keep URL ampersands readable for mobile copy/linkify, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "&userCode=Q8RY-X6E9") {
|
||||
t.Fatalf("cleanPATJSON output missing readable fragment separator, got: %s", result)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestResourceName(t *testing.T) {
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", input: "search_open_platform_docs"},
|
||||
{name: "valid", input: "search_open_platform_docs_rag"},
|
||||
{name: "valid-cjk", input: "审批查询"},
|
||||
{name: "leading-digit", input: "1tool", wantErr: true},
|
||||
{name: "shell-char", input: "tool;rm", wantErr: true},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
@@ -475,11 +476,10 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
if atAll && !strings.Contains(text, "<@all>") {
|
||||
text = "<@all> " + text
|
||||
}
|
||||
b, _ := json.Marshal(map[string]string{"title": title, "text": text})
|
||||
params := map[string]any{
|
||||
"openConversationId": group,
|
||||
"msgType": "markdown",
|
||||
"content": string(b),
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
if atAll {
|
||||
params["atAll"] = true
|
||||
@@ -495,11 +495,10 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
params := map[string]any{"title": title, "text": text, "receiverUserId": user}
|
||||
return params, "send_direct_message_as_user", nil
|
||||
default:
|
||||
b, _ := json.Marshal(map[string]string{"title": title, "text": text})
|
||||
params := map[string]any{
|
||||
"receiverOpenDingTalkId": openID,
|
||||
"msgType": "markdown",
|
||||
"content": string(b),
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
if strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
@@ -1011,3 +1010,19 @@ func jsonMarshal(v any) (string, error) {
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// marshalMessageContent builds the send_personal_message content payload
|
||||
// ({"title","text"}) WITHOUT HTML-escaping < > &. DingTalk's client renders
|
||||
// @-mentions by matching literal <@openDingTalkId> / <@all> tokens in the
|
||||
// message text; the default json.Marshal escaping turns them into
|
||||
// <@...>, which the client shows as plain text instead of a rendered
|
||||
// mention. encoding/json offers no escape toggle on Marshal, so use an Encoder.
|
||||
func marshalMessageContent(title, text string) string {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
// Encoder errors are impossible for a map[string]string; ignore safely.
|
||||
_ = enc.Encode(map[string]string{"title": title, "text": text})
|
||||
// Encoder.Encode appends a trailing newline; strip it.
|
||||
return strings.TrimRight(buf.String(), "\n")
|
||||
}
|
||||
|
||||
@@ -222,6 +222,55 @@ func TestChatMessageSendForwardsAtMentions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageSendContentNotHTMLEscaped guards the @-mention rendering fix:
|
||||
// the send_personal_message content must keep literal <@openDingTalkId> / <@all>
|
||||
// tokens. If json.Marshal's default HTML escaping is reintroduced, the tokens
|
||||
// become <@...> and the DingTalk client renders them as plain text
|
||||
// instead of a real @-mention.
|
||||
func TestChatMessageSendContentNotHTMLEscaped(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string // literal token that must survive in content
|
||||
}{
|
||||
{
|
||||
name: "group-at-all",
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@all> hi", "--at-all"},
|
||||
want: "<@all>",
|
||||
},
|
||||
{
|
||||
name: "group-at-open-dingtalk-id",
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@op-1> hi", "--at-open-dingtalk-ids", "op-1"},
|
||||
want: "<@op-1>",
|
||||
},
|
||||
{
|
||||
name: "direct-open-dingtalk-id",
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "<@OP123> hi"},
|
||||
want: "<@OP123>",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := newChatMessageSendCommand(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
content, _ := runner.last.Params["content"].(string)
|
||||
if !strings.Contains(content, tc.want) {
|
||||
t.Fatalf("content %q missing literal %q (HTML-escaped?)", content, tc.want)
|
||||
}
|
||||
if strings.Contains(content, "\\u003c") || strings.Contains(content, "\\u003e") {
|
||||
t.Fatalf("content %q is HTML-escaped; @-mention will not render", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageSendRejectsAtMentionsOutsideGroup ensures we do not silently
|
||||
// drop user intent when --at-* is combined with --user / --open-dingtalk-id
|
||||
// (single-chat tools have no @-mention semantics, so the flag would never
|
||||
|
||||
@@ -56,7 +56,19 @@ func (devdocHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
}
|
||||
article.AddCommand(newDevdocArticleSearchCommand(runner))
|
||||
errorCmd := &cobra.Command{
|
||||
Use: "error",
|
||||
Short: "错误排查",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
errorCmd.AddCommand(newDevdocErrorDiagnoseCommand(runner))
|
||||
root.AddCommand(article)
|
||||
root.AddCommand(errorCmd)
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -82,7 +94,7 @@ func newDevdocArticleSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
if size < 1 {
|
||||
size = 10
|
||||
}
|
||||
return runDevdocTool(cmd, runner, "search_open_platform_docs", map[string]any{
|
||||
return runDevdocTool(cmd, runner, "search_open_platform_docs_rag", map[string]any{
|
||||
"keyword": keyword,
|
||||
"page": page,
|
||||
"size": size,
|
||||
@@ -97,6 +109,55 @@ func newDevdocArticleSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDevdocErrorDiagnoseCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "diagnose",
|
||||
Aliases: []string{"troubleshoot"},
|
||||
Short: "排查开放平台调用错误",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
requestID := devdocFlagOrFallback(cmd, "request-id", "trace-id")
|
||||
errorCode := devdocFlagOrFallback(cmd, "error-code")
|
||||
errorMessage := devdocFlagOrFallback(cmd, "error-message")
|
||||
contextValue := devdocFlagOrFallback(cmd, "context")
|
||||
query := devdocFlagOrFallback(cmd, "query")
|
||||
hasPrimaryInput := query != "" || requestID != "" || errorCode != "" || errorMessage != "" || contextValue != ""
|
||||
if !hasPrimaryInput {
|
||||
return apperrors.NewValidation("one of --query, --request-id, --error-code, --error-message, or --context is required")
|
||||
}
|
||||
combinedQuery := devdocJoinQueryParts(query, errorMessage, devdocFlagOrFallback(cmd, "api"), contextValue)
|
||||
page, _ := cmd.Flags().GetInt("page")
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size, _ := cmd.Flags().GetInt("size")
|
||||
if size < 1 {
|
||||
size = 10
|
||||
}
|
||||
params := map[string]any{
|
||||
"page": page,
|
||||
"size": size,
|
||||
}
|
||||
devdocSetStringParam(params, "query", combinedQuery)
|
||||
devdocSetStringParam(params, "requestId", requestID)
|
||||
devdocSetStringParam(params, "errorCode", errorCode)
|
||||
return runDevdocTool(cmd, runner, "search_open_error_code_rag", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("query", "", "原始排查问题")
|
||||
cmd.Flags().String("request-id", "", "开放平台 requestId")
|
||||
addDevdocHiddenStringFlag(cmd, "trace-id", "--request-id 的兼容别名")
|
||||
cmd.Flags().String("error-code", "", "错误码")
|
||||
cmd.Flags().String("error-message", "", "错误描述,会合并进原始问题")
|
||||
cmd.Flags().String("api", "", "API 名称,会合并进原始问题作为补充检索词")
|
||||
cmd.Flags().String("context", "", "额外排查上下文,会合并进原始问题")
|
||||
cmd.Flags().Int("page", 1, "分页页码 (从 1 开始,默认 1)")
|
||||
cmd.Flags().Int("size", 10, "分页大小 (默认 10)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDevdocTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
@@ -125,6 +186,22 @@ func devdocFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string)
|
||||
return ""
|
||||
}
|
||||
|
||||
func devdocSetStringParam(params map[string]any, key, value string) {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
params[key] = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
func devdocJoinQueryParts(parts ...string) string {
|
||||
cleaned := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
cleaned = append(cleaned, trimmed)
|
||||
}
|
||||
}
|
||||
return strings.Join(cleaned, " ")
|
||||
}
|
||||
|
||||
func addDevdocHiddenStringFlag(cmd *cobra.Command, name, usage string) {
|
||||
cmd.Flags().String(name, "", usage)
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
|
||||
@@ -16,6 +16,7 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
@@ -43,8 +44,8 @@ func TestDevdocArticleSearchAcceptsWukongKeywordAlias(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if runner.last.Tool != "search_open_platform_docs" {
|
||||
t.Fatalf("tool = %q, want search_open_platform_docs", runner.last.Tool)
|
||||
if runner.last.Tool != "search_open_platform_docs_rag" {
|
||||
t.Fatalf("tool = %q, want search_open_platform_docs_rag", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["keyword"]; got != "openConversationId" {
|
||||
t.Fatalf("keyword = %#v, want openConversationId", got)
|
||||
@@ -74,3 +75,143 @@ func TestDevdocArticleSearchAcceptsPositionalKeyword(t *testing.T) {
|
||||
t.Fatalf("keyword = %#v, want MCP", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevdocErrorDiagnosePassesRequestID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &devdocCommandRunner{}
|
||||
cmd := devdocHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"error", "diagnose", "--request-id", "req-123", "--page", "2", "--size", "5"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if runner.last.Tool != "search_open_error_code_rag" {
|
||||
t.Fatalf("tool = %q, want search_open_error_code_rag", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["requestId"]; got != "req-123" {
|
||||
t.Fatalf("requestId = %#v, want req-123", got)
|
||||
}
|
||||
if got := runner.last.Params["page"]; got != 2 {
|
||||
t.Fatalf("page = %#v, want 2", got)
|
||||
}
|
||||
if got := runner.last.Params["size"]; got != 5 {
|
||||
t.Fatalf("size = %#v, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevdocErrorDiagnoseMapsTraceIDAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &devdocCommandRunner{}
|
||||
cmd := devdocHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"error", "diagnose", "--trace-id", "trace-abc", "--api", "创建日程"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if got := runner.last.Params["requestId"]; got != "trace-abc" {
|
||||
t.Fatalf("requestId = %#v, want trace-abc", got)
|
||||
}
|
||||
if _, ok := runner.last.Params["traceId"]; ok {
|
||||
t.Fatalf("traceId should not be sent, params = %#v", runner.last.Params)
|
||||
}
|
||||
if _, ok := runner.last.Params["apiName"]; ok {
|
||||
t.Fatalf("apiName should not be sent, params = %#v", runner.last.Params)
|
||||
}
|
||||
if got := runner.last.Params["query"]; got != "创建日程" {
|
||||
t.Fatalf("query = %#v, want 创建日程", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevdocErrorDiagnosePassesErrorContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &devdocCommandRunner{}
|
||||
cmd := devdocHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{
|
||||
"error", "troubleshoot",
|
||||
"--error-code", "33012",
|
||||
"--error-message", "missing scope",
|
||||
"--context", "create calendar failed",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if got := runner.last.Params["errorCode"]; got != "33012" {
|
||||
t.Fatalf("errorCode = %#v, want 33012", got)
|
||||
}
|
||||
if _, ok := runner.last.Params["errorMessage"]; ok {
|
||||
t.Fatalf("errorMessage should not be sent, params = %#v", runner.last.Params)
|
||||
}
|
||||
if _, ok := runner.last.Params["context"]; ok {
|
||||
t.Fatalf("context should not be sent, params = %#v", runner.last.Params)
|
||||
}
|
||||
if got := runner.last.Params["query"]; got != "missing scope create calendar failed" {
|
||||
t.Fatalf("query = %#v, want merged error context", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevdocErrorDiagnoseMergesAllContextIntoQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &devdocCommandRunner{}
|
||||
cmd := devdocHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{
|
||||
"error", "diagnose",
|
||||
"--query", "机器人回调失败",
|
||||
"--error-message", "missing scope",
|
||||
"--api", "创建日程",
|
||||
"--context", "应用无权限",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if got := runner.last.Tool; got != "search_open_error_code_rag" {
|
||||
t.Fatalf("tool = %q, want search_open_error_code_rag", got)
|
||||
}
|
||||
if got := runner.last.Params["query"]; got != "机器人回调失败 missing scope 创建日程 应用无权限" {
|
||||
t.Fatalf("query = %#v, want merged context", got)
|
||||
}
|
||||
for _, key := range []string{"apiName", "errorMessage", "context"} {
|
||||
if _, ok := runner.last.Params[key]; ok {
|
||||
t.Fatalf("%s should not be sent, params = %#v", key, runner.last.Params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevdocErrorDiagnoseRequiresTroubleshootInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &devdocCommandRunner{}
|
||||
cmd := devdocHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"error", "diagnose", "--api", "创建日程"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "one of --query") {
|
||||
t.Fatalf("error = %q, want required input hint", err.Error())
|
||||
}
|
||||
if runner.last.Tool != "" {
|
||||
t.Fatalf("tool = %q, want no call", runner.last.Tool)
|
||||
}
|
||||
}
|
||||
|
||||
+41
-1
@@ -357,7 +357,13 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
if sniffJsonMLLike(content) {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), `warning: 输入内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 markdown 解析。`)
|
||||
}
|
||||
params["markdown"] = content
|
||||
if stripped, ok := stripLeadingDuplicateTitleHeading(content, name); ok {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), `note: 正文首行与 --name 相同的一级标题已自动移除(文档标题会单独渲染为页面标题,保留会出现两个标题)。`)
|
||||
content = stripped
|
||||
}
|
||||
if content != "" {
|
||||
params["markdown"] = content
|
||||
}
|
||||
}
|
||||
} else if format, err := docContentFormat(cmd, "markdown", "jsonml"); err != nil {
|
||||
return err
|
||||
@@ -385,6 +391,40 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// stripLeadingDuplicateTitleHeading removes a leading markdown ATX H1 whose
|
||||
// text equals the document name. The platform already renders the document
|
||||
// name as the page title, so a body that opens with the same H1 displays the
|
||||
// title twice (the "two headings" effect). Only an exact match (trimmed,
|
||||
// case-insensitive) is removed; any other leading H1 is kept as intentional
|
||||
// content. Reports whether a heading was stripped.
|
||||
func stripLeadingDuplicateTitleHeading(content, name string) (string, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return content, false
|
||||
}
|
||||
body := strings.TrimLeft(content, "\ufeff \t\r\n")
|
||||
line := body
|
||||
rest := ""
|
||||
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||||
line = body[:idx]
|
||||
rest = body[idx+1:]
|
||||
}
|
||||
line = strings.TrimRight(line, " \t\r")
|
||||
if !strings.HasPrefix(line, "# ") {
|
||||
return content, false
|
||||
}
|
||||
heading := strings.TrimSpace(line[2:])
|
||||
// ATX closing sequence ("# Title #") — only trim trailing hashes when
|
||||
// separated by a space, so names that legitimately end with '#' survive.
|
||||
if t := strings.TrimRight(heading, "#"); t != heading && strings.HasSuffix(t, " ") {
|
||||
heading = strings.TrimSpace(t)
|
||||
}
|
||||
if !strings.EqualFold(heading, name) {
|
||||
return content, false
|
||||
}
|
||||
return strings.TrimLeft(rest, "\r\n"), true
|
||||
}
|
||||
|
||||
func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStripLeadingDuplicateTitleHeading(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
docName string
|
||||
want string
|
||||
stripped bool
|
||||
}{
|
||||
{
|
||||
name: "exact match stripped",
|
||||
content: "# 2026-06-10 Ari晚会简报\n\n聚焦今日变化。\n",
|
||||
docName: "2026-06-10 Ari晚会简报",
|
||||
want: "聚焦今日变化。\n",
|
||||
stripped: true,
|
||||
},
|
||||
{
|
||||
name: "leading blank lines tolerated",
|
||||
content: "\n\n# Title\nbody",
|
||||
docName: "Title",
|
||||
want: "body",
|
||||
stripped: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive match",
|
||||
content: "# weekly REPORT\nbody",
|
||||
docName: "Weekly Report",
|
||||
want: "body",
|
||||
stripped: true,
|
||||
},
|
||||
{
|
||||
name: "atx closing hashes",
|
||||
content: "# Title #\nbody",
|
||||
docName: "Title",
|
||||
want: "body",
|
||||
stripped: true,
|
||||
},
|
||||
{
|
||||
name: "title-only content becomes empty",
|
||||
content: "# Title",
|
||||
docName: "Title",
|
||||
want: "",
|
||||
stripped: true,
|
||||
},
|
||||
{
|
||||
name: "different heading kept",
|
||||
content: "# 背景\nbody",
|
||||
docName: "2026-06-10 Ari晚会简报",
|
||||
want: "# 背景\nbody",
|
||||
stripped: false,
|
||||
},
|
||||
{
|
||||
name: "h2 not touched",
|
||||
content: "## Title\nbody",
|
||||
docName: "Title",
|
||||
want: "## Title\nbody",
|
||||
stripped: false,
|
||||
},
|
||||
{
|
||||
name: "no heading kept",
|
||||
content: "plain text\n# Title later",
|
||||
docName: "Title",
|
||||
want: "plain text\n# Title later",
|
||||
stripped: false,
|
||||
},
|
||||
{
|
||||
name: "name ending with hash not over-trimmed",
|
||||
content: "# C#\nbody",
|
||||
docName: "C",
|
||||
want: "# C#\nbody",
|
||||
stripped: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, stripped := stripLeadingDuplicateTitleHeading(tc.content, tc.docName)
|
||||
if stripped != tc.stripped {
|
||||
t.Fatalf("stripped = %v, want %v", stripped, tc.stripped)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("content = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocCreateStripsDuplicateTitleHeading verifies the end-to-end behavior:
|
||||
// `doc create --name X --content "# X\n..."` must not forward the duplicate
|
||||
// H1 to the MCP tool — the platform renders the document name as the page
|
||||
// title, so keeping it would display two headings.
|
||||
func TestDocCreateStripsDuplicateTitleHeading(t *testing.T) {
|
||||
runner := &docCommandRunner{}
|
||||
root := newDocTestRoot(runner)
|
||||
|
||||
_, errOut, err := executeDocCommand(t, root,
|
||||
"create", "--name", "2026-06-10 Ari晚会简报",
|
||||
"--content", "# 2026-06-10 Ari晚会简报\n\n聚焦今日变化。")
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
got, _ := runner.last.Params["markdown"].(string)
|
||||
if got != "聚焦今日变化。" {
|
||||
t.Errorf("markdown param = %q, want duplicate H1 stripped", got)
|
||||
}
|
||||
if !strings.Contains(errOut, "已自动移除") {
|
||||
t.Errorf("stderr = %q, want a note about the removed heading", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocCreateKeepsDistinctHeading ensures the guard never eats an H1 that
|
||||
// differs from the document name.
|
||||
func TestDocCreateKeepsDistinctHeading(t *testing.T) {
|
||||
runner := &docCommandRunner{}
|
||||
root := newDocTestRoot(runner)
|
||||
|
||||
_, _, err := executeDocCommand(t, root,
|
||||
"create", "--name", "晚会简报", "--content", "# 背景\n正文")
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
got, _ := runner.last.Params["markdown"].(string)
|
||||
if got != "# 背景\n正文" {
|
||||
t.Errorf("markdown param = %q, want content untouched", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocCreateTitleOnlyContentOmitsMarkdown: when the body is nothing but
|
||||
// the duplicate H1, the markdown param should be omitted entirely instead of
|
||||
// sending an empty string.
|
||||
func TestDocCreateTitleOnlyContentOmitsMarkdown(t *testing.T) {
|
||||
runner := &docCommandRunner{}
|
||||
root := newDocTestRoot(runner)
|
||||
|
||||
_, _, err := executeDocCommand(t, root,
|
||||
"create", "--name", "晚会简报", "--content", "# 晚会简报")
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if _, ok := runner.last.Params["markdown"]; ok {
|
||||
t.Errorf("markdown param = %v, want omitted", runner.last.Params["markdown"])
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://mcp.dingtalk.com"
|
||||
defaultBaseURL = config.DefaultMCPBaseURL
|
||||
registryMetadataKey = "com.dingtalk.mcp.registry/metadata"
|
||||
|
||||
// discoveryAPIPath is the path appended to BaseURL when fetching the
|
||||
|
||||
@@ -102,7 +102,7 @@ func saveBrowserPolicy(configDir string, policy *BrowserPolicy) error {
|
||||
}
|
||||
|
||||
func ResolveBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) {
|
||||
agentCode, err := resolveAgentCode(explicitAgentCode, false)
|
||||
agentCode, err := resolveAgentCode(explicitAgentCode)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package pat
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -235,3 +236,30 @@ func TestBrowserPolicyCommand_NoAgentCodeWritesDefaultEvenWhenEnvSet(t *testing.
|
||||
t.Fatalf("len(policy.Agents) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserPolicyCommand_RequiresEnabledFlag(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
cmd := newBrowserPolicyCommand()
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stdout)
|
||||
cmd.SetArgs([]string{"--agentCode", "agt-command"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("browser-policy Execute() error = nil, want missing --enabled error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--enabled is required") {
|
||||
t.Fatalf("browser-policy error = %q, want --enabled requirement", err.Error())
|
||||
}
|
||||
|
||||
policy, loadErr := LoadBrowserPolicy(configDir)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("LoadBrowserPolicy error = %v", loadErr)
|
||||
}
|
||||
if policy.Default != nil || len(policy.Agents) != 0 {
|
||||
t.Fatalf("policy was modified despite missing --enabled: %#v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
+186
-54
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
@@ -58,23 +59,24 @@ func resolveSessionIDFromEnv() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// agentCodeEnv is the canonical (and only) environment variable name
|
||||
// used as a per-shell fallback for the --agentCode flag on `dws pat *`
|
||||
// commands.
|
||||
// agentCodeEnv is the canonical environment variable name used as a
|
||||
// per-shell fallback for the --agentCode flag on `dws pat *` commands.
|
||||
//
|
||||
// Why: agent hosts may set their business agent code once when spawning
|
||||
// a long-lived shell / sub-process. Exposing DINGTALK_DWS_AGENTCODE lets
|
||||
// the host export the code once and let the CLI resolve it on every pat
|
||||
// subcommand. The flag always wins when both are set so scripted one-offs
|
||||
// remain deterministic. When neither flag nor env is set, the request is
|
||||
// sent without agentCode and lippi-pat-core applies its default agentCode.
|
||||
// remain deterministic. When neither flag nor env is set, `pat chmod` omits
|
||||
// agentCode and lets the PAT server apply its open-source default. Batch PAT
|
||||
// tools receive the resolved agentCode in arguments when present, while the CLI
|
||||
// also keeps exporting it through env for older gateway paths.
|
||||
//
|
||||
// Namespace note: DWS_AGENTCODE / DINGTALK_AGENTCODE / REWIND_AGENTCODE
|
||||
// are explicitly NOT consumed. The legacy DWS_AGENTCODE alias was
|
||||
// hard-removed once the public integration surface landed on
|
||||
// DINGTALK_DWS_AGENTCODE; hosts must migrate rather than rely on a
|
||||
// silent fallback.
|
||||
const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
|
||||
// Namespace note: keep this as a single-spelled public contract. The reversed
|
||||
// draft name DWS_DINGTALK_AGENTCODE and legacy names such as DWS_AGENTCODE /
|
||||
// DINGTALK_AGENTCODE / REWIND_AGENTCODE are explicitly NOT consumed.
|
||||
const (
|
||||
agentCodeEnv = authpkg.AgentCodeEnv
|
||||
)
|
||||
|
||||
// agentCodePattern is the validation regex for any --agentCode value
|
||||
// resolved from either the flag or the agent-code env var. It matches
|
||||
@@ -84,16 +86,12 @@ const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
|
||||
// argument.
|
||||
var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
// resolveAgentCodeFromEnv returns the fallback agent code from the
|
||||
// canonical DINGTALK_DWS_AGENTCODE env var. The second return value
|
||||
// reports the env name that was consumed (for error attribution); it
|
||||
// is "" when the env is unset or blank. No legacy aliases are honored.
|
||||
// resolveAgentCodeFromEnv returns the fallback agent code from the canonical
|
||||
// DINGTALK_DWS_AGENTCODE env var. The second return value reports the env name
|
||||
// that was consumed (for error attribution); it is "" when the env var is unset
|
||||
// or blank.
|
||||
func resolveAgentCodeFromEnv() (string, string) {
|
||||
primary := strings.TrimSpace(os.Getenv(agentCodeEnv))
|
||||
if primary != "" {
|
||||
return primary, agentCodeEnv
|
||||
}
|
||||
return "", ""
|
||||
return authpkg.AgentCodeFromEnv()
|
||||
}
|
||||
|
||||
// validateAgentCode rejects agent codes that would be ambiguous or unsafe
|
||||
@@ -111,34 +109,34 @@ func validateAgentCode(code string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAgentCode implements the canonical two-tier lookup for
|
||||
// --agentCode:
|
||||
// resolveAgentCode implements the canonical optional lookup for --agentCode:
|
||||
//
|
||||
// 1. explicit --agentCode flag value (highest priority; wins over env)
|
||||
// 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback)
|
||||
// 3. empty ("") when required=false; typed error when required=true.
|
||||
// 3. empty ("") so PAT-core can apply its open-source default.
|
||||
//
|
||||
// Any non-empty resolved value is validated via validateAgentCode, so
|
||||
// callers never have to re-validate.
|
||||
func resolveAgentCode(flagVal string, required bool) (string, error) {
|
||||
func resolveAgentCode(flagVal string) (string, error) {
|
||||
code := strings.TrimSpace(flagVal)
|
||||
envSource := ""
|
||||
if code == "" {
|
||||
code, envSource = resolveAgentCodeFromEnv()
|
||||
}
|
||||
if code == "" {
|
||||
if required {
|
||||
return "", fmt.Errorf(
|
||||
"flag --agentCode is required (or set env %s)\n hint: dws pat chmod <scope>... --agentCode <id>\n hint: export %s=<id>",
|
||||
agentCodeEnv, agentCodeEnv)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if err := validateAgentCode(code); err != nil {
|
||||
if envSource != "" {
|
||||
return "", fmt.Errorf("%s env: %w", envSource, err)
|
||||
return "", apperrors.NewValidation(
|
||||
fmt.Sprintf("%s env: %v", envSource, err),
|
||||
apperrors.WithReason("invalid_agent_code"),
|
||||
)
|
||||
}
|
||||
return "", err
|
||||
return "", apperrors.NewValidation(
|
||||
err.Error(),
|
||||
apperrors.WithReason("invalid_agent_code"),
|
||||
)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
@@ -159,8 +157,25 @@ const (
|
||||
|
||||
patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED"
|
||||
patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported"
|
||||
patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD"
|
||||
patForgedIdentityCodeLower = "pat_forged_identity_field"
|
||||
)
|
||||
|
||||
var patBatchMetadataContractCodes = map[string]bool{
|
||||
"pat_batch_auth_metadata_required": true,
|
||||
"pat_batch_scope_not_declared": true,
|
||||
"pat_batch_product_not_declared": true,
|
||||
}
|
||||
|
||||
var patBatchIdentityArgumentKeys = map[string]bool{
|
||||
"agentCode": true,
|
||||
"sessionId": true,
|
||||
"orgId": true,
|
||||
"uid": true,
|
||||
"source": true,
|
||||
"caller": true,
|
||||
}
|
||||
|
||||
var validGrantTypes = map[string]bool{
|
||||
"once": true,
|
||||
"session": true,
|
||||
@@ -189,7 +204,21 @@ scope 格式: <product>.<entity>:<permission>
|
||||
grantType 规则:
|
||||
once 一次性,执行一次后自动失效
|
||||
session 当前会话有效(默认),需要 --session-id
|
||||
permanent 永久有效`,
|
||||
permanent 永久有效
|
||||
|
||||
批量授权:
|
||||
dws pat chmod 支持一次传多个 scope 直接批量授予。
|
||||
也支持 --products / --product 按产品编码批量展开 scope 模板,
|
||||
--domains / --domain 按产品域批量展开 scope 模板,
|
||||
--recommend 使用服务端推荐 scope 集合。
|
||||
使用产品 / 域 / 推荐集合时,CLI 会先生成 batch plan,确认
|
||||
selected / skipped / pending,再对 selected scopes 执行 batch grant;
|
||||
--dry-run 只返回授权计划,不写入授权。真正执行批量授权必须显式
|
||||
添加 --yes;未加 --yes 时 CLI 会阻断并提示 agent 先确认。
|
||||
|
||||
agentCode 配置:
|
||||
可通过 --agentCode 或 DINGTALK_DWS_AGENTCODE
|
||||
指定;未传 agentCode 时,CLI 会省略该字段并由服务端默认兜底。`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag)
|
||||
if len(args) > 0 || recommend || len(productCodes) > 0 {
|
||||
@@ -199,12 +228,14 @@ grantType 规则:
|
||||
},
|
||||
Example: ` dws pat chmod aitable.record:read --grant-type session --session-id session-xxx
|
||||
dws pat chmod chat.message:list --grant-type once
|
||||
dws pat chmod aitable.record:read aitable.record:write --grant-type permanent
|
||||
dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx
|
||||
dws pat chmod --recommend --grant-type session --session-id session-xxx`,
|
||||
dws pat chmod aitable.record:read aitable.record:write --grant-type permanent --yes
|
||||
dws pat chmod --product calendar --product aitable --grant-type once --dry-run --format json
|
||||
dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx --yes
|
||||
dws pat chmod --domain calendar --domain chat --grant-type once --yes
|
||||
dws pat chmod --recommend --grant-type session --session-id session-xxx --yes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
flagVal, _ := cmd.Flags().GetString("agentCode")
|
||||
agentCode, err := resolveAgentCode(flagVal, false)
|
||||
agentCode, err := resolveAgentCode(flagVal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -227,7 +258,7 @@ grantType 规则:
|
||||
|
||||
if c != nil && c.DryRun() {
|
||||
if usesPlan {
|
||||
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, sessionID, true)
|
||||
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true)
|
||||
result, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pat chmod plan failed: %w", err)
|
||||
@@ -239,8 +270,6 @@ grantType 规则:
|
||||
fmt.Printf("%-16s%s\n", "Tool:", patBatchGrantToolName)
|
||||
if agentCode != "" {
|
||||
fmt.Printf("%-16s%s\n", "AgentCode:", agentCode)
|
||||
} else {
|
||||
fmt.Printf("%-16s%s\n", "AgentCode:", "(server default)")
|
||||
}
|
||||
fmt.Printf("%-16s%v\n", "Scope:", scopes)
|
||||
fmt.Printf("%-16s%s\n", "GrantType:", grantType)
|
||||
@@ -255,7 +284,7 @@ grantType 规则:
|
||||
}
|
||||
|
||||
if usesPlan {
|
||||
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, sessionID, true)
|
||||
planArgs := buildBatchPlanArgs(scopes, productCodes, recommend, grantType, agentCode, sessionID, true)
|
||||
planResult, err := callPATBatchPlan(cmd.Context(), c, agentCode, sessionID, planArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pat chmod plan failed: %w", err)
|
||||
@@ -267,6 +296,11 @@ grantType 规则:
|
||||
if len(scopes) == 0 {
|
||||
return handleToolResult(cmd, c, planResult)
|
||||
}
|
||||
if err := requireBatchGrantConfirmation(cmd, true, scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := requireBatchGrantConfirmation(cmd, false, scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
batchArgs := map[string]any{
|
||||
"scopes": scopes,
|
||||
@@ -277,6 +311,7 @@ grantType 规则:
|
||||
"grantType": grantType,
|
||||
}
|
||||
if agentCode != "" {
|
||||
batchArgs["agentCode"] = agentCode
|
||||
toolArgs["agentCode"] = agentCode
|
||||
}
|
||||
if sessionID != "" {
|
||||
@@ -314,18 +349,37 @@ grantType 规则:
|
||||
}
|
||||
|
||||
chmodCmd.Flags().String("agentCode", "",
|
||||
"Agent 唯一标识(可选;不填则由服务端写入默认 AgentCode;env DINGTALK_DWS_AGENTCODE 可注入,flag 优先)")
|
||||
"Agent 唯一标识(可选;也可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先;未传则由服务端默认兜底)")
|
||||
chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent")
|
||||
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
|
||||
chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价")
|
||||
chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔")
|
||||
chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权")
|
||||
chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔")
|
||||
chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权")
|
||||
chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价;执行批量授权需 --yes")
|
||||
chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔;执行批量授权需 --yes")
|
||||
chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权;执行授权需 --yes")
|
||||
chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔;执行批量授权需 --yes")
|
||||
chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权;执行授权需 --yes")
|
||||
|
||||
return chmodCmd
|
||||
}
|
||||
|
||||
func requireBatchGrantConfirmation(cmd *cobra.Command, usesPlan bool, scopes []string) error {
|
||||
if !usesPlan && len(scopes) <= 1 {
|
||||
return nil
|
||||
}
|
||||
if commandBoolFlag(cmd, "yes") {
|
||||
return nil
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
"batch PAT authorization blocked: explicit user confirmation is required; rerun with --yes only after the user approves the batch grant",
|
||||
apperrors.WithReason("pat_batch_requires_yes"),
|
||||
apperrors.WithHint("先执行 dws pat chmod ... --dry-run --format json 查看 selected/skipped/pending;用户明确确认后再追加 --yes 执行批量授权。"),
|
||||
apperrors.WithActions(
|
||||
"dws pat chmod <scope1> <scope2> ... --grant-type once --yes",
|
||||
"dws pat chmod --products <product1,product2> --grant-type once --yes",
|
||||
"dws pat chmod --recommend --grant-type once --yes",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func collectChmodProductCodes(groups ...[]string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0)
|
||||
@@ -387,13 +441,11 @@ func callPATBatchGrantWithLegacyFallback(
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("internal error: tool runtime not initialized")
|
||||
}
|
||||
result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, "pat", patBatchGrantToolName, batchArgs)
|
||||
})
|
||||
if err == nil && !isPATBatchUnsupportedResult(result) {
|
||||
result, err := callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchGrantToolName, batchArgs)
|
||||
if err == nil && !isPATBatchFallbackResult(result) {
|
||||
return result, nil
|
||||
}
|
||||
if err != nil && !isPATBatchUnsupportedError(err) && !isToolNotRegisteredError(err) {
|
||||
if err != nil && !isPATBatchFallbackError(err) && !isToolNotRegisteredError(err) {
|
||||
return nil, err
|
||||
}
|
||||
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
|
||||
@@ -413,12 +465,23 @@ func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sess
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("internal error: tool runtime not initialized")
|
||||
}
|
||||
return callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchPlanToolName, args)
|
||||
}
|
||||
|
||||
func callPATBatchToolWithIdentityFallback(ctx context.Context, c edition.ToolCaller, agentCode, sessionID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, "pat", toolName, args)
|
||||
})
|
||||
if !shouldRetryPATBatchWithoutIdentityArgs(result, err, args) {
|
||||
return result, err
|
||||
}
|
||||
compatArgs := cloneWithoutPATIdentityArgs(args)
|
||||
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, "pat", patBatchPlanToolName, args)
|
||||
return c.CallTool(ctx, "pat", toolName, compatArgs)
|
||||
})
|
||||
}
|
||||
|
||||
func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, sessionID string, dryRun bool) map[string]any {
|
||||
func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, agentCode string, sessionID string, dryRun bool) map[string]any {
|
||||
args := map[string]any{
|
||||
"scopes": scopes,
|
||||
"productCodes": productCodes,
|
||||
@@ -426,6 +489,9 @@ func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool,
|
||||
"grantType": grantType,
|
||||
"dryRun": dryRun,
|
||||
}
|
||||
if agentCode != "" {
|
||||
args["agentCode"] = agentCode
|
||||
}
|
||||
if sessionID != "" {
|
||||
args["sessionId"] = sessionID
|
||||
}
|
||||
@@ -481,6 +547,25 @@ func firstToolResultText(result *edition.ToolResult) string {
|
||||
}
|
||||
|
||||
func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
|
||||
return patBatchResultHasCode(result, func(code string) bool {
|
||||
return strings.EqualFold(code, patBatchUnsupportedCode)
|
||||
})
|
||||
}
|
||||
|
||||
func isPATBatchFallbackResult(result *edition.ToolResult) bool {
|
||||
return patBatchResultHasCode(result, func(code string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(code))
|
||||
return strings.EqualFold(code, patBatchUnsupportedCode) || patBatchMetadataContractCodes[normalized]
|
||||
})
|
||||
}
|
||||
|
||||
func isPATForgedIdentityResult(result *edition.ToolResult) bool {
|
||||
return patBatchResultHasCode(result, func(code string) bool {
|
||||
return strings.EqualFold(code, patForgedIdentityCode)
|
||||
})
|
||||
}
|
||||
|
||||
func patBatchResultHasCode(result *edition.ToolResult, matches func(string) bool) bool {
|
||||
text := firstToolResultText(result)
|
||||
if text == "" {
|
||||
return false
|
||||
@@ -490,7 +575,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"code", "errorCode", "error_code"} {
|
||||
if code, ok := body[key].(string); ok && strings.EqualFold(strings.TrimSpace(code), patBatchUnsupportedCode) {
|
||||
if code, ok := body[key].(string); ok && matches(strings.TrimSpace(code)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -501,6 +586,53 @@ func isPATBatchUnsupportedError(err error) bool {
|
||||
return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower)
|
||||
}
|
||||
|
||||
func isPATBatchFallbackError(err error) bool {
|
||||
if isPATBatchUnsupportedError(err) {
|
||||
return true
|
||||
}
|
||||
text := normalizedPATErrorText(err)
|
||||
for code := range patBatchMetadataContractCodes {
|
||||
if strings.Contains(text, code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPATForgedIdentityError(err error) bool {
|
||||
return err != nil && strings.Contains(normalizedPATErrorText(err), patForgedIdentityCodeLower)
|
||||
}
|
||||
|
||||
func shouldRetryPATBatchWithoutIdentityArgs(result *edition.ToolResult, err error, args map[string]any) bool {
|
||||
if !hasPATIdentityArgs(args) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
return isPATForgedIdentityError(err)
|
||||
}
|
||||
return isPATForgedIdentityResult(result)
|
||||
}
|
||||
|
||||
func hasPATIdentityArgs(args map[string]any) bool {
|
||||
for key := range args {
|
||||
if patBatchIdentityArgumentKeys[key] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneWithoutPATIdentityArgs(args map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
if patBatchIdentityArgumentKeys[key] {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// callPATToolWithLegacyFallback invokes the canonical PAT grant tool first,
|
||||
// then silently retries the legacy Chinese alias when the server has not
|
||||
// registered the canonical tool yet. The retry intentionally emits no stderr
|
||||
|
||||
+768
-26
@@ -29,8 +29,8 @@ import (
|
||||
)
|
||||
|
||||
// fakeToolCaller captures the toolArgs passed to CallTool so tests can
|
||||
// assert how the two-tier --agentCode / DINGTALK_DWS_AGENTCODE / error
|
||||
// resolver feeds into the outgoing MCP argv.
|
||||
// assert how the optional --agentCode / DINGTALK_DWS_AGENTCODE resolver feeds
|
||||
// into the outgoing batch request.
|
||||
type fakeToolCaller struct {
|
||||
mu sync.Mutex
|
||||
dryRun bool
|
||||
@@ -68,8 +68,11 @@ func (f *fakeToolCaller) Format() string { return "json" }
|
||||
func (f *fakeToolCaller) DryRun() bool { return f.dryRun }
|
||||
|
||||
type recordedToolCall struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
tool string
|
||||
args map[string]any
|
||||
agentEnv string
|
||||
sessionEnv string
|
||||
dingSessionEnv string
|
||||
}
|
||||
|
||||
type fallbackToolCaller struct {
|
||||
@@ -198,6 +201,7 @@ func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false }
|
||||
type sequenceToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
responses []string
|
||||
errs []error
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
@@ -207,6 +211,12 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri
|
||||
copied[k] = v
|
||||
}
|
||||
s.calls = append(s.calls, recordedToolCall{tool: toolName, args: copied})
|
||||
s.calls[len(s.calls)-1].agentEnv = os.Getenv(agentCodeEnv)
|
||||
s.calls[len(s.calls)-1].sessionEnv = os.Getenv(sessionIDEnvDWS)
|
||||
s.calls[len(s.calls)-1].dingSessionEnv = os.Getenv(sessionIDEnvDingtalk)
|
||||
if len(s.errs) >= len(s.calls) && s.errs[len(s.calls)-1] != nil {
|
||||
return nil, s.errs[len(s.calls)-1]
|
||||
}
|
||||
response := `{"success":true,"data":{}}`
|
||||
if len(s.responses) >= len(s.calls) {
|
||||
response = s.responses[len(s.calls)-1]
|
||||
@@ -257,6 +267,37 @@ func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command {
|
||||
return newChmodCommand(fake)
|
||||
}
|
||||
|
||||
func attachRootYesFlag(t *testing.T, cmd *cobra.Command, yes bool) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
root.AddCommand(cmd)
|
||||
if yes {
|
||||
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
t.Fatalf("set root --yes: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attachRootPATFlags(t *testing.T, cmd *cobra.Command, yes bool, formatChanged bool) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().Bool("verbose", false, "")
|
||||
root.AddCommand(cmd)
|
||||
if yes {
|
||||
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
t.Fatalf("set root --yes: %v", err)
|
||||
}
|
||||
}
|
||||
if formatChanged {
|
||||
if err := root.PersistentFlags().Set("format", "json"); err != nil {
|
||||
t.Fatalf("set root --format: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
RegisterCommands(root, &fakeToolCaller{})
|
||||
@@ -279,6 +320,62 @@ func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATHelpDocumentsBatchAuthorization(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
RegisterCommands(root, &fakeToolCaller{})
|
||||
|
||||
patCmd, _, err := root.Find([]string{"pat"})
|
||||
if err != nil {
|
||||
t.Fatalf("pat command not found: %v", err)
|
||||
}
|
||||
var out strings.Builder
|
||||
patCmd.SetOut(&out)
|
||||
patCmd.SetErr(&out)
|
||||
if err := patCmd.Help(); err != nil {
|
||||
t.Fatalf("pat help error = %v", err)
|
||||
}
|
||||
patHelp := out.String()
|
||||
for _, want := range []string{
|
||||
"支持批量授权",
|
||||
"--products / --product",
|
||||
"--domains / --domain",
|
||||
"--recommend",
|
||||
"DINGTALK_DWS_AGENTCODE",
|
||||
"未传 agentCode 时由服务端默认兜底",
|
||||
} {
|
||||
if !strings.Contains(patHelp, want) {
|
||||
t.Fatalf("pat help missing %q\nhelp:\n%s", want, patHelp)
|
||||
}
|
||||
}
|
||||
|
||||
chmodCmd, _, err := root.Find([]string{"pat", "chmod"})
|
||||
if err != nil {
|
||||
t.Fatalf("pat chmod command not found: %v", err)
|
||||
}
|
||||
out.Reset()
|
||||
chmodCmd.SetOut(&out)
|
||||
chmodCmd.SetErr(&out)
|
||||
if err := chmodCmd.Help(); err != nil {
|
||||
t.Fatalf("pat chmod help error = %v", err)
|
||||
}
|
||||
chmodHelp := out.String()
|
||||
for _, want := range []string{
|
||||
"批量授权:",
|
||||
"一次传多个 scope",
|
||||
"batch plan",
|
||||
"--dry-run 只返回授权计划",
|
||||
"执行批量授权必须显式",
|
||||
"由服务端默认兜底",
|
||||
"aitable.record:read aitable.record:write --grant-type permanent --yes",
|
||||
"dws pat chmod --product calendar --product aitable",
|
||||
"dws pat chmod --domain calendar --domain chat",
|
||||
} {
|
||||
if !strings.Contains(chmodHelp, want) {
|
||||
t.Fatalf("pat chmod help missing %q\nhelp:\n%s", want, chmodHelp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
@@ -288,6 +385,7 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("products", "calendar,aitable")
|
||||
attachRootYesFlag(t, cmd, true)
|
||||
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
@@ -305,18 +403,68 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
|
||||
if got := fake.calls[0].args["recommend"]; got != false {
|
||||
t.Fatalf("recommend = %#v, want false", got)
|
||||
}
|
||||
if got := fake.calls[0].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("plan agent env = %q, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("batch plan agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if fake.calls[1].tool != patBatchGrantToolName {
|
||||
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
|
||||
}
|
||||
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:read"}) {
|
||||
t.Fatalf("grant scopes = %#v, want selected scopes", got)
|
||||
}
|
||||
if _, ok := fake.calls[1].args["agentCode"]; ok {
|
||||
t.Fatalf("batch grant args must not contain agentCode: %#v", fake.calls[1].args)
|
||||
if got := fake.calls[1].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("grant agent env = %q, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("batch grant agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
|
||||
func TestChmod_productsFlagBlocksGrantWithoutYes(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:read"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("products", "calendar,aitable")
|
||||
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("chmod RunE error = nil, want batch --yes blocker")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
|
||||
t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want plan only", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchPlanToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_multipleExplicitScopesBlockWithoutYes(t *testing.T) {
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
err := cmd.RunE(cmd, []string{"aitable.record:read", "aitable.record:write"})
|
||||
if err == nil {
|
||||
t.Fatal("chmod RunE error = nil, want batch --yes blocker")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
|
||||
t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
|
||||
}
|
||||
if fake.callN != 0 {
|
||||
t.Fatalf("CallTool was invoked %d times; batch without --yes must not grant", fake.callN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`,
|
||||
@@ -325,6 +473,7 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("products", "calendar")
|
||||
_ = cmd.Flags().Set("session-id", "session-123")
|
||||
attachRootYesFlag(t, cmd, true)
|
||||
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
@@ -336,15 +485,424 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
|
||||
if got := fake.calls[0].args["grantType"]; got != "session" {
|
||||
t.Fatalf("plan grantType = %#v, want session", got)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[0].args["sessionId"]; got != "session-123" {
|
||||
t.Fatalf("plan sessionId = %#v, want session-123", got)
|
||||
}
|
||||
if got := fake.calls[0].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("plan agent env = %q, want qoderwork", got)
|
||||
}
|
||||
if fake.calls[0].dingSessionEnv != "session-123" {
|
||||
t.Fatalf("plan %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
|
||||
}
|
||||
if got := fake.calls[1].args["grantType"]; got != "session" {
|
||||
t.Fatalf("grant grantType = %#v, want session", got)
|
||||
}
|
||||
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[1].args["sessionId"]; got != "session-123" {
|
||||
t.Fatalf("grant sessionId = %#v, want session-123", got)
|
||||
}
|
||||
if got := fake.calls[1].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("grant agent env = %q, want qoderwork", got)
|
||||
}
|
||||
if fake.calls[1].dingSessionEnv != "session-123" {
|
||||
t.Fatalf("grant %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[1].dingSessionEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_singleScopeReturnsServerAgentCodeInSummary(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantType":"once","grantedScopes":["contact.user:get-self"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
attachRootPATFlags(t, cmd, false, false)
|
||||
|
||||
output, err := captureStdout(t, func() error {
|
||||
return cmd.RunE(cmd, []string{"contact.user:get-self"})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchGrantToolName {
|
||||
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
|
||||
}
|
||||
if _, ok := fake.calls[0].args["agentCode"]; ok {
|
||||
t.Fatalf("agentCode arg must be omitted so PAT-core can default it: %#v", fake.calls[0].args)
|
||||
}
|
||||
if !strings.Contains(output, "agentCode: dingmbw5n9ktkkbbjv3g") {
|
||||
t.Fatalf("summary output missing server default agentCode:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_flagAgentCodeWinsAndReturnedAgentCodeMatches(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "envshouldlose")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["chat.bot:search"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("agentCode", "qoderwork")
|
||||
attachRootPATFlags(t, cmd, false, false)
|
||||
|
||||
output, err := captureStdout(t, func() error {
|
||||
return cmd.RunE(cmd, []string{"chat.bot:search"})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[0].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("%s during CallTool = %q, want qoderwork", agentCodeEnv, got)
|
||||
}
|
||||
if !strings.Contains(output, "agentCode: qoderwork") {
|
||||
t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchEntryPointMatrixRequiresYesAndReturnsAgentCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
setFlags func(*cobra.Command)
|
||||
wantPlanProducts []string
|
||||
wantRecommend bool
|
||||
wantCallCount int
|
||||
}{
|
||||
{
|
||||
name: "direct multi scope",
|
||||
args: []string{"calendar.event:list", "calendar.event:create"},
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
},
|
||||
wantCallCount: 1,
|
||||
},
|
||||
{
|
||||
name: "product repeated",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("product", "calendar")
|
||||
_ = cmd.Flags().Set("product", "aitable")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "aitable"},
|
||||
wantCallCount: 2,
|
||||
},
|
||||
{
|
||||
name: "products comma list",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("products", "calendar,aitable")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "aitable"},
|
||||
wantCallCount: 2,
|
||||
},
|
||||
{
|
||||
name: "domain repeated",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("domain", "calendar")
|
||||
_ = cmd.Flags().Set("domain", "chat")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "chat"},
|
||||
wantCallCount: 2,
|
||||
},
|
||||
{
|
||||
name: "domains comma list",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("domains", "calendar,chat")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "chat"},
|
||||
wantCallCount: 2,
|
||||
},
|
||||
{
|
||||
name: "recommend",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("recommend", "true")
|
||||
},
|
||||
wantRecommend: true,
|
||||
wantCallCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
responses := []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
|
||||
}
|
||||
if tc.wantCallCount == 2 {
|
||||
responses = []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","selectedScopes":["calendar.event:list","calendar.event:create"],"skippedScopes":[],"pendingScopes":[]}}`,
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
|
||||
}
|
||||
}
|
||||
fake := &sequenceToolCaller{responses: responses}
|
||||
cmd := newChmodCommand(fake)
|
||||
tc.setFlags(cmd)
|
||||
attachRootPATFlags(t, cmd, true, false)
|
||||
|
||||
output, err := captureStdout(t, func() error {
|
||||
return cmd.RunE(cmd, tc.args)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != tc.wantCallCount {
|
||||
t.Fatalf("CallTool count = %d, want %d", len(fake.calls), tc.wantCallCount)
|
||||
}
|
||||
if tc.wantCallCount == 1 {
|
||||
if fake.calls[0].tool != patBatchGrantToolName {
|
||||
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["scopes"]; !stringSliceArgEqual(got, tc.args) {
|
||||
t.Fatalf("grant scopes = %#v, want %#v", got, tc.args)
|
||||
}
|
||||
} else {
|
||||
if fake.calls[0].tool != patBatchPlanToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
|
||||
t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
|
||||
}
|
||||
if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
|
||||
t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
|
||||
}
|
||||
if fake.calls[1].tool != patBatchGrantToolName {
|
||||
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
|
||||
}
|
||||
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:list", "calendar.event:create"}) {
|
||||
t.Fatalf("grant scopes = %#v, want selected scopes", got)
|
||||
}
|
||||
}
|
||||
last := fake.calls[len(fake.calls)-1]
|
||||
if got := last.args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("grant agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if !strings.Contains(output, "agentCode: qoderwork") {
|
||||
t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchPlanEntryPointsDryRunOnlyReturnPlanAgentCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setFlags func(*cobra.Command)
|
||||
wantPlanProducts []string
|
||||
wantRecommend bool
|
||||
}{
|
||||
{
|
||||
name: "product",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("products", "calendar,aitable")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "aitable"},
|
||||
},
|
||||
{
|
||||
name: "domain",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("domains", "calendar,chat")
|
||||
},
|
||||
wantPlanProducts: []string{"calendar", "chat"},
|
||||
},
|
||||
{
|
||||
name: "recommend",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("recommend", "true")
|
||||
},
|
||||
wantRecommend: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{
|
||||
dryRun: true,
|
||||
responses: []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","allGranted":false,"selectedScopes":["calendar.event:list"],"skippedScopes":[],"pendingScopes":[]}}`,
|
||||
},
|
||||
}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
tc.setFlags(cmd)
|
||||
attachRootPATFlags(t, cmd, false, false)
|
||||
|
||||
output, err := captureStdout(t, func() error {
|
||||
return cmd.RunE(cmd, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want dry-run plan only", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchPlanToolName {
|
||||
t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
|
||||
t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
|
||||
}
|
||||
if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
|
||||
t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
|
||||
}
|
||||
if !strings.Contains(output, "agentCode: qoderwork") || !strings.Contains(output, "selected: 1") {
|
||||
t.Fatalf("dry-run summary missing plan agentCode/selection:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchEntryPointsWithoutYesAreBlocked(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
setFlags func(*cobra.Command)
|
||||
wantPlan bool
|
||||
}{
|
||||
{
|
||||
name: "direct multi scope",
|
||||
args: []string{"calendar.event:list", "calendar.event:create"},
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "product",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("products", "calendar")
|
||||
},
|
||||
wantPlan: true,
|
||||
},
|
||||
{
|
||||
name: "domain",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("domains", "calendar")
|
||||
},
|
||||
wantPlan: true,
|
||||
},
|
||||
{
|
||||
name: "recommend",
|
||||
setFlags: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("recommend", "true")
|
||||
},
|
||||
wantPlan: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"data":{"selectedScopes":["calendar.event:list","calendar.event:create"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
tc.setFlags(cmd)
|
||||
|
||||
err := cmd.RunE(cmd, tc.args)
|
||||
if err == nil {
|
||||
t.Fatal("chmod RunE error = nil, want batch --yes blocker")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
|
||||
t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
|
||||
}
|
||||
if tc.wantPlan {
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != patBatchPlanToolName {
|
||||
t.Fatalf("calls = %#v, want one plan call before blocker", fake.calls)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("CallTool count = %d, want no MCP calls for direct multi-scope blocker", len(fake.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_grantTypeAndSessionParameterMatrix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
grantType string
|
||||
sessionFlag string
|
||||
sessionEnv string
|
||||
wantSessionID string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "once no session", grantType: "once"},
|
||||
{name: "permanent no session", grantType: "permanent"},
|
||||
{name: "session from flag", grantType: "session", sessionFlag: "flag-session", wantSessionID: "flag-session"},
|
||||
{name: "session from env", grantType: "session", sessionEnv: "env-session", wantSessionID: "env-session"},
|
||||
{name: "session missing rejected", grantType: "session", wantErr: "--session-id is required"},
|
||||
{name: "invalid grant type rejected", grantType: "invalid", wantErr: "invalid --grant-type"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
if tc.sessionEnv != "" {
|
||||
t.Setenv(sessionIDEnvDWS, tc.sessionEnv)
|
||||
}
|
||||
fake := &sequenceToolCaller{responses: []string{
|
||||
`{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantedScopes":["aitable.record:read"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", tc.grantType)
|
||||
if tc.sessionFlag != "" {
|
||||
_ = cmd.Flags().Set("session-id", tc.sessionFlag)
|
||||
}
|
||||
|
||||
err := cmd.RunE(cmd, []string{"aitable.record:read"})
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("chmod RunE error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("CallTool count = %d, want validator to block before MCP", len(fake.calls))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if got := fake.calls[0].args["grantType"]; got != tc.grantType {
|
||||
t.Fatalf("grantType arg = %#v, want %s", got, tc.grantType)
|
||||
}
|
||||
if tc.wantSessionID == "" {
|
||||
if _, ok := fake.calls[0].args["sessionId"]; ok {
|
||||
t.Fatalf("unexpected sessionId arg: %#v", fake.calls[0].args)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := fake.calls[0].args["sessionId"]; got != tc.wantSessionID {
|
||||
t.Fatalf("sessionId arg = %#v, want %s", got, tc.wantSessionID)
|
||||
}
|
||||
if got := fake.calls[0].dingSessionEnv; got != tc.wantSessionID {
|
||||
t.Fatalf("%s during CallTool = %q, want %s", sessionIDEnvDingtalk, got, tc.wantSessionID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
|
||||
@@ -369,9 +927,99 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
|
||||
if fake.calls[0].tool != patBatchPlanToolName {
|
||||
t.Fatalf("plan tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("plan agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[0].args["sessionId"]; got != "env-session-123" {
|
||||
t.Fatalf("plan sessionId = %#v, want env-session-123", got)
|
||||
}
|
||||
if got := fake.calls[0].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("plan agent env = %q, want qoderwork", got)
|
||||
}
|
||||
if fake.calls[0].dingSessionEnv != "env-session-123" {
|
||||
t.Fatalf("plan %s env = %q, want env-session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{
|
||||
errs: []error{
|
||||
apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: patForgedIdentityCode,
|
||||
}),
|
||||
),
|
||||
nil,
|
||||
},
|
||||
responses: []string{
|
||||
"",
|
||||
`{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`,
|
||||
},
|
||||
}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("products", "calendar")
|
||||
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchPlanToolName || fake.calls[1].tool != patBatchPlanToolName {
|
||||
t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchPlanToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("first plan agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if _, ok := fake.calls[1].args["agentCode"]; ok {
|
||||
t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
|
||||
}
|
||||
if got := fake.calls[1].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
fake := &sequenceToolCaller{
|
||||
errs: []error{
|
||||
apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: patForgedIdentityCode,
|
||||
}),
|
||||
),
|
||||
nil,
|
||||
},
|
||||
responses: []string{
|
||||
"",
|
||||
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
|
||||
},
|
||||
}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"calendar.event:read"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchGrantToolName || fake.calls[1].tool != patBatchGrantToolName {
|
||||
t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchGrantToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("first grant agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if _, ok := fake.calls[1].args["agentCode"]; ok {
|
||||
t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
|
||||
}
|
||||
if got := fake.calls[1].agentEnv; got != "qoderwork" {
|
||||
t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) {
|
||||
@@ -395,6 +1043,7 @@ func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
t.Setenv(sessionIDEnvDingtalk, "ding-session-123")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
@@ -404,6 +1053,9 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
|
||||
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.gotArgs["sessionId"]; got != "ding-session-123" {
|
||||
t.Fatalf("sessionId arg = %#v, want ding-session-123", got)
|
||||
}
|
||||
@@ -416,6 +1068,7 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
t.Setenv(sessionIDEnvDingtalk, "stale-session")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
@@ -426,6 +1079,9 @@ func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
|
||||
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("agentCode arg = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.gotArgs["sessionId"]; got != "flag-session" {
|
||||
t.Fatalf("sessionId arg = %#v, want flag-session", got)
|
||||
}
|
||||
@@ -446,6 +1102,7 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T)
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("recommend", "true")
|
||||
attachRootYesFlag(t, cmd, true)
|
||||
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
@@ -511,7 +1168,7 @@ func TestChmod_explicitScopesDryRunShowsBatchGrantTool(t *testing.T) {
|
||||
|
||||
// TestChmod_agentCode_env_fallback verifies that when --agentCode is
|
||||
// omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks
|
||||
// the env value up and forwards it verbatim in the MCP argv.
|
||||
// the env value up for both batch arguments and gateway-compatible env.
|
||||
func TestChmod_agentCode_env_fallback(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
|
||||
@@ -530,8 +1187,8 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
|
||||
if got := fake.gotAgentEnv; got != "qoderwork" {
|
||||
t.Fatalf("agent env = %q, want %q", got, "qoderwork")
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("batch argv must not carry agentCode identity field: %#v", fake.gotArgs)
|
||||
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("batch agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
|
||||
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
|
||||
@@ -541,8 +1198,9 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) {
|
||||
func TestChmod_agentCode_reversedEnvIgnored(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
@@ -554,14 +1212,35 @@ func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) {
|
||||
if fake.gotTool != patBatchGrantToolName {
|
||||
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("agentCode arg must be omitted; reversed env name must not be consumed: %#v", fake.gotArgs)
|
||||
}
|
||||
if got := fake.gotAgentEnv; got != "" {
|
||||
t.Fatalf("agent env = %q, want empty so server default agentCode is used", got)
|
||||
t.Fatalf("%s during CallTool = %q, want empty because reversed env is ignored", agentCodeEnv, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_withoutAgentCodeLetsServerDefault(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
|
||||
}
|
||||
if fake.callN != 1 {
|
||||
t.Fatalf("CallTool was invoked %d times; missing agentCode must still reach the batch caller", fake.callN)
|
||||
}
|
||||
if fake.gotTool != patBatchGrantToolName {
|
||||
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("batch argv must omit agentCode when caller leaves it unset: %#v", fake.gotArgs)
|
||||
t.Fatalf("agentCode arg must be omitted for server default path: %#v", fake.gotArgs)
|
||||
}
|
||||
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
|
||||
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
|
||||
if got := fake.gotAgentEnv; got != "" {
|
||||
t.Fatalf("%s during CallTool = %q, want empty for server default path", agentCodeEnv, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,6 +1453,40 @@ func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_batchMetadataScopeErrorFallsBackToPATGrant(t *testing.T) {
|
||||
fake := &sequenceToolCaller{
|
||||
responses: []string{
|
||||
`{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{"scopes":["mail:send"]}}`,
|
||||
`{"success":true,"data":{"authRequestId":"req-ok"}}`,
|
||||
},
|
||||
}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("agentCode", "qoderwork")
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"mail:send"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0].tool != patBatchGrantToolName {
|
||||
t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
|
||||
}
|
||||
if fake.calls[1].tool != patGrantToolName {
|
||||
t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolName)
|
||||
}
|
||||
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("batch agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
|
||||
t.Fatalf("fallback agentCode = %#v, want qoderwork", got)
|
||||
}
|
||||
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"mail:send"}) {
|
||||
t.Fatalf("fallback scopes = %#v, want mail:send", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) {
|
||||
err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
|
||||
if !isToolNotRegisteredError(err) {
|
||||
@@ -801,6 +1514,13 @@ func TestIsPATBatchUnsupportedResultCaseInsensitive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPATBatchFallbackResultIncludesMetadataContractErrors(t *testing.T) {
|
||||
result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED"}`}}}
|
||||
if !isPATBatchFallbackResult(result) {
|
||||
t.Fatal("isPATBatchFallbackResult() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
|
||||
err := apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
@@ -813,6 +1533,18 @@ func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPATBatchFallbackErrorIncludesMetadataContractDiagnostics(t *testing.T) {
|
||||
err := apperrors.NewAPI("business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "PAT_BATCH_PRODUCT_NOT_DECLARED",
|
||||
}),
|
||||
)
|
||||
if !isPATBatchFallbackError(err) {
|
||||
t.Fatal("isPATBatchFallbackError() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleToolResult_emptyResultReturnsError(t *testing.T) {
|
||||
err := handleToolResult(nil, nil, &edition.ToolResult{})
|
||||
if err == nil {
|
||||
@@ -965,35 +1697,36 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
|
||||
if got := fake.gotAgentEnv; got != "flagval" {
|
||||
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval")
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("batch argv must not carry agentCode identity field: %#v", fake.gotArgs)
|
||||
if got := fake.gotArgs["agentCode"]; got != "flagval" {
|
||||
t.Fatalf("batch agentCode = %#v, want flagval", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: after
|
||||
// the SSOT hard-removal of the DWS_AGENTCODE alias, exporting only the
|
||||
// legacy env MUST NOT be consumed. The command is still allowed to run,
|
||||
// omits agentCode, and lets lippi-pat-core write its default agentCode.
|
||||
// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: only
|
||||
// DINGTALK_DWS_AGENTCODE is consumed as the env fallback. Legacy / draft names
|
||||
// MUST NOT be consumed as agentCode. The request is still sent so PAT-core can
|
||||
// apply its open-source default.
|
||||
func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_AGENTCODE", "legacyval")
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", "draftval")
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
|
||||
}
|
||||
if fake.callN != 1 {
|
||||
t.Fatalf("CallTool was invoked %d times, want 1", fake.callN)
|
||||
t.Fatalf("CallTool was invoked %d times; legacy env should be ignored but request should continue", fake.callN)
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("agentCode arg must be omitted; legacy DWS_AGENTCODE must not be consumed: %#v", fake.gotArgs)
|
||||
}
|
||||
if got := fake.gotAgentEnv; got != "" {
|
||||
t.Fatalf("agent env = %q, want empty; legacy DWS_AGENTCODE must not be consumed", got)
|
||||
}
|
||||
if _, ok := fake.gotArgs["agentCode"]; ok {
|
||||
t.Fatalf("batch argv must omit agentCode when only legacy env is set: %#v", fake.gotArgs)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1034,8 +1767,17 @@ func TestResolveAgentCodeFromEnv(t *testing.T) {
|
||||
code, src, "qoderwork", agentCodeEnv)
|
||||
}
|
||||
|
||||
// Reverse-guard: the draft reversed spelling is intentionally ignored.
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
|
||||
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
|
||||
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — DWS_DINGTALK_AGENTCODE must be ignored",
|
||||
code, src)
|
||||
}
|
||||
|
||||
// Empty primary → ("", "").
|
||||
t.Setenv(agentCodeEnv, "")
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", "")
|
||||
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
|
||||
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src)
|
||||
}
|
||||
|
||||
+10
-1
@@ -36,8 +36,17 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) {
|
||||
能力说明:
|
||||
pat chmod 默认输出轻量授权摘要;显式 --format json / --verbose 时,
|
||||
才返回服务端完整 JSON(含逐 scope 明细),便于机器校验。
|
||||
pat chmod 支持批量授权:可一次传多个 scope,也可通过
|
||||
--products / --product、--domains / --domain 或 --recommend
|
||||
让服务端按产品模板 / 推荐集合计算授权计划,再批量授予选中的 scope。
|
||||
批量计划会返回 selected / skipped / pending 明细;--dry-run 只预览计划,
|
||||
不写入授权。真正执行批量授权前必须由用户显式添加 --yes;未加 --yes
|
||||
时 CLI 会阻断并提示 agent 先确认。
|
||||
浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。
|
||||
生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
|
||||
pat chmod 可传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE;
|
||||
CLI 会把显式 agentCode 放入 batch 请求参数,
|
||||
并同步注入 gateway 兼容身份头。未传 agentCode 时由服务端默认兜底。
|
||||
浏览器策略生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
|
||||
写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。
|
||||
|
||||
Host-owned PAT 开关:
|
||||
|
||||
@@ -494,8 +494,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
|
||||
}
|
||||
|
||||
func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) (*http.Response, error) {
|
||||
// Strip any query/fragment from the endpoint to prevent parameter injection.
|
||||
endpoint = validate.StripQueryFragment(endpoint)
|
||||
endpoint = sanitizeJSONRPCEndpoint(endpoint)
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
@@ -533,7 +532,7 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
|
||||
// Diagnostic: log identity-related headers on first attempt.
|
||||
if attempt == 0 && c.FileLogger != nil {
|
||||
c.FileLogger.LogAttrs(context.Background(), slog.LevelDebug, "http_request_headers",
|
||||
slog.String("endpoint", endpoint),
|
||||
slog.String("endpoint", RedactURL(endpoint)),
|
||||
slog.String("x-user-access-token-present", fmt.Sprintf("%t", req.Header.Get("x-user-access-token") != "")),
|
||||
slog.Int("extra_headers_count", len(c.ExtraHeaders)),
|
||||
)
|
||||
@@ -593,6 +592,31 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
|
||||
)
|
||||
}
|
||||
|
||||
func sanitizeJSONRPCEndpoint(endpoint string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(endpoint))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return validate.StripQueryFragment(endpoint)
|
||||
}
|
||||
parsed.Fragment = ""
|
||||
if shouldPreserveEndpointQuery(parsed) {
|
||||
return parsed.String()
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func shouldPreserveEndpointQuery(parsed *url.URL) bool {
|
||||
if parsed == nil || !strings.EqualFold(parsed.Scheme, "https") {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(parsed.Hostname()) {
|
||||
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func retryable(statusCode int) bool {
|
||||
return statusCode == http.StatusTooManyRequests || statusCode >= http.StatusInternalServerError
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -83,6 +86,41 @@ func TestRedactURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoWithRetryRedactsGatewayQueryInHeaderDebugLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
var requestedURL string
|
||||
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requestedURL = req.URL.String()
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{}}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})})
|
||||
client.FileLogger = logger
|
||||
|
||||
resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`))
|
||||
if err != nil {
|
||||
t.Fatalf("doWithRetry() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if !strings.Contains(requestedURL, "key=secret") {
|
||||
t.Fatalf("gateway request URL = %q, want preserved key query", requestedURL)
|
||||
}
|
||||
out := logBuf.String()
|
||||
if strings.Contains(out, "key=secret") || strings.Contains(out, "secret") {
|
||||
t.Fatalf("debug log leaked gateway key: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "key=REDACTED") {
|
||||
t.Fatalf("debug log did not include redacted endpoint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
|
||||
@@ -507,6 +507,56 @@ func TestCallToolClassifiesJSONRPCInvalidParamsAsValidationError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeJSONRPCEndpointPreservesDingTalkMCPGatewayQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prepub gateway",
|
||||
endpoint: "https://pre-mcp-gw.dingtalk.com/server/demo?key=secret#frag",
|
||||
want: "https://pre-mcp-gw.dingtalk.com/server/demo?key=secret",
|
||||
},
|
||||
{
|
||||
name: "prod gateway",
|
||||
endpoint: "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag",
|
||||
want: "https://mcp-gw.dingtalk.com/server/demo?key=secret",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := sanitizeJSONRPCEndpoint(tt.endpoint); got != tt.want {
|
||||
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeJSONRPCEndpointStripsQueryForOtherHosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := sanitizeJSONRPCEndpoint("https://example.com/server/demo?admin=true#frag")
|
||||
want := "https://example.com/server/demo"
|
||||
if got != want {
|
||||
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeJSONRPCEndpointStripsQueryForHTTPGateway(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := sanitizeJSONRPCEndpoint("http://pre-mcp-gw.dingtalk.com/server/demo?key=secret#frag")
|
||||
want := "http://pre-mcp-gw.dingtalk.com/server/demo"
|
||||
if got != want {
|
||||
t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type testSnapshotRecorder struct {
|
||||
root string
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -194,6 +195,19 @@ func TestDefaultFetchServersLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMCPBaseURLDefaultsToProduction(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
|
||||
got := GetMCPBaseURL()
|
||||
if got != "https://mcp.dingtalk.com" {
|
||||
t.Fatalf("GetMCPBaseURL() = %q, want production MCP URL", got)
|
||||
}
|
||||
if strings.Contains(got, "pre-mcp") {
|
||||
t.Fatalf("GetMCPBaseURL() = %q, must not default to prepub MCP URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMCPBaseURLUsesConfigFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| Recipe | 行动指南(固定路线) |
|
||||
|--------|-------------------|
|
||||
| query-group-chat | **优先**:`chat_export_messages.py`(开源版未引入;可手动用 `dws chat message list` 翻页后写入文件)(自动搜群+翻页+导出)<br>备选:1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`<br>4. `--forward=false` 拉给定时间**之前**的消息<br>5. 合并全部消息后总结 |
|
||||
| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `dws chat search` + `dws chat message list` 组合)(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
|
||||
| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `aisearch person` + `dws chat message list-direct` 组合)(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list-direct --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表(单聊专用;旧版 `list --user` 已停用)<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
|
||||
| escalate-ding | 三级升级:<br>1. `ding message send --robot-code <robotCode> --type app --users <userId> --content "<内容>"`(必填项见 [ding.md](../products/ding.md))<br>2. `chat message send --group <openConversationId> --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](../products/chat.md))<br>3. `todo task create --title "<标题>" --executors <userId> --priority 40` 建紧急待办<br>前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` |
|
||||
| send-by-bot | **多群批量优先**:`bot_broadcast.py`(开源版未引入;可手动用 `dws chat message send-by-bot` 多次调用)<br>单群:1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"` |
|
||||
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
### 2. devdoc — 开发文档搜索
|
||||
|
||||
**用 `devdoc` 的场景**:
|
||||
- "API 调用报错 403 怎么解决" — 开发调试问题
|
||||
- "API 调用报错 403 怎么解决" — 走 `devdoc error diagnose`
|
||||
- "requestId 15r6h45w0muec 为什么失败" — 走 `devdoc error diagnose --request-id ...`
|
||||
- "搜一下 OAuth2 接入文档" — 开放平台技术文档
|
||||
- "CLI 命令出错了怎么办" — CLI 使用错误
|
||||
- 用户提到"开发"、"API"、"调用错误"
|
||||
- 用户提到"开发"、"API"、"接口文档" → `devdoc article search`
|
||||
- 用户提到"调用错误"、"错误码"、"requestId"、"traceId" → `devdoc error diagnose`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -338,35 +338,54 @@ Flags:
|
||||
|
||||
### message (会话消息管理)
|
||||
|
||||
#### 拉取会话消息内容 — 拉取指定群聊或单聊的会话消息内容
|
||||
#### 拉取群聊会话消息内容 — 拉取指定群聊的会话消息内容(仅群聊)
|
||||
|
||||
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
--group 指定群聊 openConversationId(**本命令仅支持群聊**;拉取单聊/私聊消息请改用 `chat message list-direct`)。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list [flags]
|
||||
Example:
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --forward=false
|
||||
# 拉单聊改用 list-direct: dws chat message list-direct --user <userId> --time "2025-03-01 00:00:00"
|
||||
Flags:
|
||||
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
|
||||
--group string 群聊 openconversation_id(群聊时必填)
|
||||
--group string 群聊 openconversation_id(必填,**仅支持群聊**;查单聊用 chat message list-direct --user <userId>)
|
||||
--limit int 返回数量,不传则不限制
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
|
||||
--user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
|
||||
--open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
|
||||
|
||||
注意:
|
||||
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
|
||||
- --user 和 --open-dingtalk-id 都是发起单聊消息拉取,区别在于用不同格式的用户标识:
|
||||
- --user 传 userId(企业内部应用常用)
|
||||
- --open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用)
|
||||
- 本命令**仅支持群聊**,必须指定 --group;拉取单聊(私聊)消息请改用 `chat message list-direct`(旧版的 `list --user` / `list --open-dingtalk-id` 已不再支持)
|
||||
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
|
||||
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
|
||||
- 话题圈消息拉取流程:如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息。要获取完整的话题内容,需要两步操作:(1) 先通过 dws chat message list 拉取话题主消息(即话题帖子本身);(2) 再调用 dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId> 分页拉取该话题下的所有回复消息。只有话题主消息 + 回复列表合在一起,才是一条话题的完整内容。
|
||||
```
|
||||
|
||||
#### 拉取单聊消息内容 — 按对方 userId 拉取与某同事的单聊(私聊)历史消息
|
||||
|
||||
按对方 userId(或 openDingTalkId)拉取与该同事的单聊会话消息,**专用于私聊**;查群聊请用 `chat message list --group`。同组织内同事用 --user,非同组织好友用 --open-dingtalk-id,二者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-direct [flags]
|
||||
Example:
|
||||
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --forward=false
|
||||
dws chat message list-direct --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 20
|
||||
# 查询对方 userId: dws contact user search --keyword "姓名" 或 dws aisearch person --keyword "姓名" --dimension name
|
||||
Flags:
|
||||
--forward true=从老往新(给定时间之后),false=从新往老(给定时间之前) (default true)
|
||||
--user string 对方 userId(同组织内同事,与 --open-dingtalk-id 二选一)
|
||||
--open-dingtalk-id string 对方 openDingTalkId(非同组织普通好友场景,与 --user 二选一)
|
||||
--limit int 每页返回数量(默认 50)
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
|
||||
|
||||
注意:
|
||||
- --user 与 --open-dingtalk-id 二选一,必须且只能指定其一;同组织同事优先用 --user
|
||||
- --time 必填;翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
|
||||
- 本命令是 `chat message list` 拆分出的单聊专用命令;查群聊消息请用 `chat message list --group`
|
||||
```
|
||||
|
||||
#### 以当前用户身份发送消息 — --group 群聊 / --user 或 --open-dingtalk-id 单聊
|
||||
|
||||
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
|
||||
@@ -592,7 +611,7 @@ Flags:
|
||||
|
||||
注意:
|
||||
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
|
||||
- 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
|
||||
- 与 chat message list 的区别:list 拉取指定群聊会话的消息(单聊用 list-direct),list-all 拉取当前用户所有会话的消息
|
||||
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
|
||||
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
|
||||
```
|
||||
@@ -1147,7 +1166,7 @@ Flags:
|
||||
|
||||
用户说"我特别关注的人最近发了什么消息/关注的人最近聊了啥/星标联系人最近的动态" → `chat message list-focused`(零参数一行命令)
|
||||
用户说"某人发给我的消息/指定发送者的消息/某人最近的消息" → `chat message list-by-sender --sender-user-id <userId>` 或 `--sender-open-dingtalk-id <openDingTalkId>`(跨单聊+群聊)
|
||||
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
|
||||
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list-direct --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
|
||||
用户说"某个群的聊天记录" → `chat message list --group <openConversationId>`
|
||||
用户说"我最近所有消息/我今天的消息" → `chat message list-all --start <ISO> --end <ISO>`
|
||||
用户说"@我的消息/提及我的" → `chat message list-mentions --start <ISO> --end <ISO>`
|
||||
@@ -1167,7 +1186,7 @@ Flags:
|
||||
用户说"改群名" → `chat group rename`
|
||||
用户说"聊天记录/会话消息/拉取会话" → `chat message list`
|
||||
用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
|
||||
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用)
|
||||
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(单聊专用;用户明确说"单聊"时使用)
|
||||
用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
|
||||
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
|
||||
用户说"发群消息(以个人身份)" → `chat message send --group`
|
||||
@@ -1222,8 +1241,8 @@ Flags:
|
||||
|
||||
关键区分:
|
||||
- `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆**
|
||||
- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
|
||||
- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用)
|
||||
- `chat message list` — 拉取指定**群聊**的消息(需指定 --group,**仅群聊**),按时间点 + 方向翻页
|
||||
- `chat message list-direct` — 单聊专用,拉取与指定用户的单聊(私聊)记录(--user / --open-dingtalk-id;用户明确说"单聊""私聊"时使用)
|
||||
- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
|
||||
- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
|
||||
- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
|
||||
@@ -1448,8 +1467,8 @@ Flags:
|
||||
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
|
||||
| `chat group create` | `openConversationId` | 同上 |
|
||||
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
|
||||
| `aisearch person` | `userId` | message send 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
|
||||
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id、message list 的 --open-dingtalk-id |
|
||||
| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
|
||||
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id |
|
||||
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code(仅我创建的机器人,无 openDingTalkId) |
|
||||
| `chat bot find` | `openDingTalkId` | 给机器人发单聊消息(全部可用机器人,额外返回 openDingTalkId) |
|
||||
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
|
||||
@@ -1484,7 +1503,7 @@ Flags:
|
||||
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
|
||||
- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力;`--at-all`、`--at-open-dingtalk-ids` 仅在 `--group` 群聊时生效;富媒体消息通过 `--msg-type` 指定类型(image/file),必须显式指定;发送文件/媒体消息时,必须先根据文件扩展名判断 msgType:图片→image,其他所有→file,不可跳过此判断
|
||||
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
|
||||
- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
|
||||
- `chat message list` **仅支持群聊**,必须指定 `--group`;拉取单聊用 `chat message list-direct`(`--user` / `--open-dingtalk-id` 二选一)
|
||||
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识;`--sender-user-id`(userId)与 `--sender-open-dingtalk-id`(openDingTalkId)二选一;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
|
||||
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
|
||||
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
|
||||
|
||||
@@ -20,14 +20,41 @@ Flags:
|
||||
--size int 分页大小 (默认 10)
|
||||
```
|
||||
|
||||
### 错误排查
|
||||
```
|
||||
Usage:
|
||||
dws devdoc error diagnose [flags]
|
||||
dws devdoc error troubleshoot [flags]
|
||||
Example:
|
||||
dws devdoc error diagnose --request-id 15r6h45w0muec
|
||||
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程"
|
||||
dws devdoc error diagnose --error-code 33012 --error-message "missing scope"
|
||||
dws devdoc error diagnose --query "机器人回调失败" --context "HTTP 403"
|
||||
Flags:
|
||||
--query string 原始排查问题
|
||||
--request-id string 开放平台 requestId
|
||||
--trace-id string requestId 的兼容别名
|
||||
--error-code string 错误码
|
||||
--error-message string 错误描述,会合并进原始问题
|
||||
--api string API 名称,会合并进原始问题作为补充检索词
|
||||
--context string 额外排查上下文,会合并进原始问题
|
||||
--page int 分页页码 (从 1 开始,默认 1)
|
||||
--size int 分页大小 (默认 10)
|
||||
```
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节:
|
||||
- 走 `devdoc article search`,把用户问的关键短语作为位置参数或 `--query`
|
||||
|
||||
用户已经提供 requestId / traceId / 错误码 / 错误描述 / 失败上下文:
|
||||
- 走 `devdoc error diagnose`,优先传 `--request-id`,没有 requestId 时传 `--error-code`、`--error-message`、`--query` 或 `--context`
|
||||
|
||||
关键区分:
|
||||
- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容)
|
||||
- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览
|
||||
- `devdoc error diagnose` 只返回诊断事实、参考资料和链接,不生成 AI 分析结论
|
||||
- `--api`、`--error-message`、`--context` 是 CLI 侧易用参数,调用 MCP 时会合并到 `query`;MCP 入参只发送 `query`、`requestId`、`errorCode`、`page`、`size`
|
||||
|
||||
## 核心工作流
|
||||
|
||||
@@ -43,11 +70,21 @@ dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json
|
||||
|
||||
# 查错误码 / 字段含义
|
||||
dws devdoc article search --query "errcode 40078" --format json
|
||||
|
||||
# 已经有 requestId 时排查
|
||||
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
|
||||
|
||||
# 只有 traceId 时按 requestId 兼容处理
|
||||
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程" --format json
|
||||
|
||||
# 只有错误码和错误描述时排查
|
||||
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 关键词必填;可用位置参数、`--query` 或兼容别名 `--keyword`。建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写
|
||||
- 错误排查至少提供 `--query`、`--request-id`、`--error-code`、`--error-message`、`--context` 之一;单独 `--api` 只作为补充上下文,不足以发起排查
|
||||
- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词
|
||||
- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考
|
||||
- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档**
|
||||
|
||||
@@ -41,7 +41,7 @@ Flags:
|
||||
|
||||
## 关键说明
|
||||
|
||||
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
|
||||
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。若正文首行仍是与 `--name` 相同的一级标题,CLI 会自动移除并在 stderr 提示(仅精确匹配会被移除,其他一级标题不受影响)。
|
||||
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
|
||||
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
|
||||
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
| 项目 | 要求 |
|
||||
|------|------|
|
||||
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题 |
|
||||
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题(若重复,CLI 会自动移除与 `--name` 相同的首行 H1 并提示) |
|
||||
| 位置 | 默认创建到我的文档;指定目录时只接受文档文件夹 `nodeId` 或 alidocs 文件夹 URL |
|
||||
| 正文 | 多行、表格、代码块、特殊字符或长度 >= 2KB 时必须写入 UTF-8 临时 `.md` 文件 |
|
||||
| 格式 | 按 §JSONML 起稿判定 决定起稿路径:命中 JSONML 起稿条件时**直接用 JSONML 构造**(跳过 markdown);未命中时用 Markdown 起稿,创建后按 [doc-update-workflow.md](./doc-update-workflow.md) 精修 |
|
||||
@@ -147,7 +147,7 @@ dws doc read --node <nodeId> --content-format jsonml --output /tmp/<name>-readba
|
||||
- 只使用用户已提供或对话中已确认的正文素材。
|
||||
- 如果正文素材不足,先补齐文档目标、受众、章节和缺口;不要在本文中临时扩展跨产品采集流程。
|
||||
- **先按 [doc-style-guideline.md §2.0 类型判断决策表](./doc-style-guideline.md) 确定文档类型,再用对应类型的骨架样板(§2.1 决策型 / §2.2 执行型 / §2.3 说明型 / §2.4 知识沉淀型)**。不要套通用三段式。
|
||||
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。
|
||||
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。与 `--name` 相同的首行一级标题会被 CLI 自动移除(stderr 有提示),但不要依赖这个兜底。
|
||||
- 摘要、bullet、引用块、callout 等元素的使用边界以 style-guideline §3-§7 为准。
|
||||
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
|
||||
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
|
||||
|
||||
@@ -18,6 +18,25 @@ Flags:
|
||||
--size string 每页数量 (默认 10)
|
||||
```
|
||||
|
||||
### 错误排查
|
||||
```
|
||||
Usage:
|
||||
dws devdoc error diagnose [flags]
|
||||
Example:
|
||||
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
|
||||
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
|
||||
Flags:
|
||||
--query string 原始排查问题
|
||||
--request-id string 开放平台 requestId
|
||||
--trace-id string requestId 的兼容别名
|
||||
--error-code string 错误码
|
||||
--error-message string 错误描述,会合并进原始问题
|
||||
--api string API 名称,会合并进原始问题作为补充检索词
|
||||
--context string 额外排查上下文,会合并进原始问题
|
||||
--page int 分页页码 (默认 1)
|
||||
--size int 分页大小 (默认 10)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## oa — 审批
|
||||
@@ -99,6 +118,7 @@ Example:
|
||||
## 意图判断
|
||||
|
||||
- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
|
||||
- 用户说"调用报错/requestId/traceId/错误码/错误描述" → `devdoc error diagnose`
|
||||
- 用户说"审批/请假/报销/出差" → `oa approval`
|
||||
- 用户说"同意审批/批准" → `oa approval approve`
|
||||
- 用户说"拒绝审批/驳回" → `oa approval reject`
|
||||
@@ -111,6 +131,7 @@ Example:
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `devdoc article search` | 文档链接 | 直接展示给用户 |
|
||||
| `devdoc error diagnose` | diagnosticInfo、references、materials | 排查开放平台调用错误 |
|
||||
| `oa approval list-forms` | processCode | detail / records 等 |
|
||||
| `oa approval tasks` | taskId, instanceId | approve / reject |
|
||||
| `oa approval list-pending` | instanceId | detail / approve / reject |
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
工作流:
|
||||
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
|
||||
2. 调用 chat message list --user <userId> 拉取单聊消息
|
||||
2. 调用 chat message list-direct --user <userId> 拉取单聊消息
|
||||
3. 输出到终端或导出为 JSON 文件
|
||||
"""
|
||||
|
||||
@@ -116,7 +116,7 @@ def main():
|
||||
|
||||
while page < max_pages and remaining > 0:
|
||||
cmd_args = [
|
||||
'chat', 'message', 'list',
|
||||
'chat', 'message', 'list-direct',
|
||||
'--user', user_id or '<USER_ID>',
|
||||
'--time', current_time,
|
||||
'--format', 'json',
|
||||
@@ -139,8 +139,12 @@ def main():
|
||||
page_msgs = data
|
||||
has_more = False
|
||||
else:
|
||||
page_msgs = data.get('messages', data.get('result', []))
|
||||
has_more = data.get('hasMore', False)
|
||||
# list-direct 返回 {result: {hasMore, messages: [...]}},先解包 result
|
||||
result_obj = data.get('result', data)
|
||||
if not isinstance(result_obj, dict):
|
||||
result_obj = data
|
||||
page_msgs = result_obj.get('messages', result_obj.get('records', []))
|
||||
has_more = result_obj.get('hasMore', data.get('hasMore', False))
|
||||
|
||||
if not page_msgs:
|
||||
break
|
||||
@@ -153,7 +157,8 @@ def main():
|
||||
break
|
||||
|
||||
last_msg = page_msgs[-1]
|
||||
boundary_time = last_msg.get('createAt') or last_msg.get('time', '')
|
||||
boundary_time = (last_msg.get('createTime') or last_msg.get('createAt')
|
||||
or last_msg.get('time', ''))
|
||||
if not boundary_time or boundary_time == current_time:
|
||||
break
|
||||
current_time = boundary_time
|
||||
@@ -183,9 +188,11 @@ def main():
|
||||
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
|
||||
else:
|
||||
for m in all_messages:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
sender = m.get('senderNick') or m.get('sender', '未知')
|
||||
text = m.get('text') or m.get('content', '')
|
||||
time_str = m.get('createAt') or m.get('time', '')
|
||||
time_str = m.get('createTime') or m.get('createAt') or m.get('time', '')
|
||||
print(f" [{time_str}] {sender}: {text[:80]}")
|
||||
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
|
||||
|
||||
|
||||
@@ -338,35 +338,54 @@ Flags:
|
||||
|
||||
### message (会话消息管理)
|
||||
|
||||
#### 拉取会话消息内容 — 拉取指定群聊或单聊的会话消息内容
|
||||
#### 拉取群聊会话消息内容 — 拉取指定群聊的会话消息内容(仅群聊)
|
||||
|
||||
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
--group 指定群聊 openConversationId(**本命令仅支持群聊**;拉取单聊/私聊消息请改用 `chat message list-direct`)。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list [flags]
|
||||
Example:
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --forward=false
|
||||
# 拉单聊改用 list-direct: dws chat message list-direct --user <userId> --time "2025-03-01 00:00:00"
|
||||
Flags:
|
||||
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
|
||||
--group string 群聊 openconversation_id(群聊时必填)
|
||||
--group string 群聊 openconversation_id(必填,**仅支持群聊**;查单聊用 chat message list-direct --user <userId>)
|
||||
--limit int 返回数量,不传则不限制
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
|
||||
--user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
|
||||
--open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
|
||||
|
||||
注意:
|
||||
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
|
||||
- --user 和 --open-dingtalk-id 都是发起单聊消息拉取,区别在于用不同格式的用户标识:
|
||||
- --user 传 userId(企业内部应用常用)
|
||||
- --open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用)
|
||||
- 本命令**仅支持群聊**,必须指定 --group;拉取单聊(私聊)消息请改用 `chat message list-direct`(旧版的 `list --user` / `list --open-dingtalk-id` 已不再支持)
|
||||
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
|
||||
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
|
||||
- 话题圈消息拉取流程:如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息。要获取完整的话题内容,需要两步操作:(1) 先通过 dws chat message list 拉取话题主消息(即话题帖子本身);(2) 再调用 dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId> 分页拉取该话题下的所有回复消息。只有话题主消息 + 回复列表合在一起,才是一条话题的完整内容。
|
||||
```
|
||||
|
||||
#### 拉取单聊消息内容 — 按对方 userId 拉取与某同事的单聊(私聊)历史消息
|
||||
|
||||
按对方 userId(或 openDingTalkId)拉取与该同事的单聊会话消息,**专用于私聊**;查群聊请用 `chat message list --group`。同组织内同事用 --user,非同组织好友用 --open-dingtalk-id,二者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-direct [flags]
|
||||
Example:
|
||||
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --forward=false
|
||||
dws chat message list-direct --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 20
|
||||
# 查询对方 userId: dws contact user search --keyword "姓名" 或 dws aisearch person --keyword "姓名" --dimension name
|
||||
Flags:
|
||||
--forward true=从老往新(给定时间之后),false=从新往老(给定时间之前) (default true)
|
||||
--user string 对方 userId(同组织内同事,与 --open-dingtalk-id 二选一)
|
||||
--open-dingtalk-id string 对方 openDingTalkId(非同组织普通好友场景,与 --user 二选一)
|
||||
--limit int 每页返回数量(默认 50)
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
|
||||
|
||||
注意:
|
||||
- --user 与 --open-dingtalk-id 二选一,必须且只能指定其一;同组织同事优先用 --user
|
||||
- --time 必填;翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
|
||||
- 本命令是 `chat message list` 拆分出的单聊专用命令;查群聊消息请用 `chat message list --group`
|
||||
```
|
||||
|
||||
#### 以当前用户身份发送消息 — --group 群聊 / --user 或 --open-dingtalk-id 单聊
|
||||
|
||||
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
|
||||
@@ -592,7 +611,7 @@ Flags:
|
||||
|
||||
注意:
|
||||
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
|
||||
- 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
|
||||
- 与 chat message list 的区别:list 拉取指定群聊会话的消息(单聊用 list-direct),list-all 拉取当前用户所有会话的消息
|
||||
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
|
||||
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
|
||||
```
|
||||
@@ -1147,7 +1166,7 @@ Flags:
|
||||
|
||||
用户说"我特别关注的人最近发了什么消息/关注的人最近聊了啥/星标联系人最近的动态" → `chat message list-focused`(零参数一行命令)
|
||||
用户说"某人发给我的消息/指定发送者的消息/某人最近的消息" → `chat message list-by-sender --sender-user-id <userId>` 或 `--sender-open-dingtalk-id <openDingTalkId>`(跨单聊+群聊)
|
||||
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
|
||||
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list-direct --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
|
||||
用户说"某个群的聊天记录" → `chat message list --group <openConversationId>`
|
||||
用户说"我最近所有消息/我今天的消息" → `chat message list-all --start <ISO> --end <ISO>`
|
||||
用户说"@我的消息/提及我的" → `chat message list-mentions --start <ISO> --end <ISO>`
|
||||
@@ -1167,7 +1186,7 @@ Flags:
|
||||
用户说"改群名" → `chat group rename`
|
||||
用户说"聊天记录/会话消息/拉取会话" → `chat message list`
|
||||
用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
|
||||
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用)
|
||||
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(单聊专用;用户明确说"单聊"时使用)
|
||||
用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
|
||||
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
|
||||
用户说"发群消息(以个人身份)" → `chat message send --group`
|
||||
@@ -1222,8 +1241,8 @@ Flags:
|
||||
|
||||
关键区分:
|
||||
- `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆**
|
||||
- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
|
||||
- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用)
|
||||
- `chat message list` — 拉取指定**群聊**的消息(需指定 --group,**仅群聊**),按时间点 + 方向翻页
|
||||
- `chat message list-direct` — 单聊专用,拉取与指定用户的单聊(私聊)记录(--user / --open-dingtalk-id;用户明确说"单聊""私聊"时使用)
|
||||
- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
|
||||
- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
|
||||
- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
|
||||
@@ -1448,8 +1467,8 @@ Flags:
|
||||
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
|
||||
| `chat group create` | `openConversationId` | 同上 |
|
||||
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
|
||||
| `aisearch person` | `userId` | message send 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
|
||||
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id、message list 的 --open-dingtalk-id |
|
||||
| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
|
||||
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id |
|
||||
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code(仅我创建的机器人,无 openDingTalkId) |
|
||||
| `chat bot find` | `openDingTalkId` | 给机器人发单聊消息(全部可用机器人,额外返回 openDingTalkId) |
|
||||
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
|
||||
@@ -1484,7 +1503,7 @@ Flags:
|
||||
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
|
||||
- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力;`--at-all`、`--at-open-dingtalk-ids` 仅在 `--group` 群聊时生效;富媒体消息通过 `--msg-type` 指定类型(image/file),必须显式指定;发送文件/媒体消息时,必须先根据文件扩展名判断 msgType:图片→image,其他所有→file,不可跳过此判断
|
||||
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
|
||||
- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
|
||||
- `chat message list` **仅支持群聊**,必须指定 `--group`;拉取单聊用 `chat message list-direct`(`--user` / `--open-dingtalk-id` 二选一)
|
||||
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识;`--sender-user-id`(userId)与 `--sender-open-dingtalk-id`(openDingTalkId)二选一;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
|
||||
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
|
||||
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
工作流:
|
||||
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
|
||||
2. 调用 chat message list --user <userId> 拉取单聊消息
|
||||
2. 调用 chat message list-direct --user <userId> 拉取单聊消息
|
||||
3. 输出到终端或导出为 JSON 文件
|
||||
"""
|
||||
|
||||
@@ -116,7 +116,7 @@ def main():
|
||||
|
||||
while page < max_pages and remaining > 0:
|
||||
cmd_args = [
|
||||
'chat', 'message', 'list',
|
||||
'chat', 'message', 'list-direct',
|
||||
'--user', user_id or '<USER_ID>',
|
||||
'--time', current_time,
|
||||
'--format', 'json',
|
||||
@@ -139,8 +139,12 @@ def main():
|
||||
page_msgs = data
|
||||
has_more = False
|
||||
else:
|
||||
page_msgs = data.get('messages', data.get('result', []))
|
||||
has_more = data.get('hasMore', False)
|
||||
# list-direct 返回 {result: {hasMore, messages: [...]}},先解包 result
|
||||
result_obj = data.get('result', data)
|
||||
if not isinstance(result_obj, dict):
|
||||
result_obj = data
|
||||
page_msgs = result_obj.get('messages', result_obj.get('records', []))
|
||||
has_more = result_obj.get('hasMore', data.get('hasMore', False))
|
||||
|
||||
if not page_msgs:
|
||||
break
|
||||
@@ -153,7 +157,8 @@ def main():
|
||||
break
|
||||
|
||||
last_msg = page_msgs[-1]
|
||||
boundary_time = last_msg.get('createAt') or last_msg.get('time', '')
|
||||
boundary_time = (last_msg.get('createTime') or last_msg.get('createAt')
|
||||
or last_msg.get('time', ''))
|
||||
if not boundary_time or boundary_time == current_time:
|
||||
break
|
||||
current_time = boundary_time
|
||||
@@ -183,9 +188,11 @@ def main():
|
||||
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
|
||||
else:
|
||||
for m in all_messages:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
sender = m.get('senderNick') or m.get('sender', '未知')
|
||||
text = m.get('text') or m.get('content', '')
|
||||
time_str = m.get('createAt') or m.get('time', '')
|
||||
time_str = m.get('createTime') or m.get('createAt') or m.get('time', '')
|
||||
print(f" [{time_str}] {sender}: {text[:80]}")
|
||||
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ metadata:
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "查 OAuth2 接入文档" | `dws devdoc article search --query "OAuth2 接入"` |
|
||||
| "API 调用报错怎么办" | `dws devdoc article search --query "<报错关键词>"` |
|
||||
| "API 调用报错怎么办" | `dws devdoc error diagnose --query "<报错关键词>"` |
|
||||
| "requestId 15r6h45w0muec 为什么失败" | `dws devdoc error diagnose --request-id 15r6h45w0muec` |
|
||||
| "错误码 33012" | `dws devdoc error diagnose --error-code 33012` |
|
||||
| "开放接口文档" | `dws devdoc article search --query "<接口名或场景>"` |
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
@@ -20,14 +20,41 @@ Flags:
|
||||
--size int 分页大小 (默认 10)
|
||||
```
|
||||
|
||||
### 错误排查
|
||||
```
|
||||
Usage:
|
||||
dws devdoc error diagnose [flags]
|
||||
dws devdoc error troubleshoot [flags]
|
||||
Example:
|
||||
dws devdoc error diagnose --request-id 15r6h45w0muec
|
||||
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程"
|
||||
dws devdoc error diagnose --error-code 33012 --error-message "missing scope"
|
||||
dws devdoc error diagnose --query "机器人回调失败" --context "HTTP 403"
|
||||
Flags:
|
||||
--query string 原始排查问题
|
||||
--request-id string 开放平台 requestId
|
||||
--trace-id string requestId 的兼容别名
|
||||
--error-code string 错误码
|
||||
--error-message string 错误描述,会合并进原始问题
|
||||
--api string API 名称,会合并进原始问题作为补充检索词
|
||||
--context string 额外排查上下文,会合并进原始问题
|
||||
--page int 分页页码 (从 1 开始,默认 1)
|
||||
--size int 分页大小 (默认 10)
|
||||
```
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节:
|
||||
- 走 `devdoc article search`,把用户问的关键短语作为位置参数或 `--query`
|
||||
|
||||
用户已经提供 requestId / traceId / 错误码 / 错误描述 / 失败上下文:
|
||||
- 走 `devdoc error diagnose`,优先传 `--request-id`,没有 requestId 时传 `--error-code`、`--error-message`、`--query` 或 `--context`
|
||||
|
||||
关键区分:
|
||||
- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容)
|
||||
- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览
|
||||
- `devdoc error diagnose` 只返回诊断事实、参考资料和链接,不生成 AI 分析结论
|
||||
- `--api`、`--error-message`、`--context` 是 CLI 侧易用参数,调用 MCP 时会合并到 `query`;MCP 入参只发送 `query`、`requestId`、`errorCode`、`page`、`size`
|
||||
|
||||
## 核心工作流
|
||||
|
||||
@@ -43,11 +70,21 @@ dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json
|
||||
|
||||
# 查错误码 / 字段含义
|
||||
dws devdoc article search --query "errcode 40078" --format json
|
||||
|
||||
# 已经有 requestId 时排查
|
||||
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
|
||||
|
||||
# 只有 traceId 时按 requestId 兼容处理
|
||||
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程" --format json
|
||||
|
||||
# 只有错误码和错误描述时排查
|
||||
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 关键词必填;可用位置参数、`--query` 或兼容别名 `--keyword`。建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写
|
||||
- 错误排查至少提供 `--query`、`--request-id`、`--error-code`、`--error-message`、`--context` 之一;单独 `--api` 只作为补充上下文,不足以发起排查
|
||||
- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词
|
||||
- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考
|
||||
- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档**
|
||||
|
||||
@@ -41,7 +41,7 @@ Flags:
|
||||
|
||||
## 关键说明
|
||||
|
||||
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
|
||||
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。若正文首行仍是与 `--name` 相同的一级标题,CLI 会自动移除并在 stderr 提示(仅精确匹配会被移除,其他一级标题不受影响)。
|
||||
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
|
||||
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
|
||||
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
| 项目 | 要求 |
|
||||
|------|------|
|
||||
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题 |
|
||||
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题(若重复,CLI 会自动移除与 `--name` 相同的首行 H1 并提示) |
|
||||
| 位置 | 默认创建到我的文档;指定目录时只接受文档文件夹 `nodeId` 或 alidocs 文件夹 URL |
|
||||
| 正文 | 多行、表格、代码块、特殊字符或长度 >= 2KB 时必须写入 UTF-8 临时 `.md` 文件 |
|
||||
| 格式 | 按 §JSONML 起稿判定 决定起稿路径:命中 JSONML 起稿条件时**直接用 JSONML 构造**(跳过 markdown);未命中时用 Markdown 起稿,创建后按 [doc-update-workflow.md](./doc-update-workflow.md) 精修 |
|
||||
@@ -147,7 +147,7 @@ dws doc read --node <nodeId> --content-format jsonml --output /tmp/<name>-readba
|
||||
- 只使用用户已提供或对话中已确认的正文素材。
|
||||
- 如果正文素材不足,先补齐文档目标、受众、章节和缺口;不要在本文中临时扩展跨产品采集流程。
|
||||
- **先按 [doc-style-guideline.md §2.0 类型判断决策表](./doc-style-guideline.md) 确定文档类型,再用对应类型的骨架样板(§2.1 决策型 / §2.2 执行型 / §2.3 说明型 / §2.4 知识沉淀型)**。不要套通用三段式。
|
||||
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。
|
||||
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。与 `--name` 相同的首行一级标题会被 CLI 自动移除(stderr 有提示),但不要依赖这个兜底。
|
||||
- 摘要、bullet、引用块、callout 等元素的使用边界以 style-guideline §3-§7 为准。
|
||||
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
|
||||
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
|
||||
|
||||
@@ -18,6 +18,25 @@ Flags:
|
||||
--size string 每页数量 (默认 10)
|
||||
```
|
||||
|
||||
### 错误排查
|
||||
```
|
||||
Usage:
|
||||
dws devdoc error diagnose [flags]
|
||||
Example:
|
||||
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
|
||||
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
|
||||
Flags:
|
||||
--query string 原始排查问题
|
||||
--request-id string 开放平台 requestId
|
||||
--trace-id string requestId 的兼容别名
|
||||
--error-code string 错误码
|
||||
--error-message string 错误描述,会合并进原始问题
|
||||
--api string API 名称,会合并进原始问题作为补充检索词
|
||||
--context string 额外排查上下文,会合并进原始问题
|
||||
--page int 分页页码 (默认 1)
|
||||
--size int 分页大小 (默认 10)
|
||||
```
|
||||
|
||||
## live — 直播
|
||||
|
||||
### 查看我的直播列表
|
||||
@@ -91,7 +110,8 @@ Args:
|
||||
|
||||
## 意图判断
|
||||
|
||||
- 用户说"开发文档/API 文档/接口文档/调用报错" → `devdoc article search`
|
||||
- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
|
||||
- 用户说"调用报错/requestId/traceId/错误码/错误描述" → `devdoc error diagnose`
|
||||
- 用户说"直播/我的直播" → `live stream list`
|
||||
- 用户说"搜索技能/找技能/安装技能/技能市场" → `skill search` / `skill install`(按步骤衔接)
|
||||
|
||||
@@ -100,6 +120,7 @@ Args:
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `devdoc article search` | 文档链接 | 直接展示给用户 |
|
||||
| `devdoc error diagnose` | diagnosticInfo、references、materials | 排查开放平台调用错误 |
|
||||
| `skill search` | `skillId`、名称、描述 | 用户选型后传给 `skill install <skillId> <target>` |
|
||||
| `skill install` | 安装成功/失败信息 | 确认目标 Agent 目录已注册 |
|
||||
| `skill publish` | 发布结果(成功或错误信息) | 确认企业技能库已更新 |
|
||||
|
||||
@@ -5,7 +5,7 @@ import "testing"
|
||||
// ── devdoc article search ──────────────────────────────────
|
||||
|
||||
func TestDevdocArticleSearch_should_call_correct_tool(t *testing.T) {
|
||||
cap := setupTestDeps(t, "devdoc")
|
||||
cap := setupTestDepsWithPreview(t, "devdoc")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{
|
||||
"keyword": "MCP", "page": "1", "size": "10",
|
||||
@@ -13,16 +13,18 @@ func TestDevdocArticleSearch_should_call_correct_tool(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertToolName(t, cap, "search_open_platform_docs")
|
||||
assertToolName(t, cap, "search_open_platform_docs_rag")
|
||||
}
|
||||
|
||||
func TestDevdocArticleSearch_should_pass_keyword(t *testing.T) {
|
||||
cap := setupTestDeps(t, "devdoc")
|
||||
cap := setupTestDepsWithPreview(t, "devdoc")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{
|
||||
"keyword": "openConversationId", "page": "1", "size": "10",
|
||||
})
|
||||
assertToolArg(t, cap, "keyword", "openConversationId")
|
||||
assertToolArg(t, cap, "page", float64(1))
|
||||
assertToolArg(t, cap, "size", float64(10))
|
||||
}
|
||||
|
||||
func TestDevdocArticleSearch_should_not_call_when_dry_run(t *testing.T) {
|
||||
@@ -33,3 +35,89 @@ func TestDevdocArticleSearch_should_not_call_when_dry_run(t *testing.T) {
|
||||
})
|
||||
assertCallCount(t, cap, 0)
|
||||
}
|
||||
|
||||
// ── devdoc error diagnose ──────────────────────────────────
|
||||
|
||||
func TestDevdocErrorDiagnose_should_call_correct_tool(t *testing.T) {
|
||||
cap := setupTestDepsWithPreview(t, "devdoc")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
|
||||
"request-id": "req-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertToolName(t, cap, "search_open_error_code_rag")
|
||||
}
|
||||
|
||||
func TestDevdocErrorDiagnose_should_pass_request_id(t *testing.T) {
|
||||
cap := setupTestDepsWithPreview(t, "devdoc")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
|
||||
"request-id": "req-123", "page": "2", "size": "5",
|
||||
})
|
||||
assertToolArg(t, cap, "requestId", "req-123")
|
||||
assertToolArg(t, cap, "page", float64(2))
|
||||
assertToolArg(t, cap, "size", float64(5))
|
||||
}
|
||||
|
||||
func TestDevdocErrorDiagnose_should_map_trace_id_alias(t *testing.T) {
|
||||
cap := setupTestDepsWithPreview(t, "devdoc")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
|
||||
"trace-id": "trace-abc", "api": "创建日程",
|
||||
})
|
||||
assertToolArg(t, cap, "requestId", "trace-abc")
|
||||
assertToolArg(t, cap, "query", "创建日程")
|
||||
assertArgNotPresent(t, cap, "traceId")
|
||||
assertArgNotPresent(t, cap, "apiName")
|
||||
}
|
||||
|
||||
func TestDevdocErrorTroubleshootAlias_should_pass_error_context(t *testing.T) {
|
||||
cap := setupTestDepsWithPreview(t, "devdoc")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"devdoc", "error", "troubleshoot"}, map[string]string{
|
||||
"error-code": "33012", "error-message": "missing scope", "context": "create calendar failed",
|
||||
})
|
||||
assertToolArg(t, cap, "errorCode", "33012")
|
||||
assertToolArg(t, cap, "query", "missing scope create calendar failed")
|
||||
}
|
||||
|
||||
func TestDevdocErrorDiagnose_should_merge_cli_only_context_into_query(t *testing.T) {
|
||||
cap := setupTestDepsWithPreview(t, "devdoc")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
|
||||
"query": "机器人回调失败",
|
||||
"error-message": "missing scope",
|
||||
"api": "创建日程",
|
||||
"context": "应用无权限",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertToolArg(t, cap, "query", "机器人回调失败 missing scope 创建日程 应用无权限")
|
||||
assertArgNotPresent(t, cap, "apiName")
|
||||
assertArgNotPresent(t, cap, "errorMessage")
|
||||
assertArgNotPresent(t, cap, "context")
|
||||
}
|
||||
|
||||
func TestDevdocErrorDiagnose_should_reject_api_without_primary_input(t *testing.T) {
|
||||
cap := setupTestDepsWithPreview(t, "devdoc")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
|
||||
"api": "创建日程",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
assertCallCount(t, cap, 0)
|
||||
}
|
||||
|
||||
func TestDevdocErrorDiagnose_should_not_call_when_dry_run(t *testing.T) {
|
||||
cap := setupTestDepsWithDryRun(t, "devdoc")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
|
||||
"request-id": "req-123",
|
||||
})
|
||||
assertCallCount(t, cap, 0)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type mcpCallCapture struct {
|
||||
mu sync.Mutex
|
||||
calls []capturedCall
|
||||
dryRun bool
|
||||
preview bool
|
||||
confirm bool
|
||||
}
|
||||
|
||||
@@ -103,6 +104,13 @@ func setupTestDepsWithDryRun(t *testing.T, product string) *mcpCallCapture {
|
||||
return cap
|
||||
}
|
||||
|
||||
func setupTestDepsWithPreview(t *testing.T, product string) *mcpCallCapture {
|
||||
t.Helper()
|
||||
cap := setupTestDeps(t, product)
|
||||
cap.preview = true
|
||||
return cap
|
||||
}
|
||||
|
||||
func setupTestDepsAutoConfirm(t *testing.T, product string) *mcpCallCapture {
|
||||
t.Helper()
|
||||
cap := setupTestDeps(t, product)
|
||||
@@ -130,7 +138,7 @@ func execCmdWithArgs(t *testing.T, root *cobra.Command, path []string, flags map
|
||||
cliArgs = append(cliArgs, path...)
|
||||
|
||||
// Add dry-run if capture says so
|
||||
if cap != nil && cap.dryRun {
|
||||
if cap != nil && (cap.dryRun || cap.preview) {
|
||||
cliArgs = append(cliArgs, "--dry-run")
|
||||
}
|
||||
// Add --yes if auto-confirm
|
||||
@@ -164,12 +172,22 @@ func execCmdWithArgs(t *testing.T, root *cobra.Command, path []string, flags map
|
||||
DryRun bool `json:"dry_run"`
|
||||
} `json:"invocation"`
|
||||
}
|
||||
var flatInv struct {
|
||||
Tool string `json:"tool"`
|
||||
Params map[string]any `json:"params"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
if cap != nil {
|
||||
if jsonErr := json.Unmarshal(out.Bytes(), &inv); jsonErr == nil && inv.Invocation.Tool != "" {
|
||||
if !inv.Invocation.DryRun {
|
||||
if !inv.Invocation.DryRun || cap.preview {
|
||||
cap.record(inv.Invocation.Tool, inv.Invocation.Params, "")
|
||||
}
|
||||
// For dry-run: don't record (matches old behavior: assertCallCount == 0)
|
||||
} else if jsonErr := json.Unmarshal(out.Bytes(), &flatInv); jsonErr == nil && flatInv.Tool != "" {
|
||||
dryRunPreview := flatInv.DryRun || cap.dryRun || cap.preview
|
||||
if !dryRunPreview || cap.preview {
|
||||
cap.record(flatInv.Tool, flatInv.Params, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestRecoveryClosedLoopDoesNotRecursivelyCaptureExecuteFailures(t *testing.T
|
||||
t.Fatalf("list_bases calls = %d, want 1", got)
|
||||
}
|
||||
if got := calls.docSearch.Load(); got < 1 {
|
||||
t.Fatalf("search_open_platform_docs calls = %d, want at least 1", got)
|
||||
t.Fatalf("search_open_platform_docs_rag calls = %d, want at least 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ func newRecoveryRuntimeServer(t *testing.T) (*httptest.Server, *recoveryRuntimeC
|
||||
writeJSONRPCResult(t, w, req["id"], map[string]any{
|
||||
"tools": []map[string]any{
|
||||
{
|
||||
"name": "search_open_platform_docs",
|
||||
"name": "search_open_platform_docs_rag",
|
||||
"title": "Search Docs",
|
||||
"description": "Search docs",
|
||||
"inputSchema": map[string]any{"type": "object"},
|
||||
@@ -261,7 +261,7 @@ func newRecoveryRuntimeServer(t *testing.T) (*httptest.Server, *recoveryRuntimeC
|
||||
case "tools/call":
|
||||
params, _ := req["params"].(map[string]any)
|
||||
name, _ := params["name"].(string)
|
||||
if name != "search_open_platform_docs" {
|
||||
if name != "search_open_platform_docs_rag" {
|
||||
t.Fatalf("unexpected devdoc tool %q", name)
|
||||
}
|
||||
calls.docSearch.Add(1)
|
||||
@@ -313,8 +313,8 @@ func writeRecoveryCatalogFixture(t *testing.T, baseURL string) string {
|
||||
"endpoint": baseURL + "/server/devdoc",
|
||||
"tools": []any{
|
||||
map[string]any{
|
||||
"rpc_name": "search_open_platform_docs",
|
||||
"canonical_path": "devdoc.search_open_platform_docs",
|
||||
"rpc_name": "search_open_platform_docs_rag",
|
||||
"canonical_path": "devdoc.search_open_platform_docs_rag",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+14
-2
@@ -44,7 +44,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
| `calendar` | `references/products/calendar.md` | 13 | 30 |
|
||||
| `chat` | `references/products/chat.md` | 13 | 31 |
|
||||
| `contact` | `references/products/contact.md` | 7 | 14 |
|
||||
| `devdoc` | `references/products/simple.md` | 1 | 5 |
|
||||
| `devdoc` | `references/products/simple.md` | 2 | 7 |
|
||||
| `ding` | `references/products/ding.md` | 2 | 5 |
|
||||
| `report` | `references/products/report.md` | 6 | 26 |
|
||||
| `todo` | `references/products/todo.md` | 6 | 30 |
|
||||
@@ -1007,7 +1007,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
---
|
||||
|
||||
### devdoc(5 条)
|
||||
### devdoc(7 条)
|
||||
|
||||
#### `dws devdoc article search`
|
||||
|
||||
@@ -1036,6 +1036,18 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
- Expected: `dws devdoc article search --keyword 免登鉴权 --format json`
|
||||
- Flags: `--keyword` = `免登鉴权`
|
||||
|
||||
#### `dws devdoc error diagnose`
|
||||
|
||||
**devdoc_devdoc_error_diagnose_001**
|
||||
- Prompt: 排查开放平台 requestId 15r6h45w0muec 的调用失败
|
||||
- Expected: `dws devdoc error diagnose --request-id 15r6h45w0muec --format json`
|
||||
- Flags: `--request-id` = `15r6h45w0muec`
|
||||
|
||||
**devdoc_devdoc_error_diagnose_002**
|
||||
- Prompt: 排查开放平台错误码 33012,错误描述 missing scope
|
||||
- Expected: `dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json`
|
||||
- Flags: `--error-code` = `33012`, `--error-message` = `missing scope`
|
||||
|
||||
---
|
||||
|
||||
### todo(30 条)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Summary
|
||||
|
||||
- **Total Test Cases**: 202
|
||||
- **Passed**: 202
|
||||
- **Total Test Cases**: 204
|
||||
- **Passed**: 204
|
||||
- **Failed**: 0
|
||||
- **Pass Rate**: 100.0%
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| calendar | 30 | 30 | 0 | 100.0% |
|
||||
| chat | 31 | 31 | 0 | 100.0% |
|
||||
| contact | 14 | 14 | 0 | 100.0% |
|
||||
| devdoc | 5 | 5 | 0 | 100.0% |
|
||||
| devdoc | 7 | 7 | 0 | 100.0% |
|
||||
| ding | 5 | 5 | 0 | 100.0% |
|
||||
| report | 26 | 26 | 0 | 100.0% |
|
||||
| todo | 30 | 30 | 0 | 100.0% |
|
||||
@@ -1133,6 +1133,22 @@
|
||||
- Command path: PASS (devdoc article search)
|
||||
- Flags: PASS (1 flags validated)
|
||||
|
||||
**devdoc_devdoc_error_diagnose_001** ✅ PASS
|
||||
|
||||
- Prompt: 排查开放平台 requestId 15r6h45w0muec 的调用失败
|
||||
- Expected: `dws devdoc error diagnose --request-id 15r6h45w0muec --format json`
|
||||
- Skill Reference: references/products/devdoc.md
|
||||
- Command path: PASS (devdoc error diagnose)
|
||||
- Flags: PASS (1 flags validated)
|
||||
|
||||
**devdoc_devdoc_error_diagnose_002** ✅ PASS
|
||||
|
||||
- Prompt: 排查开放平台错误码 33012,错误描述 missing scope
|
||||
- Expected: `dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json`
|
||||
- Skill Reference: references/products/devdoc.md
|
||||
- Command path: PASS (devdoc error diagnose)
|
||||
- Flags: PASS (2 flags validated)
|
||||
|
||||
### ding
|
||||
|
||||
**ding_ding_message_recall_001** ✅ PASS
|
||||
|
||||
@@ -22,8 +22,8 @@ import (
|
||||
// TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the
|
||||
// "custom authorization card" contract: the CLI switches to host-owned
|
||||
// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT /
|
||||
// claw-type is purely a server-side routing tag and must NOT influence
|
||||
// the decision, in either direction.
|
||||
// claw-type is purely a server-side routing tag and must NOT influence the
|
||||
// decision, in either direction.
|
||||
//
|
||||
// Regression guard: several earlier drafts conflated the two signals,
|
||||
// causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE
|
||||
@@ -33,6 +33,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
agentCode string
|
||||
reversed string
|
||||
agentEnv string
|
||||
want bool
|
||||
}{
|
||||
@@ -48,6 +49,13 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
|
||||
agentEnv: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "reversed draft agent code only → CLI-owned",
|
||||
agentCode: "",
|
||||
reversed: "agt-compat",
|
||||
agentEnv: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "agent code + DINGTALK_AGENT=default → host-owned",
|
||||
agentCode: "agt-cursor",
|
||||
@@ -84,6 +92,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, tc.agentCode)
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", tc.reversed)
|
||||
// DINGTALK_AGENT is set purely to demonstrate that it does NOT
|
||||
// influence the host-owned decision. The literal env name is
|
||||
// used here because the auth package no longer exports a
|
||||
@@ -97,6 +106,15 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
|
||||
got, tc.want, tc.agentCode, tc.agentEnv,
|
||||
)
|
||||
}
|
||||
if tc.want {
|
||||
gotCode, gotSource := authpkg.AgentCodeFromEnv()
|
||||
wantCode := tc.agentCode
|
||||
wantSource := authpkg.AgentCodeEnv
|
||||
if gotCode != wantCode || gotSource != wantSource {
|
||||
t.Fatalf("AgentCodeFromEnv() = (%q, %q), want (%q, %q)",
|
||||
gotCode, gotSource, wantCode, wantSource)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user