Compare commits

...
16 Commits
Author SHA1 Message Date
修雨 cdd8414891 docs: cut 1.0.37 changelog (#457) 2026-06-11 19:53:26 +08:00
xuanandshangguanxuan.sgx 2a82d07311 fix(pat): support batch agentCode and guarded grants (#455)
* fix(pat): carry agentCode in batch auth args

* fix(pat): let core default missing agent code

* fix(pat): require yes for batch grants

* test(pat): cover cli authorization matrix

* fix(pat): keep canonical agent code env only

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-11 19:21:25 +08:00
修雨 60ac0b409d fix(cli): guard canonical mcp tree against poisoned-cache flag collisions (#454)
* fix(cli): guard canonical mcp tree against poisoned-cache flag collisions

The canonical 'dws mcp' tree is built from cached catalog data before the
legacy command build and before Cobra dispatches anything, so a pflag
panic there (a tool schema property named after the reserved --params
flag, as cached during the 1.0.32 incident) aborted every invocation --
including 'dws cache refresh' and 'dws upgrade' -- and sat outside all
three poisoned-cache guards (#447/#449/#452).

Two layers, mirroring the existing guards:
- applyFlagSpecs now skips reserved (--json/--params), duplicate and
  alias-colliding flag names and sanitizes shorthands instead of letting
  pflag panic; the skipped property stays reachable through the reserved
  JSON payload flags (same degradation semantics as #449).
- newMCPCommand wraps the build in the #452 recover -> quarantine ->
  retry-once -> degrade-to-stub sequence, so even an unforeseen panic
  class no longer locks the CLI out.

* docs: amend 1.0.36 changelog for the re-cut with the canonical tree guard (#454)
2026-06-11 09:16:36 +08:00
修雨 4bc4b60dca docs: cut 1.0.36 changelog (#453) 2026-06-10 22:45:50 +08:00
修雨 31e65dda51 fix(cli): self-heal a poisoned discovery cache by quarantining it and rebuilding (#452)
A panic during the envelope-driven command build no longer just degrades
to helper commands (#447): the partition's discovery cache is first moved
aside to <partition>.quarantined (kept for inspection, previous quarantine
replaced) and the build retried once against a fresh fetch. Any path that
delivers a fixed binary -- dws upgrade or a reinstall -- now escapes the
lock-out with zero manual cache surgery; only a second panic (remote
envelope still poisoned, or offline) falls back to the degraded helper
set with the 'dws cache refresh' hint.

dws upgrade additionally clears the discovery-derived caches (market /
tools / detail across all partitions, leaving the downloads dir alone)
after the binary swap, so the upgraded binary rebuilds its command tree
from fresh data instead of inheriting snapshots written by the old
version.
2026-06-10 22:45:35 +08:00
修雨 387ae5ff59 fix(doc): strip leading H1 duplicating --name on doc create (#448)
* fix(doc): strip leading H1 duplicating --name on doc create

The doc platform renders the document name as the page title. When the
markdown body also opens with the same H1 — a habit LLM agents fall
into despite the skill docs saying otherwise — the created document
shows the title twice.

Strip a leading ATX H1 from the markdown body when its text equals the
document name (trimmed, case-insensitive), and print a stderr note so
agents learn the convention. Any other leading H1 is kept as
intentional content; names legitimately ending with '#' are not
over-trimmed. When the body is nothing but the duplicate H1, the
markdown param is omitted entirely.

* docs(skill): note the CLI auto-strip of a duplicate leading H1 in doc create

The convention stays the same (--name is the H1, body starts from ##),
but agents should recognize the new stderr note and not rely on the
fallback.
2026-06-10 16:04:20 +08:00
修雨 838e5453d8 fix(compat): guard envelope-driven flag registration against pflag panics (#449)
The discovery envelope is remote data, but four of its shapes were
forwarded to pflag registration calls that panic:

- a flag named 'params' or 'json' collides with the reserved payload
  flags registered at the end of ApplyBindings (the original pre-1.0.32
  lockout: "chat_permission_grant flag redefined: params")
- two bindings resolving to the same long flag name (cross-binding
  duplicate primary/alias; the existing dedup map was per-binding only)
- two flags claiming the same shorthand
- a multi-character shorthand

Because the command tree is built from the cached envelope before Cobra
dispatches anything, any of these aborted every CLI invocation.

Add canRegisterFlag (skip duplicate/reserved long names; CollectBindings
already tolerates missing flags via Lookup→nil→continue, and the value
stays reachable through --params) and safeShorthand (drop invalid or
taken shorthands, keep the long flag) and apply them at every
envelope-driven registration site in ApplyBindings and
registerPositionalAliasFlags. The trailing --json/--params registration
is also made idempotent.

Complements #447: that PR adds the escape hatch when the build panics;
this removes the known panic vectors so the escape hatch should never
be needed for them.
2026-06-10 15:31:37 +08:00
修雨 330922cdee fix(cli): degrade to built-in commands when dynamic build panics (#447)
The dynamic command tree is built from cached discovery data before
Cobra dispatches any command. A panic during that build (e.g. a
duplicate pflag registration fed by a poisoned cache, as seen before
1.0.32: "chat_permission_grant flag redefined: params") aborted every
invocation — including 'dws cache refresh', the very command that
repairs the cache. The only way out was manually deleting
~/.dws/cache/<partition>/tools/*.

Wrap the envelope-driven build in a local recover: on panic the CLI now
logs the failure, prints a stderr hint pointing at 'dws cache refresh',
and falls back to the hardcoded helper commands so utility commands
stay alive and users can self-heal.
2026-06-10 15:28:58 +08:00
xuanandshangguanxuan.sgx eaa60f95b5 feat(devdoc): add rag mcp cli commands (#434)
* feat(devdoc): add rag mcp cli commands

* chore(config): default mcp endpoint to prepub

* chore(config): use prepub mcp discovery host

* fix(devdoc): wrap rag search request

* fix(transport): preserve dingtalk mcp gateway query

* fix(devdoc): align cli with rag mcp schema

* fix(config): keep mcp defaults production-safe

* test(devdoc): cover rag cli parameter mapping

* chore(test): remove dead nested arg assertion

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-09 18:00:05 +08:00
修雨 e7a3010b81 docs: cut 1.0.35 changelog + fix README product-count drift (#435)
* docs: cut 1.0.35 changelog + fix README product-count drift

CHANGELOG: promote [Unreleased] to [1.0.35] - 2026-06-08, covering the
chat @-mention render fix (#433), chat list-direct skill alignment (#424),
pat chmod batch agentCode passthrough (#414), and pat JSON auth-URL
readability (#401).

README (en + zh): the multi-skills section claimed 19/20 products while the
Key Services summary says "18 products" (aiapp was taken offline in 1.0.34,
dropping the count to 18). Unify every product-count reference to 18.

* docs(readme): tidy Key Services table — concise descriptions, drop subcommand duplication

The Description column re-listed every subcommand already shown in the
Subcommands column, making rows (esp. chat) very tall and uneven. Rewrite
descriptions as concise, parallel capability summaries and drop the
malformed inline '(top-level: ...)' sprawl in the sheet row. en + zh.

* docs(readme): restore Key Services table; keep only product-count fix

The previous commit silently rewrote/reflowed the entire Key Services
table (shortening every Subcommands + Description cell) — far beyond this
PR's stated "纯文档改动 / product-count drift fix" scope.

Restore both tables (README.md + README_zh.md) byte-for-byte to main and
keep ONLY the six intended count corrections (multi skills 20/19 → 18),
so the diff matches the PR description and the table is not re-wrapped.

* docs(changelog): write 1.0.35 entries in English

The 1.0.35 Fixed entries were in Chinese while all prior releases
(1.0.34, 1.0.33, ...) use English. Translate the four entries (#433,
#424, #414, #401) to English to match the existing CHANGELOG convention;
content unchanged.
2026-06-09 10:32:23 +08:00
修雨 e7ef2c4677 fix(chat): keep @-mention tokens literal so they render (#433)
send_personal_message packs the message body via json.Marshal, whose
default HTML escaping rewrites <@openDingTalkId> / <@all> into
<@...>. The DingTalk client renders an @-mention by matching the
literal <@...> token, so the escaped form is shown as plain text and the
@ never renders (API still returns success, masking the bug).

Marshal the content with SetEscapeHTML(false) for both the group and the
direct send_personal_message paths. Add a regression test asserting the
content keeps literal <@...> tokens and is never HTML-escaped.

Verified live: @someone and @all now render as blue mentions in the client.
2026-06-08 21:46:41 +08:00
修雨 8c2093a41a fix(skill): align chat single-chat docs/script with list-direct (#424)
* fix(skill): align chat single-chat docs/script with list-direct

钉钉 MCP 服务已将单聊从 `chat message list` 拆出独立的 `list-direct` /
`send-direct`(list 现仅支持群聊,--user / --open-dingtalk-id 已移除),
但 skill 侧文档与脚本仍在教 `chat message list --user`,照做会因 unknown
flag 报错;推荐为单聊"优先"方法的 chat_history_with_user.py 也随之失效。

- chat.md (mono+multi): `message list` 改为仅群聊;新增 `list-direct` /
  `send-direct` 两段命令文档;意图路由 / 关键区分 / 上下文传递表 / 注意事项
  全部对齐单聊新命令
- best_practices/01-messaging.md (mono): query-private-chat 由 `list --user`
  改 `list-direct`(multi 版此前已改,未动)
- chat_history_with_user.py (mono+multi): 调用 `list-direct`;并修复返回体
  解析——解包 `result.messages` + 对齐 `createTime/content/sender` 字段,
  此前会崩在 `'str' object has no attribute 'get'`

* docs(changelog): add entry for chat single-chat list-direct alignment (#424)

* fix(skill): drop send-direct from chat docs (not a v1.0.34 command)

复核 v1.0.34 CLI 发现:单聊发送 rpc `send_direct_message_as_user` 在
v1.0.34 已并入 `chat message send`(cli_name=send,用 --user),不再暴露
独立的 `send-direct` 命令(仅 v1.0.29 有)。上一提交按 v1.0.29 误加的
send-direct 文档段/路由/关键区分/上下文表引用对 v1.0.34 是错的,全部移除。

list-direct 部分保留不变——v1.0.34 确认 `list` 仅群聊、`list-direct`
存在,`list --user` 仍报 unknown flag。单聊发送回归 `send --user`。
2026-06-07 16:17:31 +08:00
修雨 5fbf12fe50 Revert "fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)" (#415)
This reverts commit f826375556.
2026-06-05 12:10:17 +08:00
xuanandshangguanxuan.sgx dd419ca498 fix(pat): pass agent code to chmod batch tools (#414)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-05 11:23:48 +08:00
修雨 f826375556 fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)
- printExecutionError: in JSON mode emit the structured error to stdout
  (not stderr) so machine consumers parsing stdout get a parseable result;
  update the 3 root_execute tests to assert the new contract.
- requireYesForDelete prompt: when stdin is not a TTY (piped/scripted/JSON
  pipelines), do not show an interactive confirm that emits non-JSON and
  blocks on stdin; return a structured validation error requiring --yes.
2026-06-05 11:22:22 +08:00
xuanandshangguanxuan.sgx cb95207d5a fix(pat): keep auth URLs readable in JSON output (#401)
* fix(pat): keep auth URLs readable in JSON output

* fix(pat): narrow URL escaping fix to PAT JSON

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-04 15:57:06 +08:00
62 changed files with 3235 additions and 225 deletions
+35
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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
View File
@@ -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
+203
View File
@@ -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))
}
}
+17 -2
View File
@@ -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).
+38
View File
@@ -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) {
+2 -2
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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),
+11
View File
@@ -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")
+10
View File
@@ -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)
+28 -9
View File
@@ -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()
}
+68
View File
@@ -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
View File
@@ -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)
}
}
+36 -7
View File
@@ -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)
+116
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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 {
+135
View File
@@ -0,0 +1,135 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package 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
View File
@@ -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).
+7
View File
@@ -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)
+1 -1
View File
@@ -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},
+19 -4
View File
@@ -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")
}
+49
View File
@@ -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
+78 -1
View File
@@ -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)
+143 -2
View File
@@ -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
View File
@@ -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",
+161
View File
@@ -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"])
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
}
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 开关:
+27 -3
View File
@@ -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
}
+38
View File
@@ -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 {
+50
View File
@@ -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
}
+14
View File
@@ -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>`(备选) |
+4 -2
View File
@@ -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`
---
+38 -19
View File
@@ -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` 指定返回条数
+37
View File
@@ -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`。
+21
View File
@@ -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 |
+13 -6
View File
@@ -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} 页)")
+38 -19
View File
@@ -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} 页)")
+3 -1
View File
@@ -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` | 发布结果(成功或错误信息) | 确认企业技能库已更新 |
+91 -3
View File
@@ -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)
}
+20 -2
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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 条)
+19 -3
View File
@@ -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
+20 -2
View File
@@ -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)
}
}
})
}
}