Compare commits

...
27 Commits
Author SHA1 Message Date
修雨 f19a3ccfa5 feat(skills): 同步 chat/bot skill 参考 — 补齐消息管理与机器人新能力 (#148)
对齐 dws-service-endpoints.json 新增的 16 个 group-chat toolOverrides
和 2 个 bot toolOverrides,参考 dws-wukong 实现补齐以下命令文档:

- message: send/list/list-all/list-topic-replies/list-by-sender/
           list-mentions/list-focused/list-unread-conversations/
           search/info/send-personal/list-top-conversations
- group: create-org/search-common
- bot: create/search-groups

同步更新命令总览、意图判断、工作流与上下文传递表。
2026-04-22 16:31:56 +08:00
xuan 89c5038446 fix(auth): restore device_code polling when flowId is absent (#145)
* fix(auth): fallback to device_code when flowId is missing

* chore(auth): remove dead helper and harden fallback tests
2026-04-22 15:13:40 +08:00
修雨 4f915e4e2c refactor(plugin): drop managed/ compat layer left by PR #126 (#135)
PR #126 removed the privileged managed-plugin mechanism but kept
LoadManaged, IsManaged, and ~/.dws/plugins/managed/ fallbacks so that
plugins installed by pre-#126 builds would keep loading. The window
for that migration was ~4 days (2026-04-15 → 2026-04-19), and the
original install path mostly failed anyway (issue #124 / GitHub Pages
HTML response), so there is no real user base to preserve.

Scope of deletion:
- plugin.Loader.LoadManaged, scanDir's isManaged param, loadPlugin's
  isManaged param. LoadAll now combines user + dev only.
- Plugin.IsManaged field. Converter/root.go no longer branch on it;
  stdio and HTTP descriptors both report source="plugin".
- ~/.dws/plugins/managed/ branches in ListInstalled, SetEnabled,
  RemovePlugin.
- config.PluginManagedDir constant.
- TestLoaderLoadManaged, TestRemoveLegacyManagedPlugin, and the
  "legacy managed" table case in TestRemovePluginPurgesSettings.

Kept on purpose:
- Manifest.Type public schema field ("managed"|"user") — it appears
  in existing plugin.json files and is only validated, never
  behaviorally consumed.

Users with an orphaned ~/.dws/plugins/managed/ directory can
`rm -rf ~/.dws/plugins/managed/` — the CLI no longer reads it.
2026-04-22 14:56:11 +08:00
Ari ec03b7cca3 docs(changelog): backfill 1.0.3 through 1.0.12 release notes (#146)
CHANGELOG.md had been stuck at 1.0.2; releases 1.0.3, 1.0.4, 1.0.6,
1.0.7, 1.0.8, 1.0.9, 1.0.11, and 1.0.12 are now documented in full.
Versions 1.0.5 and 1.0.10 were not shipped as git tags and are
intentionally skipped.

Highlights:
- 1.0.3: nested/array output filtering, schema table rendering, todo
  static command family
- 1.0.4: client credential keychain persistence for token refresh
- 1.0.6: error diagnostics overhaul (trace_id/server_code/verbosity
  levels), destructive-command confirmation, OAuth credential persistence
- 1.0.7: dws upgrade self-updater, Wukong edition overlay, auth check
  flipped to fail-closed
- 1.0.8: aitable static helper commands (20 commands)
- 1.0.9: plugin system launch, dws doctor/config, 3->5 stage command
  pipeline, edition hooks
- 1.0.11: plugin cold-start perf, PAT scope error UX, i18n fixes
- 1.0.12: doc + minutes skill references, aitable dashboard/chart/export
  sections, README catalog sync to 141 commands / 14 products
2026-04-22 12:19:22 +08:00
fantiuandgithub-actions[bot] e0544579d2 fix(auth): attribute CLI auth denial to channel before user scope (#144)
* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* fix(auth): attribute CLI auth denial to channel before user scope

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-22 12:05:30 +08:00
修雨 ce43280c11 docs: add doc and minutes to Key Services table (#140)
* docs: add doc and minutes to Key Services table

* docs: sync Key Services table — aitable/minutes full capability coverage

- aitable: 20 → 37 commands; add chart/dashboard/export/import/view subgroups
- minutes: 14 → 22 commands; add hot-word/mind-graph/replace-text/speaker/upload subgroups
- Total: 116 → 141 commands
2026-04-22 00:19:36 +08:00
修雨 74ca40c197 feat(skills): 同步 aitable 参考 — 补充仪表盘/图表与导出数据章节 (#141) 2026-04-21 23:39:07 +08:00
修雨 cfaa673863 feat(skills): 同步 aitable skill 参考(对齐 dws-wukong + 二进制验证) (#139)
- aitable.md:
  - `base list` 措辞强化为「仅浏览最近,查找请用 search」
  - `base get` 新增「URL → baseId 提取」快速提示
  - `field create` 新增单字段模式(--name/--type/--config)示例与说明
  - `record query` 将 --keyword 改为 --query(与二进制一致)
  - `record query` 补充 --sort direction 规范(避免误用 order)
  - 意图判断:base 查找优先 `base search`
  - 新增「--filters 筛选语法排错与使用规范」章节
  - 新增「URL → baseId 提取」章节
  - 新增「相关产品」章节(指向 doc)
- SKILL.md:aitable 描述补齐「附件」group

注:dws-wukong 中 --ai-config、dashboard/chart/export、--table-name
别名等命令当前二进制未支持,此次不同步。
2026-04-21 16:03:22 +08:00
修雨 3bc6c31a2d feat(skills): 新增钉钉文档与 AI 听记 skill 参考 (#138)
参考 dws-wukong/dingtalk-workspace 的写法,移植 minutes / doc
两个 product 参考文档,并在 SKILL.md 中:
- 产品总览新增 doc / minutes 行
- 意图决策树新增听记/文档路由
- 危险操作表新增 doc delete / doc block delete
2026-04-21 15:44:17 +08:00
383aeefaf6 fix(cli): localize plugin/help command strings via i18n (closes #118) (#134)
Wire plugin module + help command + OAuth client-id/secret flags through
the existing i18n catalog so --help is consistent Chinese under zh locale
and English under en locale, instead of mixing the two.

- internal/app/plugin_cmd.go: wrap 16 Short strings with i18n.T
- internal/app/flags.go: wrap --client-id/--client-secret descriptions
- internal/app/root_help.go: override cobra default help command with a
  localized one so "dws help --help" and the utility-commands listing
  share the same catalog
- internal/i18n/locales/{en,zh}.json: add 20 new catalog entries

Verified under DWS_LANG=zh (all Chinese), DWS_LANG=en (original English
preserved), and LANG-based auto-routing. go test ./internal/app/... pass.

Co-authored-by: 修雨 <huyizhou.hyz@alibaba-inc.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 20:51:07 +08:00
ybcstudyand猷诺 a5bede3a19 fix: -f json模式下错误JSON从stdout改为输出到stderr (#133)
修复背景:
CI测试脚本通过检查stderr中是否包含错误关键词来判断命令是否失败。
但-f json模式下,printExecutionError将错误JSON输出到stdout,
导致stderr为空,测试断言失败。

改动:
- root.go:150: apperrors.PrintJSON(stdout, err) → apperrors.PrintJSON(stderr, err)
- root_execute_test.go: 3处断言方向同步调整(stdout↔stderr)

影响范围:
仅影响-f json模式下的错误输出流向,正常输出不受影响。
符合Unix惯例:错误信息输出到stderr,正常结果输出到stdout。

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-04-20 19:38:39 +08:00
xuan bbf66e23d6 fix(pat): avoid shared PAT command state in root registration (#129)
* chore: remove workspace and bundled artifacts

* chore: clean local-only repository artifacts

* fix(pat): avoid shared command state in registration
2026-04-20 14:54:57 +08:00
shangguanxuan633-lab 5e168c92cf Codex/remove red box files (#127)
* chore: remove workspace and bundled artifacts

* chore: clean local-only repository artifacts
2026-04-20 09:46:11 +08:00
shangguanxuan633-labandshangguanxuan.sgx 725577103d feat: PAT scope error visualization and auto-retry with authorization polling (#113)
* feat: PAT scope error visualization and auto-retry with authorization polling

1. PAT error result visualization (non-JSON)
   - Human-readable error output matching lark-cli style
   - Shows error type, message, hint, and authorization command
   - JSON output also available via --format json

2. Auto-retry with polling after authorization wall
   - Detects missing_scope / insufficient_scope / permission errors
   - Polls every 5s for token update after user completes auth
   - 10 minute timeout before giving up
   - Automatically retries the original command after successful auth

Changes:
- internal/app/pat_auth_retry.go: Core PAT error handling and retry logic
- internal/app/pat_auth_retry_test.go: 11 unit tests
- internal/app/runner.go: Integration with executeInvocation error path
- internal/auth/device_flow.go: Add SetScope() method

* feat: migrate PAT (Permission Authorization) infrastructure to open-source core

Move PAT permission management code from dws-wukong overlay to the
open-source dingtalk-workspace-cli core. This enables PAT handling
in the public distribution while keeping dws-wukong fully compatible
through the edition.Hooks mechanism.

Changes:
- pkg/edition: add 6 new Hooks fields (AuthClientID, AuthClientFromMCP,
  SaveToken, LoadToken, DeleteToken, ClassifyToolResult) for overlay
  extensions to inject custom auth/token/error behaviour
- internal/errors/pat: new PATError type with ExitCode()=4 and
  RawStderr() for embedded-mode passthrough, plus ClassifyToolResultContent
  and ClassifyMCPResponseText classification functions
- internal/pat: new command group (pat chmod) with tool result handling
- internal/app/root: register PAT commands before RegisterExtraCommands hook
- internal/app/runner: integrate ClassifyToolResult hook in the
  callResult.IsError branch so overlays can intercept PAT/gateway errors
  before generic handling

The dws-wukong overlay remains unchanged — its existing pat/ package and
RegisterExtraCommands hook continue to work. Through the go.mod replace
directive and deduplicateCommands mechanism, both codebases coexist
without conflict.

* feat(pat): add PAT auth check with device flow polling and auto-retry

- Add ClassifyPatAuthCheck / AsPatAuthCheckError in pat.go for
  AGENT_CODE_NOT_EXISTS error detection
- Add handlePatAuthCheck in pat_auth_retry.go: inject clientId as
  x-robot-uid header, poll device flow endpoint, auto-retry on APPROVED
- Integrate PAT check into runner.go executeInvocation pipeline
  (edition hook + open-source fallback)
- Add patAuthRequiredCodes map for extensible auth-required code matching
- Remove test_parse.go (temporary test script)

* fix(pat): update poll path to /cli/oauth/device/poll and add x-user-access-token header

- Change DevicePollPath from /api/dingtalk-workspace-cli/oauth/device/poll
  to /cli/oauth/device/poll (aligning with backend endpoint convention)
- Add x-user-access-token header to poll requests (loaded from stored token)

* feat(auth): PAT device flow improvements and flowId empty guard

- pat_auth_retry: fix JSON field parsing (result -> data) using DevicePollResponse
- pat_auth_retry: fix elapsed time calculation based on start timestamp
- device_flow: add flowId empty guard - skip polling and show auth URL for manual handling
- device_flow: unify terminalBaseURL to GetMCPBaseURL for polling endpoint
- device_flow: remove unused PollFlowApproval dead code
- runner: integrate DWS_CLIENT_ID env and app.json persistence
- oauth_provider: add x-robot-uid header removal on logout
- client: add request/response logging for transport debugging

* feat(env): switch all service discovery URLs from pre-release to production

- endpoints: pre-login/pre-api/pre-mcp/pre-open-dev -> login/api/mcp/open-dev
- loader: DefaultMarketBaseURL -> mcp.dingtalk.com
- registry: defaultBaseURL -> mcp.dingtalk.com
- constants: DefaultTerminalBaseURL -> open-dev.dingtalk.com

* fix: remove residual merge conflict markers and duplicate Hooks fields in edition.go; unify developer-settings URL via config helper in device_flow.go

* chore: remove lark-cli reference from PrintPatAuthError comment

* test: add comprehensive unit tests for PAT auth modules

- Create internal/errors/pat_test.go (38 tests): covers ClassifyToolResultContent,
  ClassifyMCPResponseText, ClassifyPatAuthCheck, AsPatAuthCheckError, cleanPATJSON,
  stripClassFields, getDWSGatewayErrorCode, isNotLoggedInError, isBusinessError,
  IsPATError, IsPATNoPermissionCode, suggestForBusinessErrorText
- Supplement pat_auth_retry_test.go (+7 tests): IsPatRetrying context checks,
  pollPatDeviceFlow edge cases (server error fallback, SSO redirect skip),
  extractPatScopeError nil/identity extraction

* feat(pat): exchange authCode for fresh token after PAT APPROVED

Previously pollPatDeviceFlow discarded the authCode from the poll
response, so handlePatAuthCheck retried with the stale token after
APPROVED. This mirrors device_flow.go loginOnce which calls
exchangeCode → SaveTokenData.

Changes:
- pollPatDeviceFlow: return (status, authCode, error) instead of
  (status, error); extract Data.AuthCode on APPROVED
- handlePatAuthCheck APPROVED path: call ExchangeCodeForToken +
  SaveTokenData before ResetRuntimeTokenCache, with graceful
  fallback on exchange failure
- Update all 6 poll test cases for new 3-return signature; add
  authCode assertions for APPROVED/REJECTED/EXPIRED/CANCELLED/
  ServerError scenarios

* fix(pat): CR round-2 must-fix items

- scopeValueRegex: support multi-segment scopes (mail:a.b:send)
  Updated regex to ([a-zA-Z][a-zA-Z0-9_.]*(?::[a-zA-Z][a-zA-Z0-9_.]*)+)

- handlePatAuthCheck: add 3 integration tests (APPROVED/REJECTED/EmptyFlowID)
  Covers the 132-line main orchestrator with mock runner + httptest server

- Extract ParseDeviceFlowStatus + status constants to auth package
  Eliminates string literal duplication across device_flow.go and pat_auth_retry.go

* fix(pat): use direct OAuth path when clientSecret is provided

When PAT error response includes both clientId and clientSecret,
use SetClientID() (direct DingTalk API mode) instead of
SetClientIDFromMCP() (MCP proxy mode). The MCP proxy does not
hold the secret for the PAT-assigned app, causing HTTP 400
'invalidParameter.idOrSecret.notFound' on token exchange.

Now: clientSecret present → direct mode; absent → MCP proxy mode.

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-04-19 19:12:34 +08:00
修雨 f762117d4e Merge pull request #126 from DingTalk-Real-AI/refactor/remove-managed-plugin-privilege
refactor(plugin): 移除 default/managed plugin 特权机制
2026-04-19 14:56:59 +08:00
修雨 750b6c04d6 Merge pull request #125 from DingTalk-Real-AI/fix/issue-119-plugin-mcp-timeout
perf(plugin): fast plugin MCP startup via cache + parallel discovery (#119)
2026-04-19 14:56:55 +08:00
修雨 59e51c348a perf(plugin): share cache.Store across discovery + relax cold timeouts
Address reviewer feedback on #125:

- Hoist a single *cache.Store above the discovery fan-out so all HTTP and
  stdio goroutines share one instance rather than each spawning its own in
  registerHTTPServer/registerStdioServer. Atomic tmp+rename per cache key
  keeps concurrent writers on distinct keys collision-free; global runtime
  registries (AppendDynamicServer, RegisterStdioClient) already guard
  themselves with a mutex. Added a comment pointing at those invariants.

- Relax cold-path timeouts to survive healthy cross-region endpoints and
  Python/Node interpreter warm-up: HTTP 500ms → 1s (plain), 700ms → 1.5s
  (auth), stdio 1s → 2s. A new DWS_PLUGIN_COLD_TIMEOUT env var overrides
  all three with a single duration, registered in configmeta so it shows
  up in `dws config --help`.

- Add internal/app/plugin_discovery_concurrency_test.go covering the
  thread-safety claims end-to-end: 16 parallel SaveTools with distinct
  keys, 32 parallel AppendDynamicServer, 32 parallel RegisterStdioClient,
  plus TestResolvePluginColdTimeouts exercising defaults, a valid
  override, an unparseable value, and a non-positive value. All pass with
  go test -race.

Verified: go build ./... clean; targeted -race tests PASS in 1.5s;
./internal/{app,cli,cache,plugin}/... all green.
2026-04-19 14:41:40 +08:00
修雨 a056a9abfb refactor(plugin): purge removed plugin settings instead of merely disabling
Review follow-up on #126: RemovePlugin used to call
setPluginEnabled(name, false), which left the plugin's key in
EnabledPlugins and never touched PluginConfigs. settings.json retained
dangling state for plugins no longer on disk.

Replace the disable with purgePluginFromSettings, which deletes both
the EnabledPlugins entry and any PluginConfigs entry for the removed
plugin, leaving unrelated plugins' state intact. A re-install defaults
to enabled=true via the existing install paths, matching user
expectations.

Covered by TestRemovePluginPurgesSettings across both user and legacy
managed layouts.
2026-04-19 14:32:22 +08:00
修雨 33ae780103 refactor(plugin): remove default/managed plugin privileged mechanism
Drop the hardcoded default-managed-plugin bootstrap that auto-fetched
DingTalk-Real-AI/* plugins on CLI startup. All third-party plugins are
now installed equally via `dws plugin install` — no privileged workspace.

Motivation (issue #124): first-run startup printed
  WARN failed to fetch remote info for default plugin
       DingTalk-Real-AI/conference: invalid character '<' looking for
       beginning of value
because the GitHub Pages registry returned HTML for the missing plugin.
Beyond the error, the whole "built-in plugin" concept contradicts the
CLI's lightweight design: plugin installation planning belongs to
agent-authored skills, not to the CLI binary.

Changes:
- Delete internal/plugin/updater.go (EnsureManaged, CheckAndUpdate,
  checkRemoteVersion, downloadAndInstall, promptUpdate, zip extraction
  — the entire managed-plugin update pipeline).
- Delete internal/plugin/updater_test.go.
- Remove DefaultManagedPlugins, OfficialPluginWorkspace, and
  PluginUpdateCheckInterval from pkg/config/constants.go.
- Remove the EnsureManaged/CheckAndUpdate bootstrap block from
  internal/app/root.go; only legacy LoadManaged() kept for back-compat
  so plugins already installed under ~/.dws/plugins/managed/ still load.
- internal/plugin/loader.go: InstallFromGit always installs under
  PluginUserDir; RemovePlugin allows removal of legacy managed-dir
  plugins instead of refusing.
- internal/app/plugin_cmd.go: drop `--type managed` flag and related
  gating from `plugin install`; disable/remove commands no longer
  differentiate managed vs user.
- Update plugin_test.go accordingly (replace TestRemoveManagedPluginBlocked
  with TestRemoveLegacyManagedPlugin, drop TestPromptUpdate).

Backward compatibility: `PluginManagedDir = "managed"` constant retained
so plugins already on disk from older CLI versions still load and are
removable. No migration required.

Verification:
- go build ./cmd                 → success
- go test ./internal/plugin/...  → PASS
- go test ./internal/app/...     → PASS (436s, matches main baseline)
- Smoke: `/tmp/dws-new plugin list` no longer prints the "Pulling
  built-in plugin" or "failed to fetch remote info" WARN.
- Pre-existing failures on main (unrelated): test/cli_compat,
  test/integration/extensions (requires auth login),
  test/scripts (requires DWS_PACKAGE_VERSION or git tag),
  test/unit TestOpenSourceTreeOmitsEmbeddedHostMarkers.

Closes #124
2026-04-19 11:18:56 +08:00
修雨 daf56514f7 perf(plugin): parallelize all plugin discovery and tighten cold timeouts
Follow-up to cache-first registration (c95ec04). The cold-cache wall
clock is now bounded by the slowest individual plugin, not the sum:

- Fan out HTTP and stdio discovery together in goroutines rather than
  running the stdio loop serially after HTTP.
- HTTP cold budget: 4s → 700ms (auth) / 500ms (plain). Honest dial
  timeouts on unreachable endpoints fail fast; healthy third-party
  endpoints respond well under the window. The outcome is still saved
  as a negative cache, so a miss this run costs the next run ~0ms.
- Stdio cold budget: 4s → 1s. A local subprocess handshake is milliseconds.

With three user plugins (one pointing at TEST-NET-1, permanently
unreachable), cold `dws --help` drops from ~3.3s to ~0.8s and warm stays
at ~80ms. The savings compound linearly with plugin count.
2026-04-18 14:22:53 +08:00
修雨 8bcbceb971 perf(plugin): serve plugin MCP tool list from disk cache on startup
Follow-up to the 4s startup cap (df01f36). Plugin command registration
now reads the tools snapshot directly from the cache store on the hot
path, and only falls through to an Initialize+ListTools RPC when no
snapshot exists.

- registerHTTPServer / registerStdioServer try cache.LoadTools first;
  on hit they build Cobra commands synchronously with zero network I/O.
- Cold cache falls back to the existing synchronous discovery (already
  bounded at 2-4s) and persists the outcome — including empty tool
  lists — as a negative cache so the next invocation is fast regardless
  of endpoint health.
- Cache entries are namespaced under "plugin:<name>:<server>" so they
  are distinct from Market cache entries in `dws cache status`, and
  refresh on-demand via `dws cache clean` / `dws cache refresh`; the
  existing 7d ToolsTTL otherwise expires entries naturally.

Warm-cache `dws --help` with three user plugins (one unreachable) now
returns in ~80ms versus 3.7s with synchronous discovery, a >40x win
when endpoints are offline.
2026-04-18 00:47:20 +08:00
修雨 df01f36442 fix(transport): cap plugin MCP startup at ~4s when endpoints are unreachable
Issue #119: an unreachable third-party MCP plugin (e.g. blocked/firewalled
endpoint) blocks `dws --help` for ~10s on every CLI invocation, because
plugin discovery happens eagerly during command-tree construction.

Root cause was a stack of timeouts that multiplied under transport failure:

* `transport.Client.Initialize` loops three supported protocol versions on
  every error — including dial timeouts and HTTP 5xx — even though those
  failure modes are protocol-version-independent. Three loops × the dial
  budget = 3× the worst-case startup cost.
* Default `DialContext.Timeout` was 10s, so a single dial against an
  unroutable address (e.g. TEST-NET-1) burned the full plugin context.
* `registerHTTPServer` granted plugins with `AuthHeaders` a 10s outer
  context (intended for slow third-party services), and `registerStdioServer`
  granted every stdio plugin 10s. Either single misbehaving plugin therefore
  stalled the entire CLI.
* Registry-side `defaultDiscoveryTimeout` (10s) and `perServerDiscoveryTimeout`
  (5s) had the same shape on the LoadCatalog path.

Changes:

* `transport.Client.Initialize`: short-circuit when the underlying
  `*CallError.Stage` is anything other than `CallStageJSONRPC`. Protocol
  version negotiation is the only justification for retrying with another
  version; transport/HTTP failures fail identically and should surrender.
* `transport.defaultTransport`: `DialContext.Timeout` 10s → 3s.
* `app.registerHTTPServer`: AuthHeaders timeout 10s → 4s.
* `app.registerStdioServer`: outer ctx timeout 10s → 4s.
* `cli.defaultDiscoveryTimeout`: 10s → 4s.
* `discovery.Service`: rename `perServerDiscoveryTimeout` 5s → 2s
  (`defaultPerServerDiscoveryTimeout`); add `Service.PerServerTimeout`
  override field for tests/callers needing a tighter or looser bound.

Tests:

* `TestInitializeShortCircuitsOnHTTPError` — asserts only ONE protocol
  version is attempted on HTTP 5xx.
* `TestInitializeShortCircuitsOnDialFailure` — asserts Initialize returns
  in <2s on a refused-connection address.
* Existing `TestInitializeNegotiatesProtocolVersion` continues to pass —
  JSON-RPC-stage errors still trigger version fallback.

End-to-end measurement with a blackhole plugin
(endpoint=192.0.2.1, AuthHeaders set):

  | Variant       | median `dws --help` |
  | ------------- | ------------------- |
  | main          | 10.2s               |
  | PR #121 alone | 9.1s                |
  | this branch   | 3.72s (-63%)        |

The `loader.go` and `discovery/service.go` timeout reductions overlap with
PR #121 by @utafrali; see PR description for attribution.

Refs: #119
Supersedes: #121
2026-04-17 17:45:35 +08:00
修雨 b0024aa669 Merge pull request #122 from fantiu/feat-claw
fix(auth): exit immediately on terminal auth denial and unify denial page styles
2026-04-17 16:05:20 +08:00
fantiu 4f1ee37508 fix(auth): improve login UX for terminal auth denial cases 2026-04-17 10:20:35 +08:00
fantiu fec0347cd6 feat(auth):improve login UX for terminal auth denial cases 2026-04-17 10:15:04 +08:00
fantiu c99e228669 Merge remote-tracking branch 'upstream/main' into feat-claw
merge main commit
2026-04-16 19:59:19 +08:00
fantiu 95d495f290 feat: supports claw-like products. 2026-04-16 19:49:28 +08:00
48 changed files with 5772 additions and 1285 deletions
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 48.7%"><title>coverage: 48.7%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">48.7%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">48.7%</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 49.8%"><title>coverage: 49.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">49.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">49.8%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+2
View File
@@ -28,3 +28,5 @@ credentials*
plans
_docs
dws.zip
*.code-workspace
/dingtalk-workspace.zip
+254
View File
@@ -4,6 +4,260 @@ All notable changes to this project will be documented in this file.
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
## [1.0.12] - 2026-04-21
Product-surface expansion: first-class `doc` (DingTalk Docs) and `minutes` (AI Minutes) skill references, refreshed `aitable` guide aligned with the shipped binary (including dashboard / chart / export), and a README sync that brings the full command catalog to **141 commands across 14 products**.
### Added
- **`doc` skill reference** (`skills/references/products/doc.md`) — 16-command coverage of DingTalk Docs:
- Discovery: `search`, `list`, `info`, `read`
- Authoring: `create`, `update`, `folder create`
- Files: `upload`, `download`
- Block-level editing: block `query`, `insert`, `update`, `delete`
- Comments: `comment list`, `create`, `reply`
- URL → `doc_id` extraction rules and nodeId dual-format notes
- **`minutes` skill reference** (`skills/references/products/minutes.md`) — coverage of AI Minutes:
- Lists: personal / shared-with-me / all-accessible
- Content: basic info, AI summary, keywords, transcription, extracted todos, batch detail
- Editing: title update
- Recording control: start, pause, resume, stop
- **SKILL.md routing**:
- Product overview table rows for `doc` and `minutes`
- Intent decision tree routes — `钉钉文档/云文档/知识库/块级编辑/文档评论` → `doc`; `听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词` → `minutes`
- Danger-op table entries: `doc delete`, `doc block delete`
- `aitable` description completed with the `附件` (attachment) group
- **`aitable` skill enhancements**:
- `field create` single-field mode (`--name` / `--type` / `--config`) with examples
- `base get` URL → `baseId` quick-tip
- Dedicated "URL → baseId 提取" chapter
- "`--filters` 筛选语法排错与使用规范" chapter
- "相关产品" cross-link section pointing to `doc`
- **"复杂操作" chapter** (#141) — dashboard / chart workflow (with two-call sequencing and `chart share get` vs `dashboard share get` error semantics) and two-stage `export data` polling (`scope=all/table/view` parameter constraints)
- **README Key Services sync** (#140):
- New rows: `doc` (16 commands), `minutes` (22 commands — adds `hot-word`, `mind-graph`, `replace-text`, `speaker`, `upload` subgroups)
- `aitable` expanded from 20 → 37 commands; surfaces `chart`, `dashboard`, `export`, `import`, `view` subgroups
- Total command count updated from **86 → 141 across 14 products**
- "Coming soon" list drops `doc` and `minutes`
### Changed
- `aitable record query` docs rename `--keyword` → `--query` to match the shipped binary
- `aitable record query` docs clarify `--sort` direction semantics (avoids misuse of `order`)
- `aitable base list` guidance strengthened — "only for recent browsing; use `base search` for lookups"; intent decision prioritizes `base search` for base discovery
## [1.0.11] - 2026-04-20
Plugin subsystem hardening: faster cold startup, cleaner lifecycle, stricter isolation, and polished UX for PAT / i18n / error routing.
### Added
- `feat: supports claw-like products` — overlay path for Claw-style embedded editions
- `feat(plugin): inject user identity (UserID, CorpID) into stdio plugin subprocesses`
- `feat(auth): improve login UX for terminal auth denial cases` — clearer messaging + retry affordance
- `feat: PAT scope error visualization and auto-retry with authorization polling` (#113)
- Human-readable error output (lark-cli style) with type/message/hint/authorization command
- JSON payload also available via `--format json`
- Auto-retry once the user completes scope authorization
### Changed
- `perf(plugin): serve plugin MCP tool list from disk cache on startup` — hot path skips Initialize+ListTools when snapshot exists
- `perf(plugin): parallelize all plugin discovery and tighten cold timeouts` — HTTP cold budget 4s → 700ms (auth) / 500ms (plain); stdio and HTTP fan out concurrently
- `perf(plugin): share cache.Store across discovery` — single `*cache.Store` above the fan-out instead of per-goroutine instances
- `refactor(plugin): remove default/managed plugin privileged mechanism` (#124) — third-party plugins install on an equal footing via `dws plugin install`
- `refactor(plugin): purge removed plugin settings instead of merely disabling` — `RemovePlugin` now deletes `EnabledPlugins` and `PluginConfigs` entries
### Fixed
- `fix(transport): cap plugin MCP startup at ~4s when endpoints are unreachable` (#119) — eliminates the 10s `dws --help` stall caused by compounding transport timeouts
- `fix(plugin): stop stdio child processes on exit and before removal` — no more orphaned plugin subprocesses
- `fix(pat): avoid shared PAT command state in root registration` (#129)
- `fix: -f json 模式下错误 JSON 从 stdout 改为输出到 stderr` (#133) — restores CI stderr-based failure assertions
- `fix(cli): localize plugin/help command strings via i18n` (#118, #134) — zh locale now shows consistent Chinese `--help`; wraps plugin module, help command, and OAuth client-id/secret flag descriptions
- `chore: remove workspace and bundled artifacts` (#127) — clean local-only repository leftovers
## [1.0.9] - 2026-04-16
Plugin system launch + execution-pipeline overhaul. This is the largest release since 1.0.0: third-party MCP servers become first-class commands, the command pipeline grows to five stages, and the edition overlay gains the hooks needed for embedded hosts.
### Added
#### Plugin system (new)
- `plugin` command family: `install`, `list`, `info`, `enable`, `disable`, `remove`, `create`, `dev`, `config set/get/list/unset`
- Plugin manifest parsing/validation, managed/user directory-based identity
- MCP server conversion and injection into the dynamic routing registry
- Pipeline hook adapter for shell-based hooks
- Stdio transport: subprocess lifecycle, `DWS_PLUGIN_ROOT` / `DWS_PLUGIN_DATA` variable expansion
- Stdio server tools automatically registered as CLI subcommands (e.g. `dws hello greet --name Peter`)
- Streamable-HTTP MCP tool discovery via `registerHTTPServer`
- Updater: managed plugin update check on CLI startup (10 s timeout, best-effort)
- `dws plugin create` scaffold (plugin.json, SKILL.md, hooks.json); `dws plugin dev` source-dir registration without copy
- `SyncSkills` — copies plugin skills to agent directories on startup
- **Auth Token Registry**: per-server HTTP headers declared in `plugin.json` for third-party MCP servers (e.g. Alibaba Cloud Bailian) independent from DingTalk OAuth
- **Persistent plugin config** (`dws plugin config ...`): values persisted to `~/.dws/settings.json`, auto-injected as env vars; `${KEY}` in `plugin.json` resolves without manual `export`
- **Build lifecycle**: `build` field compiles stdio servers to native binaries at install time
- **Command-name conflict protection**: reserved built-in names (`auth`, `plugin`, `cache`, …) and plugin-vs-plugin duplicate detection
- Parallel service discovery (`sync.WaitGroup`) — startup reduced from sequential `N*10s` to parallel `max(10s)`
#### Core commands & diagnostics
- `dws doctor` — one-stop environment/auth/network diagnostics
- `dws config list` — centralized view of scattered configuration
- Structured perf tracing (upgraded from debug tool to diagnostics output)
- `feat(skill): restore find/get for legacy skill market API` — `skill find`, `skill get`; `skill add` still uses aihub download
#### Edition / overlay hooks
- `edition.Hooks.SaveToken` / `LoadToken` / `DeleteToken` — delegate token persistence with keychain fallback
- `edition.Hooks.AuthClientID` / `AuthClientFromMCP` — overlay can override the OAuth client ID and route auth through MCP endpoints
- `edition.Hooks.AfterPersistentPreRun` — wire non-MCP clients (e.g. A2A gateway) after root setup
- `edition.Hooks.ClassifyToolResult` — custom MCP result classification before the default business-error detection
- Token marker file (`token.json`) for embedded hosts to detect auth state without keychain access
- `pkg/runtimetoken.ResolveAccessToken` mirroring MCP auth resolution; MCP identity headers exported via `pkg/cli` for auxiliary HTTP transports
- `ExitCoder` interface — edition-specific errors carry custom exit codes
- `RawStderrError` interface — errors that bypass CLI formatting and emit raw stderr (for desktop runtimes)
### Changed
- **Command execution pipeline: 3 → 5 stages** (`Register → PreParse → PostParse → PreRequest → PostResponse`)
- `feat(schema): return structured degraded errors instead of silent empty catalog` — new `CatalogDegraded` error with reasons `unauthenticated` / `market_unreachable` / `runtime_all_failed`; auth pre-check short-circuits doomed MCP connections
- `refactor(auth): unify auxiliary token resolution with MCP cached path` — shared `resolveAccessTokenFromDir`; overlays reuse the process-level token cache
- `feat(plugin): improve CLI overlay resolution and plugin install robustness`
- `plugin.json` `cli` field now accepts a file path (e.g. `"cli": "overlay.json"`) in addition to inline JSON
- `description` field on `CLIToolOverride` for static fallback when MCP `tools/list` is unavailable
- Windows install uses `cmd /C` instead of `sh -c` for build commands
### Fixed
- `fix(plugin): harden plugin system security boundaries`
- Reject `file://` / local paths in git URLs; allow only `https` / `ssh`
- Reject symlink entries during ZIP extraction (path-traversal defense)
- `build.output` must be a relative path within the plugin directory
- Reject absolute paths in stdio command declarations
- Block dangerous env var names (`PATH`, `LD_PRELOAD`, …) from plugin config injection
- `fix(plugin): schema flag params, HTTP tool discovery, and integration tests`
- `fix(plugin): skip min version check in dev mode`
## [1.0.8] - 2026-04-07
AITable command surface expansion, installer alignment with npm conventions, and execution-timeout hardening.
### Added
- **AITable static helper commands** (20 commands in total) replacing dynamic routing:
- `base`: `list`, `search`, `get`, `create`, `update`
- `table`: `get`, `create`, `update`
- `field`: `get`, `create`, `update`
- `record`: `query`, `create`, `update`
- `template`: `search`
- `attachment`: `upload`
- `feat(install): align skill dirs with npm and add OpenClaw` — skill install paths follow npm conventions; OpenClaw added to supported agents
- Label rendering optimization for AITable records (`to #73551688`)
- README: npm install method documented
- README: note that `dws upgrade` requires v1.0.7+
### Changed
- `perf: optimize command timeout handling, instrumentation, and diagnostics`
## [1.0.7] - 2026-04-02
Self-upgrade, edition overlay foundation, and fail-closed auth enforcement.
### Added
- **`dws upgrade`** — self-upgrade via GitHub Releases; atomic replace; cross-platform (macOS/Linux/Windows)
- `feat: edition layer for Wukong overlay` — build-time edition hook lets downstream overlays customize auth UX, config dir, static server list, visible products, and extra root commands
- `pkg/edition` defaults + `pkg/editiontest` contract tests
- `Makefile` target `edition-test`; CI job `edition-tests`
- Static server injection skips market discovery when configured
- Deduplicates top-level commands so overlay wins
- `hideNonDirectRuntimeCommands` respects edition `VisibleProducts`
- Gated `auth login` subcommand + hints for embedded editions
- Optional token auto-purge; edition `ConfigDir` override
- `dws version` — human-readable multi-line output plus JSON with edition, architecture, build, commit
- Tag reporting for case suites (`to #73551688`)
- `feat(auth): unify MCP retry constant and add retry to remaining endpoints`
### Changed
- `style(auth): redesign OAuth authorization pages UI`
### Fixed
- `fix(auth): switch CLI auth check from fail-open to fail-closed`
- When `/cli/cliAuthEnabled` is unreachable (network error/timeout/5xx), OAuth callback now routes to the permission request page instead of silently marking "enabled"
- Device Flow blocks login and asks the user to verify network connectivity
- `CheckCLIAuthEnabled` retries with backoff (3 attempts, 0s/1s/2s) to tolerate transient issues
## [1.0.6] - 2026-04-01
Error diagnostics overhaul, destructive-command confirmation, and credential auto-persistence.
### Added
- **Interactive confirmation for destructive dynamic commands** — prompts before delete/remove operations unless `--yes` is set
- **Enhanced error diagnostics**
- `ServerDiagnostics` struct extracts `trace_id`, `server_error_code`, `technical_detail`, `server_retryable` from MCP responses
- Pulls diagnostics from JSON-RPC `error.data`, tool call result content, and HTTP headers (`X-Trace-Id`, `X-Request-Id`, `x-dingtalk-trace-id`)
- Three verbosity levels for `PrintHuman`: Normal (trace ID + server code), Verbose (+ technical detail), Debug (+ RPC code / operation / reason)
- Local logging now includes sanitized request body, response body on error, retry attempts, and classification events
- `TruncateBody` / `SanitizeArguments` / `RedactHeaders` helpers with sensitive-key substring detection
- **Auth credential persistence**
- `feat(auth): enhance device flow with CLI auth check and admin guidance`
- `feat(auth): persist OAuth credentials for reliable token refresh`
- `feat(auth): persist client credentials and optimize keychain access` — auto-persist `--client-id` / `--client-secret`; keychain credential cache to avoid repeated reads; enhanced logout cleans `app.json` + keychain secrets + `token.json`
- `add report helper with flexible date parsing and defaults`
- `feat: to #73551688 支持消息通知`
- README: Official App mode (recommended, direct login without creating an app) + Custom App mode; admin guide for enabling CLI access
### Changed
- Getting Started simplified with inline login commands; whitelist references removed from the IMPORTANT banner
- Version bump documentation updated to v1.0.5 internal; co-creation group QR code refreshed
### Fixed
- `fix: resolve verbosity flag lookup, FileLogger lazy binding, and business error logging`
- `resolveVerbosity` uses `cmd.Flags()` instead of `PersistentFlags()` so subcommands inherit `--verbose` / `--debug`
- `FileLogger` lazy-binds in `executeInvocation` (after `configureLogLevel` init)
- Business errors (HTTP 200 + `success=false`) now written to the file logger for offline diagnosis
- OAuth callback race condition (write response before sending code)
- `import path for errors package in skill_command.go`
## [1.0.4] - 2026-03-30
Token-refresh reliability and onboarding clarity.
### Added
- `feat(auth): persist client credentials for token refresh` — `--client-id` / `--client-secret` are stored for automatic refresh after expiration; client secret lives in the system Keychain with a file reference
- README onboarding flow rewrite with step-by-step first-time setup and more realistic examples
- Agent skill reference polish: clearer examples, updated intent routing patterns, expanded `simple.md` onboarding, cross-skill reference fixes
## [1.0.3] - 2026-03-29
Filtering power, schema rendering, and a native `todo` command family.
### Added
- **Nested / array-indexed output filtering**
- `--fields` now accepts dot-notation (e.g. `--fields response.content`) and array index access (e.g. `response.items[0]`)
- New field-path parser with recursive extraction logic
- **`schema` command enhancements**
- Table format output for human consumption
- Product-level endpoint loading in the CLI loader
- Schema-text rendering wired into the runner output pipeline
- **`todo` task helper family** — static `create` / `update` / `done` / `get` / `delete` with `preferLegacyLeaf` replacing dynamic commands
- MCP tool alignment: `create_personal_todo`, `update_todo_task`, `update_todo_done_status`, `query_todo_detail`, `delete_todo`
- ISO-8601 due-time parsing
- Hidden title aliases and delete confirmation
- Priority field on `todo` helper
- Expanded zh / en i18n coverage (fixes `en.json` spacing/wording issues)
- README restructured with collapsible feature sections
## [1.0.2] - 2026-03-29
Deep workspace tooling upgrade: pipeline-based input correction, output filtering, enhanced stdin handling, and multi-endpoint routing.
+5 -3
View File
@@ -358,16 +358,18 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
| Ding | `ding` | 2 | `message` | Send/recall DING messages |
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
| AITable | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, templates |
| AITable | `aitable` | 37 | `base` `table` `record` `field` `attachment` `template` `chart` `dashboard` `export` `import` `view` | Full CRUD for bases/tables/records/fields; charts/dashboards; data import/export; views; templates |
| Doc | `doc` | 16 | `search` `list` `info` `read` `create` `update` `upload` `download` `folder` `block` `comment` | Search, read, create/update documents; block-level editing; file upload/download; comments |
| Minutes | `minutes` | 22 | `list` `get` `update` `record` `hot-word` `mind-graph` `replace-text` `speaker` `upload` | List/search AI meeting transcripts; summaries, transcriptions, todos, mind-maps; recording control; speaker management, hot-words, file upload |
| Workbench | `workbench` | 2 | `app` | Batch query app details |
| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes |
> 86 commands across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
> 141 commands across 14 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
<details>
<summary>Coming soon</summary>
`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
`mail` (email) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
</details>
+3 -2
View File
@@ -14,6 +14,7 @@
package app
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
@@ -35,8 +36,8 @@ type GlobalFlags struct {
}
func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
cmd.PersistentFlags().StringVar(&flags.ClientID, "client-id", "", "Override OAuth client ID (DingTalk AppKey)")
cmd.PersistentFlags().StringVar(&flags.ClientSecret, "client-secret", "", "Override OAuth client secret (DingTalk AppSecret)")
cmd.PersistentFlags().StringVar(&flags.ClientID, "client-id", "", i18n.T("覆盖 OAuth 客户端 ID (钉钉 AppKey)"))
cmd.PersistentFlags().StringVar(&flags.ClientSecret, "client-secret", "", i18n.T("覆盖 OAuth 客户端密钥 (钉钉 AppSecret)"))
cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志")
cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行")
cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
+577
View File
@@ -0,0 +1,577 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"os/exec"
"regexp"
"runtime"
"strings"
"time"
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/internal/executor"
"github.com/fatih/color"
)
const (
// PatAuthRetryTimeout is the maximum time to wait for user authorization
// when a PAT scope error is detected.
PatAuthRetryTimeout = 10 * time.Minute
// PatAuthPollInterval is how often we poll to check if the user has
// completed authorization.
PatAuthPollInterval = 5 * time.Second
)
// PatScopeError holds information about a missing PAT scope.
type PatScopeError struct {
OriginalError string
Identity string
ErrorType string
Message string
Hint string
MissingScope string
}
func (e *PatScopeError) Error() string {
return e.OriginalError
}
// patScopeRegex matches PAT-protocol scope error patterns from the API.
// Only matches explicit scope-related keywords; generic "permission denied" or
// "forbidden" are intentionally excluded to avoid false positives on business
// authorization errors (e.g. mailbox access denied, 403 Forbidden).
var patScopeRegex = regexp.MustCompile(`(?i)(missing_scope|insufficient_scope|scope.*required)`)
// scopeValueRegex extracts a scope identifier (e.g. "calendar:read",
// "mail:user_mailbox.message:send") from an error message.
// Supports multi-segment scopes with multiple colons (resource:sub:action).
var scopeValueRegex = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9_.]*(?::[a-zA-Z][a-zA-Z0-9_.]*)+)`)
// identityValueRegex extracts an identity label from an error message.
var identityValueRegex = regexp.MustCompile(`(?i)identity["\s:]+([a-zA-Z_]+)`)
// isPatScopeError checks if an error looks like a PAT scope/permission error
// that can be resolved by re-authorizing with additional scopes.
func isPatScopeError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
// Check for missing_scope pattern in error message or hint
if patScopeRegex.MatchString(msg) {
return true
}
var typed *apperrors.Error
if stderrors.As(err, &typed) {
// Check message, reason, and hint for scope-related patterns
fullText := strings.ToLower(typed.Message + " " + typed.Reason + " " + typed.Hint)
if typed.Category == apperrors.CategoryAuth {
if strings.Contains(fullText, "missing_scope") || strings.Contains(fullText, "insufficient_scope") ||
(strings.Contains(fullText, "scope") && strings.Contains(fullText, "required")) {
return true
}
}
// Any category with scope/permission hints
if strings.Contains(fullText, "missing_scope") || strings.Contains(fullText, "insufficient_scope") {
return true
}
}
return false
}
// extractPatScopeError parses an error to extract PAT scope details.
func extractPatScopeError(err error) *PatScopeError {
if err == nil {
return nil
}
msg := err.Error()
scope := ""
var typed *apperrors.Error
if stderrors.As(err, &typed) {
msg = typed.Message
if typed.Reason != "" {
msg += " (" + typed.Reason + ")"
}
}
// Try to extract scope value (e.g. "calendar:read") from error message.
scopeMatch := scopeValueRegex.FindStringSubmatch(msg)
if len(scopeMatch) > 1 {
scope = scopeMatch[1]
}
// Try to extract identity from error message.
identity := "user"
identityMatch := identityValueRegex.FindStringSubmatch(msg)
if len(identityMatch) > 1 {
identity = identityMatch[1]
}
return &PatScopeError{
OriginalError: err.Error(),
Identity: identity,
ErrorType: "missing_scope",
Message: msg,
Hint: fmt.Sprintf("run `dws auth login --scope %q` to authorize the missing scope", scope),
MissingScope: scope,
}
}
// PrintPatAuthError prints a human-readable PAT authorization error.
func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) {
bold := color.New(color.Bold).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
dim := color.New(color.Faint).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
fmt.Fprintln(w)
fmt.Fprintf(w, "{\n")
fmt.Fprintf(w, " %s: %s,\n", bold("\"ok\""), "false")
fmt.Fprintf(w, " %s: %q,\n", bold("\"identity\""), scopeErr.Identity)
fmt.Fprintf(w, " %s: {\n", bold("\"error\""))
fmt.Fprintf(w, " %s: %q,\n", bold("\"type\""), scopeErr.ErrorType)
fmt.Fprintf(w, " %s: %q,\n", bold("\"message\""), scopeErr.Message)
fmt.Fprintf(w, " %s: %q\n", bold("\"hint\""), scopeErr.Hint)
fmt.Fprintf(w, " }\n")
fmt.Fprintf(w, "}\n")
fmt.Fprintln(w)
// Print authorization instructions
fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权"))
fmt.Fprintln(w)
fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权"))
if scopeErr.MissingScope != "" {
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
} else {
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login"))
}
fmt.Fprintln(w)
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ"))
fmt.Fprintln(w)
}
// PrintPatAuthJSON prints a machine-readable PAT authorization error.
func PrintPatAuthJSON(w io.Writer, scopeErr *PatScopeError) {
payload := map[string]any{
"ok": false,
"identity": scopeErr.Identity,
"error": map[string]any{
"type": scopeErr.ErrorType,
"message": scopeErr.Message,
"hint": scopeErr.Hint,
},
}
if scopeErr.MissingScope != "" {
payload["missing_scope"] = scopeErr.MissingScope
}
data, _ := json.MarshalIndent(payload, "", " ")
fmt.Fprintln(w, string(data))
}
// WaitForPatAuthorization polls until the user completes authorization or timeout.
// It returns true if authorization was completed, false if timed out or cancelled.
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
bold := color.New(color.Bold).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
red := color.New(color.FgRed).SprintFunc()
dim := color.New(color.Faint).SprintFunc()
timeout := PatAuthRetryTimeout
deadline := time.Now().Add(timeout)
pollTicker := time.NewTicker(PatAuthPollInterval)
defer pollTicker.Stop()
start := time.Now()
fmt.Fprintln(output)
fmt.Fprintf(output, "%s %s\n", yellow("⏳"), bold("等待用户授权..."))
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", dim("ℹ"))
fmt.Fprintf(output, " %s 超时时间: %s\n", dim("⏱"), timeout)
fmt.Fprintln(output)
pollCount := 0
for {
select {
case <-ctx.Done():
fmt.Fprintf(output, "%s 操作已取消\n", red("✗"))
return false
case <-time.After(time.Until(deadline)):
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout)
fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ"))
return false
case <-pollTicker.C:
pollCount++
elapsed := time.Since(start).Truncate(time.Second)
remaining := time.Until(deadline).Truncate(time.Second)
// Check if token is now valid
tokenData, err := authpkg.LoadTokenData(configDir)
if err == nil && tokenData != nil {
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
green("✓"), bold("授权成功!"), elapsed, remaining)
fmt.Fprintln(output)
return true
}
}
// Show polling status
fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ",
dim("⟳"), pollCount, elapsed, remaining)
}
}
}
// retryWithPatAuthRetry wraps an invocation that failed with a PAT scope error.
// It waits for the user to complete authorization and then retries the invocation.
func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocation executor.Invocation, scopeErr *PatScopeError, configDir string, output io.Writer) (executor.Result, error) {
// Print the PAT error in human-readable format
PrintPatAuthError(output, scopeErr)
// Wait for user to complete authorization
authorized := WaitForPatAuthorization(ctx, configDir, output)
if !authorized {
return executor.Result{}, apperrors.NewAuth(
"等待用户授权超时",
apperrors.WithReason("pat_auth_timeout"),
apperrors.WithHint(fmt.Sprintf("授权超时 (%s),请重新执行命令", PatAuthRetryTimeout)),
apperrors.WithActions("dws auth login"),
)
}
// Clear the token cache so the new token is loaded
ResetRuntimeTokenCache()
// Retry the invocation
fmt.Fprintln(output)
fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"),
color.New(color.Bold).SprintFunc()("授权完成,正在重试..."))
fmt.Fprintln(output)
return runner.Run(ctx, invocation)
}
// ---- handlePatAuthCheck (runner.go entry point) -----------------------------
const (
// patPollInterval is how often we poll the device flow status endpoint.
patPollInterval = 2 * time.Second
// patPollTimeout is the maximum time to wait for user authorization via device flow.
patPollTimeout = 10 * time.Minute
)
// patRetryingKey is a context key to prevent recursive PAT auth checks.
// After APPROVED, the retry should not trigger another PAT flow.
type patRetryingKeyType struct{}
var patRetryingKey = patRetryingKeyType{}
// IsPatRetrying returns true if the current context is already in a PAT retry.
func IsPatRetrying(ctx context.Context) bool {
v, _ := ctx.Value(patRetryingKey).(bool)
return v
}
// handlePatAuthCheck is called by runner.executeInvocation when a PAT
// authorization error is detected. It injects the server-assigned clientId
// as x-robot-uid header, prints authorization details, opens the browser,
// polls the device flow endpoint until the user authorizes, and retries the
// original invocation on success.
func handlePatAuthCheck(
ctx context.Context,
r *runtimeRunner,
invocation executor.Invocation,
patErr *apperrors.PATError,
configDir string,
output io.Writer,
) (executor.Result, error) {
// Parse authorization details from PATError.RawJSON.
var patData struct {
Code string `json:"code"`
Data struct {
Desc string `json:"desc"`
FlowID string `json:"flowId"`
URI string `json:"uri"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil {
return executor.Result{}, patErr
}
slog.Debug("PAT auth check",
"clientId", patData.Data.ClientID,
"flowId", patData.Data.FlowID,
"hasSecret", patData.Data.ClientSecret != "",
)
// Inject clientId/clientSecret from PAT response as runtime credentials
// so that subsequent device flow auth uses the server-assigned app identity.
if patData.Data.ClientID != "" {
if patData.Data.ClientSecret != "" {
// When both clientId and clientSecret are provided, use direct mode
// (DingTalk API) rather than MCP proxy — the MCP proxy does not hold
// the secret for this particular app.
authpkg.SetClientID(patData.Data.ClientID)
authpkg.SetClientSecret(patData.Data.ClientSecret)
} else {
// No clientSecret — rely on MCP proxy to manage the secret server-side.
authpkg.SetClientIDFromMCP(patData.Data.ClientID)
}
// Persist clientId (and optionally secret) to ~/.dws/app.json so that
// future process invocations can load it at startup and populate
// DWS_CLIENT_ID env before the first MCP request.
appCfg := &authpkg.AppConfig{
ClientID: patData.Data.ClientID,
}
if patData.Data.ClientSecret != "" {
appCfg.ClientSecret = authpkg.PlainSecret(patData.Data.ClientSecret)
}
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
slog.Warn("failed to persist app config from PAT", "error", err)
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
}
}
bold := color.New(color.Bold).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
greenFn := color.New(color.FgGreen).SprintFunc()
yellowFn := color.New(color.FgYellow).SprintFunc()
redFn := color.New(color.FgRed).SprintFunc()
dim := color.New(color.Faint).SprintFunc()
fmt.Fprintln(output)
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("需要 PAT 授权"))
if patData.Data.Desc != "" {
fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc)
}
if patData.Data.URI != "" {
fmt.Fprintf(output, " %s %s\n\n", dim("🔗"), cyan(patData.Data.URI))
// Best-effort browser open.
_ = tryOpenBrowser(patData.Data.URI)
}
// If no flowId, we can't poll — fall back to returning PATError for host-app.
if patData.Data.FlowID == "" {
fmt.Fprintln(output)
return executor.Result{}, patErr
}
// Poll the device flow status until user authorizes, rejects, or timeout.
fmt.Fprintf(output, "%s %s\n", yellowFn("⏳"), bold("等待用户授权..."))
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", dim("ℹ"), patPollTimeout)
fmt.Fprintln(output)
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
defer cancel()
status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output)
if err != nil {
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err)
return executor.Result{}, patErr
}
switch status {
case authpkg.StatusApproved:
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
fmt.Fprintln(output)
// Exchange authCode for a fresh access token (mirrors device_flow loginOnce).
if authCode != "" {
slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true)
tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode)
if exchErr != nil {
slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr)
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", yellowFn("⚠"), exchErr)
} else {
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
slog.Warn("PAT retry: failed to save new token", "error", err)
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", yellowFn("⚠"), err)
} else {
slog.Debug("PAT retry: token refreshed and saved")
}
}
}
// Clear token cache so the new credentials take effect.
ResetRuntimeTokenCache()
// Workaround: brief delay to let server-side authorization state propagate
// before retrying. Without this the retry may use stale credentials.
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
time.Sleep(1 * time.Second)
// Retry the original invocation with pat-retrying flag to prevent recursion.
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("授权完成,正在重试..."))
fmt.Fprintln(output)
slog.Debug("PAT retry: identity env check",
"DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"),
)
retryCtx := context.WithValue(ctx, patRetryingKey, true)
return r.Run(retryCtx, invocation)
case authpkg.StatusRejected:
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权"))
return executor.Result{}, apperrors.NewAuth(
"用户已拒绝授权",
apperrors.WithReason("pat_auth_rejected"),
apperrors.WithHint("用户在浏览器中拒绝了授权请求,请重新执行命令。"),
)
case authpkg.StatusExpired:
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时"))
return executor.Result{}, apperrors.NewAuth(
"授权超时",
apperrors.WithReason("pat_auth_expired"),
apperrors.WithHint("授权链接已过期,请重新执行命令。"),
)
case authpkg.StatusCancelled:
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消"))
return executor.Result{}, apperrors.NewAuth(
"操作已取消",
apperrors.WithReason("pat_auth_cancelled"),
apperrors.WithHint("用户取消了授权操作。"),
)
default:
fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status)
return executor.Result{}, patErr
}
}
// 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).
func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, output io.Writer) (string, string, error) {
pollURL := fmt.Sprintf("%s%s?flowId=%s",
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
// Load user access token for the poll request header.
var accessToken string
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
accessToken = tokenData.AccessToken
}
// Use a client that does NOT follow redirects, so we can detect SSO 302.
noRedirectClient := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
ticker := time.NewTicker(patPollInterval)
defer ticker.Stop()
dim := color.New(color.Faint).SprintFunc()
pollCount := 0
for {
select {
case <-ctx.Done():
if ctx.Err() == context.Canceled {
return authpkg.StatusCancelled, "", nil
}
return authpkg.StatusExpired, "", nil
case <-ticker.C:
pollCount++
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
if err != nil {
slog.Debug("PAT poll: failed to create request", "error", err)
continue
}
if accessToken != "" {
req.Header.Set("x-user-access-token", accessToken)
}
resp, err := noRedirectClient.Do(req)
if err != nil {
slog.Debug("PAT poll: request failed", "error", err)
continue // transient network error, keep polling
}
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
// If we got a redirect (302/301), SSO gateway intercepted — skip JSON parse.
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently {
continue
}
var pollResp authpkg.DevicePollResponse
if err := json.Unmarshal(bodyBytes, &pollResp); err != nil {
slog.Debug("PAT poll: failed to parse response", "error", err, "body", string(bodyBytes))
continue
}
status := authpkg.ParseDeviceFlowStatus(pollResp.Data.Status, pollResp.Success)
switch status {
case authpkg.StatusApproved:
fmt.Fprintln(output) // clear the polling line
return status, pollResp.Data.AuthCode, nil
case authpkg.StatusRejected, authpkg.StatusExpired:
fmt.Fprintln(output) // clear the polling line
return status, "", nil
case authpkg.StatusPending:
// keep polling
default:
// ParseDeviceFlowStatus normalizes empty+!success to EXPIRED,
// so this branch handles truly unknown statuses.
fmt.Fprintln(output)
return status, "", nil
}
}
}
}
// tryOpenBrowser opens url in the default browser; errors are silently ignored.
func tryOpenBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
default:
return nil
}
return cmd.Start()
}
+606
View File
@@ -0,0 +1,606 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
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/internal/executor"
)
func TestIsPatScopeError_MissingScope(t *testing.T) {
t.Parallel()
err := apperrors.NewAuth("missing required scope(s): mail:user_mailbox.message:send")
if !isPatScopeError(err) {
t.Fatal("expected missing_scope error to be detected")
}
}
func TestIsPatScopeError_PlainString(t *testing.T) {
t.Parallel()
err := &PatScopeError{
OriginalError: "missing_scope: user lacks required scope",
ErrorType: "missing_scope",
Message: "user lacks required scope",
}
if !isPatScopeError(err) {
t.Fatal("expected plain string with missing_scope to be detected")
}
}
func TestIsPatScopeError_NotScopeError(t *testing.T) {
t.Parallel()
err := apperrors.NewValidation("invalid parameter")
if isPatScopeError(err) {
t.Fatal("expected validation error NOT to be detected as scope error")
}
}
func TestIsPatScopeError_Nil(t *testing.T) {
t.Parallel()
if isPatScopeError(nil) {
t.Fatal("nil error should not be detected as scope error")
}
}
func TestIsPatScopeError_WithReason(t *testing.T) {
t.Parallel()
err := apperrors.NewAuth("API error",
apperrors.WithReason("missing_scope"),
)
if !isPatScopeError(err) {
t.Fatal("expected error with missing_scope reason to be detected")
}
}
func TestIsPatScopeError_InsufficientScope(t *testing.T) {
t.Parallel()
err := apperrors.NewAuth("insufficient_scope for resource",
apperrors.WithReason("insufficient_scope"),
)
if !isPatScopeError(err) {
t.Fatal("expected insufficient_scope error to be detected")
}
}
func TestExtractPatScopeError_MissingScope(t *testing.T) {
t.Parallel()
err := apperrors.NewAuth("missing required scope(s): mail:user_mailbox.message:send")
scopeErr := extractPatScopeError(err)
if scopeErr == nil {
t.Fatal("expected non-nil PatScopeError")
}
if scopeErr.ErrorType != "missing_scope" {
t.Errorf("expected error type 'missing_scope', got %q", scopeErr.ErrorType)
}
if !strings.Contains(scopeErr.Hint, "dws auth login") {
t.Errorf("expected hint to contain 'dws auth login', got %q", scopeErr.Hint)
}
}
func TestExtractPatScopeError_ExtractsScope(t *testing.T) {
t.Parallel()
err := &PatScopeError{
OriginalError: "missing_scope: user needs calendar:read",
ErrorType: "missing_scope",
Message: "user needs calendar:read",
}
scopeErr := extractPatScopeError(err)
if scopeErr == nil {
t.Fatal("expected non-nil PatScopeError")
}
if scopeErr.MissingScope != "calendar:read" {
t.Errorf("expected MissingScope 'calendar:read', got %q", scopeErr.MissingScope)
}
}
func TestPrintPatAuthError_HumanReadable(t *testing.T) {
t.Parallel()
var buf strings.Builder
scopeErr := &PatScopeError{
Identity: "user",
ErrorType: "missing_scope",
Message: "missing required scope(s): mail:user_mailbox.message:send",
Hint: "run `dws auth login --scope \"mail:user_mailbox.message:send\"` to authorize",
MissingScope: "mail:user_mailbox.message:send",
}
PrintPatAuthError(&buf, scopeErr)
output := buf.String()
if !strings.Contains(output, "missing_scope") {
t.Errorf("expected output to contain 'missing_scope', got: %s", output)
}
if !strings.Contains(output, "dws auth login") {
t.Errorf("expected output to contain 'dws auth login', got: %s", output)
}
if !strings.Contains(output, "需要额外授权") {
t.Errorf("expected output to contain Chinese auth prompt, got: %s", output)
}
}
func TestPrintPatAuthJSON_MachineReadable(t *testing.T) {
t.Parallel()
var buf strings.Builder
scopeErr := &PatScopeError{
Identity: "user",
ErrorType: "missing_scope",
Message: "missing required scope(s): mail:send",
Hint: "run dws auth login --scope mail:send",
MissingScope: "mail:send",
}
PrintPatAuthJSON(&buf, scopeErr)
output := buf.String()
if !strings.Contains(output, `"ok": false`) {
t.Errorf("expected JSON to contain ok: false, got: %s", output)
}
if !strings.Contains(output, `"missing_scope": "mail:send"`) {
t.Errorf("expected JSON to contain missing_scope, got: %s", output)
}
}
func TestIsPatScopeError_BusinessPermissionDenied(t *testing.T) {
t.Parallel()
// Generic business "permission denied" should NOT trigger PAT re-auth.
err := apperrors.NewAuth("User has no permission to access this mailbox, permission denied")
if isPatScopeError(err) {
t.Fatal("generic 'permission denied' should not be detected as PAT scope error")
}
}
func TestIsPatScopeError_GenericForbidden(t *testing.T) {
t.Parallel()
// HTTP 403 Forbidden should NOT trigger PAT re-auth.
err := apperrors.NewAuth("403 Forbidden")
if isPatScopeError(err) {
t.Fatal("'403 Forbidden' should not be detected as PAT scope error")
}
}
func TestExtractPatScopeError_ComplexScope(t *testing.T) {
t.Parallel()
err := apperrors.NewAuth("missing required scope(s): mail:user_mailbox.message:send")
scopeErr := extractPatScopeError(err)
if scopeErr == nil {
t.Fatal("expected non-nil PatScopeError")
}
if scopeErr.MissingScope != "mail:user_mailbox.message:send" {
t.Errorf("expected MissingScope 'mail:user_mailbox.message:send', got %q", scopeErr.MissingScope)
}
}
func TestPatScopeError_Error(t *testing.T) {
t.Parallel()
err := &PatScopeError{
OriginalError: "test error message",
}
if err.Error() != "test error message" {
t.Errorf("expected Error() to return OriginalError, got %q", err.Error())
}
}
// ---------------------------------------------------------------------------
// pollPatDeviceFlow integration tests — httptest mock covering four terminal
// states: APPROVED, REJECTED, EXPIRED, CANCELLED (ctx cancel).
// ---------------------------------------------------------------------------
// setupPollServer creates an httptest server that responds to
// /cli/oauth/device/poll?flowId=<fid> with the given status sequence.
// It also writes the server URL into a temp DWS_CONFIG_DIR/mcp_url so that
// GetMCPBaseURL() returns the test server address.
func setupPollServer(t *testing.T, statuses []authpkg.DevicePollResponse) (*httptest.Server, string) {
t.Helper()
var callCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idx := int(callCount.Add(1)) - 1
if idx >= len(statuses) {
idx = len(statuses) - 1
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(statuses[idx])
}))
// Write mcp_url so GetMCPBaseURL picks up the test server.
tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte(server.URL), 0644)
t.Setenv("DWS_CONFIG_DIR", tmpDir)
return server, tmpDir
}
func TestPollPatDeviceFlow_Approved(t *testing.T) {
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
{Success: true, Data: authpkg.DevicePollData{Status: "PENDING"}},
{Success: true, Data: authpkg.DevicePollData{Status: "APPROVED", AuthCode: "code123"}},
})
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var buf bytes.Buffer
status, authCode, err := pollPatDeviceFlow(ctx, "flow-1", configDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != "APPROVED" {
t.Errorf("expected APPROVED, got %q", status)
}
if authCode != "code123" {
t.Errorf("expected authCode 'code123', got %q", authCode)
}
}
func TestPollPatDeviceFlow_Rejected(t *testing.T) {
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
{Success: false, Data: authpkg.DevicePollData{Status: "REJECTED"}},
})
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var buf bytes.Buffer
status, authCode, err := pollPatDeviceFlow(ctx, "flow-2", configDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != "REJECTED" {
t.Errorf("expected REJECTED, got %q", status)
}
if authCode != "" {
t.Errorf("expected empty authCode for REJECTED, got %q", authCode)
}
}
func TestPollPatDeviceFlow_Expired(t *testing.T) {
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
{Success: false, Data: authpkg.DevicePollData{Status: "EXPIRED"}},
})
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var buf bytes.Buffer
status, authCode, err := pollPatDeviceFlow(ctx, "flow-3", configDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != "EXPIRED" {
t.Errorf("expected EXPIRED, got %q", status)
}
if authCode != "" {
t.Errorf("expected empty authCode for EXPIRED, got %q", authCode)
}
}
func TestPollPatDeviceFlow_Cancelled(t *testing.T) {
// Server always returns PENDING so context cancellation is the only exit.
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
{Success: true, Data: authpkg.DevicePollData{Status: "PENDING"}},
})
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
// Cancel immediately after first poll tick.
go func() {
time.Sleep(500 * time.Millisecond)
cancel()
}()
var buf bytes.Buffer
status, authCode, err := pollPatDeviceFlow(ctx, "flow-4", configDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != "CANCELLED" {
t.Errorf("expected CANCELLED, got %q", status)
}
if authCode != "" {
t.Errorf("expected empty authCode for CANCELLED, got %q", authCode)
}
}
// ---------------------------------------------------------------------------
// IsPatRetrying tests
// ---------------------------------------------------------------------------
func TestIsPatRetrying_Default(t *testing.T) {
t.Parallel()
ctx := context.Background()
if IsPatRetrying(ctx) {
t.Fatal("expected false for plain context")
}
}
func TestIsPatRetrying_WithValue(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), patRetryingKey, true)
if !IsPatRetrying(ctx) {
t.Fatal("expected true when pat retry key is set")
}
}
// ---------------------------------------------------------------------------
// pollPatDeviceFlow edge cases
// ---------------------------------------------------------------------------
func TestPollPatDeviceFlow_ServerErrorFallback(t *testing.T) {
// When server returns success=false with empty status, should treat as EXPIRED.
server, configDir := setupPollServer(t, []authpkg.DevicePollResponse{
{Success: false, Data: authpkg.DevicePollData{Status: ""}},
})
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var buf bytes.Buffer
status, authCode, err := pollPatDeviceFlow(ctx, "flow-err", configDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != "EXPIRED" {
t.Errorf("expected EXPIRED for server error fallback, got %q", status)
}
if authCode != "" {
t.Errorf("expected empty authCode for server error, got %q", authCode)
}
}
func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) {
// When server returns 302 (SSO redirect), poll should continue until real response.
var callCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount <= 1 {
// First call: simulate SSO redirect
w.Header().Set("Location", "https://sso.example.com")
w.WriteHeader(http.StatusFound)
return
}
// Second call: return APPROVED
w.Header().Set("Content-Type", "application/json")
resp := authpkg.DevicePollResponse{
Success: true,
Data: authpkg.DevicePollData{Status: "APPROVED"},
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte(server.URL), 0644)
t.Setenv("DWS_CONFIG_DIR", tmpDir)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var buf bytes.Buffer
status, _, err := pollPatDeviceFlow(ctx, "flow-redirect", tmpDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != "APPROVED" {
t.Errorf("expected APPROVED after redirect, got %q", status)
}
}
// ---------------------------------------------------------------------------
// extractPatScopeError edge cases
// ---------------------------------------------------------------------------
func TestExtractPatScopeError_Nil(t *testing.T) {
t.Parallel()
if got := extractPatScopeError(nil); got != nil {
t.Fatalf("expected nil for nil error, got %+v", got)
}
}
func TestExtractPatScopeError_WithIdentity(t *testing.T) {
t.Parallel()
err := apperrors.NewAuth(`insufficient_scope: identity "app_user" needs calendar:write`)
scopeErr := extractPatScopeError(err)
if scopeErr == nil {
t.Fatal("expected non-nil PatScopeError")
}
if scopeErr.Identity != "app_user" {
t.Errorf("expected Identity 'app_user', got %q", scopeErr.Identity)
}
if scopeErr.MissingScope != "calendar:write" {
t.Errorf("expected MissingScope 'calendar:write', got %q", scopeErr.MissingScope)
}
}
// ---------------------------------------------------------------------------
// handlePatAuthCheck integration tests — cover the main orchestrator with
// mock runner + httptest poll server for APPROVED, REJECTED, EmptyFlowID.
// ---------------------------------------------------------------------------
// mockRunner is a simple executor.Runner for testing handlePatAuthCheck.
type mockRunner struct {
runFunc func(ctx context.Context, inv executor.Invocation) (executor.Result, error)
}
func (m *mockRunner) Run(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
return m.runFunc(ctx, inv)
}
// setupHandlePATServer creates an httptest server for handlePatAuthCheck tests.
// It responds to device poll requests with the given status after the first poll.
func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string) (*httptest.Server, string) {
t.Helper()
var pollCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/cli/oauth/device/poll") {
idx := int(pollCount.Add(1)) - 1
var resp authpkg.DevicePollResponse
if idx == 0 {
resp = authpkg.DevicePollResponse{Success: true, Data: authpkg.DevicePollData{Status: "PENDING"}}
} else {
resp = authpkg.DevicePollResponse{
Success: terminalStatus == "APPROVED",
Data: authpkg.DevicePollData{Status: terminalStatus, AuthCode: authCode},
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
return
}
http.NotFound(w, r)
}))
tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte(server.URL), 0644)
t.Setenv("DWS_CONFIG_DIR", tmpDir)
return server, tmpDir
}
func makePATErrorJSON(flowID, clientID string) string {
type patData struct {
Desc string `json:"desc"`
FlowID string `json:"flowId"`
URI string `json:"uri"`
ClientID string `json:"clientId"`
}
payload := struct {
Code string `json:"code"`
Data patData `json:"data"`
}{
Code: "AGENT_CODE_NOT_EXISTS",
Data: patData{
Desc: "test auth",
FlowID: flowID,
URI: "", // empty to avoid opening browser in test
ClientID: clientID,
},
}
data, _ := json.Marshal(payload)
return string(data)
}
func TestHandlePatAuthCheck_Approved(t *testing.T) {
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
defer server.Close()
var retryCalled bool
var retryHasKey bool
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
retryCalled = true
retryHasKey = IsPatRetrying(ctx)
return executor.Result{Response: map[string]any{"ok": true}}, nil
},
}
runner := &runtimeRunner{fallback: mock}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-approved", "test-client-id")}
ctx := context.Background()
var buf bytes.Buffer
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
CanonicalProduct: "test",
Tool: "test_tool",
}, patErr, configDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !retryCalled {
t.Fatal("expected mock runner to be called for retry")
}
if !retryHasKey {
t.Fatal("expected retry context to have patRetryingKey")
}
// Verify SetClientIDFromMCP was called with the PAT response clientId.
if cid := authpkg.ClientID(); cid != "test-client-id" {
t.Errorf("expected ClientID 'test-client-id', got %q", cid)
}
}
func TestHandlePatAuthCheck_Rejected(t *testing.T) {
server, configDir := setupHandlePATServer(t, "REJECTED", "")
defer server.Close()
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
t.Fatal("runner should not be called on REJECTED")
return executor.Result{}, nil
},
}
runner := &runtimeRunner{fallback: mock}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("flow-rejected", "test-client-id")}
ctx := context.Background()
var buf bytes.Buffer
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
CanonicalProduct: "test",
Tool: "test_tool",
}, patErr, configDir, &buf)
if err == nil {
t.Fatal("expected error for REJECTED")
}
if !strings.Contains(err.Error(), "用户已拒绝授权") {
t.Errorf("expected rejection error, got: %v", err)
}
}
func TestHandlePatAuthCheck_EmptyFlowID_FallsBackToPATError(t *testing.T) {
// No poll server needed — empty flowId means no polling, return PATError directly.
tmpDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", tmpDir)
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
t.Fatal("runner should not be called when flowId is empty")
return executor.Result{}, nil
},
}
runner := &runtimeRunner{fallback: mock}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSON("", "test-client-id")}
ctx := context.Background()
var buf bytes.Buffer
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
CanonicalProduct: "test",
Tool: "test_tool",
}, patErr, tmpDir, &buf)
if err == nil {
t.Fatal("expected PATError when flowId is empty")
}
// Should return the original PATError.
if _, ok := err.(*apperrors.PATError); !ok {
t.Errorf("expected *PATError, got %T: %v", err, err)
}
}
+19 -26
View File
@@ -20,13 +20,14 @@ import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/spf13/cobra"
)
func newPluginCommand() *cobra.Command {
pluginCmd := newPlaceholderParent("plugin", "Manage plugins")
pluginCmd := newPlaceholderParent("plugin", i18n.T("插件管理"))
pluginCmd.AddCommand(
newPluginListCommand(),
@@ -48,7 +49,7 @@ func newPluginCommand() *cobra.Command {
func newPluginListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List installed plugins",
Short: i18n.T("列出已安装的插件"),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
loader := plugin.NewLoader(RawVersion())
@@ -82,7 +83,7 @@ func newPluginListCommand() *cobra.Command {
func newPluginInstallCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "install",
Short: "Install a plugin",
Short: i18n.T("安装插件"),
Example: ` dws plugin install --dir ./conference
dws plugin install --git https://github.com/DingTalk-Real-AI/conference.git`,
DisableAutoGenTag: true,
@@ -122,7 +123,7 @@ func newPluginInstallCommand() *cobra.Command {
func newPluginInfoCommand() *cobra.Command {
return &cobra.Command{
Use: "info <name>",
Short: "Show plugin details",
Short: i18n.T("查看插件详情"),
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -152,7 +153,7 @@ func newPluginInfoCommand() *cobra.Command {
func newPluginEnableCommand() *cobra.Command {
return &cobra.Command{
Use: "enable <name>",
Short: "Enable a plugin",
Short: i18n.T("启用插件"),
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -169,7 +170,7 @@ func newPluginEnableCommand() *cobra.Command {
func newPluginDisableCommand() *cobra.Command {
return &cobra.Command{
Use: "disable <name>",
Short: "Disable a plugin (managed plugins can be disabled but not removed)",
Short: i18n.T("禁用插件"),
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -186,7 +187,7 @@ func newPluginDisableCommand() *cobra.Command {
func newPluginRemoveCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "remove <name>",
Short: "Remove a user plugin (managed plugins cannot be removed)",
Short: i18n.T("卸载已安装的插件"),
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -208,7 +209,7 @@ func newPluginRemoveCommand() *cobra.Command {
func newPluginValidateCommand() *cobra.Command {
return &cobra.Command{
Use: "validate <dir>",
Short: "Validate a plugin.json",
Short: i18n.T("校验 plugin.json"),
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -229,22 +230,15 @@ func newPluginValidateCommand() *cobra.Command {
func newPluginCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <name>",
Short: "Scaffold a new plugin directory",
Short: i18n.T("脚手架生成新插件目录"),
Example: ` dws plugin create my-tool
dws plugin create my-tool --type managed --description "My awesome tool"`,
dws plugin create my-tool --description "My awesome tool"`,
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
desc, _ := cmd.Flags().GetString("description")
pluginType, _ := cmd.Flags().GetString("type")
if pluginType == "" {
pluginType = "user"
}
if pluginType != "managed" && pluginType != "user" {
return apperrors.NewValidation("type must be 'managed' or 'user'")
}
pluginType := "user"
// Validate name format
m := &plugin.Manifest{Name: name, Version: "0.1.0", Type: pluginType}
@@ -352,14 +346,13 @@ Use this skill when the user mentions:
},
}
cmd.Flags().String("description", "", "Plugin description")
cmd.Flags().String("type", "user", "Plugin type: managed or user")
return cmd
}
func newPluginDevCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "dev <dir>",
Short: "Register a local directory as a dev plugin",
Short: i18n.T("将本地目录注册为开发态插件"),
Long: `Registers a plugin from a local source directory for development.
The plugin is loaded directly from the source directory on next CLI invocation,
without copying files to ~/.dws/plugins/. Use 'dws plugin dev --off <name>'
@@ -413,7 +406,7 @@ to unregister.`,
}
func newPluginConfigCommand() *cobra.Command {
configCmd := newPlaceholderParent("config", "Manage plugin configuration")
configCmd := newPlaceholderParent("config", i18n.T("管理插件配置"))
configCmd.AddCommand(
newPluginConfigSetCommand(),
newPluginConfigGetCommand(),
@@ -426,7 +419,7 @@ func newPluginConfigCommand() *cobra.Command {
func newPluginConfigSetCommand() *cobra.Command {
return &cobra.Command{
Use: "set <plugin-name> <key> <value>",
Short: "Set a plugin config value",
Short: i18n.T("设置插件配置项"),
Long: `Persistently set a configuration value for a plugin.
The value is stored in ~/.dws/settings.json and automatically injected
as an environment variable when the plugin is loaded.
@@ -464,7 +457,7 @@ over values stored in settings.json.`,
func newPluginConfigGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <plugin-name> <key>",
Short: "Get a plugin config value",
Short: i18n.T("读取插件配置项"),
Args: cobra.ExactArgs(2),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -485,7 +478,7 @@ func newPluginConfigGetCommand() *cobra.Command {
func newPluginConfigListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list <plugin-name>",
Short: "List all config values for a plugin",
Short: i18n.T("列出插件所有配置项"),
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -570,7 +563,7 @@ func newPluginConfigListCommand() *cobra.Command {
func newPluginConfigUnsetCommand() *cobra.Command {
return &cobra.Command{
Use: "unset <plugin-name> <key>",
Short: "Remove a plugin config value",
Short: i18n.T("删除插件配置项"),
Args: cobra.ExactArgs(2),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -614,7 +607,7 @@ func maskSensitiveValue(value string) string {
func newPluginBuildCommand() *cobra.Command {
return &cobra.Command{
Use: "build <dir>",
Short: "Build plugin's stdio server into a native binary",
Short: i18n.T("将插件 stdio server 编译为原生二进制"),
Long: `Runs the build command declared in plugin.json to compile the
plugin's server into a single executable. This ensures plugin users
don't need any language runtime (Node.js, Python, etc.) installed.
@@ -0,0 +1,198 @@
// 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 (
"fmt"
"sync"
"testing"
"time"
"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/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// TestSharedCacheStoreConcurrentSaveTools verifies that a single *cache.Store
// instance is safe for goroutines saving tool snapshots concurrently, as long
// as each goroutine targets a distinct (partition, serverKey). This mirrors
// the real plugin discovery path where each goroutine owns one plugin/server.
//
// Each call serializes to its own "<key>.json.tmp" file followed by a
// rename(2) to the final path, so concurrent writers targeting distinct keys
// never collide. The invariant asserted here: after N parallel writes, the
// Store returns each written snapshot intact under LoadTools.
func TestSharedCacheStoreConcurrentSaveTools(t *testing.T) {
const (
partition = "default/default"
writers = 16
)
store := cache.NewStore(t.TempDir())
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
key := fmt.Sprintf("plugin:concurrent:%d", idx)
if err := store.SaveTools(partition, key, cache.ToolsSnapshot{
ServerKey: key,
}); err != nil {
t.Errorf("SaveTools(%s): %v", key, err)
}
}(i)
}
wg.Wait()
for i := 0; i < writers; i++ {
key := fmt.Sprintf("plugin:concurrent:%d", i)
snapshot, _, err := store.LoadTools(partition, key)
if err != nil {
t.Fatalf("LoadTools(%s): %v", key, err)
}
if snapshot.ServerKey != key {
t.Errorf("LoadTools(%s) returned ServerKey %q", key, snapshot.ServerKey)
}
}
}
// TestAppendDynamicServerConcurrent exercises the dynamicMu mutex on the
// write path by spraying distinct server descriptors in parallel. Afterwards
// every injected product ID must be resolvable — a missing entry would
// indicate a lost write through an un-synchronized map update.
func TestAppendDynamicServerConcurrent(t *testing.T) {
dynamicMu.Lock()
prev := struct {
endpoints map[string]string
products map[string]bool
aliases map[string]string
toolEndpoints map[string]string
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
dynamicEndpoints = nil
dynamicProducts = nil
dynamicAliases = nil
dynamicToolEndpoints = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = prev.endpoints
dynamicProducts = prev.products
dynamicAliases = prev.aliases
dynamicToolEndpoints = prev.toolEndpoints
dynamicMu.Unlock()
})
const n = 32
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
id := fmt.Sprintf("plugin-id-%d", idx)
endpoint := fmt.Sprintf("https://example.test/%d", idx)
AppendDynamicServer(market.ServerDescriptor{
Endpoint: endpoint,
CLI: market.CLIOverlay{
ID: id,
Command: id,
},
})
}(i)
}
wg.Wait()
for i := 0; i < n; i++ {
id := fmt.Sprintf("plugin-id-%d", i)
if endpoint, ok := directRuntimeEndpoint(id, ""); !ok || endpoint == "" {
t.Errorf("directRuntimeEndpoint(%q) = (%q, %v), want non-empty", id, endpoint, ok)
}
}
}
// TestRegisterStdioClientConcurrent verifies the stdioMu-protected registry
// survives concurrent writers — every registered client must be looked up
// afterwards. Uses nil client pointers since LookupStdioClient only compares
// keys, not values.
func TestRegisterStdioClientConcurrent(t *testing.T) {
stdioMu.Lock()
prev := stdioClients
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
t.Cleanup(func() {
stdioMu.Lock()
stdioClients = prev
stdioMu.Unlock()
})
const n = 32
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
RegisterStdioClient(fmt.Sprintf("plugin/%d", idx), nil)
}(i)
}
wg.Wait()
for i := 0; i < n; i++ {
key := fmt.Sprintf("plugin/%d", i)
if _, ok := LookupStdioClient(key); !ok {
t.Errorf("LookupStdioClient(%q) missing after concurrent registration", key)
}
}
}
// TestResolvePluginColdTimeouts covers the three code paths of the env
// parser: unset (defaults), valid duration (applied to all three slots),
// and invalid duration (logged and ignored, defaults returned).
func TestResolvePluginColdTimeouts(t *testing.T) {
t.Run("defaults when env unset", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "")
got := resolvePluginColdTimeouts()
if got.httpNoAuth != 1*time.Second {
t.Errorf("httpNoAuth = %v, want 1s", got.httpNoAuth)
}
if got.httpAuth != 1500*time.Millisecond {
t.Errorf("httpAuth = %v, want 1.5s", got.httpAuth)
}
if got.stdio != 2*time.Second {
t.Errorf("stdio = %v, want 2s", got.stdio)
}
})
t.Run("env override applies to all slots", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "3500ms")
got := resolvePluginColdTimeouts()
want := 3500 * time.Millisecond
if got.httpNoAuth != want || got.httpAuth != want || got.stdio != want {
t.Errorf("override not propagated: %+v", got)
}
})
t.Run("invalid env falls back to defaults", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "not-a-duration")
got := resolvePluginColdTimeouts()
if got.httpNoAuth != 1*time.Second || got.stdio != 2*time.Second {
t.Errorf("invalid env should not override defaults: %+v", got)
}
})
t.Run("non-positive env falls back to defaults", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "0")
got := resolvePluginColdTimeouts()
if got.httpNoAuth != 1*time.Second {
t.Errorf("zero duration should not override defaults, got %v", got.httpNoAuth)
}
})
}
+197 -92
View File
@@ -41,6 +41,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
@@ -146,7 +147,7 @@ func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err erro
return writeErr
}
if wantsJSONErrors(root) {
return apperrors.PrintJSON(stdout, err)
return apperrors.PrintJSON(stderr, err)
}
return apperrors.PrintHumanAt(stderr, err, resolveVerbosity(root))
}
@@ -315,6 +316,10 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
addPluginCommandsSafe(root, pluginCmds)
}
// PAT authorization commands (open-source core)
patCaller := newToolCallerAdapter(runner, flags)
pat.RegisterCommands(root, patCaller)
if fn := edition.Get().RegisterExtraCommands; fn != nil {
caller := newToolCallerAdapter(runner, flags)
fn(root, caller)
@@ -767,6 +772,43 @@ func cacheStoreFromEnv() *cache.Store {
return cache.NewStore(cacheDir)
}
// pluginColdTimeouts holds the cold-path discovery budget for plugin MCP
// servers. Timeouts only apply to the *first* discovery for a given
// plugin/server; subsequent startups take the warm cache path and bypass
// the network entirely.
type pluginColdTimeouts struct {
httpNoAuth time.Duration
httpAuth time.Duration
stdio time.Duration
}
// resolvePluginColdTimeouts returns the cold-discovery budget for plugin MCP
// servers, applying the DWS_PLUGIN_COLD_TIMEOUT override when set. Defaults
// are tuned so healthy cross-region HTTP endpoints succeed on a cold start
// and Python/Node-based stdio plugins have headroom for interpreter load,
// while an unreachable host still surrenders in bounded time.
func resolvePluginColdTimeouts() pluginColdTimeouts {
t := pluginColdTimeouts{
httpNoAuth: 1 * time.Second,
httpAuth: 1500 * time.Millisecond,
stdio: 2 * time.Second,
}
raw := strings.TrimSpace(os.Getenv(cli.PluginColdTimeoutEnv))
if raw == "" {
return t
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
slog.Warn("plugin: ignoring invalid DWS_PLUGIN_COLD_TIMEOUT",
"value", raw, "error", err)
return t
}
t.httpNoAuth = d
t.httpAuth = d
t.stdio = d
return t
}
func configureOutputSink(cmd *cobra.Command) error {
if local := cmd.LocalFlags().Lookup("output"); local != nil {
return nil
@@ -1036,9 +1078,7 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
// precedence (InjectPluginConfigEnv skips already-set keys).
pluginLoader.InjectPluginConfigEnv()
// 0a. Ensure default managed plugins are installed (first-run bootstrap).
updater := plugin.NewUpdater(pluginLoader.PluginsDir, RawVersion())
// Load TokenData once; reuse for plugin bootstrap, updates, and stdio injection.
// Load TokenData once; reused for stdio injection below.
tokenData, _ := authpkg.LoadTokenData(defaultConfigDir())
var userCtx *plugin.UserContext
if tokenData != nil {
@@ -1050,38 +1090,14 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
}
}
}
accessToken := ""
if tokenData != nil && tokenData.IsAccessTokenValid() {
accessToken = tokenData.AccessToken
}
if accessToken != "" {
bootstrapCtx, bootstrapCancel := context.WithTimeout(context.Background(), 2*time.Minute)
installed := updater.EnsureManaged(bootstrapCtx, accessToken, os.Stderr)
bootstrapCancel()
if len(installed) > 0 {
slog.Debug("plugin: bootstrapped managed plugins", "names", installed)
}
// 0b. Check for managed plugin updates (non-blocking, best-effort).
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
updated := updater.CheckAndUpdate(ctx, accessToken, os.Stderr)
cancel()
if len(updated) > 0 {
slog.Debug("plugin: updated managed plugins", "names", updated)
}
}
// 1. Load official plugins (always enabled)
managedPlugins := pluginLoader.LoadManaged()
// 2. Load user plugins (per settings.json)
// 1. Load user plugins (per settings.json)
userPlugins := pluginLoader.LoadUser()
// 3. Load dev plugins (registered via `dws plugin dev`)
// 2. Load dev plugins (registered via `dws plugin dev`)
devPlugins := pluginLoader.LoadDev()
allPlugins := append(managedPlugins, userPlugins...)
allPlugins = append(allPlugins, devPlugins...)
allPlugins := append(userPlugins, devPlugins...)
// 3. Discover tools from streamable-http servers and build CLI commands.
// Third-party servers with auth headers are discovered in parallel
@@ -1110,33 +1126,15 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
}
}
// Discover tools from HTTP servers in parallel when there are multiple
// servers with auth headers (third-party services with higher latency).
if len(httpServers) > 1 {
type discoveryResult struct {
commands []*cobra.Command
}
results := make([]discoveryResult, len(httpServers))
var wg sync.WaitGroup
for i, ps := range httpServers {
wg.Add(1)
go func(idx int, ps pluginServer) {
defer wg.Done()
results[idx].commands = registerHTTPServer(ps.plugin, ps.srv, tc, runner)
}(i, ps)
}
wg.Wait()
for _, r := range results {
pluginCmds = append(pluginCmds, r.commands...)
}
} else {
for _, ps := range httpServers {
cmds := registerHTTPServer(ps.plugin, ps.srv, tc, runner)
pluginCmds = append(pluginCmds, cmds...)
}
// Collect all stdio clients up front so HTTP + stdio discovery can run
// concurrently — the slowest plugin (typically an unreachable HTTP
// endpoint hitting its dial timeout) dominates the parallel wall-clock,
// not the sum of every plugin's cold timeout.
type stdioEntry struct {
plugin *plugin.Plugin
sc plugin.StdioServerClient
}
// 4. Start stdio MCP servers, discover tools, and build CLI commands
var stdioEntries []stdioEntry
for _, p := range allPlugins {
for _, sc := range p.StdioClients(userCtx) {
// Use background context so the subprocess lives for the CLI
@@ -1146,11 +1144,47 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
continue
}
cmds := registerStdioServer(p, sc, runner)
pluginCmds = append(pluginCmds, cmds...)
stdioEntries = append(stdioEntries, stdioEntry{plugin: p, sc: sc})
}
}
// Share one cache.Store across all discovery goroutines. Each goroutine
// writes to a distinct serverKey path ("tools/<plugin>_<server>.json") with
// atomic tmp+rename, so concurrent writes to different keys never collide
// on the filesystem. Global in-process registries (AppendDynamicServer,
// RegisterStdioClient) carry their own sync.Mutex; see direct_runtime.go
// and stdio_registry.go.
sharedStore := cacheStoreFromEnv()
coldTimeouts := resolvePluginColdTimeouts()
// Fan out HTTP and stdio discovery in parallel. Each goroutine resolves
// its cache hit locally (no network) or runs a bounded cold-path probe.
// Wall-clock cost ≈ max(individual plugin latencies), not the sum.
httpResults := make([][]*cobra.Command, len(httpServers))
stdioResults := make([][]*cobra.Command, len(stdioEntries))
var wg sync.WaitGroup
for i, ps := range httpServers {
wg.Add(1)
go func(idx int, ps pluginServer) {
defer wg.Done()
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, sharedStore, coldTimeouts)
}(i, ps)
}
for i, e := range stdioEntries {
wg.Add(1)
go func(idx int, e stdioEntry) {
defer wg.Done()
stdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, sharedStore, coldTimeouts)
}(i, e)
}
wg.Wait()
for _, cmds := range httpResults {
pluginCmds = append(pluginCmds, cmds...)
}
for _, cmds := range stdioResults {
pluginCmds = append(pluginCmds, cmds...)
}
// 5. Register plugin hooks into pipeline engine
if engine != nil {
for _, p := range allPlugins {
@@ -1174,7 +1208,6 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
if len(allPlugins) > 0 {
slog.Debug("plugins loaded",
"managed", len(managedPlugins),
"user", len(userPlugins),
"dev", len(devPlugins),
)
@@ -1183,25 +1216,69 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
return pluginCmds
}
// pluginCacheKey derives the cache key used to persist a plugin MCP server's
// tool list. Prefixed with "plugin:" so entries are namespaced apart from the
// Market-derived cache, and visible distinctly via `dws cache status`.
func pluginCacheKey(pluginName, serverKey string) string {
return "plugin:" + pluginName + ":" + serverKey
}
// registerHTTPServer discovers tools from a streamable-http MCP server and
// builds CLI commands. Used for plugin-owned HTTP servers that provide CLI metadata.
//
// Startup-latency strategy (issue #119):
// - Warm cache: build commands from the persisted tools snapshot
// synchronously — no network I/O. `dws --help` returns in ms even when
// the plugin endpoint is unreachable.
// - Cold cache: synchronous discovery (Initialize + ListTools) with a tight
// timeout. The outcome — success or failure — is persisted so the next
// invocation hits the warm path. Refresh on demand via `dws cache clean`
// / `dws cache refresh`; the cache TTL (7d) otherwise expires naturally.
//
// When the server descriptor carries AuthHeaders (from plugin.json "headers"),
// a dedicated transport.Client is created with the plugin's Bearer token and
// trusted domains so that third-party MCP servers requiring independent
// authentication (e.g. Alibaba Cloud Bailian) can be discovered at startup.
func registerHTTPServer(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, runner executor.Runner) []*cobra.Command {
// Use a longer timeout for servers with custom auth headers (third-party
// services may have higher latency than local/DingTalk endpoints).
timeout := 2 * time.Second
func registerHTTPServer(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, runner executor.Runner, store *cache.Store, timeouts pluginColdTimeouts) []*cobra.Command {
partition := config.DefaultPartition
cacheKey := pluginCacheKey(p.Manifest.Name, srv.Key)
if snapshot, freshness, err := store.LoadTools(partition, cacheKey); err == nil {
slog.Debug("plugin: http server served from cache",
"plugin", p.Manifest.Name, "server", srv.Key,
"tools", len(snapshot.Tools), "freshness", string(freshness))
return buildHTTPCommandsFromTools(srv, snapshot.Tools, runner)
}
// Cold cache: synchronous discovery. Persist the outcome even on failure
// (empty tools == negative cache) so the next invocation takes the fast
// path regardless of endpoint health.
tools := discoverHTTPTools(p, srv, tc, timeouts)
_ = store.SaveTools(partition, cacheKey, cache.ToolsSnapshot{
ServerKey: cacheKey,
Tools: tools,
})
return buildHTTPCommandsFromTools(srv, tools, runner)
}
// discoverHTTPTools performs the blocking Initialize + ListTools handshake
// for an HTTP MCP server and returns the discovered tools. Returns nil on
// any transport/protocol error; errors are logged at Debug level.
func discoverHTTPTools(p *plugin.Plugin, srv market.ServerDescriptor, tc *transport.Client, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
// Cold-path budget. An unreachable endpoint will burn the full window
// via the TCP dial timeout; a healthy localhost/third-party endpoint
// typically responds in <200 ms. Third-party servers with auth get a
// slightly larger window to accommodate TLS + auth RTT. Operators with
// cross-region endpoints can relax the window via DWS_PLUGIN_COLD_TIMEOUT.
// The outcome is persisted as a negative cache so subsequent startups
// (80 ms warm) are unaffected. See issue #119.
timeout := timeouts.httpNoAuth
if len(srv.AuthHeaders) > 0 {
timeout = 10 * time.Second
timeout = timeouts.httpAuth
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// If the plugin provides custom auth headers, create a dedicated client
// so the Bearer token is sent to the third-party endpoint.
discoveryClient := tc
if len(srv.AuthHeaders) > 0 {
discoveryClient = buildPluginAuthClient(tc, srv)
@@ -1219,14 +1296,19 @@ func registerHTTPServer(p *plugin.Plugin, srv market.ServerDescriptor, tc *trans
"plugin", p.Manifest.Name, "server", srv.Key, "error", err)
return nil
}
return toolsResult.Tools
}
if len(toolsResult.Tools) == 0 {
// buildHTTPCommandsFromTools converts a tool list into Cobra commands via
// the BuildDynamicCommands path. Returns nil for an empty tool list.
func buildHTTPCommandsFromTools(srv market.ServerDescriptor, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
if len(tools) == 0 {
return nil
}
detailsByID := make(map[string][]market.DetailTool)
var detailTools []market.DetailTool
for _, tool := range toolsResult.Tools {
for _, tool := range tools {
schemaJSON := ""
if tool.InputSchema != nil {
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
@@ -1246,23 +1328,17 @@ func registerHTTPServer(p *plugin.Plugin, srv market.ServerDescriptor, tc *trans
// If the server has no ToolOverrides (e.g. third-party MCP servers that
// only declare cli.id and cli.command), auto-generate one override per
// discovered tool so BuildDynamicCommands can create leaf commands.
if len(srv.CLI.ToolOverrides) == 0 && len(toolsResult.Tools) > 0 {
srv.CLI.ToolOverrides = make(map[string]market.CLIToolOverride, len(toolsResult.Tools))
for _, tool := range toolsResult.Tools {
if len(srv.CLI.ToolOverrides) == 0 {
srv.CLI.ToolOverrides = make(map[string]market.CLIToolOverride, len(tools))
for _, tool := range tools {
srv.CLI.ToolOverrides[tool.Name] = market.CLIToolOverride{
CLIName: deriveToolCLIName(tool.Name),
}
}
}
cmds := compat.BuildDynamicCommands(
return compat.BuildDynamicCommands(
[]market.ServerDescriptor{srv}, runner, detailsByID)
slog.Debug("plugin: http server registered",
"plugin", p.Manifest.Name, "server", srv.Key,
"tools", len(toolsResult.Tools), "commands", len(cmds))
return cmds
}
// deriveToolCLIName converts an MCP tool name (e.g. "web_search" or
@@ -1341,8 +1417,37 @@ func registerPluginAuthFromHeaders(srv market.ServerDescriptor) {
// registerStdioServer initializes a stdio MCP server, discovers its tools
// via ListTools, builds CLI commands, and registers the StdioClient for
// runtime dispatch. Returns generated cobra commands.
func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner executor.Runner) []*cobra.Command {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
//
// Warm-cache fast path (issue #119): when a tools snapshot is already cached
// for this plugin/server, skip the Initialize + ListTools RPC round-trip and
// rebuild commands directly from the snapshot. Cold cache falls back to
// synchronous discovery with a 4s cap and persists the outcome.
func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner executor.Runner, store *cache.Store, timeouts pluginColdTimeouts) []*cobra.Command {
partition := config.DefaultPartition
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if snapshot, freshness, err := store.LoadTools(partition, cacheKey); err == nil {
slog.Debug("plugin: stdio server served from cache",
"plugin", p.Manifest.Name, "server", sc.Key,
"tools", len(snapshot.Tools), "freshness", string(freshness))
return buildStdioCommands(p, sc, snapshot.Tools, runner)
}
tools := discoverStdioTools(p, sc, timeouts)
_ = store.SaveTools(partition, cacheKey, cache.ToolsSnapshot{
ServerKey: cacheKey,
Tools: tools,
})
return buildStdioCommands(p, sc, tools, runner)
}
// discoverStdioTools performs the blocking Initialize + ListTools handshake
// on a stdio MCP subprocess. Returns nil on any error (logged at Warn level).
// The default 2s budget comfortably accommodates Python/Node runtimes whose
// interpreter + dependency load dominates the first response. Operators with
// heavier startup chains can relax further via DWS_PLUGIN_COLD_TIMEOUT.
func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
ctx, cancel := context.WithTimeout(context.Background(), timeouts.stdio)
defer cancel()
if _, err := sc.Client.Initialize(ctx); err != nil {
@@ -1350,15 +1455,20 @@ func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner e
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
return nil
}
toolsResult, err := sc.Client.ListTools(ctx)
if err != nil {
slog.Warn("plugin: stdio ListTools failed",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
return nil
}
return toolsResult.Tools
}
if len(toolsResult.Tools) == 0 {
// buildStdioCommands constructs Cobra commands from a tool list and
// registers the runtime dispatch state (StdioClient + dynamic server).
// Returns nil for an empty tool list.
func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
if len(tools) == 0 {
slog.Debug("plugin: stdio server has no tools",
"plugin", p.Manifest.Name, "server", sc.Key)
return nil
@@ -1403,7 +1513,7 @@ func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner e
if len(overlay.Prefixes) == 0 {
overlay.Prefixes = []string{serverID}
}
for _, tool := range toolsResult.Tools {
for _, tool := range tools {
overlay.ToolOverrides[tool.Name] = market.CLIToolOverride{
IsSensitive: tool.Sensitive,
}
@@ -1413,17 +1523,12 @@ func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner e
// Construct virtual endpoint and server descriptor.
endpoint := StdioEndpoint(p.Manifest.Name, sc.Key)
source := "plugin"
if p.IsManaged {
source = "plugin-managed"
}
descriptor := market.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
Endpoint: endpoint,
Source: source,
Source: "plugin",
CLI: overlay,
HasCLIMeta: true,
}
@@ -1435,7 +1540,7 @@ func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner e
// Convert tool descriptors to DetailTool entries for flag generation.
detailsByID := make(map[string][]market.DetailTool)
var detailTools []market.DetailTool
for _, tool := range toolsResult.Tools {
for _, tool := range tools {
schemaJSON := ""
if tool.InputSchema != nil {
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
@@ -1457,7 +1562,7 @@ func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner e
slog.Debug("plugin: stdio server registered",
"plugin", p.Manifest.Name, "server", sc.Key,
"tools", len(toolsResult.Tools), "commands", len(cmds))
"tools", len(tools), "commands", len(cmds))
return cmds
}
+12 -12
View File
@@ -51,11 +51,11 @@ func TestPrintExecutionErrorDefaultsToJSON(t *testing.T) {
if err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
}
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
}
}
@@ -73,11 +73,11 @@ func TestPrintExecutionErrorUsesJSONWhenFormatIsJSON(t *testing.T) {
if err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
}
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
}
}
@@ -105,11 +105,11 @@ func TestPrintExecutionErrorUsesJSONWhenCommandSetsJSONFlag(t *testing.T) {
if err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
}
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
}
}
+21
View File
@@ -5,6 +5,7 @@ import (
"strings"
"text/tabwriter"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -14,6 +15,26 @@ func configureRootHelp(root *cobra.Command) {
return
}
// Replace the cobra-default English help command with a localized one so
// that both its listing short (shown in `dws --help`) and its own
// `dws help --help` long text follow the active locale.
root.SetHelpCommand(&cobra.Command{
Use: "help [command]",
Short: i18n.T("查看任意命令的帮助信息"),
Long: i18n.T("显示任意命令的帮助文案。\n" +
"用法:dws help [命令路径] 查看完整说明。"),
DisableAutoGenTag: true,
Run: func(c *cobra.Command, args []string) {
target, _, err := c.Root().Find(args)
if target == nil || err != nil {
c.Root().HelpFunc()(c.Root(), args)
return
}
target.InitDefaultHelpFlag()
_ = target.Help()
},
})
defaultHelpFunc := root.HelpFunc()
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd != root {
+56 -1
View File
@@ -88,9 +88,21 @@ const (
envDingtalkTraceID = "DINGTALK_TRACE_ID"
envDingtalkSessionID = "DINGTALK_SESSION_ID"
envDingtalkMessageID = "DINGTALK_MESSAGE_ID"
// Environment variables for third-party channel integration
envDWSChannel = "DWS_CHANNEL"
)
func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner {
// Ensure DWS_CLIENT_ID env is populated from persisted config before
// resolveIdentityHeaders reads it. This covers fresh-process cold starts
// where no env var has been inherited from a parent process.
if os.Getenv("DWS_CLIENT_ID") == "" {
if cid := authpkg.ClientID(); cid != "" {
_ = os.Setenv("DWS_CLIENT_ID", cid)
}
}
var httpClient *http.Client
if flags != nil && flags.Timeout > 0 {
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
@@ -295,19 +307,50 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
}
}
// PAT scope error: offer human-readable output and retry after authorization
if isPatScopeError(err) {
scopeErr := extractPatScopeError(err)
captureRuntimeFailure(invocation, err, err)
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
}
captureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
// ---- Edition hook gets first dibs (preserves overlay PATError passthrough) ----
if fn := edition.Get().ClassifyToolResult; fn != nil {
if editionErr := fn(callResult.Content); editionErr != nil {
if patCheck := apperrors.AsPatAuthCheckError(editionErr); patCheck != nil {
if IsPatRetrying(ctx) {
return executor.Result{}, patCheck // already retried once, don't loop
}
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
}
return executor.Result{}, editionErr
}
}
// ---- Structured PAT auth check (open-source fallback) ----
if patCheck := apperrors.ClassifyPatAuthCheck(callResult.Content); patCheck != nil {
if IsPatRetrying(ctx) {
return executor.Result{}, patCheck // already retried once, don't loop
}
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
}
if callResult.IsError {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
// ClassifyToolResult hook: let the overlay intercept known error
// patterns (PAT permission, gateway-auth) before generic handling.
if classify := edition.Get().ClassifyToolResult; classify != nil {
if hookErr := classify(callResult.Content); hookErr != nil {
captureRuntimeFailure(invocation, hookErr, hookErr)
return executor.Result{}, hookErr
}
}
mcpErr := apperrors.NewAPI(
extractMCPErrorMessage(callResult),
apperrors.WithOperation("tools/call"),
@@ -316,6 +359,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithServerDiag(diag),
)
// PAT scope error in business response: offer human-readable output and retry
if isPatScopeError(mcpErr) {
scopeErr := extractPatScopeError(mcpErr)
captureRuntimeFailure(invocation, mcpErr, mcpErr)
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
}
captureRuntimeFailure(invocation, mcpErr, mcpErr)
return executor.Result{}, mcpErr
}
@@ -529,7 +578,7 @@ func resolveIdentityHeaders() map[string]string {
headers = make(map[string]string)
}
// Inject environment variable based headers for MCP gateway tracking
// Inject environment variable based headers for MCP gateway tracking.
envHeaders := map[string]string{
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
@@ -541,6 +590,12 @@ func resolveIdentityHeaders() map[string]string {
headers[k] = v
}
}
// Inject third-party channel headers
if v := os.Getenv(envDWSChannel); v != "" {
headers["x-dws-channel"] = v
}
if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers)
}
@@ -0,0 +1,144 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import "testing"
func TestClassifyDenialReason(t *testing.T) {
cases := []struct {
name string
status *CLIAuthStatus
currentChannel string
want string
}{
{
name: "error CHANNEL_REQUIRED",
status: &CLIAuthStatus{
ErrorCode: "CHANNEL_REQUIRED",
},
want: "channel_required",
},
{
name: "error NO_AUTH",
status: &CLIAuthStatus{
ErrorCode: "NO_AUTH",
},
want: "no_auth",
},
{
name: "success false or nil result → unknown",
status: &CLIAuthStatus{
Success: false,
},
want: "unknown",
},
{
name: "cliAuthEnabled true → no denial",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{CLIAuthEnabled: true},
},
want: "",
},
{
name: "userScope forbidden wins over channel",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{
CLIAuthEnabled: false,
UserScope: "forbidden",
ChannelScope: "specified",
AllowedChannels: []string{"channel-a"},
},
},
currentChannel: "channel-b",
want: "user_forbidden",
},
{
// Real-world case reported: user is in allowedUsers but the current
// DWS_CHANNEL is not in allowedChannels. Reason must be channel,
// NOT user.
name: "user allowed but channel not in allowedChannels → channel_not_allowed",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{
CLIAuthEnabled: false,
UserScope: "specified",
AllowedUsers: []string{"014566033934857460"},
ChannelScope: "specified",
AllowedChannels: []string{"2a4a658e467998befb7fa333c19ba2b3a3bacfa4"},
},
},
currentChannel: "different-channel",
want: "channel_not_allowed",
},
{
name: "channelScope specified but current channel empty → channel_required",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{
CLIAuthEnabled: false,
UserScope: "specified",
ChannelScope: "specified",
AllowedChannels: []string{"channel-a"},
},
},
currentChannel: "",
want: "channel_required",
},
{
name: "channel matches allowedChannels → fall back to user denial",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{
CLIAuthEnabled: false,
UserScope: "specified",
ChannelScope: "specified",
AllowedChannels: []string{"channel-a"},
},
},
currentChannel: "channel-a",
want: "user_not_allowed",
},
{
name: "only userScope=specified, no channel restriction → user_not_allowed",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{
CLIAuthEnabled: false,
UserScope: "specified",
},
},
currentChannel: "",
want: "user_not_allowed",
},
{
name: "no user or channel restriction → cli_not_enabled",
status: &CLIAuthStatus{
Success: true,
Result: &CLIAuthResult{CLIAuthEnabled: false},
},
want: "cli_not_enabled",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := classifyDenialReason(tc.status, tc.currentChannel)
if got != tc.want {
t.Fatalf("classifyDenialReason() = %q, want %q", got, tc.want)
}
})
}
}
+66 -59
View File
@@ -154,9 +154,7 @@ func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CLIAuthStatus{
Success: true,
Result: struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
}{CLIAuthEnabled: true},
Result: &CLIAuthResult{CLIAuthEnabled: true},
})
}))
defer srv.Close()
@@ -169,7 +167,7 @@ func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) {
if err != nil {
t.Fatalf("expected success after transient failures, got error: %v", err)
}
if !status.Success || !status.Result.CLIAuthEnabled {
if !status.Success || status.Result == nil || !status.Result.CLIAuthEnabled {
t.Fatalf("expected CLIAuthEnabled=true, got %+v", status)
}
if c := calls.Load(); c != 3 {
@@ -190,9 +188,7 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CLIAuthStatus{
Success: true,
Result: struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
}{CLIAuthEnabled: true},
Result: &CLIAuthResult{CLIAuthEnabled: true},
})
}))
defer srv.Close()
@@ -204,7 +200,7 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !status.Result.CLIAuthEnabled {
if status.Result == nil || !status.Result.CLIAuthEnabled {
t.Fatal("expected CLIAuthEnabled=true")
}
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
@@ -215,9 +211,7 @@ func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CLIAuthStatus{
Success: true,
Result: struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
}{CLIAuthEnabled: false},
Result: &CLIAuthResult{CLIAuthEnabled: false},
})
}))
defer srv.Close()
@@ -229,7 +223,7 @@ func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status.Result.CLIAuthEnabled {
if status.Result == nil || status.Result.CLIAuthEnabled {
t.Fatal("expected CLIAuthEnabled=false")
}
t.Logf("✅ Normal disabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
@@ -262,12 +256,10 @@ func TestOAuthCallback_CLIAuthEnabled_ShowsSuccessPage(t *testing.T) {
var statusErr error
authStatus := &CLIAuthStatus{
Success: true,
Result: struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
}{CLIAuthEnabled: true},
Result: &CLIAuthResult{CLIAuthEnabled: true},
}
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled
if !cliAuthEnabled {
t.Fatal("cliAuthEnabled should be true when API returns enabled")
}
@@ -280,12 +272,10 @@ func TestOAuthCallback_CLIAuthDisabledByServer_ShowsNotEnabledPage(t *testing.T)
var statusErr error
authStatus := &CLIAuthStatus{
Success: true,
Result: struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
}{CLIAuthEnabled: false},
Result: &CLIAuthResult{CLIAuthEnabled: false},
}
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled
if cliAuthEnabled {
t.Fatal("cliAuthEnabled should be false when server says disabled")
}
@@ -314,12 +304,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) {
VerificationURI: "https://example.com/verify",
ExpiresIn: 60,
Interval: 1,
FlowID: "test-flow-id",
}, "", "")
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
writeServiceResult(w, true, DeviceTokenResponse{
AuthCode: "test-auth-code",
}, "", "")
case strings.HasSuffix(r.URL.Path, DevicePollPath):
// New terminal API: return APPROVED status
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]string{
"status": "APPROVED",
"authCode": "test-auth-code",
},
})
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
json.NewEncoder(w).Encode(map[string]any{
@@ -340,13 +336,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) {
configDir := setupMCPConfigDir(t, srv.URL)
provider := &DeviceFlowProvider{
configDir: configDir,
clientID: "test-client-id",
scope: DefaultScopes,
baseURL: srv.URL,
logger: newDeviceFlowTestLogger(),
Output: io.Discard,
httpClient: srv.Client(),
configDir: configDir,
clientID: "test-client-id",
scope: DefaultScopes,
baseURL: srv.URL,
terminalBaseURL: srv.URL,
logger: newDeviceFlowTestLogger(),
Output: io.Discard,
httpClient: srv.Client(),
}
_, err := provider.loginOnce(context.Background(), 1)
@@ -378,12 +375,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
VerificationURI: "https://example.com/verify",
ExpiresIn: 60,
Interval: 1,
FlowID: "test-flow-id",
}, "", "")
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
writeServiceResult(w, true, DeviceTokenResponse{
AuthCode: "test-auth-code",
}, "", "")
case strings.HasSuffix(r.URL.Path, DevicePollPath):
// New terminal API: return APPROVED status
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]string{
"status": "APPROVED",
"authCode": "test-auth-code",
},
})
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
json.NewEncoder(w).Encode(map[string]any{
@@ -396,9 +399,7 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
json.NewEncoder(w).Encode(CLIAuthStatus{
Success: true,
Result: struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
}{CLIAuthEnabled: false},
Result: &CLIAuthResult{CLIAuthEnabled: false},
})
case strings.HasSuffix(r.URL.Path, SuperAdminPath):
@@ -415,13 +416,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
configDir := setupMCPConfigDir(t, srv.URL)
provider := &DeviceFlowProvider{
configDir: configDir,
clientID: "test-client-id",
scope: DefaultScopes,
baseURL: srv.URL,
logger: newDeviceFlowTestLogger(),
Output: io.Discard,
httpClient: srv.Client(),
configDir: configDir,
clientID: "test-client-id",
scope: DefaultScopes,
baseURL: srv.URL,
terminalBaseURL: srv.URL,
logger: newDeviceFlowTestLogger(),
Output: io.Discard,
httpClient: srv.Client(),
}
_, err := provider.loginOnce(context.Background(), 1)
@@ -450,12 +452,18 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
VerificationURI: "https://example.com/verify",
ExpiresIn: 60,
Interval: 1,
FlowID: "test-flow-id",
}, "", "")
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
writeServiceResult(w, true, DeviceTokenResponse{
AuthCode: "test-auth-code",
}, "", "")
case strings.HasSuffix(r.URL.Path, DevicePollPath):
// New terminal API: return APPROVED status
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]string{
"status": "APPROVED",
"authCode": "test-auth-code",
},
})
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
json.NewEncoder(w).Encode(map[string]any{
@@ -468,9 +476,7 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
json.NewEncoder(w).Encode(CLIAuthStatus{
Success: true,
Result: struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
}{CLIAuthEnabled: true},
Result: &CLIAuthResult{CLIAuthEnabled: true},
})
default:
@@ -481,13 +487,14 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
configDir := setupMCPConfigDir(t, srv.URL)
provider := &DeviceFlowProvider{
configDir: configDir,
clientID: "test-client-id",
scope: DefaultScopes,
baseURL: srv.URL,
logger: newDeviceFlowTestLogger(),
Output: io.Discard,
httpClient: srv.Client(),
configDir: configDir,
clientID: "test-client-id",
scope: DefaultScopes,
baseURL: srv.URL,
terminalBaseURL: srv.URL,
logger: newDeviceFlowTestLogger(),
Output: io.Discard,
httpClient: srv.Client(),
}
token, err := provider.loginOnce(context.Background(), 1)
+213 -39
View File
@@ -33,32 +33,37 @@ import (
const (
// defaultPollInterval is the default seconds between device token polls.
defaultPollInterval = 5
// The server-side Redis TTL is 10 minutes; a 2-second interval keeps the
// user-perceived latency low while staying well within rate limits.
defaultPollInterval = 2
// maxPollInterval caps the polling interval to prevent DoS via slow_down.
maxPollInterval = 30
// maxPollTotalWait caps the total wait time for device authorization.
maxPollTotalWait = 15 * time.Minute
// Aligned with the server-side Redis TTL (10 minutes).
maxPollTotalWait = 10 * time.Minute
)
type DeviceFlowProvider struct {
configDir string
clientID string
scope string
baseURL string
logger *slog.Logger
Output io.Writer
httpClient *http.Client
configDir string
clientID string
scope string
baseURL string
terminalBaseURL string
logger *slog.Logger
Output io.Writer
httpClient *http.Client
}
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
return &DeviceFlowProvider{
configDir: configDir,
clientID: ClientID(),
scope: DefaultScopes,
baseURL: DefaultDeviceBaseURL,
logger: logger,
Output: os.Stderr,
httpClient: &http.Client{Timeout: 30 * time.Second},
configDir: configDir,
clientID: ClientID(),
scope: DefaultScopes,
baseURL: DefaultDeviceBaseURL,
terminalBaseURL: GetMCPBaseURL(),
logger: logger,
Output: os.Stderr,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
@@ -66,6 +71,18 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) {
p.baseURL = strings.TrimRight(baseURL, "/")
}
// SetTerminalBaseURL sets the terminal API base URL for device flow polling.
func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) {
p.terminalBaseURL = strings.TrimRight(baseURL, "/")
}
// SetScope overrides the OAuth scope for the device flow.
func (p *DeviceFlowProvider) SetScope(scope string) {
if p != nil {
p.scope = scope
}
}
func (p *DeviceFlowProvider) output() io.Writer {
if p != nil && p.Output != nil {
return p.Output
@@ -80,6 +97,7 @@ type DeviceAuthResponse struct {
VerificationURIComplete string `json:"verificationUriComplete"`
ExpiresIn int `json:"expiresIn"`
Interval int `json:"interval"`
FlowID string `json:"flowId"`
}
type DeviceTokenResponse struct {
@@ -88,6 +106,20 @@ type DeviceTokenResponse struct {
Error string `json:"error"`
}
// DevicePollResponse represents the response from the terminal API poll endpoint.
type DevicePollResponse struct {
Success bool `json:"success"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Data DevicePollData `json:"data"`
}
type DevicePollData struct {
Status string `json:"status"`
AuthCode string `json:"authCode,omitempty"`
FlowID string `json:"flowId,omitempty"`
}
type serviceResult struct {
Success bool `json:"success"`
Result json.RawMessage `json:"result"`
@@ -176,33 +208,61 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请检查网络连接后重试。"))
_, _ = fmt.Fprintln(p.output(), "")
return nil, fmt.Errorf("%s: %w", i18n.T("检查 CLI 授权状态失败"), authErr)
} else if authStatus.Success && !authStatus.Result.CLIAuthEnabled {
// CLI auth is disabled - show detailed error with admin info
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
}
denialReason := classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL"))
if denialReason != "" {
_, _ = fmt.Fprintln(p.output(), "")
switch denialReason {
case "user_forbidden":
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织已禁止所有成员使用 CLI")))
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("该组织已禁止所有成员使用 CLI"))
case "user_not_allowed":
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 您不在该组织的 CLI 授权人员范围内")))
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员将您加入 CLI 授权人员名单。"))
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员"))
case "channel_not_allowed":
ch := os.Getenv("DWS_CHANNEL")
_, _ = fmt.Fprintf(p.output(), dfRed(i18n.T("⚠️ 当前渠道 %s 未获得该组织授权"))+"\n", ch)
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员开通该渠道的访问权限。"))
_, _ = fmt.Fprintln(p.output(), "")
return nil, fmt.Errorf(i18n.T("当前渠道 %s 未获得该组织授权,请联系组织管理员"), ch)
case "channel_required":
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 当前组织已开启渠道管控")))
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。"))
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试"))
case "no_auth":
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效")))
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。"))
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("认证已失效,请执行 dws auth 重新登录"))
default:
// cli_not_enabled or unknown — show existing admin-apply flow
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
_, _ = fmt.Fprintln(p.output(), "")
// Try to get super admin list
admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
// Show up to 3 admins
maxAdmins := 3
if len(admins.Result) < maxAdmins {
maxAdmins = len(admins.Result)
admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
maxAdmins := 3
if len(admins.Result) < maxAdmins {
maxAdmins = len(admins.Result)
}
var adminNames []string
for i := 0; i < maxAdmins; i++ {
adminNames = append(adminNames, admins.Result[i].Name)
}
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
}
var adminNames []string
for i := 0; i < maxAdmins; i++ {
adminNames = append(adminNames, admins.Result[i].Name)
}
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("管理员操作入口:"), config.GetDeveloperSettingsURL())
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
}
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings"))
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
}
// Save token data with associated client ID for refresh
@@ -211,6 +271,13 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
// Always persist clientId to app.json so future process startups
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
if p.clientID != "" {
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
}
// Persist app credentials if using custom client credentials
oauthProvider.persistAppConfigIfNeeded()
@@ -279,7 +346,91 @@ func (p *DeviceFlowProvider) pollDeviceToken(ctx context.Context, deviceCode str
return &resp, nil
}
// pollDeviceStatus polls the terminal API for device authorization status.
//
// Note: The server returns success=false for REJECTED and EXPIRED terminal
// states (with a valid data.Status value). These are normal business outcomes,
// not transport errors, so we return the response to the caller and let the
// status-switch handle them.
func (p *DeviceFlowProvider) pollDeviceStatus(ctx context.Context, flowID string) (*DevicePollResponse, error) {
endpoint := fmt.Sprintf("%s%s?flowId=%s", p.terminalBaseURL, DevicePollPath, url.QueryEscape(flowID))
body, err := p.doGet(ctx, endpoint)
if err != nil {
return nil, err
}
var resp DevicePollResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("解析响应失败"), err)
}
// REJECTED/EXPIRED carry success=false but have a valid data.Status;
// only treat as a real server error when data.Status is empty.
if !resp.Success && resp.Data.Status == "" {
return nil, fmt.Errorf("%s: [%s] %s", i18n.T("服务端返回错误"), resp.Code, resp.Message)
}
return &resp, nil
}
func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
if auth.FlowID == "" {
// Keep the pre-flowId device-code polling path for regular device flow
// login responses that do not include terminal polling metadata.
return p.waitForAuthorizationByDeviceCode(ctx, auth)
}
return p.waitForAuthorizationByFlowID(ctx, auth)
}
func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
startTime := time.Now()
interval := time.Duration(auth.Interval) * time.Second
deadline := time.Duration(auth.ExpiresIn) * time.Second
pollCount := 0
for {
elapsed := time.Since(startTime)
if elapsed >= maxPollTotalWait || elapsed >= deadline {
_, _ = fmt.Fprintln(p.output(), "")
return nil, fmt.Errorf("%s", i18n.Tf("设备授权码已过期(%d 秒),请重试", auth.ExpiresIn))
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
pollCount++
elapsedSec := int(time.Since(startTime).Seconds())
dfPrintPollStatus(p.output(), pollCount, elapsedSec)
pollResp, err := p.pollDeviceStatus(ctx, auth.FlowID)
if err != nil {
dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试..."))
if p.logger != nil {
p.logger.Debug("poll error", "error", err)
}
continue
}
switch pollResp.Data.Status {
case StatusApproved:
dfPrintPollResult(p.output(), "authorized", i18n.T("授权成功!"))
return &DeviceTokenResponse{AuthCode: pollResp.Data.AuthCode}, nil
case StatusPending:
dfPrintPollResult(p.output(), "pending", i18n.T("等待用户授权..."))
case StatusRejected:
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("用户拒绝了授权请求"))
case StatusExpired:
_, _ = fmt.Fprintln(p.output(), "")
return nil, errors.New(i18n.T("设备授权码已过期"))
default:
dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知状态: %s"), pollResp.Data.Status))
}
}
}
func (p *DeviceFlowProvider) waitForAuthorizationByDeviceCode(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
startTime := time.Now()
interval := time.Duration(auth.Interval) * time.Second
deadline := time.Duration(auth.ExpiresIn) * time.Second
@@ -359,6 +510,29 @@ func (p *DeviceFlowProvider) postForm(ctx context.Context, endpoint string, para
return body, nil
}
// doGet performs an HTTP GET request and returns the response body.
func (p *DeviceFlowProvider) doGet(ctx context.Context, endpoint string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("创建请求失败"), err)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("发送请求失败"), err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("读取响应失败"), err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(body, 200))
}
return body, nil
}
// truncateBody returns a string of at most maxLen bytes from body, appending
// "...(truncated)" when the content exceeds the limit. This prevents leaking
// potentially sensitive response payloads in error messages.
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
// Device flow authorization status constants.
// Shared across device_flow.go and pat_auth_retry.go to avoid maintaining
// string literals in multiple places.
const (
StatusPending = "PENDING"
StatusApproved = "APPROVED"
StatusRejected = "REJECTED"
StatusExpired = "EXPIRED"
StatusCancelled = "CANCELLED"
)
// ParseDeviceFlowStatus normalizes a raw status string from the device flow
// poll response into a canonical status constant. When the server returns an
// empty status with success=false, it falls back to StatusExpired (server
// error / flow not found).
func ParseDeviceFlowStatus(rawStatus string, success bool) string {
switch rawStatus {
case StatusApproved, StatusRejected, StatusExpired, StatusPending, StatusCancelled:
return rawStatus
default:
if rawStatus == "" && !success {
return StatusExpired
}
return rawStatus
}
}
+192 -10
View File
@@ -14,15 +14,21 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
)
func newDeviceFlowTestLogger() *slog.Logger {
@@ -89,22 +95,42 @@ func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// New terminal API uses GET method
if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if !strings.Contains(r.URL.RawQuery, "flowId=") {
t.Fatal("flowId query parameter should be present")
}
if calls.Add(1) <= 2 {
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
// Return PENDING status
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]string{"status": "PENDING"},
})
return
}
writeServiceResult(w, true, DeviceTokenResponse{AuthCode: "final-auth-code"}, "", "")
// Return APPROVED status with authCode
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]string{
"status": "APPROVED",
"authCode": "final-auth-code",
},
})
}))
defer server.Close()
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
provider.Output = io.Discard
provider.SetBaseURL(server.URL)
provider.SetTerminalBaseURL(server.URL)
resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{
DeviceCode: "dc-1",
ExpiresIn: 10,
Interval: 1,
FlowID: "test-flow-id",
ExpiresIn: 10,
Interval: 1,
})
if err != nil {
t.Fatalf("waitForAuthorization() error = %v", err)
@@ -117,9 +143,87 @@ func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
}
}
func TestWaitForAuthorizationFallsBackToDeviceCodeWhenFlowIDMissing(t *testing.T) {
t.Parallel()
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm() error = %v", err)
}
if got := r.FormValue("device_code"); got != "legacy-device-code" {
t.Fatalf("device_code = %q, want legacy-device-code", got)
}
if calls.Add(1) <= 2 {
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
return
}
writeServiceResult(w, true, DeviceTokenResponse{AuthCode: "legacy-auth-code"}, "", "")
}))
defer server.Close()
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
var output bytes.Buffer
provider.Output = &output
provider.SetBaseURL(server.URL)
resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{
DeviceCode: "legacy-device-code",
ExpiresIn: 10,
Interval: 1,
})
if err != nil {
t.Fatalf("waitForAuthorization() error = %v", err)
}
if resp.AuthCode != "legacy-auth-code" {
t.Fatalf("auth code = %q, want legacy-auth-code", resp.AuthCode)
}
if calls.Load() != 3 {
t.Fatalf("poll calls = %d, want 3", calls.Load())
}
if !strings.Contains(output.String(), i18n.T("等待用户授权...")) {
t.Fatalf("expected device-code path to emit pending output, got %q", output.String())
}
if !strings.Contains(output.String(), i18n.T("授权成功!")) {
t.Fatalf("expected device-code path to emit success output, got %q", output.String())
}
}
func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// New terminal API uses GET method
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]string{"status": "PENDING"},
})
}))
defer server.Close()
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
provider.Output = io.Discard
provider.SetTerminalBaseURL(server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
if _, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
FlowID: "test-flow-id-2",
ExpiresIn: 60,
Interval: 1,
}); err == nil {
t.Fatal("waitForAuthorization() error = nil, want context cancellation")
}
}
func TestWaitForAuthorizationByDeviceCodeHonorsContextCancellation(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
}))
@@ -132,11 +236,89 @@ func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
if _, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
DeviceCode: "dc-2",
_, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
DeviceCode: "legacy-device-code",
ExpiresIn: 60,
Interval: 1,
}); err == nil {
t.Fatal("waitForAuthorization() error = nil, want context cancellation")
})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("waitForAuthorization() error = %v, want context deadline exceeded", err)
}
}
func TestWaitForAuthorizationByDeviceCodeErrorStates(t *testing.T) {
t.Parallel()
tests := []struct {
name string
timeout time.Duration
responses []DeviceTokenResponse
wantErr string
wantErrIs error
wantOutput string
}{
{
name: "slow_down_then_context_cancelled",
timeout: 1500 * time.Millisecond,
responses: []DeviceTokenResponse{{Error: "slow_down"}},
wantErrIs: context.DeadlineExceeded,
wantOutput: fmt.Sprintf(i18n.T("轮询过快,间隔增加至 %ds"), 6),
},
{
name: "access_denied",
timeout: 5 * time.Second,
responses: []DeviceTokenResponse{{Error: "access_denied"}},
wantErr: i18n.T("用户拒绝了授权请求"),
wantOutput: fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), 1, 1),
},
{
name: "expired_token",
timeout: 5 * time.Second,
responses: []DeviceTokenResponse{{Error: "expired_token"}},
wantErr: i18n.T("设备授权码已过期"),
wantOutput: fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), 1, 1),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idx := int(calls.Add(1)) - 1
if idx >= len(tt.responses) {
idx = len(tt.responses) - 1
}
writeServiceResult(w, true, tt.responses[idx], "", "")
}))
defer server.Close()
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
var output bytes.Buffer
provider.Output = &output
provider.SetBaseURL(server.URL)
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
defer cancel()
_, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
DeviceCode: "legacy-device-code",
ExpiresIn: 60,
Interval: 1,
})
if tt.wantErrIs != nil {
if !errors.Is(err, tt.wantErrIs) {
t.Fatalf("waitForAuthorization() error = %v, want %v", err, tt.wantErrIs)
}
} else if err == nil || err.Error() != tt.wantErr {
t.Fatalf("waitForAuthorization() error = %v, want %q", err, tt.wantErr)
}
if !strings.Contains(output.String(), tt.wantOutput) {
t.Fatalf("expected output to contain %q, got %q", tt.wantOutput, output.String())
}
})
}
}
+27
View File
@@ -19,6 +19,7 @@ import (
"strings"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -38,6 +39,11 @@ func init() {
DefaultValue: "(内置)",
Sensitive: true,
})
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_CHANNEL",
Category: configmeta.CategoryExternal,
Description: "第三方渠道编码 (channelCode),如 Qoderwork",
})
}
const (
@@ -78,6 +84,14 @@ const (
// DeviceGrantType is the grant_type value defined by RFC 8628.
DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
// Terminal API base URL for developer settings page.
DefaultTerminalBaseURL = "https://open-dev.dingtalk.com"
// DevicePollPath is the device flow polling path (used with MCP base URL).
DevicePollPath = "/cli/oauth/device/poll"
// DeveloperSettingsPath is the path to the organization developer settings page.
DeveloperSettingsPath = "/fe/old#/developerSettings"
LogoutURL = "https://login.dingtalk.com/oauth2/logout"
LogoutContinueURL = "https://login.dingtalk.com"
@@ -94,6 +108,19 @@ const (
MCPRevokeTokenPath = "/oauth2/revokeToken"
)
// GetTerminalBaseURL returns the terminal base URL with priority:
// 1. ~/.dws/terminal_url file content (for pre-release environment)
// 2. Default value (https://open-dev.dingtalk.com)
func GetTerminalBaseURL() string {
return config.GetTerminalBaseURL()
}
// GetDeveloperSettingsURL returns the full URL to the organization developer
// settings page, derived from the terminal base URL.
func GetDeveloperSettingsURL() string {
return config.GetDeveloperSettingsURL()
}
// GetMCPBaseURL returns the MCP base URL with priority:
// 1. ~/.dws/mcp_url file content (for pre-release environment)
// 2. Default value (https://mcp.dingtalk.com)
+264 -6
View File
@@ -21,6 +21,8 @@ import (
"io"
"net/http"
"net/url"
"os"
"slices"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
@@ -59,6 +61,19 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
return data, nil
}
// ExchangeCodeForToken exchanges an authorization code for token data using
// the currently configured client credentials. This is a convenience wrapper
// around OAuthProvider.exchangeCode for callers outside the auth package.
func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenData, error) {
p := &OAuthProvider{
configDir: configDir,
clientID: ClientID(),
Output: io.Discard,
httpClient: oauthHTTPClient,
}
return p.exchangeCode(ctx, code)
}
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
// This is used when client secret is not available (server-side secret management).
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
@@ -919,14 +934,254 @@ const notEnabledHTML = `<!doctype html>
</body>
</html>`
const accessDeniedHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>钉钉 CLI</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
padding: 20px;
}
.card {
height: 600px;
width: 480px;
border-radius: 16px;
background: #ffffff;
box-sizing: border-box;
border: 1px solid #f2f2f6;
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
padding: 32px 24px 24px;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.lock-icon {
width: 120px;
height: 120px;
margin: 0 auto;
object-fit: contain;
display: block;
}
h1 {
margin: 8px 0 0;
font-family:
"PingFang SC",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
font-size: 18px;
font-weight: 600;
line-height: 44px;
text-align: center;
letter-spacing: normal;
color: #181c1f;
}
p {
margin: 0;
font-family:
"PingFang SC",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
font-size: 14px;
font-weight: normal;
line-height: 21px;
text-align: center;
letter-spacing: normal;
color: rgba(24, 28, 31, 0.6);
}
</style>
</head>
<body>
<div class="card">
<img
class="lock-icon"
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
alt="lock icon"
/>
<h1>无权限访问</h1>
<p>您不在该组织的 CLI 授权人员范围内。请联系组织管理员将您加入授权名单。此页面可以关闭。</p>
</div>
</body>
</html>`
const channelDeniedHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>钉钉 CLI</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
padding: 20px;
}
.card {
height: 600px;
width: 480px;
border-radius: 16px;
background: #ffffff;
box-sizing: border-box;
border: 1px solid #f2f2f6;
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
padding: 32px 24px 24px;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.lock-icon {
width: 120px;
height: 120px;
margin: 0 auto;
object-fit: contain;
display: block;
}
h1 {
margin: 8px 0 0;
font-family:
"PingFang SC",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
font-size: 18px;
font-weight: 600;
line-height: 44px;
text-align: center;
letter-spacing: normal;
color: #181c1f;
}
p {
margin: 0;
font-family:
"PingFang SC",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
font-size: 14px;
font-weight: normal;
line-height: 21px;
text-align: center;
letter-spacing: normal;
color: rgba(24, 28, 31, 0.6);
}
</style>
</head>
<body>
<div class="card">
<img
class="lock-icon"
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
alt="lock icon"
/>
<h1>渠道未授权</h1>
<p>当前渠道未获得该组织授权,或组织已开启渠道管控。请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI。此页面可以关闭。</p>
</div>
</body>
</html>`
// CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
type CLIAuthStatus struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
} `json:"result"`
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result *CLIAuthResult `json:"result"`
}
// CLIAuthResult holds the business data returned by /cli/cliAuthEnabled.
// The server computes cliAuthEnabled by considering the org switch, userScope,
// and channelScope together; the CLI uses it as-is.
type CLIAuthResult struct {
CLIAuthEnabled bool `json:"cliAuthEnabled"`
UserScope string `json:"userScope,omitempty"` // "all" | "specified" | "forbidden"
AllowedUsers []string `json:"allowedUsers,omitempty"` // staffId list when userScope="specified"
ChannelScope string `json:"channelScope,omitempty"` // "all" | "specified"
AllowedChannels []string `json:"allowedChannels,omitempty"` // channelCode list when channelScope="specified"
ChannelConfigEnabled bool `json:"channelConfigEnabled,omitempty"` // whether org has any channel restriction configured
}
// classifyDenialReason inspects a CLIAuthStatus response and returns a machine-readable
// denial reason string. Returns "" when access is granted.
//
// Priority rationale:
// 1. Explicit org-wide ban (userScope=forbidden) always wins.
// 2. Channel scope is evaluated BEFORE user scope because the CLI has
// authoritative knowledge of DWS_CHANNEL and can verify membership against
// allowedChannels. This avoids falsely blaming the user when the real
// denial cause is a channel mismatch (e.g. user is in allowedUsers but the
// current channel is not in allowedChannels).
// 3. Only when the channel is unrestricted or matches do we attribute the
// denial to the user scope.
func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string {
if status.ErrorCode == "CHANNEL_REQUIRED" {
return "channel_required"
}
if status.ErrorCode == "NO_AUTH" {
return "no_auth"
}
if status.Result == nil || !status.Success {
return "unknown"
}
r := status.Result
if r.CLIAuthEnabled {
return ""
}
if r.UserScope == "forbidden" {
return "user_forbidden"
}
if r.ChannelScope == "specified" {
if currentChannel == "" {
return "channel_required"
}
if !slices.Contains(r.AllowedChannels, currentChannel) {
return "channel_not_allowed"
}
}
if r.UserScope == "specified" {
return "user_not_allowed"
}
return "cli_not_enabled"
}
// SuperAdmin represents a corp super admin.
@@ -985,6 +1240,9 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("x-user-access-token", accessToken)
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
req.Header.Set("x-dws-channel", ch)
}
client := p.httpClient
if client == nil {
+37 -8
View File
@@ -124,6 +124,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
token *TokenData
err error
cliAuthDisabled bool
denialReason string
}
resultCh := make(chan callbackResult, 1)
errCh := make(chan error, 1)
@@ -236,19 +237,30 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
// Check CLI auth enabled status (fail-closed: treat errors as disabled)
authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
var denialReason string
if statusErr != nil {
denialReason = "unknown"
} else {
denialReason = classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL"))
}
cliAuthEnabled := denialReason == ""
// Update CLI auth disabled state
callbackTokenMu.Lock()
callbackAuthDisabled = !cliAuthEnabled
callbackTokenMu.Unlock()
// Display appropriate HTML based on CLI auth status
// Display appropriate HTML based on auth status and denial reason
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if !cliAuthEnabled {
_, _ = fmt.Fprint(w, notEnabledHTML)
} else {
switch {
case cliAuthEnabled:
_, _ = fmt.Fprint(w, successHTML)
case denialReason == "user_forbidden" || denialReason == "user_not_allowed":
_, _ = fmt.Fprint(w, accessDeniedHTML)
case denialReason == "channel_not_allowed" || denialReason == "channel_required":
_, _ = fmt.Fprint(w, channelDeniedHTML)
default:
_, _ = fmt.Fprint(w, notEnabledHTML)
}
// Ensure response is flushed to client
if f, ok := w.(http.Flusher); ok {
@@ -256,7 +268,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Notify main goroutine with full result
select {
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled}:
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason}:
default:
}
})
@@ -395,8 +407,18 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), result.err)
}
// Handle CLI auth disabled - keep server running for user to apply
// Handle CLI auth disabled - for terminal denial reasons, exit immediately
// (page shows accessDeniedHTML/channelDeniedHTML with no apply button,
// so polling for apply submission would hang forever).
// Error messages are kept consistent with the text shown on the HTML pages.
if result.cliAuthDisabled {
switch result.denialReason {
case "user_forbidden", "user_not_allowed":
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单"))
case "channel_not_allowed", "channel_required":
return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI"))
}
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请..."))
@@ -435,7 +457,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
// Check if CLI auth is now enabled (admin approved)
if currentToken != nil {
authStatus, err := p.CheckCLIAuthEnabled(ctx, currentToken.AccessToken)
if err == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled {
if err == nil && classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) == "" {
_, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
time.Sleep(2 * time.Second)
result.token = currentToken
@@ -463,6 +485,13 @@ continueLogin:
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
// Always persist clientId to app.json so future process startups
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
if p.clientID != "" {
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
}
// Persist app credentials if using custom client credentials
p.persistAppConfigIfNeeded()
+11 -1
View File
@@ -47,6 +47,13 @@ func init() {
Example: "/path/to/catalog.json",
Hidden: true,
})
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_PLUGIN_COLD_TIMEOUT",
Category: configmeta.CategoryCore,
Description: "插件 MCP 冷启动发现的超时时长(Go duration 格式,如 2s / 1500ms)。设置后同时覆盖 HTTP 与 stdio 插件的冷启动预算;未设置时使用内置默认值(HTTP 无鉴权 1s / 有鉴权 1.5s / stdio 2s)。",
DefaultValue: "",
Example: "3s",
})
}
// CatalogDegradedReason identifies why catalog discovery returned empty.
@@ -104,10 +111,13 @@ func newCatalogDegraded(reason CatalogDegradedReason, serverCount int) *CatalogD
const (
CatalogFixtureEnv = "DWS_CATALOG_FIXTURE"
CacheDirEnv = "DWS_CACHE_DIR"
PluginColdTimeoutEnv = "DWS_PLUGIN_COLD_TIMEOUT"
DefaultMarketBaseURL = "https://mcp.dingtalk.com"
// defaultDiscoveryTimeout bounds the time spent on live registry discovery.
defaultDiscoveryTimeout = 10 * time.Second
// Tightened to 4s so a slow/unreachable discovery endpoint cannot block
// every CLI command invocation. See issue #119.
defaultDiscoveryTimeout = 4 * time.Second
)
type CatalogLoader interface {
+16 -2
View File
@@ -59,6 +59,11 @@ type Service struct {
Tenant string
AuthIdentity string
Logger *slog.Logger
// PerServerTimeout overrides the default per-server discovery timeout
// when greater than zero. Useful for tests and for callers that need a
// tighter or looser bound. When zero, defaultPerServerDiscoveryTimeout
// applies.
PerServerTimeout time.Duration
}
type RuntimeServer struct {
@@ -170,7 +175,11 @@ func (s *Service) DiscoverServerRuntime(ctx context.Context, server market.Serve
}, nil
}
const perServerDiscoveryTimeout = 5 * time.Second
// defaultPerServerDiscoveryTimeout bounds the time spent discovering tools on
// a single registry-listed server. Tightened to 2s so a slow/unreachable
// server cannot stall every CLI command — a healthy MCP endpoint negotiates
// well under a second. See issue #119.
const defaultPerServerDiscoveryTimeout = 2 * time.Second
func (s *Service) DiscoverAllRuntime(ctx context.Context, servers []market.ServerDescriptor) ([]RuntimeServer, []RuntimeFailure) {
type discoveryResult struct {
@@ -178,6 +187,11 @@ func (s *Service) DiscoverAllRuntime(ctx context.Context, servers []market.Serve
failure *RuntimeFailure
}
perServerTimeout := defaultPerServerDiscoveryTimeout
if s.PerServerTimeout > 0 {
perServerTimeout = s.PerServerTimeout
}
filtered := make([]market.ServerDescriptor, 0, len(servers))
for _, srv := range servers {
if !srv.CLI.Skip {
@@ -194,7 +208,7 @@ func (s *Service) DiscoverAllRuntime(ctx context.Context, servers []market.Serve
wg.Add(1)
go func(server market.ServerDescriptor) {
defer wg.Done()
serverCtx, cancel := context.WithTimeout(ctx, perServerDiscoveryTimeout)
serverCtx, cancel := context.WithTimeout(ctx, perServerTimeout)
defer cancel()
start := time.Now()
rs, err := s.DiscoverServerRuntime(serverCtx, server)
+4 -2
View File
@@ -20,6 +20,8 @@ import (
"fmt"
"io"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// Category represents a stable error class with a documented exit code.
@@ -276,7 +278,7 @@ func PrintJSON(w io.Writer, err error) error {
switch typed.ServerDiag.ServerErrorCode {
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
errorPayload["friendly_hint"] = "该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。"
errorPayload["action_url"] = "https://open-dev.dingtalk.com/fe/old#/developerSettings"
errorPayload["action_url"] = config.GetDeveloperSettingsURL()
}
}
if typed.ServerDiag.TechnicalDetail != "" {
@@ -342,7 +344,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
switch typed.ServerDiag.ServerErrorCode {
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。")
lines = append(lines, "Action: 开启地址: https://open-dev.dingtalk.com/fe/old#/developerSettings")
lines = append(lines, "Action: 开启地址: "+config.GetDeveloperSettingsURL())
}
if len(typed.Actions) > 0 {
+289
View File
@@ -0,0 +1,289 @@
// 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 errors
import (
"encoding/json"
stderrors "errors"
"fmt"
"strings"
)
// ExitCodePermission is the process exit code for PAT authorisation failures.
const ExitCodePermission = 4
// PATError represents a PAT (Personal Action Token) authorization failure
// that should be passed through to stderr as raw JSON without any CLI-layer
// wrapping. The host application (e.g. RewindDesktop) parses the JSON to
// display its own authorisation UI.
type PATError struct {
RawJSON string
}
func (e *PATError) Error() string { return e.RawJSON }
// ExitCode returns the documented exit code for PAT permission errors (4).
func (e *PATError) ExitCode() int { return ExitCodePermission }
// RawStderr returns the raw JSON to be written directly to stderr.
func (e *PATError) RawStderr() string { return e.RawJSON }
// patNoPermissionCodes are PAT error codes that should be passed through
// as transparent PATError without CLI-level wrapping.
var patNoPermissionCodes = map[string]bool{
"PAT_NO_PERMISSION": true,
"PAT_LOW_RISK_NO_PERMISSION": true,
"PAT_MEDIUM_RISK_NO_PERMISSION": true,
"PAT_HIGH_RISK_NO_PERMISSION": true,
}
// patAuthRequiredCodes are error codes that trigger the PAT authorization
// flow (e.g. the server auto-created a CLI app and returned auth details).
var patAuthRequiredCodes = map[string]bool{
"AGENT_CODE_NOT_EXISTS": true,
}
// IsPATError reports whether err is a *PATError.
func IsPATError(err error) bool {
_, ok := err.(*PATError)
return ok
}
// IsPATNoPermissionCode reports whether code is a known PAT permission error code.
func IsPATNoPermissionCode(code string) bool {
return patNoPermissionCodes[code]
}
// ---- DWS gateway auth errors (shared between PAT & general auth) ----------
// dwsGatewayErrors is the set of DWS gateway-level auth error codes.
var dwsGatewayErrors = map[string]bool{
"DWS_SERVICE_UNAUTHORIZED": true,
"DWS_AUTH_SERVICE_FAILED": true,
}
// getDWSGatewayErrorCode extracts a DWS gateway error code from errBody
// (supports both errorCode and error_code field names).
func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
for _, key := range []string{"errorCode", "error_code"} {
if code, ok := errBody[key].(string); ok && dwsGatewayErrors[code] {
return code, true
}
}
return "", false
}
// isNotLoggedInError checks if the error body indicates missing authentication.
func isNotLoggedInError(body map[string]any) bool {
if errMsg, ok := body["error"].(string); ok {
if strings.Contains(errMsg, "Missing service_id or access_key") {
return true
}
}
return false
}
// isBusinessError checks if a parsed JSON body represents a business-level error.
func isBusinessError(body map[string]any) bool {
if _, ok := body["error"].(string); ok {
return true
}
if v, ok := body["success"].(bool); ok && !v {
return true
}
if v, ok := body["success"].(string); ok && strings.EqualFold(v, "false") {
return true
}
return false
}
// ---- Classification functions -----------------------------------------------
// ClassifyToolResultContent checks a raw MCP tool result content map for
// DWS gateway auth errors and PAT permission error codes. This is intended
// for use as the edition.Hooks.ClassifyToolResult callback so the framework's
// runner returns a typed error before its generic business-error classification.
//
// Check order: DWS gateway auth > PAT permission.
func ClassifyToolResultContent(content map[string]any) error {
if _, ok := getDWSGatewayErrorCode(content); ok {
raw, _ := json.Marshal(content)
return NewAuth(string(raw),
WithReason("gateway_auth_expired"),
WithHint(authExpiredHint()),
)
}
for _, key := range []string{"code", "errorCode"} {
if code, ok := content[key].(string); ok && patNoPermissionCodes[code] {
return &PATError{RawJSON: cleanPATJSON(content, code)}
}
}
return nil
}
// ClassifyMCPResponseText classifies a text response returned by an MCP tool call.
// Returns a typed error for known gateway auth failures, PAT interceptions,
// and business-level errors embedded in HTTP-200 JSON bodies.
//
// Check order: DWS gateway > PAT permission > generic business error.
func ClassifyMCPResponseText(text string) error {
var body map[string]any
if json.Unmarshal([]byte(text), &body) != nil {
return nil
}
if _, ok := getDWSGatewayErrorCode(body); ok {
return NewAuth(text,
WithReason("gateway_auth_expired"),
WithHint(authExpiredHint()),
)
}
if isNotLoggedInError(body) {
return NewAuth("当前未登录",
WithReason("not_configured"),
WithHint(notLoggedInHint()),
WithActions("dws auth login"),
)
}
for _, key := range []string{"code", "errorCode"} {
if code, ok := body[key].(string); ok && patNoPermissionCodes[code] {
return &PATError{RawJSON: cleanPATJSON(body, code)}
}
}
if isBusinessError(body) {
return NewAPI(text,
WithReason("business_error"),
WithHint(suggestForBusinessErrorText(body)),
)
}
return nil
}
// ---- Hints -----------------------------------------------------------------
func authExpiredHint() string {
return "Re-authenticate: dws auth login"
}
func notLoggedInHint() string {
return "请先登录:dws auth login"
}
func suggestForBusinessErrorText(body map[string]any) string {
msg := ""
if v, ok := body["errorMsg"].(string); ok {
msg = v
} else if v, ok := body["message"].(string); ok {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
}
switch {
case strings.Contains(msg, "搜索内容不能为空"):
return "请提供非空搜索关键词: dws doc search --query \"关键词\""
case strings.Contains(msg, "User has no permission to access this email"):
return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list"
case strings.Contains(msg, "频率超限") || strings.Contains(msg, "rate limit"):
return "API rate limit exceeded, wait a moment and retry"
case strings.Contains(msg, "参数错误") || strings.Contains(msg, "param error"):
return "Check input parameters. Use --help for available flags"
default:
return "MCP tool returned a business error; check parameters and refer to skill documentation."
}
}
// ---- PAT JSON helpers ------------------------------------------------------
var patTopLevelStrip = map[string]bool{
"success": true, "code": true, "errorCode": true, "error_code": true,
"message": true, "error": true, "trace_id": true, "class": true,
}
func cleanPATJSON(body map[string]any, code string) string {
out := map[string]any{
"success": false,
"code": code,
}
if data, ok := body["data"]; ok {
out["data"] = stripClassFields(data)
} else {
fallback := map[string]any{}
for k, v := range body {
if !patTopLevelStrip[k] {
fallback[k] = v
}
}
if len(fallback) > 0 {
out["data"] = stripClassFields(fallback)
}
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return fmt.Sprintf(`{"success":false,"code":"%s"}`, code)
}
return string(b)
}
// ---- Runner adapter functions ------------------------------------------------
// These match the function signatures referenced by runner.go's PAT check
// framework (ClassifyPatAuthCheck / AsPatAuthCheckError).
// ClassifyPatAuthCheck is the open-source fallback that checks a tool-call
// Content map for PAT permission codes and auth-required codes. Returns a
// non-nil *PATError when the content carries a recognised PAT/auth error.
func ClassifyPatAuthCheck(content map[string]any) *PATError {
for _, key := range []string{"code", "errorCode"} {
if code, ok := content[key].(string); ok {
if patNoPermissionCodes[code] || patAuthRequiredCodes[code] {
return &PATError{RawJSON: cleanPATJSON(content, code)}
}
}
}
return nil
}
// AsPatAuthCheckError extracts a *PATError from an error chain.
func AsPatAuthCheckError(err error) *PATError {
var patErr *PATError
if stderrors.As(err, &patErr) {
return patErr
}
return nil
}
func stripClassFields(v any) any {
switch val := v.(type) {
case map[string]any:
clean := make(map[string]any, len(val))
for k, item := range val {
if k == "class" {
continue
}
clean[k] = stripClassFields(item)
}
return clean
case []any:
clean := make([]any, len(val))
for i, item := range val {
clean[i] = stripClassFields(item)
}
return clean
default:
return v
}
}
+521
View File
@@ -0,0 +1,521 @@
// 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 errors
import (
stderrors "errors"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// PATError basic behaviour
// ---------------------------------------------------------------------------
func TestPATError_Implements(t *testing.T) {
t.Parallel()
raw := `{"success":false,"code":"PAT_NO_PERMISSION"}`
pe := &PATError{RawJSON: raw}
if pe.Error() != raw {
t.Errorf("Error() = %q, want %q", pe.Error(), raw)
}
if pe.ExitCode() != ExitCodePermission {
t.Errorf("ExitCode() = %d, want %d", pe.ExitCode(), ExitCodePermission)
}
if pe.RawStderr() != raw {
t.Errorf("RawStderr() = %q, want %q", pe.RawStderr(), raw)
}
}
func TestIsPATError_True(t *testing.T) {
t.Parallel()
err := &PATError{RawJSON: "{}"}
if !IsPATError(err) {
t.Fatal("expected IsPATError to return true for *PATError")
}
}
func TestIsPATError_False(t *testing.T) {
t.Parallel()
err := stderrors.New("some other error")
if IsPATError(err) {
t.Fatal("expected IsPATError to return false for non-PATError")
}
}
// ---------------------------------------------------------------------------
// IsPATNoPermissionCode
// ---------------------------------------------------------------------------
func TestIsPATNoPermissionCode(t *testing.T) {
t.Parallel()
cases := []struct {
code string
want bool
}{
{"PAT_NO_PERMISSION", true},
{"PAT_LOW_RISK_NO_PERMISSION", true},
{"PAT_MEDIUM_RISK_NO_PERMISSION", true},
{"PAT_HIGH_RISK_NO_PERMISSION", true},
{"AGENT_CODE_NOT_EXISTS", false},
{"UNKNOWN_CODE", false},
{"", false},
}
for _, tc := range cases {
if got := IsPATNoPermissionCode(tc.code); got != tc.want {
t.Errorf("IsPATNoPermissionCode(%q) = %v, want %v", tc.code, got, tc.want)
}
}
}
// ---------------------------------------------------------------------------
// getDWSGatewayErrorCode
// ---------------------------------------------------------------------------
func TestGetDWSGatewayErrorCode_ErrorCode(t *testing.T) {
t.Parallel()
body := map[string]any{"errorCode": "DWS_SERVICE_UNAUTHORIZED"}
code, ok := getDWSGatewayErrorCode(body)
if !ok || code != "DWS_SERVICE_UNAUTHORIZED" {
t.Errorf("got (%q, %v), want (DWS_SERVICE_UNAUTHORIZED, true)", code, ok)
}
}
func TestGetDWSGatewayErrorCode_ErrorCodeUnderscore(t *testing.T) {
t.Parallel()
body := map[string]any{"error_code": "DWS_AUTH_SERVICE_FAILED"}
code, ok := getDWSGatewayErrorCode(body)
if !ok || code != "DWS_AUTH_SERVICE_FAILED" {
t.Errorf("got (%q, %v), want (DWS_AUTH_SERVICE_FAILED, true)", code, ok)
}
}
func TestGetDWSGatewayErrorCode_Unknown(t *testing.T) {
t.Parallel()
body := map[string]any{"errorCode": "SOME_OTHER_ERROR"}
_, ok := getDWSGatewayErrorCode(body)
if ok {
t.Fatal("expected ok=false for unknown error code")
}
}
func TestGetDWSGatewayErrorCode_Empty(t *testing.T) {
t.Parallel()
body := map[string]any{}
_, ok := getDWSGatewayErrorCode(body)
if ok {
t.Fatal("expected ok=false for empty body")
}
}
// ---------------------------------------------------------------------------
// isNotLoggedInError
// ---------------------------------------------------------------------------
func TestIsNotLoggedInError_True(t *testing.T) {
t.Parallel()
body := map[string]any{"error": "Missing service_id or access_key in request headers"}
if !isNotLoggedInError(body) {
t.Fatal("expected true for Missing service_id message")
}
}
func TestIsNotLoggedInError_False(t *testing.T) {
t.Parallel()
body := map[string]any{"error": "something else happened"}
if isNotLoggedInError(body) {
t.Fatal("expected false for unrelated error message")
}
}
func TestIsNotLoggedInError_NoErrorField(t *testing.T) {
t.Parallel()
body := map[string]any{"message": "Missing service_id or access_key"}
if isNotLoggedInError(body) {
t.Fatal("expected false when error field is absent")
}
}
// ---------------------------------------------------------------------------
// isBusinessError
// ---------------------------------------------------------------------------
func TestIsBusinessError_ErrorField(t *testing.T) {
t.Parallel()
body := map[string]any{"error": "some error message"}
if !isBusinessError(body) {
t.Fatal("expected true when 'error' field is present")
}
}
func TestIsBusinessError_SuccessBoolFalse(t *testing.T) {
t.Parallel()
body := map[string]any{"success": false}
if !isBusinessError(body) {
t.Fatal("expected true when success=false (bool)")
}
}
func TestIsBusinessError_SuccessStringFalse(t *testing.T) {
t.Parallel()
body := map[string]any{"success": "False"}
if !isBusinessError(body) {
t.Fatal("expected true when success=\"False\" (string)")
}
}
func TestIsBusinessError_SuccessTrue(t *testing.T) {
t.Parallel()
body := map[string]any{"success": true, "data": "ok"}
if isBusinessError(body) {
t.Fatal("expected false when success=true")
}
}
func TestIsBusinessError_EmptyBody(t *testing.T) {
t.Parallel()
body := map[string]any{"data": "hello"}
if isBusinessError(body) {
t.Fatal("expected false for body without error indicators")
}
}
// ---------------------------------------------------------------------------
// ClassifyToolResultContent
// ---------------------------------------------------------------------------
func TestClassifyToolResultContent_GatewayAuth(t *testing.T) {
t.Parallel()
content := map[string]any{"errorCode": "DWS_SERVICE_UNAUTHORIZED", "message": "expired"}
err := ClassifyToolResultContent(content)
if err == nil {
t.Fatal("expected non-nil error for gateway auth")
}
var typed *Error
if !stderrors.As(err, &typed) {
t.Fatalf("expected *Error, got %T", err)
}
if typed.Category != CategoryAuth {
t.Errorf("Category = %v, want %v", typed.Category, CategoryAuth)
}
if typed.Reason != "gateway_auth_expired" {
t.Errorf("Reason = %q, want gateway_auth_expired", typed.Reason)
}
}
func TestClassifyToolResultContent_PATPermission(t *testing.T) {
t.Parallel()
content := map[string]any{
"code": "PAT_NO_PERMISSION",
"data": map[string]any{"desc": "需要授权"},
}
err := ClassifyToolResultContent(content)
if err == nil {
t.Fatal("expected non-nil error for PAT permission")
}
var patErr *PATError
if !stderrors.As(err, &patErr) {
t.Fatalf("expected *PATError, got %T", err)
}
if !strings.Contains(patErr.RawJSON, "PAT_NO_PERMISSION") {
t.Errorf("RawJSON should contain PAT_NO_PERMISSION, got: %s", patErr.RawJSON)
}
}
func TestClassifyToolResultContent_NoError(t *testing.T) {
t.Parallel()
content := map[string]any{"success": true, "data": "ok"}
if err := ClassifyToolResultContent(content); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// ClassifyMCPResponseText
// ---------------------------------------------------------------------------
func TestClassifyMCPResponseText_GatewayAuth(t *testing.T) {
t.Parallel()
text := `{"errorCode":"DWS_SERVICE_UNAUTHORIZED","message":"token expired"}`
err := ClassifyMCPResponseText(text)
if err == nil {
t.Fatal("expected non-nil error")
}
var typed *Error
if !stderrors.As(err, &typed) {
t.Fatalf("expected *Error, got %T", err)
}
if typed.Reason != "gateway_auth_expired" {
t.Errorf("Reason = %q, want gateway_auth_expired", typed.Reason)
}
}
func TestClassifyMCPResponseText_NotLoggedIn(t *testing.T) {
t.Parallel()
text := `{"error":"Missing service_id or access_key in headers"}`
err := ClassifyMCPResponseText(text)
if err == nil {
t.Fatal("expected non-nil error")
}
var typed *Error
if !stderrors.As(err, &typed) {
t.Fatalf("expected *Error, got %T", err)
}
if typed.Reason != "not_configured" {
t.Errorf("Reason = %q, want not_configured", typed.Reason)
}
}
func TestClassifyMCPResponseText_PATPermission(t *testing.T) {
t.Parallel()
text := `{"code":"PAT_HIGH_RISK_NO_PERMISSION","data":{"desc":"high risk"}}`
err := ClassifyMCPResponseText(text)
if err == nil {
t.Fatal("expected non-nil error")
}
var patErr *PATError
if !stderrors.As(err, &patErr) {
t.Fatalf("expected *PATError, got %T", err)
}
if !strings.Contains(patErr.RawJSON, "PAT_HIGH_RISK_NO_PERMISSION") {
t.Errorf("RawJSON should contain code, got: %s", patErr.RawJSON)
}
}
func TestClassifyMCPResponseText_BusinessError(t *testing.T) {
t.Parallel()
text := `{"success":false,"errorMsg":"搜索内容不能为空"}`
err := ClassifyMCPResponseText(text)
if err == nil {
t.Fatal("expected non-nil error")
}
var typed *Error
if !stderrors.As(err, &typed) {
t.Fatalf("expected *Error, got %T", err)
}
if typed.Reason != "business_error" {
t.Errorf("Reason = %q, want business_error", typed.Reason)
}
if !strings.Contains(typed.Hint, "搜索关键词") {
t.Errorf("Hint should contain search suggestion, got: %s", typed.Hint)
}
}
func TestClassifyMCPResponseText_InvalidJSON(t *testing.T) {
t.Parallel()
text := "not json at all"
if err := ClassifyMCPResponseText(text); err != nil {
t.Fatalf("expected nil for invalid JSON, got %v", err)
}
}
func TestClassifyMCPResponseText_NoError(t *testing.T) {
t.Parallel()
text := `{"success":true,"data":"hello"}`
if err := ClassifyMCPResponseText(text); err != nil {
t.Fatalf("expected nil for success response, got %v", err)
}
}
// ---------------------------------------------------------------------------
// ClassifyPatAuthCheck
// ---------------------------------------------------------------------------
func TestClassifyPatAuthCheck_PATNoPermission(t *testing.T) {
t.Parallel()
content := map[string]any{"code": "PAT_NO_PERMISSION", "data": map[string]any{"flowId": "f1"}}
patErr := ClassifyPatAuthCheck(content)
if patErr == nil {
t.Fatal("expected non-nil *PATError")
}
if !strings.Contains(patErr.RawJSON, "PAT_NO_PERMISSION") {
t.Errorf("RawJSON should contain code, got: %s", patErr.RawJSON)
}
}
func TestClassifyPatAuthCheck_AgentCodeNotExists(t *testing.T) {
t.Parallel()
content := map[string]any{"errorCode": "AGENT_CODE_NOT_EXISTS", "data": map[string]any{"clientId": "c1"}}
patErr := ClassifyPatAuthCheck(content)
if patErr == nil {
t.Fatal("expected non-nil *PATError for AGENT_CODE_NOT_EXISTS")
}
if !strings.Contains(patErr.RawJSON, "AGENT_CODE_NOT_EXISTS") {
t.Errorf("RawJSON should contain AGENT_CODE_NOT_EXISTS, got: %s", patErr.RawJSON)
}
}
func TestClassifyPatAuthCheck_NoMatch(t *testing.T) {
t.Parallel()
content := map[string]any{"code": "SOME_BUSINESS_ERROR", "message": "oops"}
if patErr := ClassifyPatAuthCheck(content); patErr != nil {
t.Fatalf("expected nil, got %v", patErr)
}
}
func TestClassifyPatAuthCheck_EmptyContent(t *testing.T) {
t.Parallel()
content := map[string]any{}
if patErr := ClassifyPatAuthCheck(content); patErr != nil {
t.Fatalf("expected nil for empty content, got %v", patErr)
}
}
// ---------------------------------------------------------------------------
// AsPatAuthCheckError
// ---------------------------------------------------------------------------
func TestAsPatAuthCheckError_Wrapped(t *testing.T) {
t.Parallel()
inner := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
wrapped := stderrors.Join(stderrors.New("context"), inner)
got := AsPatAuthCheckError(wrapped)
if got == nil {
t.Fatal("expected non-nil *PATError from wrapped error")
}
if got.RawJSON != inner.RawJSON {
t.Errorf("RawJSON = %q, want %q", got.RawJSON, inner.RawJSON)
}
}
func TestAsPatAuthCheckError_NotPAT(t *testing.T) {
t.Parallel()
err := stderrors.New("just a plain error")
if got := AsPatAuthCheckError(err); got != nil {
t.Fatalf("expected nil for non-PAT error, got %v", got)
}
}
// ---------------------------------------------------------------------------
// cleanPATJSON
// ---------------------------------------------------------------------------
func TestCleanPATJSON_WithData(t *testing.T) {
t.Parallel()
body := map[string]any{
"success": false,
"code": "PAT_NO_PERMISSION",
"data": map[string]any{
"desc": "需要授权",
"flowId": "f123",
"class": "com.foo.Bar",
},
}
result := cleanPATJSON(body, "PAT_NO_PERMISSION")
if !strings.Contains(result, "PAT_NO_PERMISSION") {
t.Errorf("expected code in output, got: %s", result)
}
if !strings.Contains(result, "flowId") {
t.Errorf("expected flowId in data, got: %s", result)
}
if strings.Contains(result, "class") {
t.Errorf("expected class field to be stripped, got: %s", result)
}
}
func TestCleanPATJSON_WithoutData(t *testing.T) {
t.Parallel()
body := map[string]any{
"success": false,
"code": "PAT_NO_PERMISSION",
"message": "no permission",
"extra": "value",
}
result := cleanPATJSON(body, "PAT_NO_PERMISSION")
if !strings.Contains(result, "extra") {
t.Errorf("expected extra field in fallback data, got: %s", result)
}
// Top-level stripped fields should not appear
if strings.Contains(result, `"message"`) {
t.Errorf("expected message to be stripped from top level, got: %s", result)
}
}
// ---------------------------------------------------------------------------
// stripClassFields
// ---------------------------------------------------------------------------
func TestStripClassFields_Map(t *testing.T) {
t.Parallel()
input := map[string]any{
"name": "test",
"class": "com.foo.Bar",
"nested": map[string]any{
"value": 42,
"class": "com.baz.Qux",
},
}
result := stripClassFields(input).(map[string]any)
if _, ok := result["class"]; ok {
t.Error("top-level class should be removed")
}
nested := result["nested"].(map[string]any)
if _, ok := nested["class"]; ok {
t.Error("nested class should be removed")
}
if nested["value"] != 42 {
t.Errorf("nested value should be preserved, got %v", nested["value"])
}
}
func TestStripClassFields_Array(t *testing.T) {
t.Parallel()
input := []any{
map[string]any{"id": 1, "class": "Foo"},
map[string]any{"id": 2},
}
result := stripClassFields(input).([]any)
first := result[0].(map[string]any)
if _, ok := first["class"]; ok {
t.Error("class in array element should be removed")
}
if first["id"] != 1 {
t.Error("other fields in array element should be preserved")
}
}
func TestStripClassFields_Scalar(t *testing.T) {
t.Parallel()
if stripClassFields("hello") != "hello" {
t.Error("scalar string should pass through unchanged")
}
if stripClassFields(42) != 42 {
t.Error("scalar int should pass through unchanged")
}
}
// ---------------------------------------------------------------------------
// suggestForBusinessErrorText
// ---------------------------------------------------------------------------
func TestSuggestForBusinessErrorText(t *testing.T) {
t.Parallel()
cases := []struct {
body map[string]any
contains string
}{
{map[string]any{"errorMsg": "搜索内容不能为空"}, "搜索关键词"},
{map[string]any{"message": "User has no permission to access this email"}, "邮箱"},
{map[string]any{"error": "频率超限"}, "rate limit"},
{map[string]any{"errorMsg": "参数错误"}, "parameters"},
{map[string]any{"error": "unknown"}, "business error"},
}
for _, tc := range cases {
hint := suggestForBusinessErrorText(tc.body)
if !strings.Contains(strings.ToLower(hint), strings.ToLower(tc.contains)) {
t.Errorf("suggestForBusinessErrorText(%v) = %q, want to contain %q", tc.body, hint, tc.contains)
}
}
}
+22 -2
View File
@@ -162,7 +162,7 @@
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".",
" 组织主管理员:": " Organization super admins: ",
" 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.",
" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " Admin settings: https://open-dev.dingtalk.com/fe/old#/developerSettings",
"管理员操作入口:": "Admin settings: ",
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it",
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...",
"✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...",
@@ -170,5 +170,25 @@
"等待提交申请中": "Waiting to submit request",
"操作超时,请重新登录": "Operation timed out, please re-login",
"检查组织 CLI 授权状态...": "Checking organization CLI auth status...",
"🔐 登录钉钉": "🔐 Login to DingTalk"
"🔐 登录钉钉": "🔐 Login to DingTalk",
"插件管理": "Manage plugins",
"列出已安装的插件": "List installed plugins",
"安装插件": "Install a plugin",
"查看插件详情": "Show plugin details",
"启用插件": "Enable a plugin",
"禁用插件": "Disable a plugin",
"卸载已安装的插件": "Remove an installed plugin",
"校验 plugin.json": "Validate a plugin.json",
"脚手架生成新插件目录": "Scaffold a new plugin directory",
"将本地目录注册为开发态插件": "Register a local directory as a dev plugin",
"管理插件配置": "Manage plugin configuration",
"设置插件配置项": "Set a plugin config value",
"读取插件配置项": "Get a plugin config value",
"列出插件所有配置项": "List all config values for a plugin",
"删除插件配置项": "Remove a plugin config value",
"将插件 stdio server 编译为原生二进制": "Build plugin's stdio server into a native binary",
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "Override OAuth client ID (DingTalk AppKey)",
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "Override OAuth client secret (DingTalk AppSecret)",
"查看任意命令的帮助信息": "Help about any command",
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "Help provides help for any command in the application.\nSimply type dws help [path to command] for full details."
}
+22 -2
View File
@@ -162,7 +162,7 @@
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。",
" 组织主管理员:": " 组织主管理员:",
" 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。",
" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings",
"管理员操作入口:": "管理员操作入口:",
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
@@ -170,5 +170,25 @@
"等待提交申请中": "等待提交申请中",
"操作超时,请重新登录": "操作超时,请重新登录",
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
"🔐 登录钉钉": "🔐 登录钉钉"
"🔐 登录钉钉": "🔐 登录钉钉",
"插件管理": "插件管理",
"列出已安装的插件": "列出已安装的插件",
"安装插件": "安装插件",
"查看插件详情": "查看插件详情",
"启用插件": "启用插件",
"禁用插件": "禁用插件",
"卸载已安装的插件": "卸载已安装的插件",
"校验 plugin.json": "校验 plugin.json",
"脚手架生成新插件目录": "脚手架生成新插件目录",
"将本地目录注册为开发态插件": "将本地目录注册为开发态插件",
"管理插件配置": "管理插件配置",
"设置插件配置项": "设置插件配置项",
"读取插件配置项": "读取插件配置项",
"列出插件所有配置项": "列出插件所有配置项",
"删除插件配置项": "删除插件配置项",
"将插件 stdio server 编译为原生二进制": "将插件 stdio server 编译为原生二进制",
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "覆盖 OAuth 客户端 ID (钉钉 AppKey)",
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "覆盖 OAuth 客户端密钥 (钉钉 AppSecret)",
"查看任意命令的帮助信息": "查看任意命令的帮助信息",
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。"
}
+137
View File
@@ -0,0 +1,137 @@
// 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 pat
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/fatih/color"
"github.com/spf13/cobra"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
var validGrantTypes = map[string]bool{
"once": true,
"session": true,
"permanent": true,
}
func newChmodCommand(caller edition.ToolCaller) *cobra.Command {
chmodCmd := &cobra.Command{
Use: "chmod <scope>...",
Short: "授予指定权限",
Long: `授予指定 scope 的操作权限。
scope 格式: <product>.<entity>:<permission>
例: aitable.record:read chat.group:write calendar.event:read
grantType 规则:
once 一次性,执行一次后自动失效
session 当前会话有效(默认),需要 --session-id
permanent 永久有效`,
Args: cobra.MinimumNArgs(1),
Example: ` dws pat chmod aitable.record:read --agentCode agt-xxxx --grant-type session --session-id session-xxx
dws pat chmod chat.message:list --grant-type once --agentCode agt-xxxx
dws pat chmod aitable.record:read aitable.record:write --agentCode agt-xxxx --grant-type permanent`,
RunE: func(cmd *cobra.Command, args []string) error {
agentCode, _ := cmd.Flags().GetString("agentCode")
if agentCode == "" {
return fmt.Errorf("flag --agentCode is required\n hint: dws pat chmod <scope>... --agentCode <id>")
}
scopes := args
grantType, _ := cmd.Flags().GetString("grant-type")
sessionID, _ := cmd.Flags().GetString("session-id")
if !validGrantTypes[grantType] {
return fmt.Errorf("invalid --grant-type %q, must be one of: once, session, permanent", grantType)
}
if grantType == "session" && sessionID == "" && os.Getenv("DWS_SESSION_ID") == "" {
return fmt.Errorf("--session-id is required when --grant-type is session\n hint: dws pat chmod <scope> --agentCode <id> --grant-type session --session-id <id>")
}
if caller != nil && caller.DryRun() {
bold := color.New(color.FgYellow, color.Bold)
bold.Println("[DRY-RUN] Preview only, not executed:")
fmt.Printf("%-16s%s\n", "Tool:", "个人授权")
fmt.Printf("%-16s%s\n", "AgentCode:", agentCode)
fmt.Printf("%-16s%v\n", "Scope:", scopes)
fmt.Printf("%-16s%s\n", "GrantType:", grantType)
if sessionID != "" {
fmt.Printf("%-16s%s\n", "SessionID:", sessionID)
}
return nil
}
if caller == nil {
return fmt.Errorf("internal error: tool runtime not initialized")
}
toolArgs := map[string]any{
"agentCode": agentCode,
"scope": scopes,
"grantType": grantType,
}
if sessionID == "" {
sessionID = os.Getenv("DWS_SESSION_ID")
}
if sessionID != "" {
toolArgs["sessionId"] = sessionID
}
ctx := context.Background()
result, err := caller.CallTool(ctx, "pat", "个人授权", toolArgs)
if err != nil {
return fmt.Errorf("pat chmod failed: %w", err)
}
return handleToolResult(result)
},
}
chmodCmd.Flags().String("agentCode", "", "Agent 唯一标识(必填)")
_ = chmodCmd.MarkFlagRequired("agentCode")
chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent")
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
return chmodCmd
}
// handleToolResult processes a ToolResult and writes output to stdout.
func handleToolResult(result *edition.ToolResult) error {
if result == nil {
return fmt.Errorf("empty tool result")
}
for _, c := range result.Content {
if c.Type != "text" || c.Text == "" {
continue
}
if respErr := apperrors.ClassifyMCPResponseText(c.Text); respErr != nil {
return respErr
}
fmt.Println(c.Text)
return nil
}
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal result: %w", err)
}
fmt.Println(string(data))
return nil
}
+39
View File
@@ -0,0 +1,39 @@
// 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 pat implements the "dws pat" command group for PAT (Personal Action
// Token) authorization management.
package pat
import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// RegisterCommands adds the pat command tree to rootCmd.
func RegisterCommands(root *cobra.Command, c edition.ToolCaller) {
patCmd := &cobra.Command{
Use: "pat",
Short: "行为授权管理",
Long: `管理行为授权(PAT)。
命令结构:
dws pat chmod <scope>... 授予指定权限`,
RunE: cmdutil.GroupRunE,
}
patCmd.AddCommand(newChmodCommand(c))
root.AddCommand(patCmd)
}
-3
View File
@@ -131,9 +131,6 @@ func (p *Plugin) ToServerDescriptors() []market.ServerDescriptor {
}
source := "plugin"
if p.IsManaged {
source = "plugin-managed"
}
// Resolve headers: expand environment variable references (e.g. ${DASHSCOPE_API_KEY}).
var resolvedHeaders map[string]string
+45 -99
View File
@@ -56,13 +56,6 @@ type Settings struct {
DevPlugins map[string]string `json:"devPlugins,omitempty"` // name → absolute path
}
// LoadManaged scans ~/.dws/plugins/managed/ and returns all valid
// official plugins. Managed plugins are always enabled.
func (l *Loader) LoadManaged() []*Plugin {
managedDir := filepath.Join(l.PluginsDir, "managed")
return l.scanDir(managedDir, true)
}
// LoadUser scans ~/.dws/plugins/user/ and returns enabled user plugins.
func (l *Loader) LoadUser() []*Plugin {
userDir := filepath.Join(l.PluginsDir, "user")
@@ -86,7 +79,7 @@ func (l *Loader) LoadUser() []*Plugin {
// Check if this is a direct plugin directory (has plugin.json)
if _, err := os.Stat(filepath.Join(entryPath, "plugin.json")); err == nil {
p := l.loadPlugin(entryPath, false)
p := l.loadPlugin(entryPath)
if p != nil && isPluginEnabled(settings, p.Manifest.Name) {
plugins = append(plugins, p)
}
@@ -103,7 +96,7 @@ func (l *Loader) LoadUser() []*Plugin {
continue
}
subPath := filepath.Join(entryPath, sub.Name())
p := l.loadPlugin(subPath, false)
p := l.loadPlugin(subPath)
if p != nil {
qualifiedName := entry.Name() + "/" + p.Manifest.Name
if isPluginEnabled(settings, qualifiedName) {
@@ -115,39 +108,15 @@ func (l *Loader) LoadUser() []*Plugin {
return plugins
}
// LoadAll loads both managed and user plugins.
// LoadAll loads user + dev plugins.
func (l *Loader) LoadAll() []*Plugin {
managed := l.LoadManaged()
user := l.LoadUser()
return append(managed, user...)
}
// scanDir reads a directory of plugin subdirectories and loads each one.
func (l *Loader) scanDir(dir string, isManaged bool) []*Plugin {
entries, err := os.ReadDir(dir)
if err != nil {
if !os.IsNotExist(err) {
slog.Debug("plugin: cannot read dir", "path", dir, "error", err)
}
return nil
}
var plugins []*Plugin
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginDir := filepath.Join(dir, entry.Name())
p := l.loadPlugin(pluginDir, isManaged)
if p != nil {
plugins = append(plugins, p)
}
}
return plugins
dev := l.LoadDev()
return append(user, dev...)
}
// loadPlugin reads and validates a single plugin directory.
func (l *Loader) loadPlugin(dir string, isManaged bool) *Plugin {
func (l *Loader) loadPlugin(dir string) *Plugin {
manifestPath := filepath.Join(dir, "plugin.json")
manifest, err := ParseManifest(manifestPath)
if err != nil {
@@ -163,9 +132,8 @@ func (l *Loader) loadPlugin(dir string, isManaged bool) *Plugin {
}
return &Plugin{
Manifest: *manifest,
Root: dir,
IsManaged: isManaged,
Manifest: *manifest,
Root: dir,
}
}
@@ -211,7 +179,7 @@ func isPluginEnabled(s *Settings, name string) bool {
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"` // "managed" or "user"
Type string `json:"type"` // "user" or "dev"
Enabled bool `json:"enabled"`
Path string `json:"path"`
Description string `json:"description,omitempty"`
@@ -222,29 +190,6 @@ func (l *Loader) ListInstalled() []PluginInfo {
var result []PluginInfo
settings := l.loadSettings()
// Managed plugins
managedDir := filepath.Join(l.PluginsDir, "managed")
if entries, err := os.ReadDir(managedDir); err == nil {
for _, entry := range entries {
if !entry.IsDir() {
continue
}
dir := filepath.Join(managedDir, entry.Name())
m, err := ParseManifest(filepath.Join(dir, "plugin.json"))
if err != nil {
continue
}
result = append(result, PluginInfo{
Name: m.Name,
Version: m.Version,
Type: "managed",
Enabled: true, // managed plugins always enabled
Path: dir,
Description: m.Description,
})
}
}
// User plugins
userDir := filepath.Join(l.PluginsDir, "user")
if entries, err := os.ReadDir(userDir); err == nil {
@@ -348,9 +293,8 @@ func (l *Loader) InstallFromDir(srcDir string) (*Plugin, error) {
l.setPluginEnabled(manifest.Name, true)
return &Plugin{
Manifest: *manifest,
Root: destDir,
IsManaged: false,
Manifest: *manifest,
Root: destDir,
}, nil
}
@@ -386,16 +330,10 @@ func (l *Loader) InstallFromGit(gitURL string) (*Plugin, error) {
return nil, fmt.Errorf("plugin validation failed: %w", err)
}
// Determine install path based on workspace.
var destDir string
var isManaged bool
if workspace == config.OfficialPluginWorkspace {
destDir = filepath.Join(l.PluginsDir, config.PluginManagedDir, manifest.Name)
isManaged = true
} else {
destDir = filepath.Join(l.PluginsDir, config.PluginUserDir, workspace, manifest.Name)
isManaged = false
}
// All plugins install to the user directory with workspace nesting:
// ~/.dws/plugins/user/{workspace}/{name}/. There is no privileged
// workspace — every plugin is third-party.
destDir := filepath.Join(l.PluginsDir, config.PluginUserDir, workspace, manifest.Name)
// Remove .git directory before copying.
_ = os.RemoveAll(filepath.Join(cloneDir, ".git"))
@@ -413,15 +351,12 @@ func (l *Loader) InstallFromGit(gitURL string) (*Plugin, error) {
}
}
if !isManaged {
qualifiedName := workspace + "/" + manifest.Name
l.setPluginEnabled(qualifiedName, true)
}
qualifiedName := workspace + "/" + manifest.Name
l.setPluginEnabled(qualifiedName, true)
return &Plugin{
Manifest: *manifest,
Root: destDir,
IsManaged: isManaged,
Manifest: *manifest,
Root: destDir,
}, nil
}
@@ -471,14 +406,8 @@ func parseGitURL(gitURL string) (workspace, repoName string, err error) {
return segments[len(segments)-2], segments[len(segments)-1], nil
}
// RemovePlugin removes a user plugin. Returns an error if it's managed.
// RemovePlugin removes an installed plugin by name.
func (l *Loader) RemovePlugin(name string, keepData bool) error {
// Check managed first — official plugins cannot be removed.
managedDir := filepath.Join(l.PluginsDir, "managed", name)
if _, err := os.Stat(managedDir); err == nil {
return fmt.Errorf("%s is a managed plugin (DingTalk-Real-AI/%s) and cannot be removed.\n To disable it, run: dws plugin disable %s", name, name, name)
}
pluginDir := l.findUserPluginDir(name)
if pluginDir == "" {
return fmt.Errorf("plugin %q not found", name)
@@ -489,22 +418,39 @@ func (l *Loader) RemovePlugin(name string, keepData bool) error {
}
if !keepData {
dataDir := filepath.Join(l.PluginsDir, "data", name)
dataDir := filepath.Join(l.PluginsDir, config.PluginDataDir, name)
_ = os.RemoveAll(dataDir)
}
l.setPluginEnabled(name, false)
l.purgePluginFromSettings(name)
return nil
}
// purgePluginFromSettings removes all traces of a plugin from settings.json:
// its enabled flag and any persisted pluginConfigs entry. Called after
// RemovePlugin succeeds so settings.json does not retain dangling state for
// plugins that no longer exist on disk.
func (l *Loader) purgePluginFromSettings(name string) {
settings := l.loadSettings()
changed := false
if _, ok := settings.EnabledPlugins[name]; ok {
delete(settings.EnabledPlugins, name)
changed = true
}
if _, ok := settings.PluginConfigs[name]; ok {
delete(settings.PluginConfigs, name)
changed = true
}
if !changed {
return
}
l.saveSettings(settings)
}
// SetEnabled enables or disables a plugin in settings.json.
func (l *Loader) SetEnabled(name string, enabled bool) error {
// Verify plugin exists
if l.findUserPluginDir(name) == "" {
managedDir := filepath.Join(l.PluginsDir, "managed", name)
if _, err := os.Stat(managedDir); err != nil {
return fmt.Errorf("plugin %q not found", name)
}
return fmt.Errorf("plugin %q not found", name)
}
l.setPluginEnabled(name, enabled)
return nil
@@ -678,7 +624,7 @@ func (l *Loader) LoadDev() []*Plugin {
"name", name, "dir", dir)
continue
}
p := l.loadPlugin(dir, false)
p := l.loadPlugin(dir)
if p != nil {
plugins = append(plugins, p)
slog.Debug("plugin: loaded dev plugin", "name", name, "dir", dir)
+2 -3
View File
@@ -93,9 +93,8 @@ type HookEntry struct {
// Plugin is a loaded, validated plugin ready for injection.
type Plugin struct {
Manifest Manifest
Root string // absolute path to plugin directory
IsManaged bool // true for official (DingTalk-Real-AI) plugins
Manifest Manifest
Root string // absolute path to plugin directory
}
// ParseManifest reads and parses a plugin.json file.
+36 -77
View File
@@ -17,7 +17,6 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -200,8 +199,7 @@ func TestPluginToServerDescriptors(t *testing.T) {
},
},
},
Root: "/tmp/plugins/conference",
IsManaged: true,
Root: "/tmp/plugins/conference",
}
descriptors := p.ToServerDescriptors()
@@ -218,8 +216,8 @@ func TestPluginToServerDescriptors(t *testing.T) {
if d.Endpoint != "https://mcp.conference.dingtalk.com" {
t.Errorf("endpoint = %q", d.Endpoint)
}
if d.Source != "plugin-managed" {
t.Errorf("source = %q, want plugin-managed", d.Source)
if d.Source != "plugin" {
t.Errorf("source = %q, want plugin", d.Source)
}
if d.CLI.ID != "conference" {
t.Errorf("cli.id = %q, want conference", d.CLI.ID)
@@ -274,7 +272,7 @@ func TestPluginToServerDescriptorsWithHeaders(t *testing.T) {
t.Errorf("AuthHeaders[X-Custom] = %q, want static-value", d.AuthHeaders["X-Custom"])
}
if d.Source != "plugin" {
t.Errorf("source = %q, want plugin (non-managed)", d.Source)
t.Errorf("source = %q, want plugin", d.Source)
}
}
@@ -355,70 +353,57 @@ func TestLoaderScanEmpty(t *testing.T) {
CLIVersion: "1.0.0",
}
managed := loader.LoadManaged()
if len(managed) != 0 {
t.Errorf("expected 0 managed plugins, got %d", len(managed))
}
user := loader.LoadUser()
if len(user) != 0 {
t.Errorf("expected 0 user plugins, got %d", len(user))
}
}
func TestLoaderLoadManaged(t *testing.T) {
// TestRemovePluginPurgesSettings verifies RemovePlugin fully purges the
// plugin's settings — both its enabled flag and any pluginConfigs entry —
// so settings.json does not retain dangling state for a plugin that no
// longer exists on disk.
func TestRemovePluginPurgesSettings(t *testing.T) {
const pkgName = "my-plugin"
dir := t.TempDir()
managedDir := filepath.Join(dir, "managed", "conference")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
pluginDir := filepath.Join(dir, "user", pkgName)
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{
"name": "conference",
"version": "1.0.0",
"type": "managed",
"mcpServers": {
"conference": {
"type": "streamable-http",
"endpoint": "https://example.com"
}
}
}`
if err := os.WriteFile(filepath.Join(managedDir, "plugin.json"), []byte(manifest), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"),
[]byte(`{"name":"`+pkgName+`","version":"1.0.0"}`), 0o644); err != nil {
t.Fatal(err)
}
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
plugins := loader.LoadManaged()
if len(plugins) != 1 {
t.Fatalf("expected 1 managed plugin, got %d", len(plugins))
// Seed settings.json with an explicit enabled flag and a
// pluginConfigs entry to verify both get purged.
settings := &Settings{
EnabledPlugins: map[string]bool{pkgName: true, "other-plugin": true},
PluginConfigs: map[string]map[string]any{
pkgName: {"API_KEY": "secret"},
"other-plugin": {"TOKEN": "keep-me"},
},
}
if plugins[0].Manifest.Name != "conference" {
t.Errorf("name = %q, want conference", plugins[0].Manifest.Name)
}
if !plugins[0].IsManaged {
t.Error("expected IsManaged = true")
}
}
loader.saveSettings(settings)
func TestRemoveManagedPluginBlocked(t *testing.T) {
dir := t.TempDir()
managedDir := filepath.Join(dir, "managed", "conference")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(managedDir, "plugin.json"), []byte(`{"name":"conference","version":"1.0.0"}`), 0o644); err != nil {
t.Fatal(err)
if err := loader.RemovePlugin(pkgName, false); err != nil {
t.Fatalf("RemovePlugin: %v", err)
}
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
err := loader.RemovePlugin("conference", false)
if err == nil {
t.Fatal("expected error when removing managed plugin")
reloaded := loader.loadSettings()
if _, exists := reloaded.EnabledPlugins[pkgName]; exists {
t.Errorf("EnabledPlugins should not retain removed plugin %q", pkgName)
}
if !contains(err.Error(), "managed plugin") {
t.Errorf("error message should mention managed plugin, got: %v", err)
if _, exists := reloaded.PluginConfigs[pkgName]; exists {
t.Errorf("PluginConfigs should not retain removed plugin %q", pkgName)
}
if !reloaded.EnabledPlugins["other-plugin"] {
t.Error("unrelated EnabledPlugins entry should be preserved")
}
if reloaded.PluginConfigs["other-plugin"]["TOKEN"] != "keep-me" {
t.Error("unrelated PluginConfigs entry should be preserved")
}
}
@@ -496,32 +481,6 @@ func TestParseGitURL(t *testing.T) {
}
}
func TestPromptUpdate(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{"empty = yes", "\n", true},
{"y = yes", "y\n", true},
{"Y = yes", "Y\n", true},
{"yes = yes", "yes\n", true},
{"n = no", "n\n", false},
{"no = no", "no\n", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
r := strings.NewReader(tt.input)
got := promptUpdate(&buf, r, "test-plugin", "1.0.0", "2.0.0", "")
if got != tt.want {
t.Errorf("promptUpdate() = %v, want %v", got, tt.want)
}
})
}
}
func TestDevPluginRegistration(t *testing.T) {
dir := t.TempDir()
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
-430
View File
@@ -1,430 +0,0 @@
// 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 plugin
import (
"archive/zip"
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// pluginDownloadEndpoint is the API endpoint for downloading plugin updates.
const pluginDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
// lastCheckFileName stores the last update check timestamp.
const lastCheckFileName = ".last-update-check"
// pluginDownloadTimeout is the timeout for plugin download operations.
const pluginDownloadTimeout = 5 * time.Minute
// Updater checks and applies updates for managed plugins.
type Updater struct {
PluginsDir string
CLIVersion string
Platform string // e.g. "darwin-arm64", "linux-amd64"
mu sync.Mutex
}
// NewUpdater creates an Updater with auto-detected platform.
func NewUpdater(pluginsDir, cliVersion string) *Updater {
return &Updater{
PluginsDir: pluginsDir,
CLIVersion: cliVersion,
Platform: runtime.GOOS + "-" + runtime.GOARCH,
}
}
// remoteVersionInfo holds version metadata returned by the download API.
type remoteVersionInfo struct {
Version string `json:"version"`
DownloadURL string `json:"downloadUrl"`
FileName string `json:"fileName"`
Changelog string `json:"changelog,omitempty"`
}
// pluginDownloadResponse represents the API response from the plugin
// download endpoint.
type pluginDownloadResponse struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Result *remoteVersionInfo `json:"result,omitempty"`
}
// CheckAndUpdate checks for updates for all managed plugins.
// It reads a last-check timestamp file to avoid checking too frequently.
// Returns the list of updated plugin names.
func (u *Updater) CheckAndUpdate(ctx context.Context, accessToken string, w io.Writer) []string {
u.mu.Lock()
defer u.mu.Unlock()
if !u.shouldCheck() {
slog.Debug("plugin: skipping update check (checked recently)")
return nil
}
managedDir := filepath.Join(u.PluginsDir, config.PluginManagedDir)
entries, err := os.ReadDir(managedDir)
if err != nil {
if !os.IsNotExist(err) {
slog.Warn("plugin: cannot read managed dir for update check",
"path", managedDir, "error", err)
}
u.recordCheckTime()
return nil
}
var updated []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginDir := filepath.Join(managedDir, entry.Name())
pluginName := config.OfficialPluginWorkspace + "/" + entry.Name()
result := u.checkAndUpdateOne(ctx, accessToken, pluginDir, pluginName, w)
if result != "" {
updated = append(updated, result)
}
}
u.recordCheckTime()
return updated
}
// EnsureManaged checks that every plugin in config.DefaultManagedPlugins
// exists locally under ~/.dws/plugins/managed/. Missing plugins are
// downloaded from the remote API and extracted automatically.
// This runs once on first launch (or after a user deletes the managed dir).
func (u *Updater) EnsureManaged(ctx context.Context, accessToken string, w io.Writer) []string {
if len(config.DefaultManagedPlugins) == 0 {
return nil
}
managedDir := filepath.Join(u.PluginsDir, config.PluginManagedDir)
var installed []string
for _, shortName := range config.DefaultManagedPlugins {
pluginDir := filepath.Join(managedDir, shortName)
// Already exists locally — skip.
if _, err := os.Stat(filepath.Join(pluginDir, "plugin.json")); err == nil {
continue
}
qualifiedName := config.OfficialPluginWorkspace + "/" + shortName
fmt.Fprintf(w, "📦 Pulling built-in plugin %s ...\n", qualifiedName)
remote, err := u.checkRemoteVersion(ctx, accessToken, qualifiedName)
if err != nil {
slog.Warn("plugin: failed to fetch remote info for default plugin",
"plugin", qualifiedName, "error", err)
fmt.Fprintf(w, " ⚠️ Failed to fetch %s info: %v\n", qualifiedName, err)
continue
}
if remote == nil || remote.DownloadURL == "" {
slog.Warn("plugin: no download URL for default plugin",
"plugin", qualifiedName)
fmt.Fprintf(w, " ⚠️ No version available for %s\n", qualifiedName)
continue
}
if err := u.downloadAndInstall(ctx, remote.DownloadURL, pluginDir); err != nil {
slog.Warn("plugin: failed to install default plugin",
"plugin", qualifiedName, "error", err)
fmt.Fprintf(w, " ❌ Failed to install %s: %v\n", qualifiedName, err)
continue
}
fmt.Fprintf(w, " ✅ Installed %s (%s)\n", qualifiedName, remote.Version)
installed = append(installed, qualifiedName)
}
return installed
}
// checkAndUpdateOne checks and potentially updates a single managed plugin.
func (u *Updater) checkAndUpdateOne(
ctx context.Context,
accessToken, pluginDir, pluginName string,
w io.Writer,
) string {
manifest, err := ParseManifest(filepath.Join(pluginDir, "plugin.json"))
if err != nil {
slog.Warn("plugin: cannot parse manifest for update check",
"plugin", pluginName, "error", err)
return ""
}
remote, err := u.checkRemoteVersion(ctx, accessToken, pluginName)
if err != nil {
slog.Warn("plugin: failed to check remote version",
"plugin", pluginName, "error", err)
return ""
}
if remote == nil || remote.Version == "" || remote.DownloadURL == "" {
slog.Debug("plugin: no remote version info available",
"plugin", pluginName)
return ""
}
if compareSemver(remote.Version, manifest.Version) <= 0 {
slog.Debug("plugin: already up to date",
"plugin", pluginName,
"local", manifest.Version,
"remote", remote.Version)
return ""
}
if !promptUpdate(w, os.Stdin, pluginName, manifest.Version, remote.Version, remote.Changelog) {
return ""
}
if err := u.downloadAndInstall(ctx, remote.DownloadURL, pluginDir); err != nil {
slog.Warn("plugin: failed to download and install update",
"plugin", pluginName, "error", err)
fmt.Fprintf(w, " Update failed: %v\n", err)
return ""
}
fmt.Fprintf(w, " ✅ Updated %s to %s\n", pluginName, remote.Version)
return pluginName
}
// shouldCheck returns true if enough time has elapsed since the last check.
func (u *Updater) shouldCheck() bool {
checkFile := filepath.Join(u.PluginsDir, lastCheckFileName)
data, err := os.ReadFile(checkFile)
if err != nil {
return true
}
lastCheck, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data)))
if err != nil {
return true
}
return time.Since(lastCheck) >= config.PluginUpdateCheckInterval
}
// recordCheckTime writes the current time to the last-check file.
func (u *Updater) recordCheckTime() {
checkFile := filepath.Join(u.PluginsDir, lastCheckFileName)
_ = os.MkdirAll(filepath.Dir(checkFile), config.DirPerm)
_ = os.WriteFile(checkFile, []byte(time.Now().Format(time.RFC3339)), config.FilePerm)
}
// checkRemoteVersion queries the aihub API for the latest version.
func (u *Updater) checkRemoteVersion(ctx context.Context, accessToken, pluginName string) (*remoteVersionInfo, error) {
apiURL := fmt.Sprintf("%s?pluginName=%s&platform=%s",
pluginDownloadEndpoint,
url.QueryEscape(pluginName),
url.QueryEscape(u.Platform),
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-user-access-token", accessToken)
client := &http.Client{Timeout: config.HTTPTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("check remote version: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download API returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var result pluginDownloadResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if !result.Success {
errMsg := result.ErrorMsg
if errMsg == "" {
errMsg = result.ErrorCode
}
if errMsg == "" {
errMsg = "unknown error"
}
return nil, fmt.Errorf("API error: %s", errMsg)
}
return result.Result, nil
}
// downloadAndInstall downloads a plugin zip and extracts it, replacing
// the previous version.
func (u *Updater) downloadAndInstall(ctx context.Context, downloadURL, pluginDir string) error {
tempFile, err := os.CreateTemp("", "dws-plugin-update-*.zip")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tempPath := tempFile.Name()
defer os.Remove(tempPath)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
tempFile.Close()
return fmt.Errorf("create download request: %w", err)
}
client := &http.Client{Timeout: pluginDownloadTimeout}
resp, err := client.Do(req)
if err != nil {
tempFile.Close()
return fmt.Errorf("download plugin: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
tempFile.Close()
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
if _, err := io.Copy(tempFile, resp.Body); err != nil {
tempFile.Close()
return fmt.Errorf("write temp file: %w", err)
}
tempFile.Close()
// Remove old plugin directory contents before extracting.
if err := os.RemoveAll(pluginDir); err != nil {
return fmt.Errorf("remove old plugin: %w", err)
}
if err := extractPluginZip(tempPath, pluginDir); err != nil {
return fmt.Errorf("extract plugin: %w", err)
}
return nil
}
// extractPluginZip extracts a zip archive to the destination directory
// with zip slip protection.
func extractPluginZip(zipPath, destDir string) error {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
reader, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("open zip: %w", err)
}
defer reader.Close()
cleanDest := filepath.Clean(destDir) + string(os.PathSeparator)
for _, file := range reader.File {
filePath := filepath.Join(destDir, file.Name)
if !strings.HasPrefix(filepath.Clean(filePath), cleanDest) {
return fmt.Errorf("invalid file path in zip: %s", file.Name)
}
// Reject symlinks in ZIP to prevent path traversal attacks.
if file.FileInfo().Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinks are not allowed in plugin zip: %s", file.Name)
}
if file.FileInfo().IsDir() {
if err := os.MkdirAll(filePath, 0o755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
return fmt.Errorf("create parent directory: %w", err)
}
if err := extractOneFile(file, filePath); err != nil {
return err
}
}
return nil
}
// extractOneFile extracts one file from a zip archive to disk.
func extractOneFile(file *zip.File, destPath string) error {
srcFile, err := file.Open()
if err != nil {
return fmt.Errorf("open file in zip: %w", err)
}
defer srcFile.Close()
fileMode := file.Mode()
if fileMode&0o600 == 0 {
fileMode = 0o644
}
destFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer destFile.Close()
if _, err := io.Copy(destFile, srcFile); err != nil {
return fmt.Errorf("extract file: %w", err)
}
return nil
}
// promptUpdate asks the user for confirmation before applying an update.
// Returns true if the user accepts (Y or empty input means yes).
func promptUpdate(w io.Writer, r io.Reader, pluginName, oldVer, newVer, changelog string) bool {
fmt.Fprintf(w, "🔄 %s %s → %s", pluginName, oldVer, newVer)
if changelog != "" {
fmt.Fprintf(w, "\n %s", changelog)
}
fmt.Fprintf(w, "\n Update? [Y/n] ")
scanner := bufio.NewScanner(r)
if !scanner.Scan() {
return false // EOF or error: non-interactive, skip
}
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
return answer == "" || answer == "y" || answer == "yes"
}
-188
View File
@@ -1,188 +0,0 @@
// 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 plugin
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// makePluginZip creates an in-memory zip containing a valid plugin.json.
func makePluginZip(t *testing.T, name, version string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
manifest := map[string]any{
"name": name,
"version": version,
"mcpServers": map[string]any{
name: map[string]any{
"type": "streamable-http",
"endpoint": "https://example.com/" + name,
},
},
}
data, _ := json.Marshal(manifest)
f, err := w.Create("plugin.json")
if err != nil {
t.Fatal(err)
}
if _, err := f.Write(data); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestEnsureManaged_PullsMissing(t *testing.T) {
pluginName := "conference"
zipData := makePluginZip(t, pluginName, "1.0.0")
// Serve the zip file.
zipServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/zip")
w.Write(zipData)
}))
defer zipServer.Close()
// Serve the download API returning the zip URL.
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := pluginDownloadResponse{
Success: true,
Result: &remoteVersionInfo{
Version: "1.0.0",
DownloadURL: zipServer.URL + "/conference.zip",
},
}
json.NewEncoder(w).Encode(resp)
}))
defer apiServer.Close()
// Override the download endpoint for this test.
origEndpoint := pluginDownloadEndpoint
defer func() {
// pluginDownloadEndpoint is a const, so we use a workaround:
// we won't restore it — instead we accept the const limitation
// and test via a helper that injects the endpoint.
_ = origEndpoint
}()
tmpDir := t.TempDir()
u := &Updater{
PluginsDir: tmpDir,
CLIVersion: "1.0.0",
Platform: "darwin-arm64",
}
// Patch checkRemoteVersion by using a custom updater method —
// since checkRemoteVersion uses the const endpoint, we test
// downloadAndInstall + EnsureManaged logic directly.
managedDir := filepath.Join(tmpDir, config.PluginManagedDir)
// Verify plugin does not exist yet.
pluginDir := filepath.Join(managedDir, pluginName)
if _, err := os.Stat(filepath.Join(pluginDir, "plugin.json")); err == nil {
t.Fatal("plugin should not exist before EnsureManaged")
}
// Simulate what EnsureManaged does: downloadAndInstall for missing plugin.
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
err := u.downloadAndInstall(context.Background(), zipServer.URL+"/conference.zip", pluginDir)
if err != nil {
t.Fatalf("downloadAndInstall: %v", err)
}
// Verify plugin.json was extracted.
m, err := ParseManifest(filepath.Join(pluginDir, "plugin.json"))
if err != nil {
t.Fatalf("ParseManifest after install: %v", err)
}
if m.Name != pluginName {
t.Errorf("name = %q, want %q", m.Name, pluginName)
}
if m.Version != "1.0.0" {
t.Errorf("version = %q, want 1.0.0", m.Version)
}
}
func TestEnsureManaged_SkipsExisting(t *testing.T) {
tmpDir := t.TempDir()
managedDir := filepath.Join(tmpDir, config.PluginManagedDir)
// Pre-create the plugin directory with a valid manifest.
pluginDir := filepath.Join(managedDir, "conference")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"conference","version":"1.0.0","mcpServers":{"conference":{"type":"streamable-http","endpoint":"https://example.com"}}}`
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
u := &Updater{
PluginsDir: tmpDir,
CLIVersion: "1.0.0",
Platform: "darwin-arm64",
}
var output bytes.Buffer
// EnsureManaged should not attempt any download (no token needed since it skips).
installed := u.EnsureManaged(context.Background(), "fake-token", &output)
if len(installed) != 0 {
t.Errorf("expected 0 installs for existing plugin, got %d: %v", len(installed), installed)
}
// Should produce no output since nothing was downloaded.
if strings.Contains(output.String(), "Pulling") {
t.Errorf("unexpected download attempt for existing plugin: %s", output.String())
}
}
func TestExtractPluginZip_ZipSlipProtection(t *testing.T) {
// Create a zip with a path traversal entry.
var buf bytes.Buffer
w := zip.NewWriter(&buf)
f, _ := w.Create("../../etc/passwd")
f.Write([]byte("malicious"))
w.Close()
tmpZip := filepath.Join(t.TempDir(), "bad.zip")
os.WriteFile(tmpZip, buf.Bytes(), 0o644)
destDir := filepath.Join(t.TempDir(), "dest")
err := extractPluginZip(tmpZip, destDir)
if err == nil {
t.Fatal("expected zip slip error, got nil")
}
if !strings.Contains(err.Error(), "invalid file path") {
t.Errorf("unexpected error: %v", err)
}
}
+28 -3
View File
@@ -212,7 +212,7 @@ func (r *ToolCallResult) UnmarshalJSON(data []byte) error {
func defaultTransport() *http.Transport {
return &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
@@ -298,6 +298,7 @@ func SupportedProtocolVersions() []string {
}
func (c *Client) Initialize(ctx context.Context, endpoint string) (InitializeResult, error) {
var lastErr error
for _, version := range SupportedProtocolVersions() {
params := map[string]any{
"capabilities": map[string]any{},
@@ -309,18 +310,33 @@ func (c *Client) Initialize(ctx context.Context, endpoint string) (InitializeRes
}
var payload InitializeResult
if err := c.callJSONRPC(ctx, endpoint, requestEnvelope{
err := c.callJSONRPC(ctx, endpoint, requestEnvelope{
JSONRPC: "2.0",
ID: 1,
Method: "initialize",
Params: params,
}, true, &payload); err == nil {
}, true, &payload)
if err == nil {
if payload.ProtocolVersion == "" {
payload.ProtocolVersion = version
}
payload.RequestedProtocolVersion = version
return payload, nil
}
lastErr = err
// Only protocol-level JSON-RPC errors justify trying another version.
// Transport/HTTP failures (dial timeout, connection refused, HTTP 5xx,
// etc.) fail identically regardless of protocol version, so looping
// over three versions only multiplies the dial cost — e.g. on an
// unreachable endpoint, three 3-second dials amount to 9 seconds
// before the outer context surrenders.
var callErr *CallError
if !errors.As(err, &callErr) || callErr.Stage != CallStageJSONRPC {
return InitializeResult{}, err
}
}
if lastErr != nil {
return InitializeResult{}, lastErr
}
return InitializeResult{}, apperrors.NewDiscovery(fmt.Sprintf("initialize failed for all supported protocol versions at %s", RedactURL(endpoint)))
}
@@ -509,6 +525,15 @@ 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("x-user-access-token-present", fmt.Sprintf("%t", req.Header.Get("x-user-access-token") != "")),
slog.Int("extra_headers_count", len(c.ExtraHeaders)),
)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
lastErr = err
+67
View File
@@ -17,6 +17,7 @@ import (
"context"
"encoding/json"
stderrors "errors"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -71,6 +72,72 @@ func TestInitializeNegotiatesProtocolVersion(t *testing.T) {
}
}
func TestInitializeShortCircuitsOnHTTPError(t *testing.T) {
t.Parallel()
// Track which protocol versions actually get sent. With the short-circuit
// in place, a transport-layer (HTTP) failure should fail Initialize on the
// FIRST version without iterating through every supported version. Without
// the short-circuit, three round-trips would happen — needlessly tripling
// every CLI startup when a plugin endpoint is broken (issue #119).
var seenVersions []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
params := req["params"].(map[string]any)
seenVersions = append(seenVersions, params["protocolVersion"].(string))
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer server.Close()
client := NewClient(server.Client())
client.MaxRetries = 0 // skip the HTTP retry loop — we only care about version iteration
if _, err := client.Initialize(context.Background(), server.URL); err == nil {
t.Fatal("Initialize() error = nil, want HTTP error")
}
if len(seenVersions) != 1 {
t.Fatalf("Initialize() attempted %d protocol versions (%v), want 1 — HTTP failures must short-circuit",
len(seenVersions), seenVersions)
}
}
func TestInitializeShortCircuitsOnDialFailure(t *testing.T) {
t.Parallel()
// Bind to an ephemeral port, then close the listener. Subsequent connects
// to that address fail with "connection refused" almost instantly. With
// the short-circuit, three protocol versions would otherwise stack three
// dial-error returns; we only want one — and we want Initialize to return
// well under the per-dial budget.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
endpoint := "http://" + ln.Addr().String()
_ = ln.Close()
client := NewClient(nil)
client.MaxRetries = 0
start := time.Now()
if _, err := client.Initialize(context.Background(), endpoint); err == nil {
t.Fatal("Initialize() error = nil, want dial failure")
}
elapsed := time.Since(start)
// Three dial attempts (one per supported version) on a refused-connection
// path is still fast on loopback, so this assertion is a sanity bound, not
// the primary signal — but if the short-circuit regresses, on a real
// unreachable address this jumps from one dial timeout to three.
if elapsed > 2*time.Second {
t.Fatalf("Initialize() took %v, want <2s — dial failure should short-circuit", elapsed)
}
}
func TestListToolsRetriesOnServerError(t *testing.T) {
t.Parallel()
+49 -19
View File
@@ -18,6 +18,8 @@ package config
import (
"os"
"path/filepath"
"strings"
"time"
)
@@ -110,32 +112,60 @@ const (
// ── Plugin system ──────────────────────────────────────────────────────
const (
// PluginManagedDir is the subdirectory under ~/.dws/plugins/ for
// official (DingTalk-Real-AI) plugins that are auto-pulled.
PluginManagedDir = "managed"
// PluginUserDir is the subdirectory under ~/.dws/plugins/ for
// user-installed third-party plugins.
// PluginUserDir is the subdirectory under ~/.dws/plugins/ where all
// third-party plugins are installed. Every plugin — whether authored
// by the DingTalk team or anyone else — lives here with equal status.
PluginUserDir = "user"
// PluginDataDir is the subdirectory under ~/.dws/plugins/ for
// plugin persistent data that survives across version updates.
PluginDataDir = "data"
// PluginUpdateCheckInterval is how often to check for official
// plugin updates (at most once per interval per CLI invocation).
PluginUpdateCheckInterval = 1 * time.Hour
// PluginHookTimeout is the default timeout for plugin hook commands.
PluginHookTimeout = 30 * time.Second
// OfficialPluginWorkspace is the workspace name that identifies
// official plugins. Plugins under this workspace are auto-pulled.
OfficialPluginWorkspace = "DingTalk-Real-AI"
)
// DefaultManagedPlugins lists the official plugins that should be
// automatically pulled on first run if not already present locally.
// Each entry is the short plugin name (without the workspace prefix);
// the full qualified name is OfficialPluginWorkspace + "/" + name.
var DefaultManagedPlugins = []string{}
// ── Platform URLs ────────────────────────────────────────────────────────────
// Shared across auth, errors, and device-flow packages.
const (
// DefaultTerminalBaseURL is the DingTalk developer platform base URL.
// Override at runtime via ~/.dws/terminal_url file.
DefaultTerminalBaseURL = "https://open-dev.dingtalk.com"
// DeveloperSettingsPath is the path to the organization developer
// settings page (CLI access management).
DeveloperSettingsPath = "/fe/old#/developerSettings"
)
// DefaultConfigDir returns the default DWS configuration directory.
// Priority: DWS_CONFIG_DIR env var > ~/.dws
func DefaultConfigDir() string {
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
return envDir
}
homeDir, err := os.UserHomeDir()
if err != nil {
return ".dws"
}
return filepath.Join(homeDir, ".dws")
}
// GetTerminalBaseURL returns the terminal base URL with priority:
// 1. ~/.dws/terminal_url file content (for pre-release environment)
// 2. Default value (https://open-dev.dingtalk.com)
func GetTerminalBaseURL() string {
terminalURLPath := filepath.Join(DefaultConfigDir(), "terminal_url")
if data, err := os.ReadFile(terminalURLPath); err == nil {
if u := strings.TrimSpace(string(data)); u != "" {
return u
}
}
return DefaultTerminalBaseURL
}
// GetDeveloperSettingsURL returns the full URL to the organization developer
// settings page, derived from the terminal base URL.
func GetDeveloperSettingsURL() string {
return GetTerminalBaseURL() + DeveloperSettingsPath
}
+7
View File
@@ -19,5 +19,12 @@ package edition
func defaultHooks() *Hooks {
return &Hooks{
Name: "open",
MergeHeaders: func(base map[string]string) map[string]string {
if base == nil {
base = make(map[string]string)
}
base["claw-type"] = "openClaw"
return base
},
}
}
+14 -19
View File
@@ -73,20 +73,22 @@ type Hooks struct {
// --- HTTP headers ---
MergeHeaders func(base map[string]string) map[string]string
// --- auth hooks ---
OnAuthError func(configDir string, err error) error
TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error)
// --- auth ---
AuthClientID string // OAuth client ID for device-flow authorisation
AuthClientFromMCP bool // true → fetch client ID from MCP at runtime
OnAuthError func(configDir string, err error) error
TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error)
// --- token persistence (overlay-only) ---
// When non-nil, these override the default keychain-based token storage.
// The data parameter is JSON-serialized TokenData.
SaveToken func(configDir string, data []byte) error
LoadToken func(configDir string) ([]byte, error)
DeleteToken func(configDir string) error
// --- token persistence (overlay-managed keychain / encrypted storage) ---
SaveToken func(configDir string, data []byte) error // persist token blob
LoadToken func(configDir string) ([]byte, error) // retrieve token blob
DeleteToken func(configDir string) error // remove persisted token
// --- auth credentials (overlay-only) ---
AuthClientID string // non-empty overrides DefaultClientID
AuthClientFromMCP bool // true routes OAuth through MCP endpoints
// --- MCP result classification ---
// ClassifyToolResult inspects raw MCP tool-call content and returns a typed
// error (e.g. PATError, CLIError) when the response contains a known
// gateway-auth or PAT-permission failure. nil → no special handling.
ClassifyToolResult func(content map[string]any) error
// --- product & endpoint ---
StaticServers func() []ServerInfo // non-nil → skip Market discovery
@@ -97,13 +99,6 @@ type Hooks struct {
// global setup (OAuth flag overrides, log level, output sink). Overlays use
// this for clients that bypass the MCP runner (e.g. A2A gateway).
AfterPersistentPreRun func(cmd *cobra.Command, args []string) error
// ClassifyToolResult is called before the framework's default business-error
// detection on MCP tool results. If it returns a non-nil error, that error
// is used instead of the generic CategoryAPI business error. Editions use
// this to return custom error types with specific exit codes (e.g. PAT
// authorization errors with exit code 4).
ClassifyToolResult func(content map[string]any) error
}
var (
+8 -2
View File
@@ -1,6 +1,6 @@
---
name: dws
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/工作台/开放平台文档等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)时使用。
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/工作台/开放平台文档/钉钉文档/AI听记等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、查询听记纪要时使用。
cli_version: ">=1.0.6"
---
@@ -24,7 +24,7 @@ cli_version: ">=1.0.6"
| 产品 | 用途 | 参考文件 |
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
| `aitable` | AI表格:表格/数据表/字段/记录增删改查/模板搜索 | [aitable.md](./references/products/aitable.md) |
| `aitable` | AI表格:Base/数据表/字段/记录/附件/模板搜索 | [aitable.md](./references/products/aitable.md) |
| `approval` | 审批:审批表单/发起实例/审批/撤销 | [simple.md](./references/products/simple.md) |
| `attendance` | 考勤:打卡记录/排班查询 | [attendance.md](./references/products/attendance.md) |
| `calendar` | 日历:日程/参与者/会议室/闲忙查询 | [calendar.md](./references/products/calendar.md) |
@@ -32,6 +32,8 @@ cli_version: ">=1.0.6"
| `contact` | 通讯录:用户查询(当前用户/搜索/详情)/部门查询(搜索/子部门/成员列表) | [contact.md](./references/products/contact.md) |
| `devdoc` | 开放平台文档:搜索开发文档 | [simple.md](./references/products/simple.md) |
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
| `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论 | [doc.md](./references/products/doc.md) |
| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/热词 | [minutes.md](./references/products/minutes.md) |
| `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) |
| `todo` | 待办:创建(含优先级/截止时间)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
| `workbench` | 工作台:应用管理 | [workbench.md](./references/products/workbench.md) |
@@ -46,6 +48,8 @@ cli_version: ">=1.0.6"
用户提到"通讯录/同事/部门/组织架构" → `contact`
用户提到"开发/API/调用错误 文档" → `devdoc`
用户提到"DING/紧急消息/电话提醒" → `ding`
用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论" → `doc`
用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes`
用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report`
用户提到"待办/TODO/任务提醒" → `todo`
用户提到"工作台/应用管理" → `workbench`
@@ -69,6 +73,8 @@ cli_version: ">=1.0.6"
| `calendar` | `participant delete` | 移除日程参与者 |
| `calendar` | `room delete` | 取消会议室预定 |
| `chat` | `group members remove` | 移除群成员 |
| `doc` | `delete` | 删除钉钉文档(不可恢复) |
| `doc` | `block delete` | 删除文档块 |
| `todo` | `task delete` | 删除待办 |
### 确认流程
+122 -8
View File
@@ -31,9 +31,9 @@ Flags:
> 📎 **操作后返回文档链接**:遍历返回的每个 base,拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 返回给用户。
> ⚠️ **注意**:`base list` 仅返回**最近访问过**的 Base,不是全部 Base。
> 新创建的 Base 如果尚未在钉钉前端打开过,可能不会出现在此列表中。
> 如需查找特定 Base,请使用 `base search`;如果刚创建完,直接使用 `create` 返回的 `baseId` 即可。
> ⚠️ **重要**:`base list` 仅返回**最近访问过**的 Base,**不是全部 Base**。
> 如需查找表格,**请优先使用 `base search`**;`base list` 仅作为浏览最近表格的辅助手段。
> 如果刚创建完,直接使用 `create` 返回的 `baseId` 即可。
#### 搜索 AI 表格
```
@@ -56,6 +56,8 @@ Flags:
--base-id string Base 唯一标识 (必填)
```
> 💡 **用户提供 URL 时**:如果用户给出了链接如 `https://alidocs.dingtalk.com/i/nodes/ABC123`,请提取末尾的 `ABC123` 作为 `--base-id` 传入。详见下方「URL → baseId 提取」章节。
返回 baseName、tables、dashboards 的 summary 信息(不含字段与记录详情)。
后续如需 tableId,优先从这里读取。
@@ -186,15 +188,24 @@ Flags:
Usage:
dws aitable field create [flags]
Example:
# 单字段模式
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--fields '[{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}}]'
--name "状态" --type "singleSelect" --config '{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}'
# 批量模式
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--fields '[{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"}]}}]'
Flags:
--base-id string Base ID (必填)
--fields string 待新增字段 JSON 数组,至少 1 个,单次最多 15 个 (必填)
--name string 单字段名称(与 --type 配合使用,替代 --fields)
--type string 单字段类型(参考 table create 字段类型)
--config string 单字段配置 JSON(可选,如 options)
--fields string 批量新增字段 JSON 数组,单次最多 15 个(与 --name/--type 二选一)
--table-id string Table ID (必填)
```
允许部分成功,返回结果逐项标明成功/失败状态。
`--name/--type/--config` 为单字段模式;`--fields` 为批量模式;两种模式二选一。
#### 更新字段
```
@@ -236,13 +247,13 @@ Usage:
Example:
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID>
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids rec1,rec2
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --keyword "关键词" --limit 50
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --query "关键词" --limit 50
Flags:
--base-id string Base ID (必填)
--cursor string 分页游标,首次不传
--field-ids string 返回字段 ID 列表,逗号分隔,单次最多 100 个
--filters string 结构化过滤条件 JSON
--keyword string 全文关键词搜索
--query string 全文关键词搜索
--limit int 单次最大记录数,默认 100,最大 100
--record-ids string 指定记录 ID 列表,逗号分隔,单次最多 100 个
--sort string 排序条件 JSON 数组
@@ -251,6 +262,10 @@ Flags:
两种模式: 按 ID 取(传 record-ids,忽略 filters/sort)或条件查(filters+sort+cursor 分页)。
> ⚠️ **排序参数规范(关键)**:`--sort` 需要传 JSON 数组,排序方向字段必须是 `direction`(`asc` 或 `desc`),**不要使用 `order`**。
>
> 正确示例:`--sort '[{"fieldId":"wm8ns9bw2vmucb45xj3ix","direction":"desc"}]'`
filters 结构:`{"operator":"and|or","operands":[{"operator":"<op>","operands":["<fieldId>","<value>"]}]}`
> 💡 **singleSelect/multipleSelect 过滤**:filters 中可传 option id 或 option name,但建议优先用 **option id**(通过 `field get` 获取),更可靠。
@@ -365,10 +380,48 @@ Flags:
> 📎 **模板预览地址**:`https://docs.dingtalk.com/table/template/{templateId}`
## 复杂操作
### 仪表盘 / 图表(建议顺序)
```bash
# 1) 先看配置模板(JSONC)
dws aitable dashboard config-example --format json
dws aitable chart widgets-example --format json
# 2) 先拿 dashboard,再拿 chart 详情
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
```
要点:
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
### 导出数据(两阶段轮询)
`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
```bash
# 第一步:创建任务(按 scope 传必要参数)
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
```
参数约束
- `scope=all`:只需 `base-id`
- `scope=table`:必须 `table-id`
- `scope=view`:必须同时 `table-id + view-id`
## 意图判断
用户说"表格/多维表/AI表格":
- 查看/列表 → `base list`
- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问)
- 搜索 → `base search`
- 详情 → `base get`
- 创建 → `base create`
@@ -433,6 +486,63 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
- 所有操作使用 ID(baseId/tableId/fieldId/recordId),不使用名称
- records 的 cells key 是 fieldId,不是字段名称
## `--filters` 筛选语法排错与使用规范(极易出错)
调用 `record query` 时,如果条件筛选**完全失效(查询返回了所有记录)**,通常是因为 `--filters` JSON 语法错误,API 默默丢弃了不合规的 filter。
**强制规则:**
1. **根节点必须是逻辑操作符**:`"operator"` 必须是 `"and"` 或 `"or"`,不能是 `"eq"` 等比较操作符。
2. 比较操作必须放在根节点的 `"operands"` 数组内的对象中。
3. `singleSelect` 和 `multipleSelect` 字段,推荐使用 **选项的 exact String 名称 (name)** 作为比较值,而不是 ID。
4. **内层比较操作符语义**:支持 `eq`(等于)、`not_eq`(不等于)、`contain`(包含/模糊搜索)、`not_contain`(不包含)、`gt/gte`(大于/大于等于)、`lt/lte`(小于/小于等于)、`is_empty/is_not_empty`(为空/不为空,对应 operands 内只传单个 fieldId)。
**精简防呆模板与 4 种衍生情况**
```json
{
"operator": "and", // 情况 4 (OR 查询): 这里改为 "or"
"operands": [
{
"operator": "eq", // 情况 3 (文本包含): 这里改为 "contain"
"operands": ["fld_state", "进行中"] // 情况 1 (基础等于)
}
// 情况 2 (多条件 AND): 在此行增加类似 {"operator":"eq","operands":["fld_priority","高"]}
]
}
```
**错误示例 1:缺失根节点 and/or**(API 将忽略该 filter,返回全表)
```json
{"operator":"eq","operands":["fldXXX","本科"]}
```
**错误示例 2:传入选项 ID 而非名称**(可能导致匹配不到 0 记录)
```json
{"operator":"and","operands":[{"operator":"eq","operands":["fldXXX","CXzrOHK9JI"]}]}
```
## URL → baseId 提取
用户经常通过钉钉链接指定表格,链接格式为:
```
https://alidocs.dingtalk.com/i/nodes/{baseId}
https://alidocs.dingtalk.com/i/nodes/{baseId}?xxx=yyy
```
**处理规则**(必须严格遵守):
1. 当用户提供了包含 `alidocs.dingtalk.com/i/nodes/` 的 URL 时,提取 `/nodes/` 后的路径段作为 `baseId`
2. 去掉尾部的查询参数(`?` 及其后内容)和尾部斜杠
3. 将提取得到的 ID 传入 `--base-id` 参数
**示例**:
```
用户输入:帮我查看 https://alidocs.dingtalk.com/i/nodes/ABC123XYZ 这个表格
→ 提取 baseId = ABC123XYZ
→ 执行: dws aitable base get --base-id ABC123XYZ --format json
```
> 💡 **注意**:URL 中的 nodeId 在 AI 表格场景下等同于 baseId,可以直接作为 `--base-id` 使用。
### cells 写入/读取格式速查
| 字段类型 | 写入格式 | 读取返回格式 |
@@ -450,3 +560,7 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
| group | `[{"cid":"xxx"}]` (注意: key 是 cid,不是 openConversationId) | 同写入 |
- 详见 [field-rules.md](../field-rules.md) 和 [error-codes.md](../error-codes.md)
## 相关产品
- [doc](./doc.md) — 富文本文档编辑,不是结构化数据表格
+652 -127
View File
@@ -1,77 +1,113 @@
# 群聊与机器人 (chat) 命令参考
# 会话与群聊 (chat) 命令参考
> 命令别名: `dws im` 等价于 `dws chat`
## 命令总览
### group (群组管理)
| 子命令 | 用途 |
|-------|------|
| `search` | 搜索群聊 |
| `group create` | 创建群 |
| `group create` | 创建内部群 |
| `group create-org` | 创建企业全员群 |
| `group members list` | 查看群成员列表 |
| `group members add` | 添加群成员 |
| `group members remove` | 移除群成员(⚠️ 危险操作) |
| `group members add-bot` | 添加机器人到群 |
| `group rename` | 修改群名称 |
| `bot search` | 搜索我的机器人 |
| `search` | 搜索群会话 |
| `search-common` | 搜索共同群 |
### message (会话消息管理)
| 子命令 | 用途 |
|-------|------|
| `message send` | 以当前用户身份发群消息或单聊消息 |
| `message send-personal` | 发送个人消息(⚠️ 敏感操作) |
| `message list` | 拉取群聊或单聊会话消息 |
| `message list-all` | 按时间范围拉取当前用户所有会话消息 |
| `message list-topic-replies` | 拉取群话题回复消息列表 |
| `message list-by-sender` | 搜索指定发送者的消息 |
| `message list-mentions` | 拉取 @我 的消息 |
| `message list-focused` | 拉取特别关注人的消息 |
| `message list-unread-conversations` | 获取未读会话列表 |
| `message search` | 按关键词搜索消息 |
| `message info` | 获取会话信息 |
| `message send-by-bot` | 机器人发消息(群聊或批量单聊) |
| `message recall-by-bot` | 机器人撤回消息(群聊或批量单聊) |
| `message recall-by-bot` | 机器人撤回消息 |
| `message send-by-webhook` | 自定义机器人 Webhook 发消息 |
| `list-top-conversations` | 拉取置顶会话列表 |
### bot (机器人管理)
| 子命令 | 用途 |
|-------|------|
| `bot search` | 搜索我的机器人 |
| `bot create` | 创建企业机器人 |
| `bot search-groups` | 搜索机器人所在群 |
---
## search — 搜索群聊
## group create — 创建内部群
```
Usage:
dws chat search [flags]
Example:
dws chat search --query "项目冲刺" --format json
Flags:
--query string 搜索关键词 (必填)
--cursor string 分页游标(首页留空)
```
---
## group create — 创建群
当前登录用户自动成为群主。
```
Usage:
dws chat group create [flags]
Example:
dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3 --format json
dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3
Flags:
--name string 群名称 (必填)
--users string 群成员 userId 列表,逗号分隔 (必填)
--users string 成员 userId 列表,用户本身会自动加入,无需包含,逗号分隔,不超过20个 (必填)
--name string 群名称 (必填)
```
> 当前用户自动作为群主加入,无需在 --users 中重复传入。
---
## group create-org — 创建企业全员群
创建面向企业组织的群,成员通过 userId 列表指定。
```
Usage:
dws chat group create-org [flags]
Example:
dws chat group create-org --name "全员通知群" --users userId1,userId2
Flags:
--name string 群名称 (必填)
--users string 成员 userId 列表,逗号分隔 (必填)
```
---
## group members list — 查看群成员列表
分页查询指定群聊的成员。
```
Usage:
dws chat group members list [flags]
Example:
dws chat group members list --id <openConversationId> --format json
dws chat group members list --id <openconversation_id>
Flags:
--id string 群会话 ID (必填)
--cursor string 分页游标
--cursor string 分页游标,首次从 0 开始
--id string 群 ID / openconversation_id (必填)
```
---
## group members add — 添加群成员
向指定群聊添加成员,需传入群 ID 与用户 ID 列表。
```
Usage:
dws chat group members add [flags]
Example:
dws chat group members add --id <openConversationId> --users userId1,userId2 --format json
dws chat group members add --id <openconversation_id> --users userId1,userId2
Flags:
--id string 群会话 ID (必填)
--users string 要添加的 userId 列表,逗号分隔 (必填)
--id string 群 ID / openconversation_id (必填)
--users string 要添加的用户 userId 列表,逗号分隔 (必填)
```
---
@@ -84,24 +120,26 @@ Flags:
Usage:
dws chat group members remove [flags]
Example:
dws chat group members remove --id <openConversationId> --users userId1,userId2 --format json
dws chat group members remove --id <openconversation_id> --users userId1,userId2
Flags:
--id string 群会话 ID (必填)
--users string 要移除的 userId 列表,逗号分隔 (必填)
--id string 群 ID / openconversation_id (必填)
--users string 要移除的用户 userId 列表,逗号分隔 (必填)
```
---
## group members add-bot — 添加机器人到群
将自定义机器人添加到当前用户有管理权限的群聊中,如果没有权限则会报错。
```
Usage:
dws chat group members add-bot [flags]
Example:
dws chat group members add-bot --id <openConversationId> --robot-code <robotCode> --format json
dws chat group members add-bot --robot-code <robot-code> --id <openconversation_id>
Flags:
--id string 群会话 ID (必填)
--robot-code string 机器人 code (必填)
--id string 群聊 openConversationId (必填)
--robot-code string 机器人 Code (必填)
```
---
@@ -112,10 +150,351 @@ Flags:
Usage:
dws chat group rename [flags]
Example:
dws chat group rename --id <openConversationId> --name "新群名" --format json
dws chat group rename --id <openconversation_id> --name "新群名"
Flags:
--id string 群会话 ID (必填)
--name string 新群名称 (必填)
--id string 群 ID / openconversation_id (必填)
--name string 修改后的群名称 (必填)
```
---
## search — 搜索群会话
根据名称搜索会话列表。
```
Usage:
dws chat search [flags]
Example:
dws chat search --query "项目冲刺"
Flags:
--cursor string 分页游标 (首页留空)
--query string 搜索关键词 (必填)
```
---
## search-common — 搜索共同群
根据昵称列表搜索共同群聊。--nicks 指定要搜索的人员昵称(逗号分隔,必填)。--match-mode 控制匹配模式:AND 表示所有人都在群里,OR 表示任一人在群里(默认 AND)。
```
Usage:
dws chat search-common [flags]
Example:
dws chat search-common --nicks "风雷,山乔" --limit 20 --cursor 0
dws chat search-common --nicks "天鸡,乐函" --match-mode OR --limit 20 --cursor 0
dws chat search-common --nicks "风雷,山乔,天鸡" --limit 10 --cursor <nextCursor>
Flags:
--nicks string 要搜索的昵称列表,逗号分隔 (必填)
--match-mode string 匹配模式:AND=所有人都在群里,OR=任一人在群里(默认 AND)
--limit int 每页返回数量(默认 20)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --nicks 传人员昵称(花名),逗号分隔,如 "风雷,山乔"
- --match-mode AND 表示群里必须包含所有指定的人;OR 表示包含任意一人即可
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
---
## message send — 以当前用户身份发消息
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。可选 --title 作为消息标题。
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
```
Usage:
dws chat message send [flags] [<text>]
Example:
dws chat message send --group <openconversation_id> --text "hello"
dws chat message send --user <userId> --text "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --text "请查收"
dws chat message send --group <openconversation_id> "hello"
dws chat message send --group <openconversation_id> --title "周报提醒" --text "请大家本周五前提交周报"
dws chat message send --group <openconversation_id> --at-all "<@all> 请大家注意"
dws chat message send --group <openconversation_id> --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
dws chat message send --group <openconversation_id> --media-id <mediaId>
dws chat message send --open-dingtalk-id <openDingTalkId> --media-id <mediaId>
Flags:
--text string 消息内容(推荐使用,也可用位置参数)
--group string 群聊 openconversation_id(群聊时必填)
--user string 接收人 userId(单聊时与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 接收人 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
--title string 消息标题(可选,默认「消息」)
--at-all @所有人(仅群聊时生效,可选,默认 false)
--at-users string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选)
--media-id string 图片 mediaId(通过 dt_media_upload 工具上传获得,需从返回链接中去除 _宽_高.格式 后缀并加上 @ 前缀),指定后发送图片消息,不需要传文本内容
注意:
- --text 和位置参数二选一,--text 优先
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-all 和 --at-users 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2>
- --media-id 指定图片 mediaId 时自动发送图片消息(msgType=image),不需要传 --text;图片单聊仅支持 --open-dingtalk-id,不支持 --user
```
---
## message send-personal — 发送个人消息
> ⚠️ 敏感操作:执行前必须向用户确认,同意后才加 `--yes`。
发送个人消息到指定会话或指定用户。支持 @指定人。
```
Usage:
dws chat message send-personal [flags]
Example:
dws chat message send-personal --id <openConversationId> --content "你好" --type text
dws chat message send-personal --open-id <openDingTalkId> --content "消息内容" --type text
dws chat message send-personal --id <openConversationId> --content "内容" --at-all
Flags:
--content string 消息内容 (必填)
--type string 消息类型,如 text、markdown (必填)
--id string 群聊会话 ID openConversationId(与 --open-id 二选一)
--open-id string 接收人 openDingTalkId(与 --id 二选一)
--at-all @所有人(可选)
--at-users string @指定人的 openDingTalkId 列表,逗号分隔(可选)
注意:
- --id(群聊会话)和 --open-id(指定用户 openDingTalkId)二选一
```
---
## message list — 拉取会话消息内容
拉取指定群聊或单聊的会话消息内容。
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--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" --forward=false
Flags:
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
--group string 群聊 openconversation_id(群聊时必填)
--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
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
- 如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息,需要调用 dws chat message list-topic-replies 拉取话题的回复内容列表,openConvThreadId 作为 --topic-id 参数
```
---
## message list-all — 拉取指定时间范围内当前用户的所有会话消息
分页拉取当前登录用户在指定时间范围内的所有会话消息。
--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 "0",后续从响应中的 nextCursor 获取)。
```
Usage:
dws chat message list-all [flags]
Example:
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token"
Flags:
--start string 起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
--end string 结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)
--limit int 每页返回数量(默认 50)
--cursor string 分页游标(首页传 "0",后续从响应中的 nextCursor 获取)
注意:
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
- 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
```
---
## message list-topic-replies — 拉取群话题回复消息列表
查询指定群聊中某条话题消息的全部回复。--group 指定群会话 ID,--topic-id 指定话题 ID(由 dws chat message list 返回)。
```
Usage:
dws chat message list-topic-replies [flags]
Example:
dws chat message list-topic-replies --group <openconversation_id> --topic-id <topicId>
dws chat message list-topic-replies --group <openconversation_id> --topic-id <topicId> --time "2025-03-01 00:00:00" --limit 20
Flags:
--group string 群会话 openconversationId (必填)
--topic-id string 话题 ID,由 dws chat message list 返回 (必填)
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(可选)
--limit int 返回数量(默认 50)
--forward true=从老往新,false=从新往老(默认 false)
```
---
## message list-by-sender — 拉取指定发送者的消息
搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。
```
Usage:
dws chat message list-by-sender [flags]
Example:
dws chat message list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
Flags:
--sender-user-id string 发送者 userId(与 --sender-open-dingtalk-id 二选一)
--sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一)
--start string 开始时间,ISO-8601 格式 (必填)
--end string 结束时间,ISO-8601 格式 (必填)
--limit int 每页返回数量(默认 50)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一
- 不需要指定单聊/群聊,MCP 返回结果自带会话类型标识
- 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00" 等
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
---
## message list-mentions — 拉取 @我 的消息
搜索时间范围内 @我 的消息,可选指定群聊。
```
Usage:
dws chat message list-mentions [flags]
Example:
dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-mentions --group <openconversation_id> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
Flags:
--group string 群聊 openconversation_id(可选,不传则查全部)
--start string 开始时间,ISO-8601 格式 (必填)
--end string 结束时间,ISO-8601 格式 (必填)
--limit int 每页返回数量(默认 50)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --group 可选,不传则查询所有会话中 @我 的消息;传入则只查指定群聊
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
---
## message list-focused — 拉取特别关注人的消息
拉取当前用户特别关注人的消息。
```
Usage:
dws chat message list-focused [flags]
Example:
dws chat message list-focused --limit 50
dws chat message list-focused --limit 20 --cursor <nextCursor>
Flags:
--limit int 每页返回数量(默认 50)
--cursor int64 分页游标(首次不传或传 0,翻页传 nextCursor)
注意:
- 首次调用不传 --cursor 或传 0,后续翻页传 nextCursor
```
---
## message list-unread-conversations — 获取未读会话列表
获取当前用户有未读消息的会话信息。可选通过 `--count` 限制返回条数。
```
Usage:
dws chat message list-unread-conversations [flags]
Example:
dws chat message list-unread-conversations
dws chat message list-unread-conversations --count 20
Flags:
--count int 返回未读会话条数(可选)
```
---
## message search — 按关键词搜索消息
在当前用户的会话中按关键词搜索消息。--keyword 必填,可选 --group 限定搜索某个会话。
```
Usage:
dws chat message search [flags]
Example:
dws chat message search --keyword "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0
dws chat message search --keyword "codereview" --group <openconversation_id> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
Flags:
--keyword string 搜索关键词 (必填)
--group string 群聊 openconversation_id(可选,不传则搜索所有会话)
--start string 开始时间,ISO-8601 格式 (必填)
--end string 结束时间,ISO-8601 格式 (必填)
--limit int 每页返回数量(默认 100)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --group 可选,不传则搜索所有会话中的消息;传入则只搜索指定会话
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
---
## message info — 获取会话信息
获取指定群聊或单聊会话的详情信息。
```
Usage:
dws chat message info [flags]
Example:
dws chat message info --id <openConversationId>
dws chat message info --open-id <openDingTalkId>
Flags:
--id string 群聊会话 ID openConversationId(与 --open-id 二选一)
--open-id string 用户 openDingTalkId(单聊时与 --id 二选一)
注意:
- --id(群聊)和 --open-id(单聊用户 openDingTalkId)二选一
```
---
## list-top-conversations — 拉取置顶会话列表
拉取当前用户的置顶会话列表。
```
Usage:
dws chat list-top-conversations [flags]
Example:
dws chat list-top-conversations --limit 1000
dws chat list-top-conversations --limit 1000 --cursor <nextCursor>
Flags:
--limit int 每页返回数量(默认 1000)
--cursor int 分页游标(首次不传或传 0,翻页传 nextCursor)
注意:
- 用户询问"置顶会话"时,直接调用此命令返回置顶会话列表即可
- 用户询问"置顶消息"时,需两步:先调用此命令拉取置顶会话列表获取各会话的 openConversationId,再用 `chat message list --group <openConversationId>` 分别拉取每个会话内的消息
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
---
@@ -126,161 +505,307 @@ Flags:
Usage:
dws chat bot search [flags]
Example:
dws chat bot search --name "考勤" --format json
dws chat bot search --page 1
dws chat bot search --page 1 --size 10 --name "日报"
Flags:
--name string 机器人名称(模糊搜索)
--page int 页码(默认 1)
--size int 每页数量(默认 50)
--name string 按名称搜索
--page int 页码,从1开始 (默认 1)
--size int 每页条数 (默认 50),别名: --limit
```
---
## bot create — 创建企业机器人
```
Usage:
dws chat bot create [flags]
Example:
dws chat bot create --name "日报提醒机器人" --desc "负责每日日报提醒"
Flags:
--name string 机器人名称 (必填)
--desc string 机器人描述(可选)
```
---
## bot search-groups — 搜索机器人所在群
搜索指定机器人已加入的群列表。
```
Usage:
dws chat bot search-groups [flags]
Example:
dws chat bot search-groups --keyword "项目"
dws chat bot search-groups --keyword "冲刺" --cursor <nextCursor>
Flags:
--keyword string 搜索关键词 (必填)
--cursor string 分页游标(首页留空,翻页传返回的 cursor)
```
---
## message send-by-bot — 机器人发消息
支持两种模式:群聊发送 和 批量单聊发送,通过 `--group` 和 `--users` 互斥区分。
群聊:传 --group 指定群;单聊:传 --users 指定用户列表,二者只能选其一,不能同时指定。--text 支持 Markdown。
### 群聊发送
```
Usage:
dws chat message send-by-bot [flags]
Example:
dws chat message send-by-bot --robot-code <code> --group <openConversationId> \
--title "日报提醒" --text "请提交今日日报" --format json
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --title "日报" --text "## 今日完成..."
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
Flags:
--robot-code string 机器人 code (必填)
--group string 群会话 ID (必填,与 --users 互斥)
--group string 群聊 openConversationId(群聊时必填)
--robot-code string 机器人 Code (必填)
--text string 消息内容 Markdown (必填)
--title string 消息标题 (必填)
--text string 消息内容,支持 Markdown (必填)
```
--users string 用户 userId 列表,逗号分隔,最多20个(单聊时必填)
### 批量单聊发送
注意:
- --group 与 --users 互斥,必须且只能指定其一
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
```
Usage:
dws chat message send-by-bot [flags]
Example:
dws chat message send-by-bot --robot-code <code> --users "user1,user2" \
--title "通知" --text "会议已取消" --format json
Flags:
--robot-code string 机器人 code (必填)
--users string 用户 ID 列表,逗号分隔,最多 20 个 (必填,与 --group 互斥)
--title string 消息标题 (必填)
--text string 消息内容,支持 Markdown (必填)
```
> ⚠️ `--group` 和 `--users` 互斥:群聊用 `--group`,单聊用 `--users`,不能同时传。
---
## message recall-by-bot — 机器人撤回消息
支持两种模式:群聊撤回 和 批量单聊撤回。
群聊:传 --group 与 --keys;单聊:仅传 --keys。--keys 为发送时返回的 processQueryKey 列表,逗号分隔。
### 群聊撤回
```
Usage:
dws chat message recall-by-bot [flags]
Example:
dws chat message recall-by-bot --robot-code <code> --group <openConversationId> \
--keys "key1,key2" --format json
dws chat message recall-by-bot --robot-code <robot-code> --group <openconversation_id> --keys <process-query-key>
dws chat message recall-by-bot --robot-code <robot-code> --keys key1,key2
Flags:
--robot-code string 机器人 code (必填)
--group string 群会话 ID (必填,与批量单聊互斥)
--keys string 消息 key 列表,逗号分隔 (必填)
--group string 群聊 openConversationId(群聊撤回时必填)
--keys string 消息 processQueryKey 列表,逗号分隔 (必填)
--robot-code string 机器人 Code (必填)
```
### 批量单聊撤回
```
Usage:
dws chat message recall-by-bot [flags]
Example:
dws chat message recall-by-bot --robot-code <code> --keys "key1,key2" --format json
Flags:
--robot-code string 机器人 code (必填)
--keys string 消息 key 列表,逗号分隔 (必填)
```
> ⚠️ 消息 key 从 `send-by-bot` 返回结果中提取。
---
## message send-by-webhook — 自定义机器人 Webhook 发消息
@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效。
```
Usage:
dws chat message send-by-webhook [flags]
Example:
dws chat message send-by-webhook --token <robotToken> \
--title "告警" --text "CPU 使用率超过 90%" --format json
dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "CPU 超 90%" --at-all
dws chat message send-by-webhook --token <webhook-token> --title "test" --text "hi @118785" --at-users 118785
Flags:
--token string 自定义机器人 Webhook Token (必填)
--title string 消息标题 (必填)
--at-all @ 所有人
--at-mobiles string @ 指定手机号,逗号分隔
--at-users string @ 指定用户,逗号分隔(需在 text 中包含 @userId)
--text string 消息内容 (必填)
--at-all @所有人
--at-mobiles string @指定手机号列表,逗号分隔
--at-users string @指定用户 ID 列表,逗号分隔
--title string 消息标题 (必填)
--token string Webhook Token (必填)
```
---
## 意图判断
- 用户说"搜索一个群" → `search`
- 用户说"帮我建个群" → `group create`
- 用户说"看看群里有谁" → `group members list`
- 用户说"把张三拉进群" → 先 `contact user search` 获取 userId,再 `group members add`
- 用户说"把张三移出群" → 先 `contact user search` 获取 userId,再 `group members remove`(⚠️ 需确认)
- 用户说"改一下群名" → `group rename`
- 用户说"让机器人在群里发通知" → `message send-by-bot --group`
- 用户说"机器人给张三发消息" → 先 `contact user search` 获取 userId,再 `message send-by-bot --users`
- 用户说"通过 Webhook 发告警" / 用户有 Webhook Token → `message send-by-webhook`
- 用户说"撤回机器人消息" → `message recall-by-bot`
- 用户说"查一下我的机器人" → `bot search`
- 用户说"把机器人加到群里" → `group members add-bot`
用户说"建群/创建群聊" → `chat group create`
用户说"创建企业全员群/组织群" → `chat group create-org`
用户说"搜索群/找群" → `chat search`
用户说"群成员/看群里有谁" → `chat group members list`
用户说"拉人进群/加群成员" → `chat group members add`
用户说"踢人/移除群成员" → `chat group members remove`
用户说"加机器人到群" → `chat group members add-bot`
用户说"改群名" → `chat group rename`
用户说"聊天记录/会话消息/拉取会话" → `chat message list`
用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用)
用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
用户说"发群消息(以个人身份)" → `chat message send --group`
用户说"发单聊消息(以个人身份)" → `chat message send --user`(有 userId 时)或 `chat message send --open-dingtalk-id`(有 openDingTalkId 时)
用户说"发个人消息/个人通知" → `chat message send-personal`(⚠️ 敏感操作,需确认)
用户说"机器人发消息/机器人群发" → `chat message send-by-bot`
用户说"机器人撤回消息" → `chat message recall-by-bot`
用户说"Webhook 发消息/告警消息" → `chat message send-by-webhook`
用户说"话题回复/群话题消息回复/拉取话题回复" → `chat message list-topic-replies`
用户说"所有消息/全部会话消息/拉取全部消息/时间范围内消息/我的消息/我今天的消息/查我的钉钉消息/最近的消息" → `chat message list-all`
用户说"特别关注人的消息/关注的人的消息/星标联系人的消息" → `chat message list-focused`
用户说"查看我的机器人" → `chat bot search`
用户说"创建机器人" → `chat bot create`
用户说"搜索消息/查找关键词/搜一下消息里的XX" → `chat message search`
用户说"我和XX的共同群/我们都在哪些群/查共同群" → `chat search-common`
用户说"置顶会话/置顶消息/我的置顶/查看置顶" → `chat list-top-conversations`
用户说"获取会话信息/会话详情" → `chat message info`
用户说"机器人在哪些群/机器人的群" → `chat bot search-groups`
**关键区分**: `send-by-bot`(企业内部机器人,需 robotCode) vs `send-by-webhook`(自定义机器人 Webhook,需 token)
关键区分:
- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用)
- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
- `chat message list-all` — 拉取当前用户所有会话的消息,按时间范围 + cursor 分页。只要用户没有指定某个具体的会话(如某个群名、某个人名),即使提到"单聊消息""群聊消息"等笼统范围,也应路由到此命令
- `chat message list-topic-replies` — 拉取群话题的回复消息列表
- `chat message list-focused` — 拉取特别关注人的消息,cursor 分页
- `chat list-top-conversations` — 拉取置顶会话列表(用户询问"置顶会话"或"置顶消息"时路由到此),cursor 分页
- `chat message send` — 以**当前用户**身份发消息(群聊或单聊),text 为位置参数;支持 --media-id 发送图片消息
- `chat message send-personal` — 发送个人消息,支持通过 openConversationId 或 openDingTalkId 指定目标(⚠️ 敏感操作)
- `chat message search` — 按关键词搜索消息内容(跨所有会话,可选指定群)
- `chat search-common` — 搜索共同群,查询指定人共同所在的群聊(AND=所有人都在,OR=任一人在)
- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊),text 为 --text flag
- `chat message send-by-webhook` — 通过**自定义机器人 Webhook** 发群消息
- `chat message recall-by-bot` — 通过机器人撤回已发送的消息
- `chat message info` — 获取指定会话的详情信息
- `chat bot create` — 创建新的企业机器人
- `chat bot search-groups` — 搜索机器人所在群列表
## 核心工作流
```bash
# ── 工作流: 建群并添加机器人 ──
# 1. 搜索群 — 提取 openconversation_id
dws chat search --query "项目冲刺" --format json
# 1. 搜索同事 userId
dws contact user search --keyword "张三" --format json
# 2. 拉取群消息
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --format json
# 2. 创建群
dws chat group create --name "项目群" --users <userId1>,<userId2> --format json
# 2b. 拉取未读会话列表
dws chat message list-unread-conversations --count 20 --format json
# 3. 搜索机器人
dws chat bot search --format json
# 3. 以个人身份发送群消息
dws chat message send --group <openconversation_id> --title "周报提醒" "请大家本周五前提交周报" --format json
# 4. 添加机器人到群
dws chat group members add-bot --id <openConversationId> --robot-code <code> --format json
# 4. 以个人身份单聊(通过 userId)
dws chat message send --user <userId> "你好" --format json
# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用)
dws chat message send --open-dingtalk-id <openDingTalkId> "你好" --format json
# 5. 机器人发群消息(Markdown)
dws chat message send-by-bot --robot-code <robot-code> \
--group <openconversation_id> --title "日报" --text "## 今日完成..." --format json
# 6. 机器人单聊发消息
dws chat message send-by-bot --robot-code <robot-code> \
--users userId1,userId2 --title "提醒" --text "请提交周报" --format json
# 7. Webhook 发告警
dws chat message send-by-webhook --token <webhook-token> \
--title "告警" --text "CPU 超 90%" --at-all --format json
```
```bash
# ── 工作流: 机器人群发消息 ──
## 复合工作流
# 1. 搜索可用机器人
### 机器人发消息后撤回(完整流程)
撤回只能用于 `send-by-bot` 发出的消息。个人身份 (`chat message send`) 发出的消息**无法通过 API 撤回**。
```bash
# Step 1: 查我的机器人 — 提取 robot-code
dws chat bot search --format json
# 2. 发送群消息
dws chat message send-by-bot --robot-code <code> --group <groupId> \
# Step 2: 用机器人发消息 — 提取返回中的 processQueryKey
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
--title "通知" --text "内容" --format json
# Step 3: 用同一个 robot-code + processQueryKey 撤回
dws chat message recall-by-bot --robot-code <robot-code> --group <openconversation_id> \
--keys <processQueryKey> --format json
```
```bash
# ── 工作流: Webhook 告警 ──
### 创建并使用机器人(完整流程)
# 直接通过 Webhook Token 发送
dws chat message send-by-webhook --token <token> \
--title "告警" --text "服务异常" --at-all --format json
```bash
# Step 1: 创建机器人
dws chat bot create --name "项目提醒机器人" --desc "项目状态提醒" --format json
# Step 2: 搜索群 — 提取 openConversationId
dws chat search --query "项目群" --format json
# Step 3: 将机器人添加到群
dws chat group members add-bot --id <openConversationId> --robot-code <robotCode> --format json
# Step 4: 机器人发消息
dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> \
--title "提醒" --text "请及时更新项目状态" --format json
```
### 机器人 @指定人发群消息
`--text` 中**必须**包含 `<@userId>` 占位符,否则 @ 不生效。
```bash
# Step 1: 搜人获取 userId
dws aisearch person --keyword "张三" --dimension name --format json
# Step 2: 取 userId 发送(注意 text 中的占位符)
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
--title "提醒" --text "<@userId1> <@userId2> 请查收本周报告" --format json
```
### 发送图片/文件消息(跨产品: drive → chat)
```bash
# Step 1: 上传文件到钉盘 — 获取 uploadId 和凭证
dws drive upload-info --file-name "截图.png" --file-size <字节数> --format json
# Step 2: HTTP PUT 上传文件到 OSS
curl -X PUT -T "截图.png" "<upload-info 返回的上传 URL>"
# Step 3: 提交上传 — 获取 dentryUuid
dws drive commit --file-name "截图.png" --file-size <字节数> --upload-id <uploadId> --format json
# Step 4: 获取下载链接
dws drive download --file-id <dentryUuid> --format json
# Step 5: 用 Markdown 图片语法发送
dws chat message send --group <openconversation_id> \
--text "![截图](下载链接)" --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `search` | openConversationId | `group members` / `group rename` / `group members add` / `send-by-bot --group` |
| `group create` | openConversationId | 同上 |
| `bot search` | robotCode | `send-by-bot` / `recall-by-bot` / `add-bot` |
| `message send-by-bot` | processQueryKey | `recall-by-bot --keys` |
| `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、--at-users、send-by-bot 的 --users、list-by-sender 的 --sender-user-id |
| `aisearch person` → `contact user get` | `openDingTalkId` | list-by-sender 的 --sender-open-dingtalk-id、message send/list 的 --open-dingtalk-id |
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code、group members add-bot 的 --robot-code |
| `chat bot create` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code |
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
| `chat message search` | `nextCursor` | 下次 message search 的 --cursor |
| `chat search-common` | `openConversationId` | message send/list 等的 --group |
| `drive download` | 下载链接 | message send 的 Markdown 图片/链接语法 |
## 注意事项
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;`--at-all`、`--at-users` 仅在 `--group` 群聊时生效;发送图片消息用 `--media-id`
- `chat message send-personal` 为敏感操作(isSensitive),执行前需用户明确确认
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
- `chat message search` 按关键词搜索消息内容,`--keyword` 必填,可选 `--group` 限定搜索某个会话
- `chat search-common` 搜索共同群,`--nicks` 传人员昵称(逗号分隔),`--match-mode` AND/OR 控制匹配逻辑
- `chat list-top-conversations` 拉取置顶会话列表,分页用 `--limit`(默认 1000)/`--cursor`
- `send-by-bot` 群聊传 `--group`,单聊传 `--users`,二者互斥且必选其一
- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回)
- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
## 相关产品
- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、--at-users、send-by-bot --users、list-by-sender --sender-user-id;获取 openDingTalkId 用于 list-by-sender --sender-open-dingtalk-id、--open-dingtalk-id
- [drive](./drive.md) — 上传文件获取下载链接,用于 Markdown 图片/文件消息
+462
View File
@@ -0,0 +1,462 @@
# 文档 (doc) 命令参考
## 命令总览
### 搜索文档
```
Usage:
dws doc search [flags]
Example:
dws doc search --query "会议纪要"
dws doc search
dws doc search --extensions pdf,docx
dws doc search --query "方案" --created-from 1700000000000 --created-to 1710000000000
dws doc search --creator-uids uid1,uid2
dws doc search --workspace-ids wsId1,wsId2
Flags:
--query string 搜索关键词 (不传则返回最近访问)
--extensions strings 按文件扩展名过滤,不含点号,逗号分隔 (如 pdf,docx,png)。支持的在线文档类型后缀名: adoc=文字, axls=表格, appt=演示文稿, awbd=白板, adraw=画板, amind=脑图, able=多维表格, aform=收集表
--created-from int 创建时间起始 (毫秒时间戳,含)
--created-to int 创建时间截止 (毫秒时间戳,含)
--visited-from int 访问时间起始 (毫秒时间戳,含)
--visited-to int 访问时间截止 (毫秒时间戳,含)
--creator-uids strings 按创建者用户 ID 过滤,逗号分隔
--editor-uids strings 按编辑者用户 ID 过滤,逗号分隔
--mentioned-uids strings 按 @提及的用户 ID 过滤,逗号分隔
--workspace-ids strings 按知识库 ID 过滤,支持知识库 URL,逗号分隔
--page-size int 每页数量 (默认 10,最大 30)
--page-token string 分页游标 (从上次结果的 nextPageToken 获取)
```
### 遍历文件列表
```
Usage:
dws doc list [flags]
Example:
dws doc list
dws doc list --folder <FOLDER_ID>
dws doc list --workspace <WS_ID> --page-size 20
Flags:
--folder string 文件夹 ID 或 URL
--workspace string 知识库 ID
--page-size int 每页数量 (默认 50,最大 50)
--page-token string 分页游标 (从上次结果的 nextPageToken 获取)
```
### 获取文档元信息
```
Usage:
dws doc info [flags]
Example:
dws doc info --node <DOC_ID>
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
Flags:
--node string 文档 ID 或 URL (必填)
```
### 读取文档内容
```
Usage:
dws doc read [flags]
Example:
dws doc read --node <DOC_ID>
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
Flags:
--node string 文档 ID 或 URL (必填)
```
### 创建文档
```
Usage:
dws doc create [flags]
Example:
dws doc create --name "项目周报"
dws doc create --name "Q1 总结" --markdown "# Q1 总结" --folder <FOLDER_ID>
dws doc create --name "知识库文档" --workspace <WS_ID>
Flags:
--name string 文档名称 (必填)
--folder string 目标文件夹 ID 或 URL
--workspace string 目标知识库 ID
--markdown string 文档初始 Markdown 内容
```
### 更新文档内容
```
Usage:
dws doc update [flags]
Example:
dws doc update --node <DOC_ID> --markdown "# 追加内容" --mode append
dws doc update --node <DOC_ID> --markdown "# 完整替换" --mode overwrite
Flags:
--node string 文档 ID 或 URL (必填)
--markdown string Markdown 内容 (必填)
--mode string 更新模式: overwrite=覆盖, append=追加 (默认 append)
```
### 上传文件到钉钉文档或钉钉知识库
```
Usage:
dws doc upload [flags]
Example:
dws doc upload --file ./report.pdf
dws doc upload --file ./slides.pptx --name "Q1汇报.pptx" --folder <FOLDER_ID>
dws doc upload --file ./data.xlsx --workspace <WS_ID> --convert
Flags:
--file string 本地文件路径 (必填)
--name string 文件显示名称 (默认使用文件名)
--folder string 目标文件夹 ID 或 URL
--workspace string 目标知识库 ID
--convert 是否转换为钉钉在线文档
```
### 下载文件到本地
```
Usage:
dws doc download [flags]
Example:
dws doc download --node <NODE_ID>
dws doc download --node <NODE_ID> --output ./report.pdf
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --output ~/downloads/
Flags:
--node string 文件节点 ID 或 URL (必填)
--output string 本地保存路径 (文件路径或目录,必填)
```
### 创建文件夹
```
Usage:
dws doc folder create [flags]
Example:
dws doc folder create --name "项目资料"
dws doc folder create --name "子文件夹" --folder <PARENT_FOLDER_ID>
Flags:
--name string 文件夹名称 (必填)
--folder string 父文件夹 ID 或 URL
--workspace string 目标知识库 ID
```
### 查询块元素
```
Usage:
dws doc block list [flags]
Example:
dws doc block list --node <DOC_ID>
dws doc block list --node <DOC_ID> --start-index 0 --end-index 5
dws doc block list --node <DOC_ID> --block-type heading
Flags:
--node string 文档 ID 或 URL (必填)
--start-index int 起始位置 (从 0 开始)
--end-index int 终止位置 (含)
--block-type string 按块类型过滤
```
### 插入块元素
```
Usage:
dws doc block insert [flags]
Example:
dws doc block insert --node <DOC_ID> --text "这是一段文字"
dws doc block insert --node <DOC_ID> --heading "二级标题" --level 2
dws doc block insert --node <DOC_ID> --element '{"blockType":"paragraph","paragraph":{"text":"内容"}}'
dws doc block insert --node <DOC_ID> --text "在此处之前插入" --ref-block <BLOCK_ID> --where before
Flags:
--node string 文档 ID 或 URL (必填)
--text string 快捷: 段落文本内容
--heading string 快捷: 标题文本
--level int 标题级别 1-6 (配合 --heading,默认 1)
--element string 块元素 JSON (高级)
--index int 参照位置索引 (从 0 开始)
--where string 插入方向: before / after (默认 after)
--ref-block string 参照块 ID (优先级高于 --index)
```
### 更新块元素
```
Usage:
dws doc block update [flags]
Example:
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "新内容"
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}'
Flags:
--node string 文档 ID 或 URL (必填)
--block-id string 目标块 ID (必填)
--text string 快捷: 段落文本内容
--heading string 快捷: 标题文本
--level int 标题级别 1-6 (配合 --heading,默认 1)
--element string 块元素 JSON (高级)
```
### 删除块元素
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws doc block delete [flags]
Example:
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
Flags:
--node string 文档 ID 或 URL (必填)
--block-id string 目标块 ID (必填)
```
### 查询文档评论列表
```
Usage:
dws doc comment list [flags]
Example:
dws doc comment list --node <DOC_ID>
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved
dws doc comment list --node <DOC_ID> --page-size 20 --next-token <TOKEN>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--page-size int 每页返回的评论数量,默认 50,最大 50
--next-token string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
--type string 按评论类型过滤: global (全文评论) / inline (划词评论)
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
```
### 创建文档评论
```
Usage:
dws doc comment create [flags]
Example:
dws doc comment create --node <DOC_ID> --content "这里需要修改"
dws doc comment create --node <DOC_ID> --content "请review" --mention uid1,uid2
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--content string 评论的文字内容,纯文本 (必填)
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
### 回复文档评论
```
Usage:
dws doc comment reply [flags]
Example:
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "同意"
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请确认" --mention uid1,uid2
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--content string 回复的文字内容,表情回复时填写表情名称 (必填)
--comment-key string 被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
--emoji 设为 true 时作为表情贴图回复 (默认 false)
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
## URL 识别与 DOC_ID 提取
当用户输入包含钉钉文档 URL 时,**必须先识别并提取 DOC_ID**,再判断意图。
### 支持的 URL 格式
| 格式 | 示例 | DOC_ID 提取方式 |
|------|------|----------------|
| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段:`9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` |
| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段:`abc123` |
### 提取规则
1. 匹配 URL 中 `alidocs.dingtalk.com` 域名
2. 取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment)
3. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析)
### 处理流程
```
用户输入含 alidocs.dingtalk.com URL
→ 提取 DOC_ID(URL 路径最后一段)
→ 结合用户意图选择命令(默认 read)
→ 将 DOC_ID 传给 --node 参数
```
## 意图判断
用户说"找文档/搜文档/最近文档":
- 搜索 → `search`
- 浏览 → `list`
用户说"看文档/读内容/文档内容":
- 读取 → `read` (需文档 ID 或 URL)
- 元信息 → `info`
用户说"写文档/创建文档":
- 新建 → `create`
- 追加内容 → `update --mode append`
- 覆盖替换 → `update --mode overwrite`
用户说"建文件夹/新建目录":
- 创建 → `folder create`
用户说"上传文件/传文件/上传到文档/上传到知识库":
- 上传 → `upload`(需本地文件路径)
- 上传并转换 → `upload --convert`
用户说"下载文件/导出文件/下载到本地":
- 下载 → `download`(需文件节点 ID 或 URL)
用户说"编辑块/改段落/插入标题/删除块":
- 查看结构 → `block list`
- 插入 → `block insert`
- 修改 → `block update`
- 删除 → `block delete`
**用户直接粘贴文档 URL(无其他指令)**:
- 默认 → `read`(读取文档内容)
- 如 URL 明显是文件夹 → `list`(列出文件夹内容)
**用户粘贴 URL + 附加指令**:
- "帮我看看这个文档" → `read`
- "这个文档的信息" → `info`
- "往这个文档追加内容" → `update --mode append`
- "编辑这个文档的标题" → `block update`
关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(钉盘文件管理)
## 核心工作流
```bash
# ── 工作流 1: 浏览并阅读文档 ──
# 1. 浏览我的文档根目录
dws doc list --format json
# 2. 浏览子文件夹
dws doc list --folder <FOLDER_ID> --format json
# 3. 获取文档元信息 (标题、类型、权限)
dws doc info --node <DOC_ID> --format json
# 4. 读取文档内容 (Markdown 格式)
dws doc read --node <DOC_ID> --format json
# ── 工作流 2: 创建文档并写入内容 ──
# 1. (可选) 创建文件夹 — 提取 nodeId
dws doc folder create --name "项目资料" --format json
# 2. 创建文档 — 提取 nodeId
dws doc create --name "项目周报" --folder <FOLDER_ID> --format json
# 3. 写入内容 (追加模式)
dws doc update --node <DOC_ID> --markdown "# 本周总结\n\n- 完成了 A\n- 推进了 B" --mode append --format json
# ── 工作流 3: 一步创建带内容的文档 ──
dws doc create --name "会议纪要" --markdown "# 会议纪要\n\n## 议题\n\n1. ..." --format json
# ── 工作流 4: 上传本地文件到钉钉文档/知识库 ──
# 1. 上传到"我的文档"根目录
dws doc upload --file ./report.pdf
# 2. 上传到指定文件夹
dws doc upload --file ./slides.pptx --name "Q1汇报.pptx" --folder <FOLDER_ID>
# 3. 上传到知识库并转换为在线文档
dws doc upload --file ./data.xlsx --workspace <WS_ID> --convert
# ── 工作流 5: 下载文件到本地 ──
# 1. 下载到当前目录 (自动推断文件名)
dws doc download --node <NODE_ID>
# 2. 下载到指定路径
dws doc download --node <NODE_ID> --output ./report.pdf
# 3. 下载到指定目录 (自动推断文件名)
dws doc download --node <NODE_ID> --output ~/downloads/
# ── 工作流 6: 块级精细编辑 ──
# 1. 查看文档块结构 — 获取 blockId
dws doc block list --node <DOC_ID> --format json
# 2. 在文档末尾插入段落
dws doc block insert --node <DOC_ID> --text "新增内容"
# 3. 在指定块之前插入标题
dws doc block insert --node <DOC_ID> --heading "新章节" --level 2 --ref-block <BLOCK_ID> --where before
# 4. 更新某个块的内容
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "修改后的内容"
# 5. 删除块
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
# ── 工作流 7: 文档评论管理 ──
# 1. 查看文档的所有评论
dws doc comment list --node <DOC_ID> --format json
# 2. 在文档上创建评论
dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源" --format json
# 3. 创建评论并 @ 相关人
# 先搜索用户: dws contact user search --query "张三" --format json → 提取 userId
# 再将 userId 传入 --mention
dws doc comment create --node <DOC_ID> --content "请确认这部分内容" --mention <userId1>,<userId2> --format json
# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取)
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
# 5. 用表情回复评论
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | `nodes[].nodeId` | read / info / update / block 操作的 --node |
| `list` | folder 类型的 `nodeId` | list 的 --folder, create 的 --folder |
| `search` | 文档 `nodeId` / URL / `createTime` / `creatorUid` | read / info / update 的 --node;创建时间与创建者信息 |
| `create` | `nodeId` | update / block 操作的 --node |
| `folder create` | `nodeId` | create / list / upload 的 --folder |
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
| `upload` | `nodeId` / URL | 上传后文件的访问链接 |
| `download` | 本地文件路径 | 下载后的文件保存位置 |
| `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key |
| `comment create` | `commentKey` | comment reply 的 --comment-key |
| `contact user search` | `userId` | comment create/reply 的 --mention |
## nodeId 双格式说明
所有 `--node` 参数同时支持两种格式,系统自动识别:
- **文档 ID**: 字母数字字符串,如 `9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
- **文档 URL**: `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}`,如 `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
两种方式等价,以下命令效果相同:
```bash
dws doc read --node 9E05BDRVQePjzLkZt2p2vE7kV63zgkYA
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA"
```
`--folder` 参数同样支持文件夹 URL 或 ID。
## 注意事项
- `update --mode overwrite` 会**清空原内容后重写**,⚠️ 谨慎使用;默认 `--mode append` (追加) 更安全
- `read` 返回 Markdown 格式的文档内容,仅限有"下载"权限的文档
- `create` 不传 `--folder` 和 `--workspace` 时,默认创建在"我的文档"根目录
- `block list/insert/update/delete` 是块级精细编辑,适合结构化修改;简单内容追加建议用 `update --mode append`
- `block insert` 优先使用 `--text` 或 `--heading` 快捷方式;复杂块类型 (table, callout 等) 使用 `--element` JSON
- `markdown` 参数中的换行必须使用**真实换行符**(即实际的换行字符,Unicode `U+000A`),而不是字面量字符串 `\n`(反斜杠加字母 n)。在通过程序或大模型构造此参数时,请确保字符串在发送前已正确反转义。如果传入的是两个字符的字面量 `\n`,所有内容将渲染在同一行,导致标题、段落和表格格式全部错乱。
- 块类型包括: paragraph, heading, blockquote, callout, columns, orderedList, unorderedList, table, sheet, attachment, slot
- 关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(钉盘文件管理)
- `upload` 支持上传任意类型文件 (PDF、Office、图片等) 到钉钉文档空间或知识库;`--convert` 可将 Office 文件转换为钉钉在线文档
- `upload` 是三步自动完成的流程 (获取凭证 → OSS 上传 → 提交入库),无需手动分步操作
- `download` 是两步自动完成的流程 (获取下载链接 → HTTP GET 下载),支持自动推断文件名;`--output` 可指定文件路径或目录
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [doc_create_and_write.py](../../scripts/doc_create_and_write.py) | 创建文档并写入 Markdown 内容 | `python doc_create_and_write.py --name "周报" --content "# 本周总结"` |
## 相关产品
- [aitable](./aitable.md) — 结构化数据表格(行列/字段/记录),不是富文本文档
- [drive](./drive.md) — 钉盘文件存储/上传/下载,不是文档内容编辑
- [report](./report.md) — 钉钉日志系统(日报/周报模版),不是在线文档
+274
View File
@@ -0,0 +1,274 @@
# AI听记 (minutes) 命令参考
## 命令总览
### 查询我创建的听记列表
```
Usage:
dws minutes list mine [flags]
Example:
dws minutes list mine
dws minutes list mine --max 10
dws minutes list mine --max 10 --next-token <nextToken>
dws minutes list mine --query "周会"
Flags:
--max float 查询的听记篇数 (默认 10)
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
--query string 关键字筛选 (可选)
--start string 开始时间 ISO-8601 (可选)
--end string 结束时间 ISO-8601 (可选)
```
查询我创建的听记列表,支持 `--max` 和 `--next-token` 分页,支持按关键字和时间范围筛选。
### 查询他人共享给我的听记列表
```
Usage:
dws minutes list shared [flags]
Example:
dws minutes list shared
dws minutes list shared --max 20
dws minutes list shared --max 5 --next-token <nextToken>
Flags:
--max float 查询的听记篇数 (默认 10)
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
--query string 关键字筛选 (可选)
--start string 开始时间 ISO-8601 (可选)
--end string 结束时间 ISO-8601 (可选)
```
查询他人共享给我的听记列表,支持 `--max` 和 `--next-token` 分页,支持按关键字和时间范围筛选。
### 查询我有权限访问的所有听记列表
```
Usage:
dws minutes list all [flags]
Example:
dws minutes list all
dws minutes list all --max 20
dws minutes list all --query "周会" --max 20
dws minutes list all --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00"
dws minutes list all --max 10 --next-token <nextToken>
Flags:
--end string 结束时间 ISO-8601 (可选)
--query string 关键字筛选 (可选)
--max float 查询的听记篇数 (默认 10)
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
--start string 开始时间 ISO-8601 (可选)
```
查询我有权限访问的所有听记列表(包括我创建的、他人共享给我的等所有有权限的听记)。支持按关键字和时间范围筛选。时间范围和关键字为可选参数,不传则返回所有有权限的听记。支持使用 `--max` 和 `--next-token` 进行分页查询。
### 获取听记基础信息
```
Usage:
dws minutes get info [flags]
Example:
dws minutes get info --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
```
返回字段: 创建人、开始时间、截止时间、听记标题、听记访问链接URL
### 获取听记 AI 摘要
```
Usage:
dws minutes get summary [flags]
Example:
dws minutes get summary --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
```
返回 Markdown 格式摘要,涵盖会议主题、核心结论、关键讨论点等
### 获取听记关键字列表
```
Usage:
dws minutes get keywords [flags]
Example:
dws minutes get keywords --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
```
### 获取听记语音转写原文
```
Usage:
dws minutes get transcription [flags]
Example:
dws minutes get transcription --id <taskUuid>
dws minutes get transcription --id <taskUuid> --direction 1
Flags:
--direction string 排序方向: 0=正序, 1=倒序 (默认 0)
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
--next-token string 下一页的token 首次查询可空 后续查询需填写前次请求返回的nextToken
```
每条记录包含: 发言人信息、转写文本、对应时间戳
### 获取听记中提取的待办事项
```
Usage:
dws minutes get todos [flags]
Example:
dws minutes get todos --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
```
每条记录包含: 待办内容、待办唯一ID、参与人信息、待办时间
### 批量查询听记详情
```
Usage:
dws minutes get batch [flags]
Example:
dws minutes get batch --ids uuid1,uuid2,uuid3
Flags:
--ids string 听记 taskUuid 列表,逗号分隔 (必填)
```
返回字段: 听记标题、时长、参与人列表、创建时间、taskUuid、听记状态
### 修改听记标题
```
Usage:
dws minutes update title [flags]
Example:
dws minutes update title --id <taskUuid> --title "Q2 复盘会议"
Flags:
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
--title string 新标题 (必填)
```
### 发起听记(开始录音)
```
Usage:
dws minutes record start [flags]
Example:
dws minutes record start
dws minutes record start --session-id <sessionId>
Flags:
--session-id string AI 助理会话 ID (可选)
```
### 暂停听记录音
```
Usage:
dws minutes record pause [flags]
Example:
dws minutes record pause --id <taskUuid>
dws minutes record pause --id <taskUuid> --session-id <sessionId>
Flags:
--id string 听记 taskUuid (必填)
--session-id string AI 助理会话 ID (可选)
```
### 恢复听记录音
```
Usage:
dws minutes record resume [flags]
Example:
dws minutes record resume --id <taskUuid>
dws minutes record resume --id <taskUuid> --session-id <sessionId>
Flags:
--id string 听记 taskUuid (必填)
--session-id string AI 助理会话 ID (可选)
```
### 结束听记录音
```
Usage:
dws minutes record stop [flags]
Example:
dws minutes record stop --id <taskUuid>
dws minutes record stop --id <taskUuid> --session-id <sessionId>
Flags:
--id string 听记 taskUuid (必填)
--session-id string AI 助理会话 ID (可选)
```
## 意图判断
用户说"我的听记/我创建的听记" → `list mine`(可附加 `--query`、`--start`、`--end` 筛选)
用户说"别人给我的听记/共享听记" → `list shared`(可附加 `--query`、`--start`、`--end` 筛选)
用户说"有权限的听记/我能访问的听记/所有听记" → `list all`(可附加 `--query`、`--start`、`--end` 筛选)
用户说"某时间段内的听记/按时间查听记/按关键词查听记" → 根据所属范围选择 `list mine`/`list shared`/`list all`,附加 `--start`、`--end`、`--query` 参数
用户说"听记详情/听记信息" → `get info`
用户说"摘要/总结/会议纪要" → `get summary`
用户说"关键字/关键词" → `get keywords`
用户说"原文/转写/录音文字" → `get transcription`
用户说"会议待办/听记待办" → `get todos`
用户说"改听记标题/重命名听记" → `update title`
用户说"发起听记/开始录音" → `record start`
用户说"暂停听记/暂停录音" → `record pause`
用户说"继续听记/恢复录音" → `record resume`
用户说"结束听记/结束录音" → `record stop`
用户传入听记 URL(如 `https://shanji.dingtalk.com/app/transcribes/xxx`),从 URL 提取 taskUuid,再执行对应的 get/update 操作
## 核心工作流
```bash
# 0. 发起听记(开始录音)
dws minutes record start --format json
# 1. 查看我的听记列表 — 提取 taskUuid
dws minutes list mine --format json
dws minutes list mine --max 10 --next-token <nextToken> --format json
dws minutes list mine --query "周会" --format json
# 1b. 查看共享给我的听记
dws minutes list shared --max 20 --format json
dws minutes list shared --query "日报" --format json
# 1c. 查看我有权限访问的所有听记(支持关键字和时间范围筛选)
dws minutes list all --format json
dws minutes list all --query "周会" --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00" --format json
# 2. 获取 AI 摘要
dws minutes get summary --id <taskUuid> --format json
# 3. 查看完整转写原文
dws minutes get transcription --id <taskUuid> --format json
# 4. 提取待办事项
dws minutes get todos --id <taskUuid> --format json
# 5. 修改标题
dws minutes update title --id <taskUuid> --title "新标题" --format json
# 6. 录音控制(基于 start 返回的 taskUuid)
dws minutes record pause --id <taskUuid> --format json
dws minutes record resume --id <taskUuid> --format json
dws minutes record stop --id <taskUuid> --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list mine` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `list shared` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `list all` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `get batch` | 各听记 `taskUuid` | 进一步查询详情 |
## 注意事项
- `taskUuid` 是听记的唯一标识,所有 get/update 操作均以此为入参
- `record start` 对应 MCP 工具 `execute_listening_note_command` 的 `cmd=create`,通常会返回可继续控制录音的 `taskUuid/uuid`
- `record pause` / `record resume` / `record stop` 对应 `cmd=pause/resume/end`,需要传入 `--id`(映射 MCP 入参 `uuid`)
- 如果用户传入听记 URL(格式: `https://shanji.dingtalk.com/app/transcribes/<taskUuid>`),直接从路径末段提取 taskUuid 作为 `--id` 参数,无需再调用 list 查询
- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`)
- 三个 list 命令均支持 `--max`、`--next-token` 分页及 `--query`、`--start`、`--end` 筛选
- `list mine`、`list shared` 默认每页 20 条,`list all` 默认每页 10 条
- `get summary` 返回 AI 生成的结构化 Markdown 摘要
- `get transcription` 的 `--direction` 控制时间排序: 0=正序(默认), 1=倒序
- `get batch` 支持一次查询多个听记,用逗号分隔 taskUuid
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [minutes_recent_summary.py](../../scripts/minutes_recent_summary.py) | 获取最近听记的 AI 摘要并合并 | `python minutes_recent_summary.py --max 5` |
| [minutes_extract_todos.py](../../scripts/minutes_extract_todos.py) | 从听记中提取待办事项汇总 | `python minutes_extract_todos.py --max 5` |
+10 -5
View File
@@ -4,8 +4,9 @@ DWS Skill Test Runner
Validates AI Agent's ability to translate natural language prompts into DWS CLI commands.
"""
import re
import json
import re
from pathlib import Path
from dataclasses import dataclass
from typing import Optional
from collections import defaultdict
@@ -404,8 +405,12 @@ def generate_report(results: list[dict]) -> str:
return '\n'.join(report)
def main():
# Read the test file
with open('/Users/tianlei.qjb/Documents/my_python_project/cli/test/skill_tests.md', 'r', encoding='utf-8') as f:
test_dir = Path(__file__).resolve().parent
test_cases_path = test_dir / 'skill_tests.md'
results_path = test_dir / 'skill_tests_results.md'
# Read the test file from the repo instead of a developer-local absolute path.
with open(test_cases_path, 'r', encoding='utf-8') as f:
content = f.read()
# Parse test cases
@@ -421,8 +426,8 @@ def main():
# Generate report
report = generate_report(results)
# Write results
with open('/Users/tianlei.qjb/Documents/my_python_project/cli/test/skill_tests_results.md', 'w', encoding='utf-8') as f:
# Write results next to the test cases so the script stays portable.
with open(results_path, 'w', encoding='utf-8') as f:
f.write(report)
print(f"\nResults written to skill_tests_results.md")
-34
View File
@@ -1,34 +0,0 @@
package main
import (
"encoding/json"
"fmt"
)
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
func main() {
data := []byte(`{"content":[{"type":"text","text":"{\"summary\":\"...\",\"data\":{\"tableId\":\"abc\"},\"status\":\"success\"}"}],"structuredContent":{"summary":"...","data":{"tableId":"abc"},"status":"success"},"isError":false}`)
type rawResult struct {
Content json.RawMessage `json:"content"`
StructuredContent map[string]any `json:"structuredContent"`
IsError bool `json:"isError,omitempty"`
}
var raw rawResult
_ = json.Unmarshal(data, &raw)
fmt.Printf("raw.Content string: %s\n", string(raw.Content))
var object map[string]any
errMap := json.Unmarshal(raw.Content, &object)
fmt.Printf("errMap: %v\n", errMap)
var blocks []ContentBlock
errBlocks := json.Unmarshal(raw.Content, &blocks)
fmt.Printf("errBlocks: %v, len(blocks): %d\n", errBlocks, len(blocks))
}